Day 9|Vue Router:把单页后台拆成能跳转的多页面

看看今天的效果图,是不是有点后台的意思了

昨天的商品面板只有"一屏"------所有东西堆在一个 #app 里。但真实后台不是一页:左边一排菜单,点"商品"进商品页,点"订单"进订单页,点某个商品进它的详情页,地址栏还要变成 /product/5。可前端只有一个 HTML 文件,传统那种"每个页面一个 .html、点链接整页刷新"的做法会把你昨天写的购物车、登录态、响应式数据全部冲没。

今天讲 Vue Router ------单页应用(SPA)的"页面切换器":URL 和组件之间建一张对照表,地址一变,对应的组件就换进来,页面不刷新、状态不丢 。今天要串起 路由表、router-view/router-link、动态路由参数、编程式导航、嵌套布局路由、路由守卫 ,并把它接到后台框架最刚需的"菜单跳转 + 详情跳转"上。

文末同样有一个完整 index.html:一个用 Vue Router(hash 模式) 搭起来的 MiniMall 多页后台框架 ------侧边栏菜单跳转、概览/商品/订单三个页面、点商品进 /product/:id 详情页,复制保存即可打开。全程零构建、零后端。


一、先看清问题:后台为什么离不开"路由"

先想清楚传统多页网站怎么跳页:点一个 <a href="order.html">,浏览器整页卸载、重新请求 order.html。在后台里这意味着------你昨天做的购物车、当前登录用户、刚填了一半的表单,全随着整页刷新没了;每次跳页还要白屏重新加载,体验很"重"。

传统多页(多个 .html) SPA + 路由(一个 HTML)
跳页 = 整页刷新、重新请求 跳页 = 只换中间那块组件,页面不刷新
公共状态(登录、购物车)每次丢 状态留在内存里,跨页不丢
每次加载白屏一下 切换几乎瞬时
后端要渲染每个页面 后端只给数据,页面全在前端组装

一句话:路由 = 维护一张「URL → 组件」的对照表,地址变了就把对应组件渲染到出口,而不刷新整页。 这是所有后台、所有 SPA 的骨架。菜单点哪、地址栏写啥、中间显示哪个组件,全由它串起来。


二、把 Vue Router 跑起来:CDN + createRouter + createWebHashHistory

延续昨天的零构建方式,多引一个 vue-router 的 CDN 就能跑:

html 复制代码
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/vue-router@4/dist/vue-router.global.prod.js"></script>

<div id="app">
  <!-- 导航:点它 = 换地址 -->
  <router-link to="/">概览</router-link>
  <router-link to="/products">商品</router-link>
  <!-- 出口:当前 URL 对应的组件显示在这里 -->
  <router-view></router-view>
</div>

<script>
const { createApp } = Vue;
const { createRouter, createWebHashHistory } = VueRouter;

// 1. 定义几个"页面组件"(就是普通对象,有 template 即可)
const Home     = { template: `<h2>概览页</h2>` };
const Products = { template: `<h2>商品页</h2>` };

// 2. 配一张路由表:路径 -> 组件
const router = createRouter({
  history: createWebHashHistory(),          // 用 # 模式,本地双击文件就能跑
  routes: [
    { path: '/',         component: Home },
    { path: '/products', component: Products },
  ],
});

// 3. 把路由装进应用
createApp({}).use(router).mount('#app');
</script>
  • createRouter :创建路由实例,核心是 history 和 routes 两样。
  • history :决定地址怎么"记账"。createWebHashHistory() 用 #/products 这种带 # 的地址------好处是纯静态文件双击就能跑、不依赖服务器配置 ,所以今天用它。真实部署用 createWebHistory()(干净地址 /products,需后端配回退)。
  • .use(router) :把路由装进 Vue 应用,router-link/router-view 这些全局组件才生效。

为什么今天用 hash?因为我们要保持"复制 index.html 双击就能跑"的一贯体验。createWebHistory 的地址更好看,但直接双击打开 file:// 会刷新 404,所以放在真实项目(Vite 起本地服务)里才用。


三、路由表 routes:地址和组件的对照清单

路由表是一个数组,每一项把"一个 URL 路径"绑到"一个组件"。这是整个后台的地图。

js 复制代码
const routes = [
  { path: '/',          component: Home },        // 首页
  { path: '/products',  component: ProductList }, // 商品列表
  { path: '/orders',    component: OrderList },   // 订单列表
  { path: '/users',     component: UserList },    // 用户列表

  // 重定向:访问老地址自动跳到新地址
  { path: '/mall', redirect: '/products' },

  // 兜底:以上都不匹配 -> 跳 404 页
  { path: '/:catchAll(.*)', component: NotFound },
];
  • path:匹配的地址。
  • component:匹配后要渲染的组件。
  • redirect:访问 A 自动改到 B,常用于"默认页""老链接迁移"。
  • 兜底路由 /:catchAll(.*):Vue Router 4 的写法,把所有没匹配到的地址收进一个 404 页------后台必备,避免用户乱输地址白屏。

后台的菜单其实就是一串固定的 path:侧边栏每一项对应路由表里的一条。菜单、地址、组件三者一一对齐,后台的"页面感"就是这么造出来的。


  • <router-link> :渲染成一个 <a>,点它换地址但不刷新整页 (内部帮你拦截了默认跳转)。等价于"受路由管制的 <a>"。
  • <router-view>:一个"出口",当前 URL 匹配到的组件就渲染在它所在的位置。
html 复制代码
<!-- 导航条 -->
<nav>
  <router-link to="/">概览</router-link>
  <router-link to="/products">商品</router-link>
  <router-link to="/orders">订单</router-link>
</nav>

<!-- 出口:右侧内容区,URL 变这里跟着换 -->
<main>
  <router-view></router-view>
</main>

router-link 自带"当前项高亮"------匹配的链接会被加上 router-link-active / router-link-exact-active 类,直接给侧边菜单做选中态:

html 复制代码
<style>
  .router-link-active { color:#2f6fed; font-weight:bold; border-left:3px solid #2f6fed; }
</style>
<!-- 还可以自定义高亮类名 -->
<router-link to="/products" active-class="is-on">商品</router-link>

后台侧边栏菜单为什么点谁谁高亮?就是 router-link-active 在起作用。你不用自己写 if (当前页==='商品') 去加类,路由自动加。


五、动态路由参数 /product/:id:一个详情页吃下所有商品

商品有 1000 个,不可能配 1000 条路由。用 :id 占位,一条路由通吃:

js 复制代码
const routes = [
  { path: '/products',    component: ProductList },
  { path: '/product/:id', component: ProductDetail },   // :id 是参数
];

在详情组件里用 useRoute() 取出这个 id,再去拿对应数据:

js 复制代码
const { useRoute } = VueRouter;

const ProductDetail = {
  setup() {
    const route = useRoute();                  // 读"当前路由"的信息
    const id = route.params.id;                // 地址里的 id(字符串)
    const product = computed(() =>
      allProducts.find(p => p.id === Number(id))   // 按 id 找数据
    );
    return { product };
  },
  template: `
    <div v-if="product">
      <h2>{{ product.name }}</h2>
      <p>¥{{ product.price }}</p>
    </div>
    <p v-else>商品不存在</p>`,
};
  • useRoute() :拿"当前这一条路由",读 params(路径参数)、query(?a=1 查询串)、meta 等。
  • useRouter() :拿"路由控制器",用来主动跳转(下一节)。注意两个名字差一个 r,别搞混。

从列表点某商品 → 地址变 /product/3 → 详情组件用 route.params.id 拿到 3 → 渲染 3 号商品。列表页只负责跳,详情页只负责按 id 取数,这就是"动态路由"的分工。


六、编程式导航:用代码跳页(router.push / replace / back)

不是所有跳转都来自点链接------"保存成功后自动回列表""登录后跳首页""点返回"这些要在逻辑里跳。用 useRouter():

js 复制代码
const { useRouter } = VueRouter;

setup() {
  const router = useRouter();

  function saveAndBack() {
    saveOrder().then(() => {
      router.push('/orders');       // 跳订单页(留下一条历史记录,能后退)
    });
  }

  function toDetail(id) {
    router.push('/product/' + id);  // 也能拼带参数的路径
  }

  function goBack() { router.back(); }   // 相当于浏览器"后退"

  return { saveAndBack, toDetail, goBack };
}
方法 作用 能否后退
router.push('/x') 前进到新地址 能(入栈)
router.replace('/x') 替换当前地址 不能(不占历史)
router.back() 后退一步 ---

push vs replace:登录成功跳首页用 replace(不让用户一后退又退回登录页);正常跳页用 push。"商品列表点进详情"既可以用 <router-link>,也可以在卡片 @click 里 router.push('/product/'+id)------后者更适合"整张卡片可点"的场景,今天小项目就是这么做的。


七、嵌套路由 children:搭后台的"侧边栏 + 内容区"布局

后台几乎都长这样:外层一层固定的"侧边栏 + 顶栏",中间那块内容随路由换。用 children + 嵌套 router-view 实现------外层布局只渲染一次,切换时只换内层出口。

js 复制代码
const Layout = {
  template: `
    <div class="layout">
      <aside>
        <router-link to="/">概览</router-link>
        <router-link to="/products">商品</router-link>
      </aside>
      <main><router-view></router-view></main>   <!-- 内层出口 -->
    </div>`,
};

const routes = [
  { path: '/', component: Layout, children: [     // Layout 是父,子路由塞进它的内层出口
      { path: '',         component: Home },        // 匹配 /
      { path: 'products', component: ProductList }, // 匹配 /products(注意子 path 不带前导斜杠)
      { path: 'orders',   component: OrderList },
  ]},
];

关键点:外层布局组件里有自己的 <router-view>,父路由命中后,子路由命中谁,就渲染在外层这个 <router-view> 里。这样切页面时侧边栏/顶栏不重建,只换中间,体验顺、性能好。真实后台的整框架就是这么搭的。


八、路由守卫 beforeEach:进页面前先"查票"(接 Day 14 权限)

"没登录不许进后台""没权限的菜单点进去弹回登录"------这类"进页面前的拦截"叫路由守卫 。全局守卫 beforeEach 在每次跳转前跑一次:

js 复制代码
router.beforeEach((to, from) => {
  const isLogin = localStorage.getItem('token');   // 有没有登录态

  // 没登录且要去的是"需要登录的页" -> 强制弹回登录
  if (to.meta.needAuth && !isLogin) {
    return '/login';                               // 返回一个路径 = 重定向
  }
  return true;                                     // 放行
});

配合路由表里的 meta(给每条路由挂自定义信息)声明"这页要不要登录":

js 复制代码
{ path: '/products', component: ProductList, meta: { needAuth: true, title: '商品管理' } }

今天先认识守卫的门长什么样:"跳转前 → 读目标路由的 meta → 判断放行还是重定向"。第 14 天"权限路由(按角色动态渲染菜单)"会把它和登录、RBAC 串成完整一套。今天先把"页面按 URL 跳转"这条主线跑通。


九、meta 标题 + 监听路由变化

meta 除了给守卫用,还常挂页面标题。配合 watch 路由,可以在每次跳页时同步浏览器标题、埋点:

js 复制代码
const { watch } = Vue;
const { useRoute } = VueRouter;

setup() {
  const route = useRoute();
  watch(() => route.fullPath, () => {            // 地址每次变就触发
    document.title = route.meta.title || 'MiniMall 后台';
    // trackPageView(route.path);                 // 顺便打个页面访问埋点
  }, { immediate: true });
  return {};
}

这正好接上第 20 天"SEO/给页面加 title、meta"------SPA 的标题不能写死在一个 HTML 上,必须跟着路由动态改,套路就是监听 route + 读 meta。


十、新手六个坑 + 调试

js 复制代码
// 坑1:useRoute / useRouter 名字看花,功能完全不同
const route = useRoute();   // 只读:params / query / meta(当前是哪条路由)
const router = useRouter(); // 操作:push / replace / back(主动跳)

// 坑2:useRoute/useRouter 只能在 setup() 里用,写在外面拿不到上下文
// 坑3:createWebHistory 双击 file:// 打开刷新会 404 ------ 本地演示用 createWebHashHistory

// 坑4:子路由 path 不要写前导斜杠
{ path: '/', component: Layout, children: [
  { path: 'products', component: List }   // ✓ 拼出 /products
  // { path: '/products' ... }            // ✗ 变成根级 /products,脱离 Layout
]}

// 坑5:params 拿到的永远是字符串,比较/运算前先 Number(id)
// 坑6:同一条路由只改参数(/product/1 -> /product/2)组件会被复用,
//       里面 setup 只跑一次,要响应"id 变了"用 watch(() => route.params.id, ...)

// 坑7:router-link 会自动拦截默认跳转,别在 @click 里再手动 location.href 硬跳,那会整页刷新

调试 :Vue Devtools 里有专门的 Router 面板 ,能看当前路由、完整路由表、匹配到的 params/query/meta、历史栈------排查"点了没跳转/跳错页/参数拿不到"必备。浏览器地址栏本身也是最直观的调试器:手动改 #/product/3 回车,看中间组件换没换。


十一、完整小项目:MiniMall 多页后台框架(可直接复制)

下面是一份完整可运行的 index.html:用 **Vue Router(hash 模式)**搭的后台框架------侧边栏菜单跳转 + 概览/商品/订单三页 + 点商品进 /product/:id 详情页,全部套在"侧边栏+内容区"的嵌套布局里,router-link 自动高亮当前菜单。复制保存为 index.html 双击打开即可。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>MiniMall 后台管理</title>
  <!-- 国内可用 CDN 镜像 -->
  <script src="https://gcore.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
  <script src="https://gcore.jsdelivr.net/npm/vue-router@4/dist/vue-router.global.js"></script>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f6fa; }

    .layout { display: flex; min-height: 100vh; }
    .sidebar {
      width: 200px; background: #1e2329; color: #fff;
      padding: 20px 0; position: fixed; height: 100vh;
    }
    .sidebar h2 { padding: 0 20px 20px; border-bottom: 1px solid #2d333a; font-size: 18px; }
    .sidebar nav { margin-top: 10px; }
    .sidebar a {
      display: block; padding: 14px 20px; color: #b0b8c1;
      text-decoration: none; transition: all 0.2s;
    }
    .sidebar a:hover { background: #2d333a; color: #fff; }
    .sidebar a.router-link-active { background: #0d6efd; color: #fff; }

    .content { margin-left: 200px; flex: 1; padding: 30px; }

    .card {
      background: #fff; border-radius: 8px; padding: 20px;
      margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06);
    }
    .card h3 { margin-bottom: 15px; color: #333; }

    .product-grid {
      display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px;
    }
    .product-card {
      background: #fff; border-radius: 8px; overflow: hidden;
      box-shadow: 0 2px 8px rgba(0,0,0,0.06); cursor: pointer; transition: transform 0.2s;
    }
    .product-card:hover { transform: translateY(-4px); }
    .product-card img { width: 100%; height: 150px; object-fit: cover; }
    .product-card .info { padding: 12px; }
    .product-card .info h4 { font-size: 14px; color: #333; margin-bottom: 8px; }
    .product-card .info .price { color: #e74c3c; font-weight: bold; font-size: 16px; }
    .product-card .info .stock { font-size: 12px; color: #999; margin-top: 4px; }

    .detail { display: flex; gap: 30px; }
    .detail img { width: 300px; height: 300px; object-fit: cover; border-radius: 8px; }
    .detail .info { flex: 1; }
    .detail .info h2 { margin-bottom: 10px; }
    .detail .info .price { font-size: 24px; color: #e74c3c; font-weight: bold; margin: 15px 0; }
    .detail .info .desc { color: #666; line-height: 1.6; margin-bottom: 20px; }

    .btn {
      padding: 10px 24px; background: #0d6efd; color: #fff;
      border: none; border-radius: 6px; cursor: pointer; font-size: 14px;
    }
    .btn:hover { background: #0b5ed7; }
    .btn:disabled { background: #ccc; cursor: not-allowed; }

    .not-found { text-align: center; padding: 60px 0; color: #999; }
    .back-link { color: #0d6efd; text-decoration: none; display: inline-block; margin-top: 15px; }

    table { width: 100%; border-collapse: collapse; }
    th { text-align: left; padding: 10px 0; }
    td { padding: 10px 0; }
    tr { border-bottom: 1px solid #eee; }
  </style>
</head>
<body>
  <div id="app"></div>

  <script>
    const { createApp, ref, computed } = Vue;
    const { createRouter, createWebHashHistory, useRouter, useRoute } = VueRouter;

    // ====== 模拟数据 ======
    const products = [
      { id: 1, name: '机械键盘 RGB',     price: 299, stock: 50,  category: '数码', desc: '87键紧凑布局,青轴/红轴可选,全键无冲。' },
      { id: 2, name: '无线鼠标 静音版',   price: 89,  stock: 120, category: '数码', desc: '2.4G无线连接,人体工学设计,静音微动。' },
      { id: 3, name: '纯棉T恤 基础款',    price: 59,  stock: 200, category: '服饰', desc: '100%精梳棉,亲肤透气,多色可选。' },
      { id: 4, name: '帆布双肩包',        price: 129, stock: 80,  category: '箱包', desc: '大容量多隔层,防水面料,可装15寸笔记本。' },
      { id: 5, name: '保温杯 500ml',      price: 69,  stock: 0,   category: '生活', desc: '304不锈钢内胆,6小时保温/保冷。' },
      { id: 6, name: '蓝牙音箱 便携',     price: 159, stock: 45,  category: '数码', desc: 'IPX6防水,12小时续航,双喇叭立体声。' },
    ];

    const orders = [
      { id: 'ORD-1001', product: '机械键盘 RGB',  amount: 299, status: '已发货' },
      { id: 'ORD-1002', product: '纯棉T恤 基础款', amount: 118, status: '待发货' },
      { id: 'ORD-1003', product: '保温杯 500ml',   amount: 138, status: '已取消' },
      { id: 'ORD-1004', product: '蓝牙音箱 便携',  amount: 159, status: '已发货' },
    ];

    // ====== 页面组件(全部定义在 routes 之前)======

    const Dashboard = {
      template: `
        <div>
          <h1 style="margin-bottom:20px;">📊 概览</h1>
          <div class="card">
            <h3>今日数据</h3>
            <div style="display:flex;gap:20px;flex-wrap:wrap;margin-top:15px;">
              <div style="flex:1;min-width:150px;background:#f0f7ff;padding:20px;border-radius:8px;text-align:center;">
                <div style="font-size:28px;font-weight:bold;color:#0d6efd;">{{ totalProducts }}</div>
                <div style="color:#666;margin-top:5px;">商品总数</div>
              </div>
              <div style="flex:1;min-width:150px;background:#f0fff4;padding:20px;border-radius:8px;text-align:center;">
                <div style="font-size:28px;font-weight:bold;color:#28a745;">{{ inStockCount }}</div>
                <div style="color:#666;margin-top:5px;">有货商品</div>
              </div>
              <div style="flex:1;min-width:150px;background:#fff5f5;padding:20px;border-radius:8px;text-align:center;">
                <div style="font-size:28px;font-weight:bold;color:#e74c3c;">{{ outOfStockCount }}</div>
                <div style="color:#666;margin-top:5px;">已售罄</div>
              </div>
              <div style="flex:1;min-width:150px;background:#fff8f0;padding:20px;border-radius:8px;text-align:center;">
                <div style="font-size:28px;font-weight:bold;color:#f39c12;">¥{{ totalPrice }}</div>
                <div style="color:#666;margin-top:5px;">库存总价值</div>
              </div>
            </div>
          </div>
          <div class="card">
            <h3>分类统计</h3>
            <table style="width:100%;margin-top:15px;">
              <tr style="border-bottom:2px solid #eee;">
                <th>分类</th><th style="text-align:center;">商品数</th><th style="text-align:center;">库存</th>
              </tr>
              <tr v-for="c in categoryStats" :key="c.name">
                <td>{{ c.name }}</td>
                <td style="text-align:center;">{{ c.count }}</td>
                <td style="text-align:center;">{{ c.stock }}</td>
              </tr>
            </table>
          </div>
        </div>`,
      setup() {
        const totalProducts  = products.length;
        const inStockCount   = products.filter(p => p.stock > 0).length;
        const outOfStockCount= products.length - inStockCount;
        const totalPrice     = products.reduce((s, p) => s + p.price * p.stock, 0);
        const categoryStats  = computed(() => {
          const map = {};
          products.forEach(p => {
            if (!map[p.category]) map[p.category] = { name: p.category, count: 0, stock: 0 };
            map[p.category].count++;
            map[p.category].stock += p.stock;
          });
          return Object.values(map);
        });
        return { totalProducts, inStockCount, outOfStockCount, totalPrice, categoryStats };
      }
    };

    const ProductList = {
      template: `
        <div>
          <h1 style="margin-bottom:20px;">🛍️ 商品管理</h1>
          <div class="card">
            <input v-model="search" placeholder="搜索商品名或分类..."
              style="width:100%;padding:10px 14px;border:1px solid #ddd;border-radius:6px;font-size:14px;outline:none;" />
          </div>
          <div class="product-grid">
            <div class="product-card" v-for="p in filtered" :key="p.id" @click="goDetail(p.id)">
              <img :src="'https://picsum.photos/seed/' + p.id + '/400/300'" :alt="p.name" />
              <div class="info">
                <h4>{{ p.name }}</h4>
                <div class="price">¥{{ p.price }}</div>
                <div class="stock">{{ p.stock > 0 ? '库存 ' + p.stock : '已售罄' }}</div>
              </div>
            </div>
            <p v-if="filtered.length === 0" style="color:#999;text-align:center;padding:40px;grid-column:1/-1;">
              没有找到匹配的商品
            </p>
          </div>
        </div>`,
      setup() {
        const search = ref('');
        const filtered = computed(() => {
          const q = search.value.toLowerCase();
          return products.filter(p =>
            p.name.toLowerCase().includes(q) || p.category.toLowerCase().includes(q)
          );
        });
        const router = useRouter();
        const goDetail = id => router.push('/product/' + id);
        return { search, filtered, goDetail };
      }
    };

    const ProductDetail = {
      template: `
        <div>
          <h1 style="margin-bottom:20px;">📦 商品详情</h1>
          <div class="card detail" v-if="product">
            <img :src="'https://picsum.photos/seed/' + product.id + '/400/300'" :alt="product.name" />
            <div class="info">
              <h2>{{ product.name }}</h2>
              <div style="color:#999;">分类:{{ product.category }}</div>
              <div class="price">¥{{ product.price }}</div>
              <div class="desc">{{ product.desc }}</div>
              <div style="margin-bottom:20px;color:#999;">库存:{{ product.stock > 0 ? product.stock : '已售罄' }}</div>
              <button class="btn" :disabled="product.stock === 0"
                @click="added = true; setTimeout(() => added = false, 1500);">
                {{ product.stock > 0 ? '加入购物车' : '已售罄' }}
              </button>
              <span v-if="added" style="color:#28a745;margin-left:15px;">✓ 已加入</span>
            </div>
          </div>
          <div class="not-found" v-else>
            <h2>商品不存在</h2>
            <router-link to="/products" class="back-link">← 返回商品列表</router-link>
          </div>
        </div>`,
      setup() {
        const route = useRoute();
        const product = computed(() => products.find(p => p.id === Number(route.params.id)));
        const added = ref(false);
        return { product, added };
      }
    };

    const OrdersPage = {
      template: `
        <div>
          <h1 style="margin-bottom:20px;">📋 订单管理</h1>
          <div class="card">
            <table>
              <tr style="border-bottom:2px solid #eee;">
                <th>订单号</th><th style="text-align:center;">商品</th>
                <th style="text-align:center;">金额</th><th style="text-align:center;">状态</th>
              </tr>
              <tr v-for="o in orders" :key="o.id">
                <td>{{ o.id }}</td>
                <td style="text-align:center;">{{ o.product }}</td>
                <td style="text-align:center;">¥{{ o.amount }}</td>
                <td style="text-align:center;">
                  <span :style="{
                    padding:'4px 12px',borderRadius:'12px',fontSize:'12px',color:'#fff',
                    background: o.status==='已发货'?'#28a745' : o.status==='待发货'?'#f39c12' : '#999'
                  }">{{ o.status }}</span>
                </td>
              </tr>
            </table>
          </div>
        </div>`
    };

    const NotFound = {
      template: `
        <div class="not-found">
          <h2>🚫 404 - 页面不存在</h2>
          <router-link to="/dashboard" class="back-link">← 回到概览</router-link>
        </div>`
    };

    // ====== 布局组件(必须在 routes 之前定义)======
    const Layout = {
      template: `
        <div class="layout">
          <aside class="sidebar">
            <h2>MiniMall 后台</h2>
            <nav>
              <router-link to="/dashboard">概览</router-link>
              <router-link to="/products">商品</router-link>
              <router-link to="/orders">订单</router-link>
            </nav>
          </aside>
          <main class="content">
            <router-view></router-view>
          </main>
        </div>`
    };

    // ====== 路由配置 ======
    const routes = [
      {
        path: '/',
        component: Layout,
        children: [
          { path: '',              redirect: '/dashboard' },
          { path: 'dashboard',     component: Dashboard },
          { path: 'products',      component: ProductList },
          { path: 'product/:id',   component: ProductDetail },
          { path: 'orders',        component: OrdersPage },
          { path: ':pathMatch(.*)*', component: NotFound },
        ]
      }
    ];

    const router = createRouter({
      history: createWebHashHistory(),
      routes
    });

    // ====== 挂载 ======
    const app = createApp({ template: '<router-view></router-view>' });
    app.use(router);
    app.mount('#app');
  </script>
</body>
</html>

跑起来观察三件事:① 点左侧"商品/订单"------地址栏在 #/products、#/orders 间变,中间内容换但侧边栏不重建、页面不刷新;② 点任一商品卡------地址变 #/product/3,详情页从 route.params.id 拿到 3 号商品;③ 当前菜单项自动高亮(active-class)。这三件正是所有后台框架最核心的日常。


本篇覆盖要点清单

  • 路由的本质:一张「URL → 组件」对照表,地址变就换组件、不刷新整页
  • 零构建跑路由:CDN 引 vue-router + createRouter + createWebHashHistory
  • 路由表 routes:path/component/redirect/兜底 /:catchAll(.*)
  • 导航两件套:<router-link>(入口)+ <router-view>(出口),自动高亮 router-link-active
  • 动态路由 /product/:id + useRoute().params(值是字符串,用前 Number)
  • 编程式导航:useRouter().push/replace/back,push 能后退、replace 不能
  • 嵌套路由 children + 嵌套 router-view:搭"侧边栏+内容区"后台布局
  • 路由守卫 beforeEach + meta:进页前拦截(接 Day 14 权限路由)
  • 监听 route 动态改标题(接 Day 20 SEO)
  • 六个坑:useRoute/useRouter 别混、只在 setup 里用、hash vs history、子 path 不带斜杠、params 是字符串、参数变化组件复用需 watch

动手任务

  1. 给概览页加一个"最近订单"表格(提示:复用 OrderList 里的 ORDERS,取 ORDERS.slice(0,3))。
  2. 让详情页的"加入购物车"按钮把商品 id 存进 localStorage,再在概览页顶部显示"购物车件数"(提示:跨页面共享状态------你会自然发现"路由管跳转、但跨页共享数据还缺个东西",那正是明天的 Pinia)。
  3. 打开守卫:给 /orders 挂 meta:{needAuth:true},在 beforeEach 里判断 localStorage.token 不存在就 return '/',体验"没登录被弹回"。

明天预告(Day 10)

明天讲 Pinia 状态管理 :今天你已经踩到痛点------购物车想在概览页、商品页、详情页都能读写,用 localStorage 传太笨、组件间传来传去更乱。Pinia 就是"放全局共享数据的地方":一处定义,处处读写,专门管全局用户信息、菜单、购物车状态。学完今天的路由 + 明天的 Pinia,一个能跳转、能共享状态的后台框架就成型了。

相关推荐
Sapphire~1 小时前
odoo-095 视图的重写规则(全部重写还是部分改写)
开发语言·javascript·ecmascript
肖永威1 小时前
python版微电网仿真工具的前端托管选型:从http.server到FastAPI StaticFiles
前端·python·http·fastapi
风骏时光牛马1 小时前
AI插件开发与效能提效工具箱
前端
IT_陈寒1 小时前
为什么你应该学习Java?
前端·人工智能·后端
weixin_417396831 小时前
监控画面卡顿与录像缺失:码率、NVR与存储容量的排查顺序
前端·网络·技术·酒店·综合布线·弱电
曹牧2 小时前
Eclipse 工作空间(Workspace)的默认 JDK 配置
前端
其实防守也摸鱼3 小时前
Cylab在线靶场——挑战图书馆初学者指南
服务器·前端·数据库·chrome·安全·网络安全·靶场
拆房老料5 小时前
【第四部分:Agentic 智能体设计模式】16. 一个 Prompt 搞不定复杂任务?试试 Prompt Chaining
java·前端·人工智能·设计模式·prompt
曹牧10 小时前
C#:空合并运算符
java·前端·数据库