Commit 0b5f754b by chunhong.mu

feat(*): 为四个包搭建基础布局与示例页

- admin: 顶部导航 + 左侧双栏(一级 rail + 二级菜单),业务页改为 basic-layout 子路由
- web: 顶部导航 + 左侧单栏,布局组件全部用 UnoCSS 工具类
- mini: 新增三 tabBar(首页 / 消息 / 我的)与 message、mine 两个页面
- site: 新增 layouts/default.vue 顶部导航布局,app.vue 改用 NuxtLayout
- 各包补 api/demo.ts + services/demo-service.ts,示例页走完 pages→services→api 分层
- 修 mini password 页用 navigateTo 跳 tabBar 页会静默失败的问题,改为 switchTab
- 补 admin 缺失的 handleLogout
- 同步根与四个子包 CLAUDE.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parent 8d5ae395
Showing with 1479 additions and 92 deletions
...@@ -71,6 +71,21 @@ frontend-hub/ ...@@ -71,6 +71,21 @@ frontend-hub/
`admin` / `web` / `mini``@` 别名指向各自的 `src/``site` 用 Nuxt 的 `~/` `admin` / `web` / `mini``@` 别名指向各自的 `src/``site` 用 Nuxt 的 `~/`
### 基础布局与示例页
四个包都已搭好骨架,各带一个「Hello World + 一次接口请求」的示例页,照它的形状写新页面:
| 包 | 布局 | 布局文件 | 导航配置 | 示例页 |
|----|------|---------|---------|--------|
| `admin` | 顶部导航 + **左侧双栏**(一级 rail + 二级菜单) | `src/components/layout/` | `src/config/menu.ts` | `src/pages/index.vue` |
| `web` | 顶部导航 + 左侧单栏 | `src/components/layout/` | `src/config/menu.ts` | `src/pages/index.vue` |
| `mini` | **三 tabBar**:首页 / 消息 / 我的 | `src/pages.json``tabBar` | 同上 | `src/pages/home/index.vue` |
| `site` | 顶部导航 + 页脚 | `layouts/default.vue` | `config/index.ts``NAV_LINKS` | `pages/index.vue` |
- `admin` / `web`:业务页是 `basic-layout.vue`**子路由**,新增页面要同时改 `router/index.ts``config/menu.ts`
- `mini`:tabBar 页只能用 `uni.switchTab` / `uni.reLaunch` 打开,`navigateTo` 会静默失败
- 示例接口在各包 `api/demo.ts`,接口名是占位值(`getHomeData` / `/demo/hello`),**接项目时必须替换**
## ⚠️ 动手前必读:每个子包有自己的 CLAUDE.md ## ⚠️ 动手前必读:每个子包有自己的 CLAUDE.md
**修改任何子包的文件之前,先读该包根目录的 `CLAUDE.md`**(用 Read 工具显式读一遍,不要假设已加载)。里面写的是该包独有的约定和踩过的坑,与本文件不重复: **修改任何子包的文件之前,先读该包根目录的 `CLAUDE.md`**(用 Read 工具显式读一遍,不要假设已加载)。里面写的是该包独有的约定和踩过的坑,与本文件不重复:
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
| 构建 | Vite 5(`vite.config.ts`) | | 构建 | Vite 5(`vite.config.ts`) |
| 路由 | Vue Router,配置在 `src/router/index.ts` | | 路由 | Vue Router,配置在 `src/router/index.ts` |
| 状态 | Pinia,`src/stores/` | | 状态 | Pinia,`src/stores/` |
| 样式 | **纯手写 CSS**(scoped),无 UnoCSS、无 CSS 框架 | | 样式 | **纯手写 CSS**(scoped),无 UnoCSS、无 CSS 框架;全局变量在 `src/assets/styles/index.css` |
| SDK | `src/api/request.ts``webAdapter` + `src/config/index.ts` | | SDK | `src/api/request.ts``webAdapter` + `src/config/index.ts` |
启动:`pnpm -F admin dev`,或根目录 `pnpm dev:project --project=admin --env=test` 启动:`pnpm -F admin dev`,或根目录 `pnpm dev:project --project=admin --env=test`
...@@ -23,12 +23,26 @@ import { sdk } from "@/src/api/request" // ❌ 改造前的历史写法,已全 ...@@ -23,12 +23,26 @@ import { sdk } from "@/src/api/request" // ❌ 改造前的历史写法,已全
`vite.config.ts` 的 alias 与 `tsconfig.json``paths` 必须同时改,否则类型能过但运行时挂(或反之)。 `vite.config.ts` 的 alias 与 `tsconfig.json``paths` 必须同时改,否则类型能过但运行时挂(或反之)。
## 新增页面的两步 ## 布局:顶部导航 + 左侧双栏
```
src/components/layout/
├── basic-layout.vue # 骨架:AppHeader 在上,AppSidebar + <RouterView> 在下
├── app-header.vue # 顶部导航:品牌 + 快捷入口 + 用户名 + 退出登录
└── app-sidebar.vue # 左侧双栏:第一栏一级菜单(深色 rail),第二栏该组的二级菜单
```
菜单结构在 **`src/config/menu.ts`**`menuGroups` 双栏用、`topNavLinks` 顶部用),图标是 SVG 的 `path d` 字符串,**没有图标库**,加菜单就照抄一段 path。
侧边栏第二栏靠 `watch(route.path)` 反查所属一级菜单来同步,所以**菜单里的 `path` 必须和 router 里登记的完全一致**,否则刷新后高亮不上、二级栏停在第一组。
## 新增页面的三步
1.`src/pages/<feature>/index.vue` 建页面 1.`src/pages/<feature>/index.vue` 建页面
2.`src/router/index.ts``routes` 里登记,并写 `meta.title``meta.requiresAuth` 2.`src/router/index.ts` 里登记 —— 业务页要挂在 `basic-layout.vue` 那条路由的 **`children`** 下(子路由 `path` 不带前导 `/`),并写 `meta.title``meta.requiresAuth`;登录页这类无框页面放在 layout 之外
3. 需要出现在导航里,就在 `src/config/menu.ts` 加一项
`router.beforeEach` 会用 `meta.title``document.title`(兜底取 `config.appName`),用 `meta.requiresAuth` + `TokenManager.hasToken()` 做鉴权跳转。**漏写 `requiresAuth` 等于该页免登录** `router.beforeEach` 会用 `meta.title``document.title`(兜底取 `config.appName`),用 `meta.requiresAuth` + `TokenManager.hasToken()` 做鉴权跳转。**漏写 `requiresAuth` 等于该页免登录**`meta` 在嵌套路由下是父子合并、子级覆盖,父 layout 路由上不写也不影响子级)
## Toast 是自己实现的,不是组件库的 ## Toast 是自己实现的,不是组件库的
...@@ -42,10 +56,15 @@ import { sdk } from "@/src/api/request" // ❌ 改造前的历史写法,已全 ...@@ -42,10 +56,15 @@ import { sdk } from "@/src/api/request" // ❌ 改造前的历史写法,已全
src/ src/
├── api/request.ts # SDK 实例 + loginService/verifyService + initToken() ├── api/request.ts # SDK 实例 + loginService/verifyService + initToken()
├── api/auth.ts # 登录相关接口,纯委托给 SDK 服务 ├── api/auth.ts # 登录相关接口,纯委托给 SDK 服务
├── api/demo.ts # 示例接口,演示 domainService 调用姿势(TODO 替换)
├── services/ # handleXxx,组合 api + store + Toast ├── services/ # handleXxx,组合 api + store + Toast
├── stores/auth.ts # token 与 userInfo ├── stores/auth.ts # token 与 userInfo
├── config/index.ts # 唯一读 import.meta.env 的地方 ├── config/index.ts # 唯一读 import.meta.env 的地方
├── config/menu.ts # 导航结构(与 router 成对维护)
├── components/layout/ # 布局组件
└── utils/toast.ts # DOM Toast └── utils/toast.ts # DOM Toast
``` ```
示例页 `src/pages/index.vue` 是「Hello World + 一次 SDK 请求」的四层调用样板(`pages → services → api → @common/sdk`),照它的形状写新页面就行。
`main.ts` 在挂载前调 `initToken()` 恢复本地登录态。 `main.ts` 在挂载前调 `initToken()` 恢复本地登录态。
import { sdk } from '@/api/request'
import { config } from '@/config'
/** 示例接口返回结构 */
export interface DemoData {
[key: string]: any
}
/**
* 示例 SDK 请求
*
* TODO: 替换为项目实际的子项目名 / 服务名 / 接口名(接口名是 camelCase)。
* 这里只演示调用姿势:api 层只负责发请求,不碰 store、不弹提示、不跳路由。
*/
export function fetchDemoData() {
return sdk.core
.domainService(
config.domainService.subProjectName,
config.domainService.serviceName,
'getHomeData',
)
.request<any, any, DemoData>('get', {})
}
...@@ -7,4 +7,8 @@ export { ...@@ -7,4 +7,8 @@ export {
export type { LoginRegisterResult, VerifyImageResult } from './auth' export type { LoginRegisterResult, VerifyImageResult } from './auth'
export { fetchDemoData } from './demo'
export type { DemoData } from './demo'
export { getSdk, initToken, loginService, sdk, verifyService } from './request' export { getSdk, initToken, loginService, sdk, verifyService } from './request'
/* 全局基础样式与设计变量(admin 不引入 CSS 框架,变量集中在这里) */
:root {
--color-primary: #4c6ef5;
--color-primary-soft: #edf0ff;
--color-text: #1f2329;
--color-text-weak: #8a919f;
--color-border: #e5e6eb;
--color-bg-page: #f5f7fa;
--color-bg-card: #ffffff;
--color-bg-rail: #1f2733;
--header-height: 56px;
--rail-width: 84px;
--submenu-width: 176px;
--radius: 6px;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--color-text);
background-color: var(--color-bg-page);
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: inherit;
}
export { default as AppHeader } from './layout/app-header.vue'
export { default as AppSidebar } from './layout/app-sidebar.vue'
export { default as BasicLayout } from './layout/basic-layout.vue'
<template>
<header class="app-header">
<div class="app-header__brand">
<span class="app-header__logo">A</span>
<span class="app-header__name">{{ config.appName }}</span>
</div>
<nav class="app-header__nav">
<RouterLink
v-for="link in topNavLinks"
:key="link.path"
class="app-header__link"
:class="{ 'is-active': route.path === link.path }"
:to="link.path"
>
{{ link.title }}
</RouterLink>
</nav>
<div class="app-header__right">
<span class="app-header__user">{{ userName }}</span>
<button class="app-header__logout" type="button" @click="onLogout">
退出登录
</button>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { config, topNavLinks } from '@/config'
import { handleLogout } from '@/services'
import { useAuthStore } from '@/stores'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const userName = computed(() => authStore.userInfo?.username || '未登录')
function onLogout() {
handleLogout()
router.push('/login')
}
</script>
<style scoped>
.app-header {
display: flex;
align-items: center;
height: var(--header-height);
padding: 0 20px;
background: var(--color-bg-card);
border-bottom: 1px solid var(--color-border);
}
.app-header__brand {
display: flex;
align-items: center;
gap: 10px;
width: calc(var(--rail-width) + var(--submenu-width) - 20px);
flex-shrink: 0;
}
.app-header__logo {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius);
background: var(--color-primary);
color: #fff;
font-weight: 600;
}
.app-header__name {
font-size: 16px;
font-weight: 600;
}
.app-header__nav {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
}
.app-header__link {
padding: 6px 12px;
border-radius: var(--radius);
color: var(--color-text-weak);
}
.app-header__link:hover {
color: var(--color-text);
background: var(--color-bg-page);
}
.app-header__link.is-active {
color: var(--color-primary);
background: var(--color-primary-soft);
}
.app-header__right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.app-header__user {
color: var(--color-text-weak);
}
.app-header__logout {
height: 30px;
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
font-size: 13px;
cursor: pointer;
}
.app-header__logout:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
</style>
<template>
<aside class="sidebar">
<!-- 第一栏:一级菜单 -->
<nav class="sidebar__rail">
<button
v-for="group in menuGroups"
:key="group.key"
class="rail-item"
:class="{ 'is-active': group.key === activeKey }"
type="button"
@click="activeKey = group.key"
>
<svg class="rail-item__icon" viewBox="0 0 24 24" aria-hidden="true">
<path :d="group.icon" fill="currentColor" />
</svg>
<span class="rail-item__text">{{ group.title }}</span>
</button>
</nav>
<!-- 第二栏:当前一级菜单下的二级菜单 -->
<nav class="sidebar__submenu">
<div class="submenu__title">{{ activeGroup?.title }}</div>
<RouterLink
v-for="item in activeGroup?.children"
:key="item.path"
class="submenu__item"
:class="{ 'is-active': route.path === item.path }"
:to="item.path"
>
{{ item.title }}
</RouterLink>
</nav>
</aside>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { menuGroups } from '@/config'
const route = useRoute()
const activeKey = ref(menuGroups[0]?.key ?? '')
const activeGroup = computed(() => menuGroups.find(g => g.key === activeKey.value))
// 二级菜单栏跟随当前路由自动切换(直接刷新 / 从其它入口跳转时也要对上)
watch(
() => route.path,
(path) => {
const hit = menuGroups.find(g => g.children.some(child => child.path === path))
if (hit) {
activeKey.value = hit.key
}
},
{ immediate: true },
)
</script>
<style scoped>
.sidebar {
display: flex;
flex-shrink: 0;
height: 100%;
}
/* 第一栏 */
.sidebar__rail {
display: flex;
flex-direction: column;
gap: 4px;
width: var(--rail-width);
padding: 12px 8px;
background: var(--color-bg-rail);
}
.rail-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 0;
border: none;
border-radius: var(--radius);
background: transparent;
color: rgb(255 255 255 / 65%);
font-size: 12px;
cursor: pointer;
}
.rail-item:hover {
color: #fff;
background: rgb(255 255 255 / 8%);
}
.rail-item.is-active {
color: #fff;
background: var(--color-primary);
}
.rail-item__icon {
width: 20px;
height: 20px;
}
/* 第二栏 */
.sidebar__submenu {
display: flex;
flex-direction: column;
gap: 2px;
width: var(--submenu-width);
padding: 12px 8px;
background: var(--color-bg-card);
border-right: 1px solid var(--color-border);
}
.submenu__title {
padding: 4px 12px 10px;
color: var(--color-text-weak);
font-size: 12px;
}
.submenu__item {
padding: 8px 12px;
border-radius: var(--radius);
color: var(--color-text);
}
.submenu__item:hover {
background: var(--color-bg-page);
}
.submenu__item.is-active {
color: var(--color-primary);
background: var(--color-primary-soft);
font-weight: 500;
}
</style>
<template>
<div class="layout">
<AppHeader />
<div class="layout__body">
<AppSidebar />
<main class="layout__main">
<div class="layout__breadcrumb">
{{ pageTitle }}
</div>
<div class="layout__content">
<RouterView />
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppHeader from './app-header.vue'
import AppSidebar from './app-sidebar.vue'
const route = useRoute()
const pageTitle = computed(() => (route.meta.title as string) || '')
</script>
<style scoped>
.layout {
display: flex;
flex-direction: column;
height: 100%;
}
.layout__body {
display: flex;
flex: 1;
min-height: 0;
}
.layout__main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.layout__breadcrumb {
padding: 14px 20px 0;
color: var(--color-text-weak);
font-size: 13px;
}
.layout__content {
flex: 1;
padding: 12px 20px 20px;
overflow: auto;
}
</style>
...@@ -29,3 +29,6 @@ export const config: AdminConfig = { ...@@ -29,3 +29,6 @@ export const config: AdminConfig = {
/** 免登录白名单(路由 path) */ /** 免登录白名单(路由 path) */
export const noNeedAuthPages = ['/login'] export const noNeedAuthPages = ['/login']
export { menuGroups, topNavLinks } from './menu'
export type { MenuGroup, MenuItem } from './menu'
/**
* 侧边栏与顶部导航的菜单配置
*
* 这里只描述「导航结构」,路由本身仍要在 `src/router/index.ts` 登记。
* 两处的 path 必须一致,否则菜单点不动(或高亮不上)。
*/
/** 二级菜单项 */
export interface MenuItem {
/** 展示名称 */
title: string
/** 路由 path,需与 router 里登记的完全一致 */
path: string
}
/** 一级菜单项(侧边栏第一栏) */
export interface MenuGroup {
/** 唯一标识,用于记录当前展开的一级菜单 */
key: string
title: string
/** 图标:SVG 的 path d 属性,避免引入图标库 */
icon: string
children: MenuItem[]
}
/**
* 左侧双栏菜单
*
* TODO: 替换为项目实际的菜单结构
*/
export const menuGroups: MenuGroup[] = [
{
key: "workbench",
title: "工作台",
icon: "M3 3h8v8H3V3zm10 0h8v8h-8V3zM3 13h8v8H3v-8zm10 0h8v8h-8v-8z",
children: [
{ title: "首页", path: "/" },
],
},
{
key: "help",
title: "帮助",
icon: "M12 2a10 10 0 100 20 10 10 0 000-20zm1 15h-2v-2h2v2zm1.6-6.7l-.9.9c-.5.5-.7.9-.7 1.8h-2v-.5c0-.7.3-1.4.8-1.9l1.2-1.2c.3-.3.5-.7.5-1.1a1.5 1.5 0 00-3 0H9.5a3.5 3.5 0 017 0c0 .7-.3 1.4-.9 2z",
children: [
{ title: "关于模板", path: "/about" },
],
},
]
/** 顶部导航的快捷入口 */
export const topNavLinks: MenuItem[] = [
{ title: "首页", path: "/" },
{ title: "关于模板", path: "/about" },
]
...@@ -3,6 +3,7 @@ import { createApp } from 'vue' ...@@ -3,6 +3,7 @@ import { createApp } from 'vue'
import { initToken } from '@/api/request' import { initToken } from '@/api/request'
import App from '@/App.vue' import App from '@/App.vue'
import router from '@/router' import router from '@/router'
import '@/assets/styles/index.css'
// 恢复本地 Token 登录态(SDK 已在 api/request.ts 中初始化) // 恢复本地 Token 登录态(SDK 已在 api/request.ts 中初始化)
initToken() initToken()
......
<template>
<div class="about">
<h1 class="about__title">关于模板</h1>
<p class="about__desc">
这是第二个路由,用来验证「顶部导航 + 左侧双栏」在多页面下的高亮与跳转。
</p>
<ul class="about__list">
<li>菜单结构在 <code>src/config/menu.ts</code></li>
<li>路由在 <code>src/router/index.ts</code>,新增页面两处都要登记</li>
<li>布局组件在 <code>src/components/layout/</code></li>
</ul>
</div>
</template>
<style scoped>
.about {
padding: 20px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
}
.about__title {
margin: 0 0 8px;
font-size: 20px;
}
.about__desc {
margin: 0 0 16px;
color: var(--color-text-weak);
}
.about__list {
margin: 0;
padding-left: 20px;
line-height: 2;
}
code {
padding: 1px 4px;
border-radius: 3px;
background: var(--color-bg-page);
}
</style>
<template> <template>
<div class="home-container"> <div class="home">
<h1>{{ config.appName }}</h1> <section class="card">
<button class="test-btn" @click="testApiCall">测试 SDK 请求(携带 Token)</button> <h1 class="card__title">Hello World</h1>
<div v-if="result" class="result"> <p class="card__desc">
<h3>请求结果:</h3> 这是 {{ config.appName }} 的示例页:顶部导航 + 左侧双栏布局 + 一次 SDK 请求。
<pre>{{ result }}</pre> </p>
</div> </section>
<section class="card">
<h2 class="card__subtitle">SDK 请求示例</h2>
<p class="card__hint">
<code>pages → services → api → @common/sdk</code> 四层,接口名在
<code>src/api/demo.ts</code> 里替换。
</p>
<button class="btn" type="button" :disabled="loading" @click="onFetch">
{{ loading ? "请求中..." : "发起请求" }}
</button>
<pre v-if="result" class="result">{{ result }}</pre>
</section>
<section class="card">
<h2 class="card__subtitle">环境信息</h2>
<dl class="info">
<dt>当前环境</dt>
<dd>{{ config.logEnv }}</dd>
<dt>API 地址</dt>
<dd>{{ config.uniplatApi || "未配置(请填 VITE_APP_UNIPLAT)" }}</dd>
<dt>子项目 / 服务</dt>
<dd>{{ config.domainService.subProjectName }} / {{ config.domainService.serviceName }}</dd>
</dl>
</section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useLoading } from '@common/vue-kit/composables'
import { ref } from 'vue' import { ref } from 'vue'
import { sdk } from '@/api/request'
import { config } from '@/config' import { config } from '@/config'
import { handleFetchDemoData } from '@/services'
const result = ref<any>(null) const { loading, wrap } = useLoading()
const result = ref<string>('')
async function testApiCall() {
try { async function onFetch() {
// 示例:调用一个匿名接口。请替换为项目实际的子项目名/服务名/接口名 const data = await wrap(() => handleFetchDemoData())
const res = await sdk.core result.value = data ? JSON.stringify(data, null, 2) : ''
.domainService(
config.domainService.subProjectName,
`anonymous/${config.domainService.serviceName}`,
'test',
)
.get()
result.value = res
}
catch (e: any) {
result.value = { error: e?.message || '请求失败' }
}
} }
</script> </script>
<style scoped> <style scoped>
.home-container { .home {
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
padding: 20px; padding: 20px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
} }
.test-btn { .card__title {
padding: 12px 24px; margin: 0 0 8px;
background: #667eea; font-size: 22px;
color: #fff; }
.card__subtitle {
margin: 0 0 8px;
font-size: 16px;
}
.card__desc,
.card__hint {
margin: 0 0 16px;
color: var(--color-text-weak);
}
code {
padding: 1px 4px;
border-radius: 3px;
background: var(--color-bg-page);
}
.btn {
height: 34px;
padding: 0 18px;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius);
cursor: pointer; background: var(--color-primary);
color: #fff;
font-size: 14px; font-size: 14px;
cursor: pointer;
} }
.result { .btn:disabled {
margin-top: 20px; opacity: 0.6;
padding: 16px; cursor: not-allowed;
background: #f5f5f5;
border-radius: 4px;
} }
.result pre { .result {
margin: 16px 0 0;
padding: 12px;
border-radius: var(--radius);
background: var(--color-bg-page);
font-size: 12px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
} }
.info {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px 0;
margin: 0;
}
.info dt {
color: var(--color-text-weak);
}
.info dd {
margin: 0;
word-break: break-all;
}
</style> </style>
...@@ -10,11 +10,24 @@ const routes: RouteRecordRaw[] = [ ...@@ -10,11 +10,24 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/login/index.vue'), component: () => import('@/pages/login/index.vue'),
meta: { title: '登录', requiresAuth: false }, meta: { title: '登录', requiresAuth: false },
}, },
// 业务页面挂在 BasicLayout 下(顶部导航 + 左侧双栏),登录页在布局之外
{ {
path: '/', path: '/',
name: 'Home', component: () => import('@/components/layout/basic-layout.vue'),
component: () => import('@/pages/index.vue'), children: [
meta: { title: '首页', requiresAuth: true }, {
path: '',
name: 'Home',
component: () => import('@/pages/index.vue'),
meta: { title: '首页', requiresAuth: true },
},
{
path: 'about',
name: 'About',
component: () => import('@/pages/about/index.vue'),
meta: { title: '关于模板', requiresAuth: true },
},
],
}, },
] ]
......
...@@ -66,3 +66,8 @@ export async function handlePasswordLogin( ...@@ -66,3 +66,8 @@ export async function handlePasswordLogin(
return false return false
} }
} }
/** 退出登录:清 store + 清本地 Token */
export function handleLogout() {
useAuthStore().logout()
}
import type { DemoData } from '@/api/demo'
import { resolveSdkError } from '@common/sdk'
import { fetchDemoData } from '@/api/demo'
import { Toast } from '@/utils/toast'
/**
* 拉取示例数据
*
* service 层负责:调 api + 统一错误提示。失败返回 null,页面只管渲染。
* SDK 的业务错误 reject 的是纯字符串,必须用 resolveSdkError 提取。
*/
export async function handleFetchDemoData(): Promise<DemoData | null> {
try {
return await fetchDemoData()
}
catch (e: any) {
Toast.error(resolveSdkError(e, '请求失败'))
return null
}
}
export { export {
handleLogout,
handlePasswordLogin, handlePasswordLogin,
handleSendSmsCode, handleSendSmsCode,
handleSmsLogin, handleSmsLogin,
} from './auth-service' } from './auth-service'
export { handleFetchDemoData } from './demo-service'
...@@ -22,7 +22,21 @@ pnpm build:project --project=mini --env=production -p mp-weixin ...@@ -22,7 +22,21 @@ pnpm build:project --project=mini --env=production -p mp-weixin
1.`src/pages/<feature>/index.vue` 1.`src/pages/<feature>/index.vue`
2.`src/pages.json``pages` 数组里加一项 2.`src/pages.json``pages` 数组里加一项
当前 7 个页面(home / login/index / login/login / settings / profile / verify / password),**无分包、无 tabBar**。要加分包用 `subPackages` 当前 9 个页面(home / message / mine / login/index / login/login / settings / profile / verify / password),**无分包**。要加分包用 `subPackages`
## tabBar 是三个页面:首页 / 消息 / 我的
```
pages/home/index → 首页(同时是启动页与示例页)
pages/message/index → 消息
pages/mine/index → 我的
```
三条硬约束:
1. **tabBar 页面不能用 `uni.navigateTo` 打开**,会静默失败。跳 tab 页只能 `uni.switchTab``uni.reLaunch``pages/password/index.vue` 就踩过这个坑,已改成 `switchTab`
2. tabBar 页必须同时出现在 `pages` 数组和 `tabBar.list` 里,两处 path 写法不同:`pages` 里是 `"path": "pages/home/index"``tabBar.list` 里是 `"pagePath": "pages/home/index"`
3. 当前 **没有配 `iconPath` / `selectedIconPath`**,是纯文字 tabBar(微信端允许省略图标)。TODO: 补图标时图片要放进 `src/static/`,用相对项目根的路径,且不能是网络地址
## SDK 必须走 `utils/sdk.ts` 的懒加载单例 ## SDK 必须走 `utils/sdk.ts` 的懒加载单例
......
...@@ -7,6 +7,19 @@ ...@@ -7,6 +7,19 @@
} }
}, },
{ {
"path": "pages/message/index",
"style": {
"navigationBarTitleText": "消息"
}
},
{
"path": "pages/mine/index",
"style": {
"navigationBarTitleText": "我的",
"navigationBarBackgroundColor": "#f5f7fa"
}
},
{
"path": "pages/login/index", "path": "pages/login/index",
"style": { "style": {
"navigationBarTitleText": "", "navigationBarTitleText": "",
...@@ -48,6 +61,26 @@ ...@@ -48,6 +61,26 @@
} }
} }
], ],
"tabBar": {
"color": "#8a919f",
"selectedColor": "#409eff",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/home/index",
"text": "首页"
},
{
"pagePath": "pages/message/index",
"text": "消息"
},
{
"pagePath": "pages/mine/index",
"text": "我的"
}
]
},
"globalStyle": { "globalStyle": {
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"navigationBarTitleText": "", "navigationBarTitleText": "",
......
<template> <template>
<view class="home-page"> <view class="home-page">
<view class="header"> <view class="header">
<text class="title">欢迎使用 {{ config.appName }}</text> <text class="title">Hello World</text>
<text class="subtitle">{{ config.appName }} · 三 tabBar 示例</text>
</view> </view>
<view class="content"> <view class="content">
...@@ -68,16 +69,24 @@ async function handleFetchData() { ...@@ -68,16 +69,24 @@ async function handleFetchData() {
} }
.header { .header {
display: flex;
flex-direction: column;
gap: 12rpx;
padding: 40rpx 0; padding: 40rpx 0;
text-align: center; text-align: center;
} }
.title { .title {
font-size: 40rpx; font-size: 44rpx;
font-weight: bold; font-weight: bold;
color: #333; color: #333;
} }
.subtitle {
font-size: 26rpx;
color: #999;
}
.content { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
......
<template>
<view class="message-page">
<view v-if="list.length" class="list">
<view v-for="item in list" :key="item.id" class="item">
<view class="item-head">
<text class="item-title">{{ item.title }}</text>
<text class="item-time">{{ item.time }}</text>
</view>
<text class="item-content">{{ item.content }}</text>
</view>
</view>
<view v-else class="empty">
<text class="empty-text">暂无消息</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from "vue"
interface MessageItem {
id: string
title: string
content: string
time: string
}
// TODO: 替换为真实接口(api/ 下新增函数,页面通过 services/ 调用)
const list = ref<MessageItem[]>([
{ id: "1", title: "系统通知", content: "这是 tabBar 第二个页面的占位数据。", time: "刚刚" },
{ id: "2", title: "活动提醒", content: "接入真实接口后删掉这份 mock。", time: "昨天" },
])
</script>
<style lang="scss" scoped>
.message-page {
min-height: 100vh;
padding: 24rpx;
background-color: #f5f7fa;
}
.list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.item {
padding: 28rpx;
background-color: #fff;
border-radius: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.item-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.item-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.item-time {
font-size: 24rpx;
color: #999;
}
.item-content {
font-size: 26rpx;
color: #666;
}
.empty {
padding: 200rpx 0;
text-align: center;
}
.empty-text {
font-size: 28rpx;
color: #999;
}
</style>
<template>
<view class="mine-page">
<!-- 用户信息区 -->
<view class="profile" @click="handleProfileTap">
<image v-if="avatar" class="profile-avatar" :src="avatar" mode="aspectFill" />
<view v-else class="profile-avatar profile-avatar--empty">
<text class="profile-avatar-text">{{ nameInitial }}</text>
</view>
<view class="profile-info">
<text class="profile-name">{{ displayName }}</text>
<text class="profile-tip">{{ isLoggedIn ? "点击查看个人资料" : "点击登录" }}</text>
</view>
</view>
<!-- 功能入口 -->
<view class="group">
<view
v-for="item in entries"
:key="item.route"
class="entry"
@click="toPage(item.route)"
>
<text class="entry-label">{{ item.label }}</text>
<text class="entry-arrow"></text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { useUserInfo } from "@/stores/useUserInfo"
const userStore = useUserInfo()
const isLoggedIn = computed(() => userStore.isLoggedIn)
const displayName = computed(() => userStore.get("realname") || userStore.get("mobile") || "未登录")
const avatar = computed(() => userStore.get("avatar"))
const nameInitial = computed(() => displayName.value.slice(0, 1))
const entries = [
{ label: "个人资料", route: "/pages/profile/index" },
{ label: "设置", route: "/pages/settings/index" },
]
function toPage(route: string) {
uni.navigateTo({ url: route })
}
function handleProfileTap() {
if (!isLoggedIn.value) {
uni.navigateTo({ url: "/pages/login/index" })
return
}
toPage("/pages/profile/index")
}
</script>
<style lang="scss" scoped>
.mine-page {
min-height: 100vh;
padding: 24rpx;
background-color: #f5f7fa;
}
.profile {
display: flex;
align-items: center;
gap: 24rpx;
padding: 40rpx 32rpx;
background-color: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
}
.profile-avatar {
width: 112rpx;
height: 112rpx;
border-radius: 56rpx;
background-color: #e8eaf0;
}
.profile-avatar--empty {
display: flex;
align-items: center;
justify-content: center;
}
.profile-avatar-text {
font-size: 40rpx;
color: #8a919f;
}
.profile-info {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.profile-name {
font-size: 34rpx;
font-weight: 600;
color: #333;
}
.profile-tip {
font-size: 24rpx;
color: #999;
}
.group {
background-color: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.entry {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid #f0f1f5;
&:last-child {
border-bottom: none;
}
}
.entry-label {
font-size: 28rpx;
color: #333;
}
.entry-arrow {
font-size: 32rpx;
color: #bdbdc7;
}
</style>
...@@ -275,7 +275,8 @@ function confirm() { ...@@ -275,7 +275,8 @@ function confirm() {
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: "密码重置成功", icon: "success" }) uni.showToast({ title: "密码重置成功", icon: "success" })
setTimeout(() => { setTimeout(() => {
uni.navigateTo({ url: "/pages/home/index" }) // 首页是 tabBar 页,只能用 switchTab / reLaunch,navigateTo 会失败
uni.switchTab({ url: "/pages/home/index" })
}, 2000) }, 2000)
}, 1500) }, 1500)
} }
......
...@@ -52,6 +52,21 @@ if (import.meta.client) { ...@@ -52,6 +52,21 @@ if (import.meta.client) {
`api/request.ts` 的请求拦截器已经这么做了,注入 token 只在客户端发生。写新代码时同理。 `api/request.ts` 的请求拦截器已经这么做了,注入 token 只在客户端发生。写新代码时同理。
## 布局:顶部导航
`layouts/default.vue` 是唯一布局(sticky 顶部导航 + 内容区 + 页脚),由 `app.vue``<NuxtLayout>` 套上:
```vue
<!-- app.vue -->
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
```
`layouts/` 是 Nuxt 约定目录,**新建 `layouts/xxx.vue` 后在页面里用 `definePageMeta({ layout: "xxx" })` 切换**,不用手动 import。导航项在 `config/index.ts``NAV_LINKS`,高亮判断是 `route.path === link.path`
样式全部用 UnoCSS 工具类(`@unocss/nuxt` 默认只开 presetUno,**没有 attributify**,别写 `<div flex>`——那是 web 包才有的)。
## 目录是 Nuxt 约定,没有 src/ ## 目录是 Nuxt 约定,没有 src/
页面是**文件路由**——`pages/about.vue` 自动就是 `/about`,不需要注册路由表。 页面是**文件路由**——`pages/about.vue` 自动就是 `/about`,不需要注册路由表。
......
import { request } from '~/api/request'
/** 示例接口返回结构 */
export interface DemoData {
[key: string]: any
}
/**
* 示例请求
*
* 注意:site 是唯一不用 @common/sdk 的包,走 axios + Nuxt runtimeConfig。
* baseURL 由 NUXT_PUBLIC_API_BASE_URL 注入,未配置时这个请求必然失败。
*
* TODO: 替换为项目实际的接口路径。
*/
export function fetchDemoData() {
return request.get<any, DemoData>('/demo/hello')
}
export { fetchDemoData } from './demo'
export type { DemoData } from './demo'
export { request } from './request' export { request } from './request'
<template> <template>
<div> <NuxtLayout>
<NuxtPage /> <NuxtPage />
</div> </NuxtLayout>
</template> </template>
...@@ -6,3 +6,9 @@ export const SITE_NAME = '官网' ...@@ -6,3 +6,9 @@ export const SITE_NAME = '官网'
/** 免登录路由白名单 */ /** 免登录路由白名单 */
export const noNeedAuthPages = ['/'] export const noNeedAuthPages = ['/']
/** 顶部导航(TODO: 替换为项目实际栏目) */
export const NAV_LINKS: { title: string, path: string }[] = [
{ title: '首页', path: '/' },
{ title: '关于我们', path: '/about' },
]
<template>
<div class="min-h-screen flex flex-col bg-white text-gray-800">
<header class="sticky top-0 z-10 border-b border-gray-200 bg-white/90 backdrop-blur">
<div class="mx-auto max-w-1200px flex items-center gap-6 px-6 py-4">
<NuxtLink class="flex items-center gap-2" to="/">
<span class="h-7 w-7 flex items-center justify-center rounded bg-gray-900 text-sm text-white font-bold">
S
</span>
<span class="text-base font-semibold">{{ SITE_NAME }}</span>
</NuxtLink>
<nav class="flex flex-1 items-center gap-1">
<NuxtLink
v-for="link in NAV_LINKS"
:key="link.path"
class="rounded px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-900"
:class="route.path === link.path ? '!bg-gray-100 !text-gray-900' : ''"
:to="link.path"
>
{{ link.title }}
</NuxtLink>
</nav>
<a
class="border border-gray-300 rounded px-3 py-1.5 text-sm hover:border-gray-900"
href="#contact"
>
联系我们
</a>
</div>
</header>
<main class="mx-auto w-full max-w-1200px flex-1 px-6 py-8">
<slot />
</main>
<footer id="contact" class="border-t border-gray-200">
<div class="mx-auto max-w-1200px px-6 py-6 text-sm text-gray-400">
© {{ SITE_NAME }} — Nuxt 3 SSR 模板
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { NAV_LINKS, SITE_NAME } from '~/config'
const route = useRoute()
</script>
<template>
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-bold">关于我们</h1>
<p class="text-gray-500">
这是第二个页面,用来验证顶部导航的高亮与文件路由。
</p>
<ul class="list-disc pl-5 text-sm leading-loose text-gray-600">
<li>布局在 <code>layouts/default.vue</code>,由 <code>app.vue</code> 的 NuxtLayout 套上</li>
<li>导航项在 <code>config/index.ts</code><code>NAV_LINKS</code></li>
<li>Nuxt 是文件路由,<code>pages/about.vue</code> 自动就是 <code>/about</code>,不用注册</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { SITE_NAME } from '~/config'
useHead({ title: `关于我们 - ${SITE_NAME}` })
</script>
<template> <template>
<div class="p-4"> <div class="flex flex-col gap-8">
<h1 class="text-2xl font-bold">{{ SITE_NAME }}</h1> <section>
<h1 class="text-3xl font-bold">Hello World</h1>
<p class="mt-2 text-gray-500">
{{ SITE_NAME }} 示例页:顶部导航布局 + 一次接口请求。
</p>
</section>
<section class="border border-gray-200 rounded p-5">
<h2 class="text-lg font-semibold">接口请求示例</h2>
<p class="mt-1 text-sm text-gray-500">
site 不用 <code>@common/sdk</code>,走 <code>axios + runtimeConfig</code>
baseURL 来自 <code>NUXT_PUBLIC_API_BASE_URL</code>
</p>
<button
class="mt-4 rounded bg-gray-900 px-4 py-2 text-sm text-white disabled:opacity-60"
type="button"
:disabled="pending"
@click="onFetch"
>
{{ pending ? "请求中..." : "发起请求" }}
</button>
<pre v-if="result" class="mt-4 overflow-x-auto rounded bg-gray-50 p-3 text-xs">{{ result }}</pre>
<p v-if="errorMsg" class="mt-4 text-sm text-red-500">{{ errorMsg }}</p>
</section>
<section class="border border-gray-200 rounded p-5">
<h2 class="text-lg font-semibold">运行时配置</h2>
<dl class="mt-3 grid grid-cols-[140px_1fr] gap-y-2 text-sm">
<dt class="text-gray-500">当前环境</dt>
<dd>{{ runtime.public.appEnv }}</dd>
<dt class="text-gray-500">API 地址</dt>
<dd class="break-all">
{{ runtime.public.apiBaseUrl || "未配置(请填 NUXT_PUBLIC_API_BASE_URL)" }}
</dd>
</dl>
</section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SITE_NAME } from '~/config' import { SITE_NAME } from '~/config'
import { handleFetchDemoData } from '~/services/demo'
const runtime = useRuntimeConfig()
const pending = ref(false)
const result = ref('')
const errorMsg = ref('')
useHead({ title: SITE_NAME }) useHead({ title: SITE_NAME })
async function onFetch() {
pending.value = true
result.value = ''
errorMsg.value = ''
const { data, error } = await handleFetchDemoData()
result.value = data ? JSON.stringify(data, null, 2) : ''
errorMsg.value = error
pending.value = false
}
</script> </script>
import type { DemoData } from '~/api/demo'
import { fetchDemoData } from '~/api/demo'
/**
* 拉取示例数据
*
* service 层负责错误兜底,页面只管渲染。site 不用 SDK,
* 所以这里的错误是 axios 的 Error 对象,直接取 message。
*/
export async function handleFetchDemoData(): Promise<{ data: DemoData | null, error: string }> {
try {
const data = await fetchDemoData()
return { data, error: '' }
}
catch (e: any) {
return { data: null, error: e?.message || '请求失败' }
}
}
...@@ -34,10 +34,26 @@ ...@@ -34,10 +34,26 @@
与 admin 相同,`vite.config.ts` 的 alias 和 `tsconfig.json``paths` 要同步改。 与 admin 相同,`vite.config.ts` 的 alias 和 `tsconfig.json``paths` 要同步改。
## 新增页面的两步 ## 布局:顶部导航 + 左侧单栏
```
src/components/layout/
├── basic-layout.vue # 骨架:AppHeader 在上,AppSidebar + <RouterView> 在下
├── app-header.vue # 顶部导航:品牌 + 快捷入口 + 用户名 + 退出登录
└── app-sidebar.vue # 左侧单栏菜单(admin 是双栏,这里是单栏,别照搬)
```
布局组件**全部用 UnoCSS 工具类**,没有 `<style>` 块。菜单结构在 `src/config/menu.ts``menuItems` 侧栏用、`topNavLinks` 顶部用),图标是 SVG 的 `path d` 字符串,没有图标库。
菜单里的 `path` 必须和 router 里登记的完全一致,高亮判断就是 `route.path === item.path`
## 新增页面的三步
1. `src/pages/<feature>/index.vue` 1. `src/pages/<feature>/index.vue`
2.`src/router/index.ts` 登记,写 `meta.title``meta.requiresAuth`(漏写 `requiresAuth` 等于免登录) 2.`src/router/index.ts` 登记 —— 业务页挂在 `basic-layout.vue` 那条路由的 **`children`** 下(子路由 `path` 不带前导 `/`),写 `meta.title``meta.requiresAuth`(漏写 `requiresAuth` 等于免登录);登录页在 layout 之外
3. 需要出现在导航里,就在 `src/config/menu.ts` 加一项
示例页 `src/pages/index.vue` 是「Hello World + 一次 SDK 请求」的四层调用样板(`pages → services → api → @common/sdk`)。
## Toast ## Toast
......
import { sdk } from '@/api/request'
import { config } from '@/config'
/** 示例接口返回结构 */
export interface DemoData {
[key: string]: any
}
/**
* 示例 SDK 请求
*
* TODO: 替换为项目实际的子项目名 / 服务名 / 接口名(接口名是 camelCase)。
* 这里只演示调用姿势:api 层只负责发请求,不碰 store、不弹提示、不跳路由。
*/
export function fetchDemoData() {
return sdk.core
.domainService(
config.domainService.subProjectName,
config.domainService.serviceName,
'getHomeData',
)
.request<any, any, DemoData>('get', {})
}
...@@ -7,4 +7,8 @@ export { ...@@ -7,4 +7,8 @@ export {
export type { LoginRegisterResult, VerifyImageResult } from './auth' export type { LoginRegisterResult, VerifyImageResult } from './auth'
export { fetchDemoData } from './demo'
export type { DemoData } from './demo'
export { getSdk, initToken, loginService, sdk, verifyService } from './request' export { getSdk, initToken, loginService, sdk, verifyService } from './request'
export { default as AppHeader } from './layout/app-header.vue'
export { default as AppSidebar } from './layout/app-sidebar.vue'
export { default as BasicLayout } from './layout/basic-layout.vue'
<template>
<header class="h-14 flex shrink-0 items-center gap-4 border-b border-gray-200 bg-white px-4">
<div class="w-45 flex shrink-0 items-center gap-2">
<span class="h-7 w-7 flex items-center justify-center rounded bg-blue-500 text-white font-600">W</span>
<span class="text-base font-600">{{ config.appName }}</span>
</div>
<nav class="flex flex-1 items-center gap-1">
<RouterLink
v-for="link in topNavLinks"
:key="link.path"
class="rounded px-3 py-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-800"
:class="route.path === link.path ? '!bg-blue-50 !text-blue-500' : ''"
:to="link.path"
>
{{ link.title }}
</RouterLink>
</nav>
<div class="flex shrink-0 items-center gap-3">
<span class="text-gray-500">{{ userName }}</span>
<button
class="h-7.5 border border-gray-200 rounded bg-transparent px-3 text-13px hover:border-blue-500 hover:text-blue-500"
type="button"
@click="onLogout"
>
退出登录
</button>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { config, topNavLinks } from '@/config'
import { handleLogout } from '@/services'
import { useAuthStore } from '@/stores'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const userName = computed(() => authStore.userInfo?.username || '未登录')
function onLogout() {
handleLogout()
router.push('/login')
}
</script>
<template>
<aside class="w-45 shrink-0 border-r border-gray-200 bg-white p-2">
<nav class="flex flex-col gap-0.5">
<RouterLink
v-for="item in menuItems"
:key="item.path"
class="flex items-center gap-2 rounded px-3 py-2.5 text-gray-700 hover:bg-gray-100"
:class="route.path === item.path ? '!bg-blue-50 !text-blue-500 font-500' : ''"
:to="item.path"
>
<svg class="h-4.5 w-4.5" viewBox="0 0 24 24" aria-hidden="true">
<path :d="item.icon" fill="currentColor" />
</svg>
<span>{{ item.title }}</span>
</RouterLink>
</nav>
</aside>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { menuItems } from '@/config'
const route = useRoute()
</script>
<template>
<div class="h-full flex flex-col">
<AppHeader />
<div class="min-h-0 flex flex-1">
<AppSidebar />
<main class="min-w-0 flex flex-1 flex-col overflow-auto">
<div class="px-5 pt-3.5 text-13px text-gray-400">
{{ pageTitle }}
</div>
<div class="flex-1 px-5 pb-5 pt-3">
<RouterView />
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppHeader from './app-header.vue'
import AppSidebar from './app-sidebar.vue'
const route = useRoute()
const pageTitle = computed(() => (route.meta.title as string) || '')
</script>
...@@ -32,3 +32,6 @@ export const config: WebConfig = { ...@@ -32,3 +32,6 @@ export const config: WebConfig = {
/** 免登录白名单(路由 path) */ /** 免登录白名单(路由 path) */
export const noNeedAuthPages = ['/login'] export const noNeedAuthPages = ['/login']
export { menuItems, topNavLinks } from './menu'
export type { MenuItem } from './menu'
/**
* 侧边栏与顶部导航的菜单配置
*
* 这里只描述「导航结构」,路由本身仍要在 `src/router/index.ts` 登记。
* 两处的 path 必须一致,否则菜单点不动(或高亮不上)。
*/
export interface MenuItem {
/** 展示名称 */
title: string
/** 路由 path,需与 router 里登记的完全一致 */
path: string
/** 图标:SVG 的 path d 属性,避免引入图标库 */
icon: string
}
/**
* 左侧菜单(单栏)
*
* TODO: 替换为项目实际的菜单结构
*/
export const menuItems: MenuItem[] = [
{
title: '首页',
path: '/',
icon: 'M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3l9-8z',
},
{
title: '关于模板',
path: '/about',
icon: 'M12 2a10 10 0 100 20 10 10 0 000-20zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z',
},
]
/** 顶部导航的快捷入口 */
export const topNavLinks: Pick<MenuItem, 'title' | 'path'>[] = [
{ title: '首页', path: '/' },
{ title: '关于模板', path: '/about' },
]
<template>
<div class="rounded border border-gray-200 bg-white p-5">
<h1 class="mb-2 text-20px font-600">关于模板</h1>
<p class="mb-4 text-gray-500">
这是第二个路由,用来验证「顶部导航 + 左侧菜单」在多页面下的高亮与跳转。
</p>
<ul class="m-0 pl-5 leading-loose">
<li>菜单结构在 <code>src/config/menu.ts</code></li>
<li>路由在 <code>src/router/index.ts</code>,新增页面两处都要登记</li>
<li>布局组件在 <code>src/components/layout/</code></li>
</ul>
</div>
</template>
<style scoped lang="scss">
code {
padding: 1px 4px;
border-radius: 3px;
background: #f5f7fa;
}
</style>
<template> <template>
<div class="home"> <div class="flex flex-col gap-4">
<h1 class="home__title">{{ config.appName }}</h1> <section class="card">
<p class="home__desc">Vue 3 + Vite + Pinia + Vue Router 模板骨架</p> <h1 class="mb-2 text-22px font-600">Hello World</h1>
<p class="text-gray-500">
这是 {{ config.appName }} 的示例页:顶部导航 + 左侧菜单 + 一次 SDK 请求。
</p>
</section>
<button class="home__btn" @click="handleLogout">退出登录</button> <section class="card">
<h2 class="mb-2 text-base font-600">SDK 请求示例</h2>
<p class="mb-4 text-gray-500">
<code>pages → services → api → @common/sdk</code> 四层,接口名在
<code>src/api/demo.ts</code> 里替换。
</p>
<button
class="h-8.5 border-none rounded bg-blue-500 px-4.5 text-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
:disabled="loading"
@click="onFetch"
>
{{ loading ? "请求中..." : "发起请求" }}
</button>
<pre v-if="result" class="mt-4 whitespace-pre-wrap break-all rounded bg-gray-100 p-3 text-12px">{{ result }}</pre>
</section>
<section class="card">
<h2 class="mb-2 text-base font-600">环境信息</h2>
<dl class="m-0 grid grid-cols-[140px_1fr] gap-y-2">
<dt class="text-gray-500">当前环境</dt>
<dd class="m-0">{{ config.logEnv }}</dd>
<dt class="text-gray-500">API 地址</dt>
<dd class="m-0 break-all">{{ config.uniplatApi || "未配置(请填 VITE_APP_UNIPLAT)" }}</dd>
<dt class="text-gray-500">子项目 / 服务</dt>
<dd class="m-0">{{ config.domainService.subProjectName }} / {{ config.domainService.serviceName }}</dd>
</dl>
</section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { useLoading } from '@common/vue-kit/composables'
import { ref } from 'vue'
import { config } from '@/config' import { config } from '@/config'
import { handleLogout as logout } from '@/services' import { handleFetchDemoData } from '@/services'
const router = useRouter() const { loading, wrap } = useLoading()
const result = ref<string>('')
function handleLogout() { async function onFetch() {
logout() const data = await wrap(() => handleFetchDemoData())
router.push('/login') result.value = data ? JSON.stringify(data, null, 2) : ''
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.home { .card {
padding: 24px; padding: 20px;
background: #fff;
&__title { border: 1px solid #e5e6eb;
margin: 0 0 8px; border-radius: 6px;
font-size: 24px; }
font-weight: 600;
} code {
padding: 1px 4px;
&__desc { border-radius: 3px;
margin: 0 0 24px; background: #f5f7fa;
color: #666;
}
&__btn {
height: 40px;
padding: 0 24px;
border: none;
border-radius: 4px;
background: #1890ff;
color: #fff;
font-size: 14px;
cursor: pointer;
}
} }
</style> </style>
...@@ -10,11 +10,24 @@ const routes: RouteRecordRaw[] = [ ...@@ -10,11 +10,24 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/login/index.vue'), component: () => import('@/pages/login/index.vue'),
meta: { title: '登录', requiresAuth: false }, meta: { title: '登录', requiresAuth: false },
}, },
// 业务页面挂在 BasicLayout 下(顶部导航 + 左侧菜单),登录页在布局之外
{ {
path: '/', path: '/',
name: 'Home', component: () => import('@/components/layout/basic-layout.vue'),
component: () => import('@/pages/index.vue'), children: [
meta: { title: '首页', requiresAuth: true }, {
path: '',
name: 'Home',
component: () => import('@/pages/index.vue'),
meta: { title: '首页', requiresAuth: true },
},
{
path: 'about',
name: 'About',
component: () => import('@/pages/about/index.vue'),
meta: { title: '关于模板', requiresAuth: true },
},
],
}, },
] ]
......
import type { DemoData } from '@/api/demo'
import { resolveSdkError } from '@common/sdk'
import { fetchDemoData } from '@/api/demo'
import { Toast } from '@/utils/toast'
/**
* 拉取示例数据
*
* service 层负责:调 api + 统一错误提示。失败返回 null,页面只管渲染。
* SDK 的业务错误 reject 的是纯字符串,必须用 resolveSdkError 提取。
*/
export async function handleFetchDemoData(): Promise<DemoData | null> {
try {
return await fetchDemoData()
}
catch (e: any) {
Toast.error(resolveSdkError(e, '请求失败'))
return null
}
}
...@@ -4,3 +4,5 @@ export { ...@@ -4,3 +4,5 @@ export {
handleSendSmsCode, handleSendSmsCode,
handleSmsLogin, handleSmsLogin,
} from './auth-service' } from './auth-service'
export { handleFetchDemoData } from './demo-service'
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment