Commit 2aa802b3 by chunhong.mu

feat(*): 重做 admin/web/site 配色,顶部栏改搜索+个人信息区并加多标签页

配色(三个包统一为品牌紫 #4f46e5 + 冷灰 slate):
- admin: src/assets/styles/index.css 扩成完整设计变量(颜色/圆角/阴影/尺寸)
- web: uno.config.ts 加 brand 色阶与 card、btn-primary shortcut,index.scss 同步变量
- site: 新增 uno.config.ts 与 assets/css/main.css,并在 nuxt.config 注册
- 登录页改深色渐变背景,tab 改 segmented 胶囊,输入框 focus 加品牌色 ring
- 两个包 utils/toast.ts 里 JS 注入的配色一并替换

顶部栏与标签页(admin / web 同构):
- 去掉顶部导航链接,改为 品牌 + 搜索框(过滤菜单跳转)+ 个人信息区(操作菜单)
- 新增 stores/tabs.ts 与 tabs-bar.vue,支持多标签页与关闭其他/关闭全部
- 标签以 route.fullPath 为标识,文案取 route.meta.title,首页标签固定不可关闭
- 登记标签的 watch 放在 basic-layout 而非 router.afterEach,登录页不产生标签
- 浮层收起用全屏透明遮罩,不挂 document 事件监听

site 全局 reset:
- a 去掉下划线并继承颜色
- button 去掉自带边框与底色(border-width: 0 保留 solid,避免以后 border 工具类失效;
  appearance: none 覆盖 antd reset 的 -webkit-appearance: button)

同步根与 admin/web/site 的 CLAUDE.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parent 0b5f754b
...@@ -77,15 +77,28 @@ frontend-hub/ ...@@ -77,15 +77,28 @@ frontend-hub/
| 包 | 布局 | 布局文件 | 导航配置 | 示例页 | | 包 | 布局 | 布局文件 | 导航配置 | 示例页 |
|----|------|---------|---------|--------| |----|------|---------|---------|--------|
| `admin` | 顶部导航 + **左侧双栏**(一级 rail + 二级菜单) | `src/components/layout/` | `src/config/menu.ts` | `src/pages/index.vue` | | `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` | | `web` | 顶部栏(搜索 + 个人信息)+ 标签栏 + 左侧单栏 | `src/components/layout/` | `src/config/menu.ts` | `src/pages/index.vue` |
| `mini` | **三 tabBar**:首页 / 消息 / 我的 | `src/pages.json``tabBar` | 同上 | `src/pages/home/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` | | `site` | 顶部导航 + 页脚 | `layouts/default.vue` | `config/index.ts``NAV_LINKS` | `pages/index.vue` |
- `admin` / `web`:业务页是 `basic-layout.vue`**子路由**,新增页面要同时改 `router/index.ts``config/menu.ts` - `admin` / `web`:业务页是 `basic-layout.vue`**子路由**,新增页面要同时改 `router/index.ts``config/menu.ts`
- `admin` / `web`**顶部栏没有导航链接**,导航只有左侧菜单 + 顶部搜索框;多标签页状态在各自的 `src/stores/tabs.ts`,标签文案取 `route.meta.title`(漏写会显示成路径)
- `mini`:tabBar 页只能用 `uni.switchTab` / `uni.reLaunch` 打开,`navigateTo` 会静默失败 - `mini`:tabBar 页只能用 `uni.switchTab` / `uni.reLaunch` 打开,`navigateTo` 会静默失败
- 示例接口在各包 `api/demo.ts`,接口名是占位值(`getHomeData` / `/demo/hello`),**接项目时必须替换** - 示例接口在各包 `api/demo.ts`,接口名是占位值(`getHomeData` / `/demo/hello`),**接项目时必须替换**
### 配色
`admin` / `web` / `site` 共用一套取值(品牌紫 `--brand-500: #4f46e5` + 冷灰 slate),但落地方式各不相同,改主题要三处都改:
| 包 | 改哪里 |
|----|--------|
| `admin` | `src/assets/styles/index.css` 的 CSS 变量 |
| `web` | `uno.config.ts``theme.colors` + `src/assets/styles/index.scss` 的 CSS 变量(两边取值要一致) |
| `site` | `uno.config.ts``theme.colors` + `assets/css/main.css` |
`admin` / `web``src/utils/toast.ts` 里还各有一份 JS 字符串注入的配色,容易漏。
## ⚠️ 动手前必读:每个子包有自己的 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 框架;全局变量在 `src/assets/styles/index.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,19 +23,41 @@ import { sdk } from "@/src/api/request" // ❌ 改造前的历史写法,已全 ...@@ -23,19 +23,41 @@ import { sdk } from "@/src/api/request" // ❌ 改造前的历史写法,已全
`vite.config.ts` 的 alias 与 `tsconfig.json``paths` 必须同时改,否则类型能过但运行时挂(或反之)。 `vite.config.ts` 的 alias 与 `tsconfig.json``paths` 必须同时改,否则类型能过但运行时挂(或反之)。
## 布局:顶部导航 + 左侧双栏 ## 布局:顶部栏 + 标签栏 + 左侧双栏
``` ```
src/components/layout/ src/components/layout/
├── basic-layout.vue # 骨架:AppHeader 在上,AppSidebar + <RouterView> 在下 ├── basic-layout.vue # 骨架:AppHeader / (AppSidebar + TabsBar + <RouterView>)
├── app-header.vue # 顶部导航:品牌 + 快捷入口 + 用户名 + 退出登录 ├── app-header.vue # 顶部栏:品牌 + 搜索框 + 个人信息区(无导航链接)
├── tabs-bar.vue # 多标签页 + 标签管理(关闭其他 / 关闭全部)
└── app-sidebar.vue # 左侧双栏:第一栏一级菜单(深色 rail),第二栏该组的二级菜单 └── app-sidebar.vue # 左侧双栏:第一栏一级菜单(深色 rail),第二栏该组的二级菜单
``` ```
菜单结构在 **`src/config/menu.ts`**`menuGroups` 双栏用、`topNavLinks` 顶部用),图标是 SVG 的 `path d` 字符串,**没有图标库**,加菜单就照抄一段 path。 **顶部栏没有导航链接**,导航只有两个入口:左侧双栏菜单、顶部搜索框。
- 搜索框过滤的是 `flatMenuItems``menuGroups` 打平),回车跳第一条。接业务全局搜索时换掉数据源即可
- 个人信息区点开是操作菜单(当前只有「关于模板」「退出登录」),有个人中心/改密页面后加在 `app-header.vue` 的 TODO 处
- 浮层收起用的是**全屏透明遮罩**,不是 `document` 事件监听 —— 新增浮层沿用这个写法,省掉解绑
菜单结构在 **`src/config/menu.ts`**,图标是 SVG 的 `path d` 字符串,**没有图标库**,加菜单就照抄一段 path。
侧边栏第二栏靠 `watch(route.path)` 反查所属一级菜单来同步,所以**菜单里的 `path` 必须和 router 里登记的完全一致**,否则刷新后高亮不上、二级栏停在第一组。 侧边栏第二栏靠 `watch(route.path)` 反查所属一级菜单来同步,所以**菜单里的 `path` 必须和 router 里登记的完全一致**,否则刷新后高亮不上、二级栏停在第一组。
## 多标签页
状态在 `src/stores/tabs.ts`,标签的唯一标识是 **`route.fullPath`**(同一页面带不同 query 算两个标签)。
- 登记标签的 `watch` 放在 `basic-layout.vue` 里,不是 `router.afterEach` —— 这样登录页不会产生标签,也不用担心在 pinia 激活前调 store
- 首页标签 `closable: false` 且在布局 setup 时无条件 `addTab` 一次,保证深链进 `/about` 时它也在
- `closeTab` **返回**关闭后该停留的 path,跳转由调用方做;store 不碰 router
- 标签文案取 `route.meta.title`,所以新页面**漏写 `meta.title` 标签上会显示成路径**
## 配色
所有颜色 / 圆角 / 阴影 / 尺寸都是 `src/assets/styles/index.css` 里的 CSS 变量(`--brand-*``--ink-*``--line``--shadow-*`…),由 `main.ts` 引入。**组件里不要写裸 hex**,加新颜色先加变量。
`src/utils/toast.ts` 的配色是 JS 字符串里注入的,改主题时别忘了它。
## 新增页面的三步 ## 新增页面的三步
1.`src/pages/<feature>/index.vue` 建页面 1.`src/pages/<feature>/index.vue` 建页面
...@@ -61,7 +83,9 @@ src/ ...@@ -61,7 +83,9 @@ src/
├── 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 成对维护) ├── config/menu.ts # 导航结构(与 router 成对维护)
├── stores/tabs.ts # 多标签页状态
├── components/layout/ # 布局组件 ├── components/layout/ # 布局组件
├── assets/styles/index.css # 设计变量 + reset
└── utils/toast.ts # DOM Toast └── utils/toast.ts # DOM Toast
``` ```
......
/* 全局基础样式与设计变量(admin 不引入 CSS 框架,变量集中在这里) */ /* 全局基础样式与设计变量(admin 不引入 CSS 框架,所有颜色都从这里取) */
:root { :root {
--color-primary: #4c6ef5; /* 品牌色 */
--color-primary-soft: #edf0ff; --brand-50: #eef2ff;
--color-text: #1f2329; --brand-100: #e0e7ff;
--color-text-weak: #8a919f; --brand-200: #c7d2fe;
--color-border: #e5e6eb; --brand-500: #4f46e5;
--color-bg-page: #f5f7fa; --brand-600: #4338ca;
--color-bg-card: #ffffff; --brand-700: #3730a3;
--color-bg-rail: #1f2733;
--header-height: 56px; /* 中性色(冷灰,配合品牌紫更干净) */
--rail-width: 84px; --ink-900: #0f172a;
--submenu-width: 176px; --ink-700: #334155;
--radius: 6px; --ink-600: #475569;
--ink-500: #64748b;
--ink-400: #94a3b8;
--ink-300: #cbd5e1;
--line: #e2e8f0;
--line-soft: #f1f5f9;
/* 背景 */
--bg-page: #f6f8fb;
--bg-card: #ffffff;
--bg-hover: #f1f5f9;
--bg-rail: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
/* 语义色 */
--success: #16a34a;
--danger: #ef4444;
--warning: #f59e0b;
/* 尺寸 */
--header-height: 60px;
--tabs-height: 42px;
--rail-width: 88px;
--submenu-width: 184px;
/* 圆角与阴影 */
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--shadow-sm: 0 1px 2px rgb(15 23 42 / 6%);
--shadow-md: 0 4px 16px rgb(15 23 42 / 8%);
--shadow-lg: 0 12px 32px rgb(15 23 42 / 12%);
} }
* { * {
...@@ -31,10 +60,18 @@ body { ...@@ -31,10 +60,18 @@ body {
font-family: font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.55;
color: var(--color-text); color: var(--ink-700);
background-color: var(--color-bg-page); background-color: var(--bg-page);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4 {
color: var(--ink-900);
} }
a { a {
...@@ -42,6 +79,45 @@ a { ...@@ -42,6 +79,45 @@ a {
text-decoration: none; text-decoration: none;
} }
button { button,
input {
font-family: inherit; font-family: inherit;
font-size: inherit;
color: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
/* 键盘可达性:不要为了好看去掉 focus 环,换成品牌色的柔和外圈 */
:focus-visible {
outline: 2px solid var(--brand-200);
outline-offset: 2px;
}
/* 细滚动条,避免默认滚动条把布局撑得很脏 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: var(--ink-300);
border: 2px solid transparent;
border-radius: 8px;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ink-400);
background-clip: content-box;
}
::-webkit-scrollbar-track {
background: transparent;
} }
export { default as AppHeader } from './layout/app-header.vue' export { default as AppHeader } from './layout/app-header.vue'
export { default as AppSidebar } from './layout/app-sidebar.vue' export { default as AppSidebar } from './layout/app-sidebar.vue'
export { default as BasicLayout } from './layout/basic-layout.vue' export { default as BasicLayout } from './layout/basic-layout.vue'
export { default as TabsBar } from './layout/tabs-bar.vue'
<template> <template>
<header class="app-header"> <header class="header">
<div class="app-header__brand"> <!-- 品牌区,宽度对齐左侧双栏 -->
<span class="app-header__logo">A</span> <div class="brand">
<span class="app-header__name">{{ config.appName }}</span> <span class="brand__logo">A</span>
<span class="brand__name">{{ config.appName }}</span>
</div> </div>
<nav class="app-header__nav"> <!-- 搜索框:过滤菜单做快捷跳转(TODO: 接业务全局搜索时替换数据源) -->
<RouterLink <div class="search">
v-for="link in topNavLinks" <svg class="search__icon" viewBox="0 0 24 24" aria-hidden="true">
:key="link.path" <path
class="app-header__link" d="M10 4a6 6 0 104 10.5l4.3 4.3 1.4-1.4-4.3-4.3A6 6 0 0010 4zm0 2a4 4 0 110 8 4 4 0 010-8z"
:class="{ 'is-active': route.path === link.path }" fill="currentColor"
:to="link.path" />
</svg>
<input
v-model="keyword"
class="search__input"
type="search"
placeholder="搜索菜单"
@keydown.enter="gotoFirstMatch"
@keydown.esc="keyword = ''"
/>
<div v-if="keyword.trim()" class="popover popover--search">
<template v-if="matches.length">
<button
v-for="item in matches"
:key="item.path"
class="popover__item"
type="button"
@click="goto(item.path)"
> >
{{ link.title }} <span>{{ item.title }}</span>
</RouterLink> <span class="popover__meta">{{ item.group }}</span>
</nav> </button>
</template>
<div v-else class="popover__empty">没有匹配的菜单</div>
</div>
</div>
<!-- 个人信息区:点击展开相关操作 -->
<div class="user">
<button class="user__trigger" :class="{ 'is-open': userMenuOpen }" type="button" @click="userMenuOpen = !userMenuOpen">
<span class="user__avatar">{{ userInitial }}</span>
<span class="user__name">{{ userName }}</span>
<svg class="user__caret" viewBox="0 0 24 24" aria-hidden="true">
<path d="M7 10l5 5 5-5z" fill="currentColor" />
</svg>
</button>
<div class="app-header__right"> <div v-if="userMenuOpen" class="popover popover--user">
<span class="app-header__user">{{ userName }}</span> <div class="popover__header">
<button class="app-header__logout" type="button" @click="onLogout"> <span class="popover__title">{{ userName }}</span>
<span class="popover__meta">{{ userMobile || "未绑定手机号" }}</span>
</div>
<!-- TODO: 有「个人中心」「修改密码」页面后加在这里 -->
<button class="popover__item" type="button" @click="goto('/about')">
关于模板
</button>
<button class="popover__item popover__item--danger" type="button" @click="onLogout">
退出登录 退出登录
</button> </button>
</div> </div>
</div>
<!-- 点击空白处收起浮层,避免挂 document 事件监听 -->
<div v-if="userMenuOpen || keyword.trim()" class="mask" @click="closeAll" />
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { config, topNavLinks } from '@/config' import { config, flatMenuItems } from '@/config'
import { handleLogout } from '@/services' import { handleLogout } from '@/services'
import { useAuthStore } from '@/stores' import { useAuthStore } from '@/stores'
const route = useRoute()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const keyword = ref('')
const userMenuOpen = ref(false)
const userName = computed(() => authStore.userInfo?.username || '未登录') const userName = computed(() => authStore.userInfo?.username || '未登录')
const userMobile = computed(() => authStore.userInfo?.mobile || '')
const userInitial = computed(() => userName.value.slice(0, 1).toUpperCase())
const matches = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return []
return flatMenuItems.filter(
item => item.title.toLowerCase().includes(kw) || item.group.toLowerCase().includes(kw),
)
})
function closeAll() {
userMenuOpen.value = false
keyword.value = ''
}
function goto(path: string) {
closeAll()
router.push(path)
}
function gotoFirstMatch() {
if (matches.value.length) goto(matches.value[0].path)
}
function onLogout() { function onLogout() {
closeAll()
handleLogout() handleLogout()
router.push('/login') router.push('/login')
} }
</script> </script>
<style scoped> <style scoped>
.app-header { .header {
position: relative;
z-index: 20;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px;
height: var(--header-height); height: var(--header-height);
padding: 0 20px; padding-right: 20px;
background: var(--color-bg-card); background: var(--bg-card);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--line);
} }
.app-header__brand { /* 品牌 */
.brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
width: calc(var(--rail-width) + var(--submenu-width) - 20px); width: calc(var(--rail-width) + var(--submenu-width));
padding-left: 20px;
flex-shrink: 0; flex-shrink: 0;
} }
.app-header__logo { .brand__logo {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 28px; width: 30px;
height: 28px; height: 30px;
border-radius: var(--radius); border-radius: 9px;
background: var(--color-primary); background: linear-gradient(135deg, var(--brand-500), var(--brand-700));
color: #fff; color: #fff;
font-weight: 600; font-weight: 700;
box-shadow: 0 4px 10px rgb(79 70 229 / 30%);
} }
.app-header__name { .brand__name {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: var(--ink-900);
letter-spacing: 0.2px;
} }
.app-header__nav { /* 搜索 */
.search {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; width: 280px;
flex: 1; height: 36px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: var(--radius);
background: var(--line-soft);
transition:
border-color 0.15s,
background-color 0.15s;
} }
.app-header__link { .search:focus-within {
padding: 6px 12px; border-color: var(--brand-200);
border-radius: var(--radius); background: var(--bg-card);
color: var(--color-text-weak); }
.search__icon {
width: 16px;
height: 16px;
margin-right: 8px;
color: var(--ink-400);
flex-shrink: 0;
} }
.app-header__link:hover { .search__input {
color: var(--color-text); width: 100%;
background: var(--color-bg-page); border: none;
background: transparent;
outline: none;
} }
.app-header__link.is-active { .search__input::placeholder {
color: var(--color-primary); color: var(--ink-400);
background: var(--color-primary-soft);
} }
.app-header__right { /* 个人信息区 */
.user {
position: relative;
margin-left: auto;
}
.user__trigger {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
flex-shrink: 0; height: 40px;
padding: 0 10px;
border: none;
border-radius: var(--radius);
background: transparent;
transition: background-color 0.15s;
} }
.app-header__user { .user__trigger:hover,
color: var(--color-text-weak); .user__trigger.is-open {
background: var(--bg-hover);
} }
.app-header__logout { .user__avatar {
height: 30px; display: flex;
padding: 0 12px; align-items: center;
border: 1px solid var(--color-border); justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--brand-100);
color: var(--brand-700);
font-size: 13px;
font-weight: 600;
}
.user__name {
color: var(--ink-700);
font-weight: 500;
}
.user__caret {
width: 16px;
height: 16px;
color: var(--ink-400);
}
/* 浮层 */
.popover {
position: absolute;
z-index: 30;
padding: 6px;
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.popover--search {
top: calc(100% + 8px);
left: 0;
width: 100%;
}
.popover--user {
top: calc(100% + 6px);
right: 0;
width: 200px;
}
.popover__header {
display: flex;
flex-direction: column;
padding: 8px 10px 10px;
border-bottom: 1px solid var(--line-soft);
margin-bottom: 4px;
}
.popover__title {
color: var(--ink-900);
font-weight: 600;
}
.popover__meta {
color: var(--ink-400);
font-size: 12px;
}
.popover__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent; background: transparent;
color: var(--color-text); color: var(--ink-700);
font-size: 13px; text-align: left;
cursor: pointer; }
.popover__item:hover {
background: var(--brand-50);
color: var(--brand-700);
}
.popover__item--danger:hover {
background: #fef2f2;
color: var(--danger);
}
.popover__empty {
padding: 12px 10px;
color: var(--ink-400);
text-align: center;
} }
.app-header__logout:hover { .mask {
border-color: var(--color-primary); position: fixed;
color: var(--color-primary); inset: 0;
z-index: 10;
} }
</style> </style>
<template> <template>
<aside class="sidebar"> <aside class="sidebar">
<!-- 第一栏:一级菜单 --> <!-- 第一栏:一级菜单 -->
<nav class="sidebar__rail"> <nav class="rail">
<button <button
v-for="group in menuGroups" v-for="group in menuGroups"
:key="group.key" :key="group.key"
class="rail-item" class="rail__item"
:class="{ 'is-active': group.key === activeKey }" :class="{ 'is-active': group.key === activeKey }"
type="button" type="button"
@click="activeKey = group.key" @click="activeKey = group.key"
> >
<svg class="rail-item__icon" viewBox="0 0 24 24" aria-hidden="true"> <svg class="rail__icon" viewBox="0 0 24 24" aria-hidden="true">
<path :d="group.icon" fill="currentColor" /> <path :d="group.icon" fill="currentColor" />
</svg> </svg>
<span class="rail-item__text">{{ group.title }}</span> <span class="rail__text">{{ group.title }}</span>
</button> </button>
</nav> </nav>
<!-- 第二栏:当前一级菜单下的二级菜单 --> <!-- 第二栏:当前一级菜单下的二级菜单 -->
<nav class="sidebar__submenu"> <nav class="submenu">
<div class="submenu__title">{{ activeGroup?.title }}</div> <div class="submenu__title">{{ activeGroup?.title }}</div>
<RouterLink <RouterLink
v-for="item in activeGroup?.children" v-for="item in activeGroup?.children"
...@@ -65,74 +65,98 @@ watch( ...@@ -65,74 +65,98 @@ watch(
} }
/* 第一栏 */ /* 第一栏 */
.sidebar__rail { .rail {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 6px;
width: var(--rail-width); width: var(--rail-width);
padding: 12px 8px; padding: 12px 10px;
background: var(--color-bg-rail); background: var(--bg-rail);
} }
.rail-item { .rail__item {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
padding: 10px 0; padding: 12px 0 10px;
border: none; border: none;
border-radius: var(--radius); border-radius: var(--radius);
background: transparent; background: transparent;
color: rgb(255 255 255 / 65%); color: rgb(226 232 240 / 60%);
font-size: 12px; font-size: 12px;
cursor: pointer; transition:
color 0.15s,
background-color 0.15s;
} }
.rail-item:hover { .rail__item:hover {
color: #fff; color: #fff;
background: rgb(255 255 255 / 8%); background: rgb(255 255 255 / 7%);
} }
.rail-item.is-active { .rail__item.is-active {
color: #fff; color: #fff;
background: var(--color-primary); background: linear-gradient(135deg, var(--brand-500), var(--brand-700));
box-shadow: 0 6px 16px rgb(79 70 229 / 35%);
} }
.rail-item__icon { .rail__icon {
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
/* 第二栏 */ /* 第二栏 */
.sidebar__submenu { .submenu {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
width: var(--submenu-width); width: var(--submenu-width);
padding: 12px 8px; padding: 14px 10px;
background: var(--color-bg-card); background: var(--bg-card);
border-right: 1px solid var(--color-border); border-right: 1px solid var(--line);
} }
.submenu__title { .submenu__title {
padding: 4px 12px 10px; padding: 2px 12px 12px;
color: var(--color-text-weak); color: var(--ink-400);
font-size: 12px; font-size: 12px;
font-weight: 500;
letter-spacing: 0.5px;
} }
.submenu__item { .submenu__item {
padding: 8px 12px; position: relative;
border-radius: var(--radius); padding: 9px 12px;
color: var(--color-text); border-radius: var(--radius-sm);
color: var(--ink-600);
transition:
color 0.15s,
background-color 0.15s;
} }
.submenu__item:hover { .submenu__item:hover {
background: var(--color-bg-page); color: var(--ink-900);
background: var(--bg-hover);
} }
.submenu__item.is-active { .submenu__item.is-active {
color: var(--color-primary); color: var(--brand-700);
background: var(--color-primary-soft); background: var(--brand-50);
font-weight: 500; font-weight: 500;
} }
/* 选中项左侧加一条品牌色竖条,比纯底色更容易定位 */
.submenu__item.is-active::before {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 3px;
height: 16px;
border-radius: 0 3px 3px 0;
background: var(--brand-500);
transform: translateY(-50%);
}
</style> </style>
...@@ -4,9 +4,7 @@ ...@@ -4,9 +4,7 @@
<div class="layout__body"> <div class="layout__body">
<AppSidebar /> <AppSidebar />
<main class="layout__main"> <main class="layout__main">
<div class="layout__breadcrumb"> <TabsBar />
{{ pageTitle }}
</div>
<div class="layout__content"> <div class="layout__content">
<RouterView /> <RouterView />
</div> </div>
...@@ -16,14 +14,43 @@ ...@@ -16,14 +14,43 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useTabsStore } from '@/stores'
import AppHeader from './app-header.vue' import AppHeader from './app-header.vue'
import AppSidebar from './app-sidebar.vue' import AppSidebar from './app-sidebar.vue'
import TabsBar from './tabs-bar.vue'
const route = useRoute() const route = useRoute()
const router = useRouter()
const tabsStore = useTabsStore()
// 固定的首页标签:直接深链进 /about 时也要有它,标题从路由表取,不重复写死
const homeRoute = router.resolve('/')
tabsStore.addTab({
path: '/',
title: (homeRoute.meta.title as string) || '首页',
closable: false,
})
const pageTitle = computed(() => (route.meta.title as string) || '') /**
* 进入布局内的任意路由都登记成一个标签页。
*
* 放在布局里而不是 router.afterEach,是为了让登录页这类布局外的路由不产生标签,
* 同时避免在 pinia 激活前调用 store。
*/
watch(
() => route.fullPath,
() => {
tabsStore.addTab({
path: route.fullPath,
title: (route.meta.title as string) || route.fullPath,
// 首页固定,防止标签全被关掉后无处可去
closable: route.path !== '/',
})
},
{ immediate: true },
)
</script> </script>
<style scoped> <style scoped>
...@@ -46,15 +73,9 @@ const pageTitle = computed(() => (route.meta.title as string) || '') ...@@ -46,15 +73,9 @@ const pageTitle = computed(() => (route.meta.title as string) || '')
min-width: 0; min-width: 0;
} }
.layout__breadcrumb {
padding: 14px 20px 0;
color: var(--color-text-weak);
font-size: 13px;
}
.layout__content { .layout__content {
flex: 1; flex: 1;
padding: 12px 20px 20px; padding: 20px;
overflow: auto; overflow: auto;
} }
</style> </style>
<template>
<div class="tabs">
<div class="tabs__scroll">
<!-- 外层不用 button:里面还要放一个关闭按钮,button 不能嵌套 -->
<div
v-for="tab in tabsStore.tabs"
:key="tab.path"
class="tab"
:class="{ 'is-active': tab.path === route.fullPath }"
role="button"
tabindex="0"
@click="router.push(tab.path)"
@keydown.enter="router.push(tab.path)"
>
<span class="tab__dot" />
<span class="tab__title">{{ tab.title }}</span>
<button
v-if="tab.closable"
class="tab__close"
type="button"
aria-label="关闭标签"
@click.stop="onClose(tab.path)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6 6l12 12M18 6L6 18"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
<!-- 标签管理 -->
<div class="tabs__actions">
<button
class="tabs__more"
:class="{ 'is-open': menuOpen }"
type="button"
aria-label="标签管理"
@click="menuOpen = !menuOpen"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6 12h.01M12 12h.01M18 12h.01"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
</svg>
</button>
<div v-if="menuOpen" class="menu">
<button class="menu__item" type="button" @click="onCloseOthers">关闭其他</button>
<button class="menu__item" type="button" @click="onCloseAll">关闭全部</button>
</div>
</div>
<div v-if="menuOpen" class="mask" @click="menuOpen = false" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTabsStore } from '@/stores'
const route = useRoute()
const router = useRouter()
const tabsStore = useTabsStore()
const menuOpen = ref(false)
function onClose(path: string) {
const next = tabsStore.closeTab(path)
// 关掉的是当前标签才需要跳转,关别的标签停在原地
if (path === route.fullPath) router.push(next)
}
function onCloseOthers() {
menuOpen.value = false
tabsStore.closeOthers(route.fullPath)
}
function onCloseAll() {
menuOpen.value = false
router.push(tabsStore.closeAll())
}
</script>
<style scoped>
.tabs {
position: relative;
z-index: 10;
display: flex;
align-items: center;
height: var(--tabs-height);
padding: 0 8px 0 12px;
background: var(--bg-card);
border-bottom: 1px solid var(--line);
}
.tabs__scroll {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.tabs__scroll::-webkit-scrollbar {
display: none;
}
.tab {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 8px 0 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--bg-card);
color: var(--ink-600);
font-size: 13px;
white-space: nowrap;
cursor: pointer;
user-select: none;
transition: all 0.15s;
}
.tab:hover {
border-color: var(--brand-200);
color: var(--brand-700);
}
.tab.is-active {
border-color: transparent;
background: var(--brand-500);
color: #fff;
box-shadow: 0 2px 8px rgb(79 70 229 / 28%);
}
.tab__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ink-300);
flex-shrink: 0;
}
.tab.is-active .tab__dot {
background: rgb(255 255 255 / 85%);
}
.tab__title {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
}
.tab__close {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
flex-shrink: 0;
}
.tab__close svg {
width: 10px;
height: 10px;
}
.tab__close:hover {
background: rgb(15 23 42 / 8%);
}
.tab.is-active .tab__close:hover {
background: rgb(255 255 255 / 25%);
}
/* 标签管理 */
.tabs__actions {
position: relative;
flex-shrink: 0;
padding-left: 8px;
}
.tabs__more {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--ink-500);
}
.tabs__more svg {
width: 16px;
height: 16px;
}
.tabs__more:hover,
.tabs__more.is-open {
border-color: var(--brand-200);
color: var(--brand-600);
background: var(--brand-50);
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 30;
width: 132px;
padding: 6px;
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.menu__item {
display: block;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-700);
font-size: 13px;
text-align: left;
}
.menu__item:hover {
background: var(--brand-50);
color: var(--brand-700);
}
.mask {
position: fixed;
inset: 0;
z-index: 10;
}
</style>
...@@ -30,5 +30,5 @@ export const config: AdminConfig = { ...@@ -30,5 +30,5 @@ export const config: AdminConfig = {
/** 免登录白名单(路由 path) */ /** 免登录白名单(路由 path) */
export const noNeedAuthPages = ['/login'] export const noNeedAuthPages = ['/login']
export { menuGroups, topNavLinks } from './menu' export { flatMenuItems, menuGroups } from './menu'
export type { MenuGroup, MenuItem } from './menu' export type { MenuGroup, MenuItem } from './menu'
...@@ -47,8 +47,11 @@ export const menuGroups: MenuGroup[] = [ ...@@ -47,8 +47,11 @@ export const menuGroups: MenuGroup[] = [
}, },
] ]
/** 顶部导航的快捷入口 */ /**
export const topNavLinks: MenuItem[] = [ * 打平后的菜单项,供顶部搜索框做快捷跳转
{ title: "首页", path: "/" }, *
{ title: "关于模板", path: "/about" }, * 顶部栏没有导航链接,导航只走左侧双栏 + 搜索框。
] */
export const flatMenuItems: (MenuItem & { group: string })[] = menuGroups.flatMap(
group => group.children.map(item => ({ ...item, group: group.title })),
)
...@@ -2,43 +2,72 @@ ...@@ -2,43 +2,72 @@
<div class="about"> <div class="about">
<h1 class="about__title">关于模板</h1> <h1 class="about__title">关于模板</h1>
<p class="about__desc"> <p class="about__desc">
这是第二个路由,用来验证「顶部导航 + 左侧双栏」在多页面下的高亮与跳转。 这是第二个路由,用来验证多标签页、菜单高亮与顶部搜索跳转。
</p> </p>
<ul class="about__list"> <ul class="about__list">
<li>菜单结构在 <code>src/config/menu.ts</code></li> <li><code>src/config/menu.ts</code> — 菜单结构与顶部搜索的数据源</li>
<li>路由在 <code>src/router/index.ts</code>,新增页面两处都要登记</li> <li><code>src/router/index.ts</code> — 路由表,业务页是 basic-layout 的子路由</li>
<li>布局组件在 <code>src/components/layout/</code></li> <li><code>src/components/layout/</code> — 顶部栏、标签栏、双栏侧边栏</li>
<li><code>src/stores/tabs.ts</code> — 标签页状态</li>
<li><code>src/assets/styles/index.css</code> — 全部颜色 / 圆角 / 阴影变量</li>
</ul> </ul>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.about { .about {
padding: 20px; padding: 24px;
background: var(--color-bg-card); background: var(--bg-card);
border: 1px solid var(--color-border); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
} }
.about__title { .about__title {
margin: 0 0 8px; margin: 0 0 6px;
font-size: 20px; font-size: 18px;
font-weight: 600;
} }
.about__desc { .about__desc {
margin: 0 0 16px; margin: 0 0 20px;
color: var(--color-text-weak); color: var(--ink-400);
font-size: 13px;
} }
.about__list { .about__list {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0; margin: 0;
padding-left: 20px; padding: 0;
line-height: 2; list-style: none;
font-size: 13px;
}
.about__list li {
padding-left: 14px;
position: relative;
color: var(--ink-600);
}
.about__list li::before {
content: "";
position: absolute;
top: 9px;
left: 0;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--brand-200);
} }
code { code {
padding: 1px 4px; padding: 1px 5px;
border-radius: 3px; border-radius: 4px;
background: var(--color-bg-page); background: var(--line-soft);
color: var(--brand-700);
font-size: 12px;
} }
</style> </style>
<template> <template>
<div class="home"> <div class="home">
<section class="card"> <!-- 欢迎区 -->
<h1 class="card__title">Hello World</h1> <section class="hero">
<p class="card__desc"> <div class="hero__text">
这是 {{ config.appName }} 的示例页:顶部导航 + 左侧双栏布局 + 一次 SDK 请求。 <h1 class="hero__title">Hello World</h1>
<p class="hero__desc">
{{ config.appName }} 示例页:顶部栏(搜索 + 个人信息)、左侧双栏菜单、多标签页,
以及一次走完分层的 SDK 请求。
</p> </p>
</div>
<span class="hero__badge">{{ config.logEnv }}</span>
</section> </section>
<div class="grid">
<!-- SDK 请求 -->
<section class="card"> <section class="card">
<h2 class="card__subtitle">SDK 请求示例</h2> <header class="card__head">
<h2 class="card__title">SDK 请求示例</h2>
<p class="card__hint"> <p class="card__hint">
<code>pages → services → api → @common/sdk</code> 四层,接口名在 <code>pages → services → api → @common/sdk</code>,接口名在
<code>src/api/demo.ts</code> 里替换。 <code>src/api/demo.ts</code> 里替换
</p> </p>
</header>
<button class="btn" type="button" :disabled="loading" @click="onFetch"> <button class="btn" type="button" :disabled="loading" @click="onFetch">
{{ loading ? "请求中..." : "发起请求" }} {{ loading ? "请求中..." : "发起请求" }}
...@@ -21,8 +30,13 @@ ...@@ -21,8 +30,13 @@
<pre v-if="result" class="result">{{ result }}</pre> <pre v-if="result" class="result">{{ result }}</pre>
</section> </section>
<!-- 环境信息 -->
<section class="card"> <section class="card">
<h2 class="card__subtitle">环境信息</h2> <header class="card__head">
<h2 class="card__title">环境信息</h2>
<p class="card__hint">全部来自 <code>src/config/index.ts</code></p>
</header>
<dl class="info"> <dl class="info">
<dt>当前环境</dt> <dt>当前环境</dt>
<dd>{{ config.logEnv }}</dd> <dd>{{ config.logEnv }}</dd>
...@@ -33,6 +47,7 @@ ...@@ -33,6 +47,7 @@
</dl> </dl>
</section> </section>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
...@@ -57,74 +72,140 @@ async function onFetch() { ...@@ -57,74 +72,140 @@ async function onFetch() {
gap: 16px; gap: 16px;
} }
/* 欢迎区 */
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 24px 28px;
border-radius: var(--radius-lg);
background: linear-gradient(120deg, var(--brand-600) 0%, var(--brand-500) 55%, #7c6ff0 100%);
color: #fff;
box-shadow: 0 10px 28px rgb(79 70 229 / 25%);
}
.hero__title {
margin: 0 0 6px;
color: #fff;
font-size: 24px;
font-weight: 650;
letter-spacing: 0.3px;
}
.hero__desc {
max-width: 640px;
margin: 0;
color: rgb(255 255 255 / 82%);
font-size: 13px;
line-height: 1.7;
}
.hero__badge {
flex-shrink: 0;
padding: 4px 12px;
border: 1px solid rgb(255 255 255 / 35%);
border-radius: 999px;
background: rgb(255 255 255 / 15%);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.6px;
}
/* 卡片 */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px;
}
.card { .card {
padding: 20px; padding: 22px;
background: var(--color-bg-card); background: var(--bg-card);
border: 1px solid var(--color-border); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
} }
.card__title { .card__head {
margin: 0 0 8px; margin-bottom: 18px;
font-size: 22px;
} }
.card__subtitle { .card__title {
margin: 0 0 8px; margin: 0 0 4px;
font-size: 16px; font-size: 15px;
font-weight: 600;
} }
.card__desc,
.card__hint { .card__hint {
margin: 0 0 16px; margin: 0;
color: var(--color-text-weak); color: var(--ink-400);
font-size: 12px;
line-height: 1.7;
} }
code { code {
padding: 1px 4px; padding: 1px 5px;
border-radius: 3px; border-radius: 4px;
background: var(--color-bg-page); background: var(--line-soft);
color: var(--ink-600);
font-size: 12px;
} }
.btn { .btn {
height: 34px; height: 36px;
padding: 0 18px; padding: 0 20px;
border: none; border: none;
border-radius: var(--radius); border-radius: var(--radius-sm);
background: var(--color-primary); background: var(--brand-500);
color: #fff; color: #fff;
font-size: 14px; font-weight: 500;
cursor: pointer; box-shadow: 0 4px 12px rgb(79 70 229 / 25%);
transition:
background-color 0.15s,
box-shadow 0.15s;
}
.btn:hover:not(:disabled) {
background: var(--brand-600);
box-shadow: 0 6px 16px rgb(79 70 229 / 32%);
} }
.btn:disabled { .btn:disabled {
opacity: 0.6; background: var(--ink-300);
cursor: not-allowed; box-shadow: none;
} }
.result { .result {
max-height: 240px;
margin: 16px 0 0; margin: 16px 0 0;
padding: 12px; padding: 12px 14px;
border-radius: var(--radius); border: 1px solid var(--line);
background: var(--color-bg-page); border-radius: var(--radius-sm);
background: var(--line-soft);
color: var(--ink-600);
font-size: 12px; font-size: 12px;
line-height: 1.7;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
overflow: auto;
} }
.info { .info {
display: grid; display: grid;
grid-template-columns: 140px 1fr; grid-template-columns: 108px 1fr;
gap: 8px 0; gap: 10px 0;
margin: 0; margin: 0;
font-size: 13px;
} }
.info dt { .info dt {
color: var(--color-text-weak); color: var(--ink-400);
} }
.info dd { .info dd {
margin: 0; margin: 0;
color: var(--ink-700);
word-break: break-all; word-break: break-all;
} }
</style> </style>
...@@ -253,54 +253,95 @@ refreshVerifyImage() ...@@ -253,54 +253,95 @@ refreshVerifyImage()
<style scoped> <style scoped>
.login-container { .login-container {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); overflow: hidden;
background: radial-gradient(circle at 20% 20%, #312e81 0%, #1e1b4b 45%, #0f172a 100%);
}
/* 背景光斑,避免大面积纯色显得死板 */
.login-container::before,
.login-container::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(90px);
pointer-events: none;
}
.login-container::before {
top: -120px;
left: -80px;
width: 380px;
height: 380px;
background: rgb(99 102 241 / 45%);
}
.login-container::after {
right: -100px;
bottom: -140px;
width: 420px;
height: 420px;
background: rgb(139 92 246 / 32%);
} }
.login-card { .login-card {
width: 400px; position: relative;
padding: 40px; z-index: 1;
background: #fff; width: 408px;
border-radius: 8px; padding: 36px 36px 40px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); background: var(--bg-card);
border-radius: 18px;
box-shadow: 0 24px 60px rgb(2 6 23 / 45%);
} }
.login-title { .login-title {
text-align: center; text-align: center;
font-size: 24px; font-size: 22px;
font-weight: 600; font-weight: 650;
color: #333; color: var(--ink-900);
margin: 0 0 30px; letter-spacing: 0.3px;
margin: 0 0 28px;
} }
.login-tabs { .login-tabs {
display: flex; display: flex;
gap: 4px;
margin-bottom: 24px; margin-bottom: 24px;
border-bottom: 1px solid #eee; padding: 4px;
border-radius: var(--radius);
background: var(--line-soft);
} }
.tab-item { .tab-item {
flex: 1; flex: 1;
text-align: center; text-align: center;
padding: 12px 0; padding: 8px 0;
font-size: 16px; border-radius: var(--radius-sm);
color: #666; font-size: 14px;
color: var(--ink-500);
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.15s;
}
.tab-item:hover {
color: var(--ink-700);
} }
.tab-item.active { .tab-item.active {
color: #667eea; background: var(--bg-card);
border-bottom: 2px solid #667eea; color: var(--brand-600);
font-weight: 500;
box-shadow: var(--shadow-sm);
} }
.login-form { .login-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 14px;
} }
.form-item { .form-item {
...@@ -316,17 +357,24 @@ refreshVerifyImage() ...@@ -316,17 +357,24 @@ refreshVerifyImage()
.form-input { .form-input {
width: 100%; width: 100%;
height: 44px; height: 44px;
padding: 0 16px; padding: 0 14px;
border: 1px solid #ddd; border: 1px solid var(--line);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; background: var(--bg-card);
outline: none; outline: none;
transition: border-color 0.3s; transition:
border-color 0.15s,
box-shadow 0.15s;
box-sizing: border-box; box-sizing: border-box;
} }
.form-input::placeholder {
color: var(--ink-400);
}
.form-input:focus { .form-input:focus {
border-color: #667eea; border-color: var(--brand-500);
box-shadow: 0 0 0 3px var(--brand-100);
} }
.flex-1 { .flex-1 {
...@@ -337,14 +385,16 @@ refreshVerifyImage() ...@@ -337,14 +385,16 @@ refreshVerifyImage()
width: 120px; width: 120px;
height: 44px; height: 44px;
cursor: pointer; cursor: pointer;
border-radius: 4px; border: 1px solid var(--line);
border-radius: var(--radius-sm);
flex-shrink: 0; flex-shrink: 0;
object-fit: cover;
} }
.verify-img--fallback { .verify-img--fallback {
border: 1px dashed #ddd; border: 1px dashed var(--ink-300);
background: #fafafa; background: var(--line-soft);
color: #999; color: var(--ink-400);
font-size: 12px; font-size: 12px;
line-height: 1.3; line-height: 1.3;
padding: 0; padding: 0;
...@@ -353,37 +403,52 @@ refreshVerifyImage() ...@@ -353,37 +403,52 @@ refreshVerifyImage()
.sms-btn { .sms-btn {
width: 110px; width: 110px;
height: 44px; height: 44px;
border: 1px solid #667eea; border: 1px solid var(--brand-200);
border-radius: 4px; border-radius: var(--radius-sm);
background: #fff; background: var(--brand-50);
color: #667eea; color: var(--brand-600);
font-size: 14px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.15s;
white-space: nowrap; white-space: nowrap;
} }
.sms-btn:hover:not(:disabled) {
border-color: var(--brand-500);
background: var(--brand-100);
}
.sms-btn:disabled { .sms-btn:disabled {
border-color: #ccc; border-color: var(--line);
color: #ccc; background: var(--line-soft);
color: var(--ink-400);
cursor: not-allowed; cursor: not-allowed;
} }
.submit-btn { .submit-btn {
width: 100%; width: 100%;
height: 44px; height: 46px;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, var(--brand-500), var(--brand-700));
color: #fff; color: #fff;
font-size: 16px; font-size: 15px;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: opacity 0.3s; transition:
margin-top: 8px; box-shadow 0.15s,
opacity 0.15s;
margin-top: 10px;
box-shadow: 0 8px 20px rgb(79 70 229 / 32%);
}
.submit-btn:hover:not(:disabled) {
box-shadow: 0 10px 26px rgb(79 70 229 / 42%);
} }
.submit-btn:disabled { .submit-btn:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
box-shadow: none;
} }
</style> </style>
export { useAuthStore } from './auth' export { useAuthStore } from './auth'
export { useTabsStore } from './tabs'
export type { TabItem } from './tabs'
import { defineStore } from 'pinia'
import { ref } from 'vue'
/** 一个已打开的路由标签页 */
export interface TabItem {
/** 路由 fullPath,标签的唯一标识(带 query 的同一页面算两个标签) */
path: string
/** 标签文案,取 route.meta.title */
title: string
/** 是否可关闭。首页固定不可关闭,避免标签被清空后无处可去 */
closable: boolean
}
/** 关闭全部标签后的兜底落点 */
const FALLBACK_PATH = '/'
export const useTabsStore = defineStore('tabs', () => {
const tabs = ref<TabItem[]>([])
/** 打开(或复用)一个标签 */
function addTab(tab: TabItem) {
if (tabs.value.some(item => item.path === tab.path)) return
tabs.value.push(tab)
}
/**
* 关闭一个标签
*
* @returns 关闭后应该停留的 path —— 关掉的是当前标签时,调用方拿它去跳转
*/
function closeTab(path: string): string {
const index = tabs.value.findIndex(item => item.path === path)
if (index === -1) return path
tabs.value.splice(index, 1)
const next = tabs.value[index] || tabs.value[index - 1]
return next?.path ?? FALLBACK_PATH
}
/** 关闭除 path 之外的标签(不可关闭的标签保留) */
function closeOthers(path: string) {
tabs.value = tabs.value.filter(item => item.path === path || !item.closable)
}
/** 关闭所有可关闭的标签 */
function closeAll(): string {
tabs.value = tabs.value.filter(item => !item.closable)
return tabs.value[0]?.path ?? FALLBACK_PATH
}
return { tabs, addTab, closeTab, closeOthers, closeAll }
})
...@@ -81,15 +81,15 @@ if (typeof document !== 'undefined' && !document.getElementById('toast-styles')) ...@@ -81,15 +81,15 @@ if (typeof document !== 'undefined' && !document.getElementById('toast-styles'))
pointer-events: none; pointer-events: none;
} }
.toast { .toast {
padding: 12px 24px; padding: 11px 20px;
border-radius: 8px; border-radius: 10px;
font-size: 14px; font-size: 14px;
color: #fff; color: #fff;
background: rgba(0, 0, 0, 0.8); background: #0f172a;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 12px 32px rgba(15, 23, 42, 0.22);
opacity: 0; opacity: 0;
transform: translateY(-20px); transform: translateY(-20px);
transition: all 0.3s ease; transition: all 0.25s ease;
white-space: nowrap; white-space: nowrap;
max-width: 80vw; max-width: 80vw;
overflow: hidden; overflow: hidden;
...@@ -100,16 +100,16 @@ if (typeof document !== 'undefined' && !document.getElementById('toast-styles')) ...@@ -100,16 +100,16 @@ if (typeof document !== 'undefined' && !document.getElementById('toast-styles'))
transform: translateY(0); transform: translateY(0);
} }
.toast-success { .toast-success {
background: #52c41a; background: #16a34a;
} }
.toast-error { .toast-error {
background: #ff4d4f; background: #ef4444;
} }
.toast-warning { .toast-warning {
background: #faad14; background: #f59e0b;
} }
.toast-info { .toast-info {
background: #1890ff; background: #4f46e5;
} }
` `
document.head.appendChild(style) document.head.appendChild(style)
......
...@@ -65,7 +65,12 @@ if (import.meta.client) { ...@@ -65,7 +65,12 @@ if (import.meta.client) {
`layouts/` 是 Nuxt 约定目录,**新建 `layouts/xxx.vue` 后在页面里用 `definePageMeta({ layout: "xxx" })` 切换**,不用手动 import。导航项在 `config/index.ts``NAV_LINKS`,高亮判断是 `route.path === link.path` `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 包才有的)。 ## 样式
- `uno.config.ts`:只开 `presetUno()`**没有 attributify**——别写 `<div flex>`,那是 web 包才有的。里面扩了 `brand` 色阶和 `card` / `btn-primary` / `btn-ghost` 三个 shortcut,中性色用 presetUno 自带的 **slate**
- `assets/css/main.css`:字体、正文色、`code` 等全局基础样式与 CSS 变量,在 `nuxt.config.ts``css` 里注册。**顺序是 reset 在前、它在后**,调换会被 antd 的 reset 盖掉
配色取值与 admin / web 保持一致(`--brand-500: #4f46e5` 等),三个包看起来是同一套设计。
## 目录是 Nuxt 约定,没有 src/ ## 目录是 Nuxt 约定,没有 src/
......
/* 全局基础样式与设计变量(与 admin / web 用同一套取值) */
:root {
--brand-50: #eef2ff;
--brand-100: #e0e7ff;
--brand-500: #4f46e5;
--brand-600: #4338ca;
--brand-700: #3730a3;
--ink-900: #0f172a;
--ink-600: #475569;
--ink-400: #94a3b8;
--line: #e2e8f0;
--line-soft: #f1f5f9;
}
html {
scroll-behavior: smooth;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 15px;
line-height: 1.6;
color: var(--ink-600);
background-color: #fff;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3 {
color: var(--ink-900);
}
/* 链接一律不要下划线,颜色跟随上下文(含 NuxtLink,它渲染出来就是 a) */
a,
a:hover,
a:visited {
color: inherit;
text-decoration: none;
}
/*
* 按钮不要浏览器自带的边框与底色。
*
* 两个坑:
* 1. 用 border-width: 0 而不是 border: none —— 后者会把 border-style 也设成 none,
* 之后给按钮加 UnoCSS 的 `border` 工具类(只设 width)就不显示了。保留 solid,
* 需要边框时加一个 border 类即可。
* 2. appearance: none 是必须的 —— antd 的 reset.css 给 button 设了
* `-webkit-appearance: button`,不覆盖掉原生外观还会漏出来。本文件在
* nuxt.config.ts 的 css 数组里排在 antd reset 之后,所以能盖住。
*/
button {
appearance: none;
border-width: 0;
border-style: solid;
background-color: transparent;
background-image: none;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
:focus-visible {
outline: 2px solid var(--brand-200, #c7d2fe);
outline-offset: 2px;
}
/* 内嵌代码片段,官网里用来标注文件路径 */
code {
padding: 1px 5px;
border-radius: 4px;
background: var(--line-soft);
color: var(--brand-700);
font-size: 0.875em;
}
<template> <template>
<div class="min-h-screen flex flex-col bg-white text-gray-800"> <div class="min-h-screen flex flex-col">
<header class="sticky top-0 z-10 border-b border-gray-200 bg-white/90 backdrop-blur"> <header class="sticky top-0 z-20 border-b border-slate-200/80 bg-white/80 backdrop-blur-md">
<div class="mx-auto max-w-1200px flex items-center gap-6 px-6 py-4"> <div class="mx-auto max-w-1120px flex items-center gap-8 px-6 py-3.5">
<NuxtLink class="flex items-center gap-2" to="/"> <NuxtLink class="flex items-center gap-2.5" to="/">
<span class="h-7 w-7 flex items-center justify-center rounded bg-gray-900 text-sm text-white font-bold"> <span
class="h-8 w-8 flex items-center justify-center rounded-10px from-brand-500 to-brand-700 bg-gradient-to-br text-sm text-white font-bold shadow-[0_4px_12px_rgba(79,70,229,0.3)]"
>
S S
</span> </span>
<span class="text-base font-semibold">{{ SITE_NAME }}</span> <span class="text-slate-900 font-600">{{ SITE_NAME }}</span>
</NuxtLink> </NuxtLink>
<nav class="flex flex-1 items-center gap-1"> <nav class="flex flex-1 items-center gap-1">
<NuxtLink <NuxtLink
v-for="link in NAV_LINKS" v-for="link in NAV_LINKS"
:key="link.path" :key="link.path"
class="rounded px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-900" class="rounded-8px px-3 py-1.5 text-sm text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
:class="route.path === link.path ? '!bg-gray-100 !text-gray-900' : ''" :class="route.path === link.path ? '!bg-brand-50 !text-brand-700' : ''"
:to="link.path" :to="link.path"
> >
{{ link.title }} {{ link.title }}
</NuxtLink> </NuxtLink>
</nav> </nav>
<a <a class="btn-primary" href="#contact">联系我们</a>
class="border border-gray-300 rounded px-3 py-1.5 text-sm hover:border-gray-900"
href="#contact"
>
联系我们
</a>
</div> </div>
</header> </header>
<main class="mx-auto w-full max-w-1200px flex-1 px-6 py-8"> <main class="mx-auto w-full max-w-1120px flex-1 px-6 py-14">
<slot /> <slot />
</main> </main>
<footer id="contact" class="border-t border-gray-200"> <footer id="contact" class="mt-10 border-t border-slate-200 bg-slate-50">
<div class="mx-auto max-w-1200px px-6 py-6 text-sm text-gray-400"> <div class="mx-auto max-w-1120px flex flex-wrap items-center justify-between gap-4 px-6 py-8">
© {{ SITE_NAME }} — Nuxt 3 SSR 模板 <div>
<div class="text-slate-900 font-600">{{ SITE_NAME }}</div>
<p class="mt-1 text-13px text-slate-400">Nuxt 3 SSR 模板 · 顶部导航布局</p>
</div>
<!-- TODO: 换成项目真实的联系方式与备案信息 -->
<div class="text-13px text-slate-400">
<div>contact@example.com</div>
<div class="mt-1">© {{ SITE_NAME }}</div>
</div>
</div> </div>
</footer> </footer>
</div> </div>
......
...@@ -7,8 +7,10 @@ export default defineNuxtConfig({ ...@@ -7,8 +7,10 @@ export default defineNuxtConfig({
'@pinia/nuxt', '@pinia/nuxt',
], ],
// reset 在前、项目样式在后,顺序不要调换
css: [ css: [
'ant-design-vue/dist/reset.css', 'ant-design-vue/dist/reset.css',
'~/assets/css/main.css',
], ],
// 运行时配置:public 下的值会通过 NUXT_PUBLIC_* 环境变量覆盖 // 运行时配置:public 下的值会通过 NUXT_PUBLIC_* 环境变量覆盖
......
<template> <template>
<div class="flex flex-col gap-4"> <div class="mx-auto max-w-720px">
<h1 class="text-2xl font-bold">关于我们</h1> <h1 class="text-32px text-slate-900 font-700">关于我们</h1>
<p class="text-gray-500"> <p class="mt-3 text-slate-500">
这是第二个页面,用来验证顶部导航的高亮与文件路由。 这是第二个页面,用来验证顶部导航的高亮与 Nuxt 的文件路由。
</p> </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> <div class="mt-10 flex flex-col gap-4">
<li>导航项在 <code>config/index.ts</code><code>NAV_LINKS</code></li> <div v-for="item in items" :key="item.file" class="card flex gap-4 p-5">
<li>Nuxt 是文件路由,<code>pages/about.vue</code> 自动就是 <code>/about</code>,不用注册</li> <span
</ul> class="mt-0.5 h-6 w-6 shrink-0 flex items-center justify-center rounded-full bg-brand-50 text-12px text-brand-600 font-600"
>
{{ item.index }}
</span>
<div>
<code>{{ item.file }}</code>
<p class="mt-1.5 text-14px text-slate-500">{{ item.desc }}</p>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SITE_NAME } from '~/config' import { SITE_NAME } from '~/config'
const items = [
{ index: 1, file: 'layouts/default.vue', desc: '顶部导航布局,由 app.vue 的 NuxtLayout 套上' },
{ index: 2, file: 'config/index.ts', desc: '导航项 NAV_LINKS、站点名、Token key' },
{ index: 3, file: 'uno.config.ts', desc: 'brand 色阶与 card / btn-primary / btn-ghost 快捷类' },
{ index: 4, file: 'assets/css/main.css', desc: '字体、正文色、code 样式等全局基础样式' },
{ index: 5, file: 'pages/about.vue', desc: '文件路由,放进 pages/ 自动就是 /about' },
]
useHead({ title: `关于我们 - ${SITE_NAME}` }) useHead({ title: `关于我们 - ${SITE_NAME}` })
</script> </script>
<template> <template>
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-16">
<section> <!-- Hero -->
<h1 class="text-3xl font-bold">Hello World</h1> <section class="relative overflow-hidden rounded-20px px-8 py-16 text-center">
<p class="mt-2 text-gray-500"> <div class="absolute inset-0 from-brand-50 to-white bg-gradient-to-b" />
<div
class="absolute left-1/2 top-[-140px] h-320px w-320px translate-x-[-50%] rounded-full bg-brand-200/40 blur-3xl"
/>
<div class="relative">
<span
class="inline-flex items-center gap-1.5 border border-brand-200 rounded-full bg-white px-3 py-1 text-12px text-brand-700"
>
Nuxt 3 · SSR
</span>
<h1 class="mx-auto mt-5 max-w-680px text-40px text-slate-900 font-700 leading-tight">
Hello World
</h1>
<p class="mx-auto mt-4 max-w-560px text-slate-500">
{{ SITE_NAME }} 示例页:顶部导航布局 + 一次接口请求。 {{ SITE_NAME }} 示例页:顶部导航布局 + 一次接口请求。
本包不用 <code>@common/sdk</code>,走 axios + runtimeConfig。
</p> </p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<button class="btn-primary" type="button" :disabled="pending" @click="onFetch">
{{ pending ? "请求中..." : "发起请求" }}
</button>
<NuxtLink class="btn-ghost" to="/about">了解更多</NuxtLink>
</div>
</div>
</section> </section>
<section class="border border-gray-200 rounded p-5"> <!-- 请求结果 -->
<h2 class="text-lg font-semibold">接口请求示例</h2> <section v-if="result || errorMsg">
<p class="mt-1 text-sm text-gray-500"> <h2 class="text-lg font-600">请求结果</h2>
site 不用 <code>@common/sdk</code>,走 <code>axios + runtimeConfig</code> <pre
baseURL 来自 <code>NUXT_PUBLIC_API_BASE_URL</code> v-if="result"
class="mt-3 overflow-x-auto border border-slate-200 rounded-10px bg-slate-50 p-4 text-13px leading-relaxed"
>{{ result }}</pre>
<p v-if="errorMsg" class="mt-3 border border-red-100 rounded-10px bg-red-50 px-4 py-3 text-sm text-red-500">
{{ errorMsg }}
</p> </p>
</section>
<button <!-- 特性卡片 -->
class="mt-4 rounded bg-gray-900 px-4 py-2 text-sm text-white disabled:opacity-60" <section>
type="button" <h2 class="text-center text-22px font-650">模板里已经有的东西</h2>
:disabled="pending" <div class="mt-8 grid grid-cols-1 gap-5 md:grid-cols-3">
@click="onFetch" <article
v-for="feature in features"
:key="feature.title"
class="card p-6 transition-shadow hover:shadow-[0_12px_32px_rgba(15,23,42,0.08)]"
> >
{{ pending ? "请求中..." : "发起请求" }} <div
</button> class="h-10 w-10 flex items-center justify-center rounded-10px bg-brand-50 text-brand-600"
>
<pre v-if="result" class="mt-4 overflow-x-auto rounded bg-gray-50 p-3 text-xs">{{ result }}</pre> <svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<p v-if="errorMsg" class="mt-4 text-sm text-red-500">{{ errorMsg }}</p> <path :d="feature.icon" fill="currentColor" />
</svg>
</div>
<h3 class="mt-4 text-base font-600">{{ feature.title }}</h3>
<p class="mt-1.5 text-14px text-slate-500">{{ feature.desc }}</p>
</article>
</div>
</section> </section>
<section class="border border-gray-200 rounded p-5"> <!-- 运行时配置 -->
<h2 class="text-lg font-semibold">运行时配置</h2> <section class="card p-6">
<dl class="mt-3 grid grid-cols-[140px_1fr] gap-y-2 text-sm"> <h2 class="text-base font-600">运行时配置</h2>
<dt class="text-gray-500">当前环境</dt> <p class="mt-1 text-13px text-slate-400">
<dd>{{ runtime.public.appEnv }}</dd> 取自 <code>useRuntimeConfig()</code>,由 <code>NUXT_PUBLIC_*</code> 注入
<dt class="text-gray-500">API 地址</dt> </p>
<dd class="break-all"> <dl class="mt-5 grid grid-cols-[120px_1fr] gap-y-3 text-14px">
<dt class="text-slate-400">当前环境</dt>
<dd class="m-0">{{ runtime.public.appEnv }}</dd>
<dt class="text-slate-400">API 地址</dt>
<dd class="m-0 break-all">
{{ runtime.public.apiBaseUrl || "未配置(请填 NUXT_PUBLIC_API_BASE_URL)" }} {{ runtime.public.apiBaseUrl || "未配置(请填 NUXT_PUBLIC_API_BASE_URL)" }}
</dd> </dd>
</dl> </dl>
...@@ -51,6 +91,24 @@ const pending = ref(false) ...@@ -51,6 +91,24 @@ const pending = ref(false)
const result = ref('') const result = ref('')
const errorMsg = ref('') const errorMsg = ref('')
const features = [
{
title: '顶部导航布局',
desc: 'layouts/default.vue,sticky 顶栏 + 内容区 + 页脚,导航项在 config/index.ts',
icon: 'M3 5h18v3H3V5zm0 5h18v9H3v-9z',
},
{
title: 'axios + runtimeConfig',
desc: 'api/request.ts 已处理 baseURL 与 token 注入,SSR 阶段不碰 localStorage',
icon: 'M12 2a10 10 0 100 20 10 10 0 000-20zm0 3a7 7 0 016.7 5H5.3A7 7 0 0112 5zm0 14a7 7 0 01-6.7-5h13.4A7 7 0 0112 19z',
},
{
title: 'UnoCSS',
desc: 'uno.config.ts 里定义了 brand 色阶与 card / btn-primary 快捷类',
icon: 'M4 4h7v7H4V4zm9 0h7v4h-7V4zm0 6h7v10h-7V10zM4 13h7v7H4v-7z',
},
]
useHead({ title: SITE_NAME }) useHead({ title: SITE_NAME })
async function onFetch() { async function onFetch() {
......
import { defineConfig, presetUno } from 'unocss'
/**
* site 的 UnoCSS 配置
*
* 只开 presetUno(**没有 attributify**,别写 `<div flex>`,那是 web 包才有的)。
* 中性色直接用 presetUno 自带的 slate 系列,这里只补品牌色。
*/
export default defineConfig({
presets: [presetUno()],
theme: {
colors: {
brand: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
500: '#4f46e5',
600: '#4338ca',
700: '#3730a3',
},
},
},
shortcuts: {
'card': 'bg-white border border-slate-200 rounded-14px',
'btn-primary':
'inline-flex items-center justify-center h-10 px-5 rounded-8px bg-brand-500 text-white text-sm font-medium transition-colors hover:bg-brand-600',
'btn-ghost':
'inline-flex items-center justify-center h-10 px-5 rounded-8px border border-slate-300 text-sm transition-colors hover:border-slate-900',
},
})
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
## UnoCSS ## UnoCSS
`uno.config.ts` 启用了 `presetUno()` + `presetAttributify()`,所以两种写法都可用: `uno.config.ts` 启用了 `presetUno()` + `presetAttributify()`(还扩了 `brand` 色阶与 shortcut,见下),所以两种写法都可用:
```vue ```vue
<div class="flex items-center p-4" /> <div class="flex items-center p-4" />
...@@ -34,19 +34,42 @@ ...@@ -34,19 +34,42 @@
与 admin 相同,`vite.config.ts` 的 alias 和 `tsconfig.json``paths` 要同步改。 与 admin 相同,`vite.config.ts` 的 alias 和 `tsconfig.json``paths` 要同步改。
## 布局:顶部导航 + 左侧单栏 ## 布局:顶部栏 + 标签栏 + 左侧单栏
``` ```
src/components/layout/ src/components/layout/
├── basic-layout.vue # 骨架:AppHeader 在上,AppSidebar + <RouterView> 在下 ├── basic-layout.vue # 骨架:AppHeader / (AppSidebar + TabsBar + <RouterView>)
├── app-header.vue # 顶部导航:品牌 + 快捷入口 + 用户名 + 退出登录 ├── app-header.vue # 顶部栏:品牌 + 搜索框 + 个人信息区(无导航链接)
├── tabs-bar.vue # 多标签页 + 标签管理(关闭其他 / 关闭全部)
└── app-sidebar.vue # 左侧单栏菜单(admin 是双栏,这里是单栏,别照搬) └── app-sidebar.vue # 左侧单栏菜单(admin 是双栏,这里是单栏,别照搬)
``` ```
布局组件**全部用 UnoCSS 工具类**,没有 `<style>` 块。菜单结构在 `src/config/menu.ts``menuItems` 侧栏用、`topNavLinks` 顶部用),图标是 SVG 的 `path d` 字符串,没有图标库。 **顶部栏没有导航链接**,导航只有两个入口:左侧菜单、顶部搜索框(数据源就是 `menuItems`,回车跳第一条)。个人信息区点开是操作菜单,浮层收起用的是**全屏透明遮罩**而不是 `document` 事件监听。
布局与页面的结构、间距用 UnoCSS 工具类;**浮层、标签胶囊这类多状态样式写在 `<style scoped lang="scss">` 里**,摊成一长串工具类反而没法读。菜单结构在 `src/config/menu.ts`,图标是 SVG 的 `path d` 字符串,没有图标库。
菜单里的 `path` 必须和 router 里登记的完全一致,高亮判断就是 `route.path === item.path` 菜单里的 `path` 必须和 router 里登记的完全一致,高亮判断就是 `route.path === item.path`
## 多标签页
与 admin 是**各自一份**`src/stores/tabs.ts` + `tabs-bar.vue`),改一个不影响另一个。规则同 admin:
- 标签唯一标识是 `route.fullPath`;标签文案取 `route.meta.title`,漏写会显示成路径
- 登记标签的 `watch``basic-layout.vue` 里,登录页不产生标签
- 首页标签 `closable: false`,布局 setup 时无条件登记一次
- `closeTab` 返回该停留的 path,跳转由调用方做,store 不碰 router
## 配色:两处来源,取值保持一致
| 写法 | 来源 |
|------|------|
| UnoCSS 工具类 `brand-* / slate-*` | `uno.config.ts``theme.colors` |
| 手写 SCSS 里的 `var(--brand-500)` 等 | `src/assets/styles/index.scss``:root` |
中性色一律用 presetUno 自带的 **slate** 系列,不要再造第二套灰。`uno.config.ts` 里还有 `card` / `btn-primary` 两个 shortcut。
`src/utils/toast.ts` 的配色是 JS 字符串里注入的,改主题时别忘了它。
## 新增页面的三步 ## 新增页面的三步
1. `src/pages/<feature>/index.vue` 1. `src/pages/<feature>/index.vue`
......
// 全局基础样式 // 全局基础样式与设计变量
//
// 颜色有两处来源,别混用:
// - UnoCSS 工具类(brand-* / slate-*)→ uno.config.ts
// - 手写 SCSS 里的 var(--xxx) → 这里
// 两边的取值保持一致。
:root {
--brand-50: #eef2ff;
--brand-100: #e0e7ff;
--brand-200: #c7d2fe;
--brand-500: #4f46e5;
--brand-600: #4338ca;
--brand-700: #3730a3;
--ink-900: #0f172a;
--ink-700: #334155;
--ink-600: #475569;
--ink-500: #64748b;
--ink-400: #94a3b8;
--ink-300: #cbd5e1;
--line: #e2e8f0;
--line-soft: #f1f5f9;
--bg-page: #f6f8fb;
--bg-card: #ffffff;
--danger: #ef4444;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--shadow-sm: 0 1px 2px rgb(15 23 42 / 6%);
--shadow-lg: 0 12px 32px rgb(15 23 42 / 12%);
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
...@@ -15,12 +49,61 @@ body { ...@@ -15,12 +49,61 @@ body {
font-family: font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 14px; font-size: 14px;
color: #333; line-height: 1.55;
background-color: #f5f7fa; color: var(--ink-700);
background-color: var(--bg-page);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3 {
color: var(--ink-900);
} }
a { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
button,
input {
font-family: inherit;
font-size: inherit;
color: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
:focus-visible {
outline: 2px solid var(--brand-200);
outline-offset: 2px;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: var(--ink-300);
border: 2px solid transparent;
border-radius: 8px;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ink-400);
background-clip: content-box;
}
::-webkit-scrollbar-track {
background: transparent;
}
export { default as AppHeader } from './layout/app-header.vue' export { default as AppHeader } from './layout/app-header.vue'
export { default as AppSidebar } from './layout/app-sidebar.vue' export { default as AppSidebar } from './layout/app-sidebar.vue'
export { default as BasicLayout } from './layout/basic-layout.vue' export { default as BasicLayout } from './layout/basic-layout.vue'
export { default as TabsBar } from './layout/tabs-bar.vue'
<template> <template>
<header class="h-14 flex shrink-0 items-center gap-4 border-b border-gray-200 bg-white px-4"> <header class="relative z-20 h-15 flex shrink-0 items-center gap-5 border-b border-slate-200 bg-white pr-5">
<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> <div class="w-52 flex shrink-0 items-center gap-2.5 pl-5">
<span class="text-base font-600">{{ config.appName }}</span> <span
class="h-7.5 w-7.5 flex items-center justify-center rounded-9px from-brand-500 to-brand-700 bg-gradient-to-br text-white font-bold shadow-[0_4px_10px_rgba(79,70,229,0.3)]"
>
W
</span>
<span class="text-base text-slate-900 font-600">{{ config.appName }}</span>
</div> </div>
<nav class="flex flex-1 items-center gap-1"> <!-- 搜索框:过滤菜单做快捷跳转(TODO: 接业务全局搜索时替换数据源) -->
<RouterLink <div
v-for="link in topNavLinks" class="relative h-9 w-70 flex items-center border border-transparent rounded-10px bg-slate-100 px-3 transition-colors focus-within:border-brand-200 focus-within:bg-white"
: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 }} <svg class="mr-2 h-4 w-4 shrink-0 text-slate-400" viewBox="0 0 24 24" aria-hidden="true">
</RouterLink> <path
</nav> d="M10 4a6 6 0 104 10.5l4.3 4.3 1.4-1.4-4.3-4.3A6 6 0 0010 4zm0 2a4 4 0 110 8 4 4 0 010-8z"
fill="currentColor"
/>
</svg>
<input
v-model="keyword"
class="w-full border-none bg-transparent outline-none placeholder-slate-400"
type="search"
placeholder="搜索菜单"
@keydown.enter="gotoFirstMatch"
@keydown.esc="keyword = ''"
/>
<div v-if="keyword.trim()" class="popover left-0 top-[calc(100%+8px)] w-full">
<template v-if="matches.length">
<button
v-for="item in matches"
:key="item.path"
class="popover-item"
type="button"
@click="goto(item.path)"
>
{{ item.title }}
</button>
</template>
<div v-else class="px-2.5 py-3 text-center text-slate-400">没有匹配的菜单</div>
</div>
</div>
<div class="flex shrink-0 items-center gap-3"> <!-- 个人信息区:点击展开相关操作 -->
<span class="text-gray-500">{{ userName }}</span> <div class="relative ml-auto">
<button <button
class="h-7.5 border border-gray-200 rounded bg-transparent px-3 text-13px hover:border-blue-500 hover:text-blue-500" class="h-10 flex items-center gap-2 border-none rounded-10px bg-transparent px-2.5 transition-colors hover:bg-slate-100"
:class="userMenuOpen ? '!bg-slate-100' : ''"
type="button" type="button"
@click="onLogout" @click="userMenuOpen = !userMenuOpen"
>
<span
class="h-7 w-7 flex items-center justify-center rounded-full bg-brand-100 text-13px text-brand-700 font-600"
> >
{{ userInitial }}
</span>
<span class="text-slate-700 font-500">{{ userName }}</span>
<svg class="h-4 w-4 text-slate-400" viewBox="0 0 24 24" aria-hidden="true">
<path d="M7 10l5 5 5-5z" fill="currentColor" />
</svg>
</button>
<div v-if="userMenuOpen" class="popover right-0 top-[calc(100%+6px)] w-50">
<div class="mb-1 flex flex-col border-b border-slate-100 px-2.5 pb-2.5 pt-2">
<span class="text-slate-900 font-600">{{ userName }}</span>
<span class="text-12px text-slate-400">{{ userMobile || "未绑定手机号" }}</span>
</div>
<!-- TODO: 有「个人中心」「修改密码」页面后加在这里 -->
<button class="popover-item" type="button" @click="goto('/about')">关于模板</button>
<button class="popover-item popover-item--danger" type="button" @click="onLogout">
退出登录 退出登录
</button> </button>
</div> </div>
</div>
<!-- 点击空白处收起浮层,避免挂 document 事件监听 -->
<div v-if="userMenuOpen || keyword.trim()" class="fixed inset-0 z-10" @click="closeAll" />
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { config, topNavLinks } from '@/config' import { config, menuItems } from '@/config'
import { handleLogout } from '@/services' import { handleLogout } from '@/services'
import { useAuthStore } from '@/stores' import { useAuthStore } from '@/stores'
const route = useRoute()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const keyword = ref('')
const userMenuOpen = ref(false)
const userName = computed(() => authStore.userInfo?.username || '未登录') const userName = computed(() => authStore.userInfo?.username || '未登录')
const userMobile = computed(() => authStore.userInfo?.mobile || '')
const userInitial = computed(() => userName.value.slice(0, 1).toUpperCase())
const matches = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return []
return menuItems.filter(item => item.title.toLowerCase().includes(kw))
})
function closeAll() {
userMenuOpen.value = false
keyword.value = ''
}
function goto(path: string) {
closeAll()
router.push(path)
}
function gotoFirstMatch() {
if (matches.value.length) goto(matches.value[0].path)
}
function onLogout() { function onLogout() {
closeAll()
handleLogout() handleLogout()
router.push('/login') router.push('/login')
} }
</script> </script>
<style scoped lang="scss">
// 浮层用手写样式:z-index / 阴影这类值不适合摊成一长串工具类
.popover {
position: absolute;
z-index: 30;
padding: 6px;
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.popover-item {
display: block;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-700);
text-align: left;
&:hover {
background: var(--brand-50);
color: var(--brand-700);
}
&--danger:hover {
background: #fef2f2;
color: var(--danger);
}
}
</style>
<template> <template>
<aside class="w-45 shrink-0 border-r border-gray-200 bg-white p-2"> <aside class="w-52 shrink-0 border-r border-slate-200 bg-white px-2.5 py-3.5">
<div class="px-3 pb-3 pt-0.5 text-12px text-slate-400 font-500 tracking-wide">导航</div>
<nav class="flex flex-col gap-0.5"> <nav class="flex flex-col gap-0.5">
<RouterLink <RouterLink
v-for="item in menuItems" v-for="item in menuItems"
:key="item.path" :key="item.path"
class="flex items-center gap-2 rounded px-3 py-2.5 text-gray-700 hover:bg-gray-100" class="menu-item"
:class="route.path === item.path ? '!bg-blue-50 !text-blue-500 font-500' : ''" :class="{ 'is-active': route.path === item.path }"
:to="item.path" :to="item.path"
> >
<svg class="h-4.5 w-4.5" viewBox="0 0 24 24" aria-hidden="true"> <svg class="h-4.5 w-4.5 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
<path :d="item.icon" fill="currentColor" /> <path :d="item.icon" fill="currentColor" />
</svg> </svg>
<span>{{ item.title }}</span> <span>{{ item.title }}</span>
...@@ -23,3 +24,42 @@ import { menuItems } from '@/config' ...@@ -23,3 +24,42 @@ import { menuItems } from '@/config'
const route = useRoute() const route = useRoute()
</script> </script>
<style scoped lang="scss">
.menu-item {
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: var(--radius-sm);
color: var(--ink-600);
transition:
color 0.15s,
background-color 0.15s;
&:hover {
color: var(--ink-900);
background: var(--line-soft);
}
&.is-active {
color: var(--brand-700);
background: var(--brand-50);
font-weight: 500;
}
// 选中项左侧加一条品牌色竖条,比纯底色更容易定位
&.is-active::before {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 3px;
height: 16px;
border-radius: 0 3px 3px 0;
background: var(--brand-500);
transform: translateY(-50%);
}
}
</style>
...@@ -3,11 +3,9 @@ ...@@ -3,11 +3,9 @@
<AppHeader /> <AppHeader />
<div class="min-h-0 flex flex-1"> <div class="min-h-0 flex flex-1">
<AppSidebar /> <AppSidebar />
<main class="min-w-0 flex flex-1 flex-col overflow-auto"> <main class="min-w-0 flex flex-1 flex-col">
<div class="px-5 pt-3.5 text-13px text-gray-400"> <TabsBar />
{{ pageTitle }} <div class="flex-1 overflow-auto p-5">
</div>
<div class="flex-1 px-5 pb-5 pt-3">
<RouterView /> <RouterView />
</div> </div>
</main> </main>
...@@ -16,12 +14,41 @@ ...@@ -16,12 +14,41 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useTabsStore } from '@/stores'
import AppHeader from './app-header.vue' import AppHeader from './app-header.vue'
import AppSidebar from './app-sidebar.vue' import AppSidebar from './app-sidebar.vue'
import TabsBar from './tabs-bar.vue'
const route = useRoute() const route = useRoute()
const router = useRouter()
const tabsStore = useTabsStore()
// 固定的首页标签:直接深链进 /about 时也要有它,标题从路由表取,不重复写死
const homeRoute = router.resolve('/')
tabsStore.addTab({
path: '/',
title: (homeRoute.meta.title as string) || '首页',
closable: false,
})
const pageTitle = computed(() => (route.meta.title as string) || '') /**
* 进入布局内的任意路由都登记成一个标签页。
*
* 放在布局里而不是 router.afterEach,是为了让登录页这类布局外的路由不产生标签,
* 同时避免在 pinia 激活前调用 store。
*/
watch(
() => route.fullPath,
() => {
tabsStore.addTab({
path: route.fullPath,
title: (route.meta.title as string) || route.fullPath,
// 首页固定,防止标签全被关掉后无处可去
closable: route.path !== '/',
})
},
{ immediate: true },
)
</script> </script>
<template>
<div class="relative z-10 h-10.5 flex shrink-0 items-center border-b border-slate-200 bg-white pl-3 pr-2">
<div class="tabs-scroll min-w-0 flex flex-1 items-center gap-1.5">
<!-- 外层不用 button:里面还要放一个关闭按钮,button 不能嵌套 -->
<div
v-for="tab in tabsStore.tabs"
:key="tab.path"
class="tab"
:class="{ 'is-active': tab.path === route.fullPath }"
role="button"
tabindex="0"
@click="router.push(tab.path)"
@keydown.enter="router.push(tab.path)"
>
<span class="tab__dot" />
<span class="tab__title">{{ tab.title }}</span>
<button
v-if="tab.closable"
class="tab__close"
type="button"
aria-label="关闭标签"
@click.stop="onClose(tab.path)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6 6l12 12M18 6L6 18"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</div>
<!-- 标签管理 -->
<div class="relative shrink-0 pl-2">
<button
class="more"
:class="{ 'is-open': menuOpen }"
type="button"
aria-label="标签管理"
@click="menuOpen = !menuOpen"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6 12h.01M12 12h.01M18 12h.01"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
</svg>
</button>
<div v-if="menuOpen" class="menu">
<button class="menu__item" type="button" @click="onCloseOthers">关闭其他</button>
<button class="menu__item" type="button" @click="onCloseAll">关闭全部</button>
</div>
</div>
<div v-if="menuOpen" class="fixed inset-0 z-10" @click="menuOpen = false" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTabsStore } from '@/stores'
const route = useRoute()
const router = useRouter()
const tabsStore = useTabsStore()
const menuOpen = ref(false)
function onClose(path: string) {
const next = tabsStore.closeTab(path)
// 关掉的是当前标签才需要跳转,关别的标签停在原地
if (path === route.fullPath) router.push(next)
}
function onCloseOthers() {
menuOpen.value = false
tabsStore.closeOthers(route.fullPath)
}
function onCloseAll() {
menuOpen.value = false
router.push(tabsStore.closeAll())
}
</script>
<style scoped lang="scss">
.tabs-scroll {
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.tab {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 8px 0 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--bg-card);
color: var(--ink-600);
font-size: 13px;
white-space: nowrap;
cursor: pointer;
user-select: none;
transition: all 0.15s;
&:hover {
border-color: var(--brand-200);
color: var(--brand-700);
}
&.is-active {
border-color: transparent;
background: var(--brand-500);
color: #fff;
box-shadow: 0 2px 8px rgb(79 70 229 / 28%);
}
&__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ink-300);
flex-shrink: 0;
}
&.is-active &__dot {
background: rgb(255 255 255 / 85%);
}
&__title {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
}
&__close {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
flex-shrink: 0;
svg {
width: 10px;
height: 10px;
}
&:hover {
background: rgb(15 23 42 / 8%);
}
}
&.is-active &__close:hover {
background: rgb(255 255 255 / 25%);
}
}
.more {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--ink-500);
svg {
width: 16px;
height: 16px;
}
&:hover,
&.is-open {
border-color: var(--brand-200);
background: var(--brand-50);
color: var(--brand-600);
}
}
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 30;
width: 132px;
padding: 6px;
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
&__item {
display: block;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-700);
font-size: 13px;
text-align: left;
&:hover {
background: var(--brand-50);
color: var(--brand-700);
}
}
}
</style>
...@@ -33,5 +33,5 @@ export const config: WebConfig = { ...@@ -33,5 +33,5 @@ export const config: WebConfig = {
/** 免登录白名单(路由 path) */ /** 免登录白名单(路由 path) */
export const noNeedAuthPages = ['/login'] export const noNeedAuthPages = ['/login']
export { menuItems, topNavLinks } from './menu' export { menuItems } from './menu'
export type { MenuItem } from './menu' export type { MenuItem } from './menu'
...@@ -32,8 +32,4 @@ export const menuItems: MenuItem[] = [ ...@@ -32,8 +32,4 @@ export const menuItems: MenuItem[] = [
}, },
] ]
/** 顶部导航的快捷入口 */ // 顶部栏没有导航链接,导航只走左侧菜单 + 顶部搜索框(搜索的数据源就是 menuItems)
export const topNavLinks: Pick<MenuItem, 'title' | 'path'>[] = [
{ title: '首页', path: '/' },
{ title: '关于模板', path: '/about' },
]
<template> <template>
<div class="rounded border border-gray-200 bg-white p-5"> <div class="card p-6">
<h1 class="mb-2 text-20px font-600">关于模板</h1> <h1 class="mb-1.5 text-18px font-600">关于模板</h1>
<p class="mb-4 text-gray-500"> <p class="mb-5 text-13px text-slate-400">
这是第二个路由,用来验证「顶部导航 + 左侧菜单」在多页面下的高亮与跳转。 这是第二个路由,用来验证多标签页、菜单高亮与顶部搜索跳转。
</p> </p>
<ul class="m-0 pl-5 leading-loose">
<li>菜单结构在 <code>src/config/menu.ts</code></li> <ul class="m-0 flex flex-col list-none gap-2.5 p-0 text-13px">
<li>路由在 <code>src/router/index.ts</code>,新增页面两处都要登记</li> <li v-for="line in lines" :key="line.file" class="dot-item text-slate-600">
<li>布局组件在 <code>src/components/layout/</code></li> <code>{{ line.file }}</code>{{ line.desc }}
</li>
</ul> </ul>
</div> </div>
</template> </template>
<script setup lang="ts">
const lines = [
{ file: 'src/config/menu.ts', desc: '菜单结构与顶部搜索的数据源' },
{ file: 'src/router/index.ts', desc: '路由表,业务页是 basic-layout 的子路由' },
{ file: 'src/components/layout/', desc: '顶部栏、标签栏、侧边栏' },
{ file: 'src/stores/tabs.ts', desc: '标签页状态' },
{ file: 'uno.config.ts', desc: 'brand 色阶与 card / btn-primary / field 快捷类' },
{ file: 'src/assets/styles/index.scss', desc: '手写 SCSS 用的 CSS 变量' },
]
</script>
<style scoped lang="scss"> <style scoped lang="scss">
.dot-item {
position: relative;
padding-left: 14px;
&::before {
content: "";
position: absolute;
top: 9px;
left: 0;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--brand-200);
}
}
code { code {
padding: 1px 4px; padding: 1px 5px;
border-radius: 3px; border-radius: 4px;
background: #f5f7fa; background: var(--line-soft);
color: var(--brand-700);
font-size: 12px;
} }
</style> </style>
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<section class="card"> <!-- 欢迎区 -->
<h1 class="mb-2 text-22px font-600">Hello World</h1> <section
<p class="text-gray-500"> class="flex items-start justify-between gap-5 rounded-14px from-brand-600 via-brand-500 to-[#7c6ff0] bg-gradient-to-r px-7 py-6 text-white shadow-[0_10px_28px_rgba(79,70,229,0.25)]"
这是 {{ config.appName }} 的示例页:顶部导航 + 左侧菜单 + 一次 SDK 请求。 >
<div>
<h1 class="mb-1.5 text-24px text-white font-650 tracking-wide">Hello World</h1>
<p class="max-w-160 text-13px leading-relaxed text-white/82">
{{ config.appName }} 示例页:顶部栏(搜索 + 个人信息)、左侧菜单、多标签页,
以及一次走完分层的 SDK 请求。
</p> </p>
</div>
<span
class="shrink-0 border border-white/35 rounded-full bg-white/15 px-3 py-1 text-12px uppercase tracking-wider"
>
{{ config.logEnv }}
</span>
</section> </section>
<section class="card"> <div class="grid grid-cols-[repeat(auto-fit,minmax(340px,1fr))] gap-4">
<h2 class="mb-2 text-base font-600">SDK 请求示例</h2> <!-- SDK 请求 -->
<p class="mb-4 text-gray-500"> <section class="card p-5.5">
<code>pages → services → api → @common/sdk</code> 四层,接口名在 <h2 class="mb-1 text-15px font-600">SDK 请求示例</h2>
<code>src/api/demo.ts</code> 里替换。 <p class="mb-4.5 text-12px leading-relaxed text-slate-400">
<code>pages → services → api → @common/sdk</code>,接口名在
<code>src/api/demo.ts</code> 里替换
</p> </p>
<button <button class="btn-primary" type="button" :disabled="loading" @click="onFetch">
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 ? "请求中..." : "发起请求" }} {{ loading ? "请求中..." : "发起请求" }}
</button> </button>
<pre v-if="result" class="mt-4 whitespace-pre-wrap break-all rounded bg-gray-100 p-3 text-12px">{{ result }}</pre> <pre v-if="result" class="result">{{ result }}</pre>
</section> </section>
<section class="card"> <!-- 环境信息 -->
<h2 class="mb-2 text-base font-600">环境信息</h2> <section class="card p-5.5">
<dl class="m-0 grid grid-cols-[140px_1fr] gap-y-2"> <h2 class="mb-1 text-15px font-600">环境信息</h2>
<dt class="text-gray-500">当前环境</dt> <p class="mb-4.5 text-12px text-slate-400">
全部来自 <code>src/config/index.ts</code>
</p>
<dl class="m-0 grid grid-cols-[108px_1fr] gap-y-2.5 text-13px">
<dt class="text-slate-400">当前环境</dt>
<dd class="m-0">{{ config.logEnv }}</dd> <dd class="m-0">{{ config.logEnv }}</dd>
<dt class="text-gray-500">API 地址</dt> <dt class="text-slate-400">API 地址</dt>
<dd class="m-0 break-all">{{ config.uniplatApi || "未配置(请填 VITE_APP_UNIPLAT)" }}</dd> <dd class="m-0 break-all">
<dt class="text-gray-500">子项目 / 服务</dt> {{ config.uniplatApi || "未配置(请填 VITE_APP_UNIPLAT)" }}
<dd class="m-0">{{ config.domainService.subProjectName }} / {{ config.domainService.serviceName }}</dd> </dd>
<dt class="text-slate-400">子项目 / 服务</dt>
<dd class="m-0">
{{ config.domainService.subProjectName }} / {{ config.domainService.serviceName }}
</dd>
</dl> </dl>
</section> </section>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
...@@ -56,16 +74,26 @@ async function onFetch() { ...@@ -56,16 +74,26 @@ async function onFetch() {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.card { code {
padding: 20px; padding: 1px 5px;
background: #fff; border-radius: 4px;
border: 1px solid #e5e6eb; background: var(--line-soft);
border-radius: 6px; color: var(--ink-600);
font-size: 12px;
} }
code { .result {
padding: 1px 4px; max-height: 240px;
border-radius: 3px; margin: 16px 0 0;
background: #f5f7fa; padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--line-soft);
color: var(--ink-600);
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
overflow: auto;
} }
</style> </style>
...@@ -215,50 +215,94 @@ refreshVerifyImage() ...@@ -215,50 +215,94 @@ refreshVerifyImage()
<style scoped lang="scss"> <style scoped lang="scss">
.login { .login {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
padding: 20px;
overflow: hidden;
background: radial-gradient(circle at 20% 20%, #312e81 0%, #1e1b4b 45%, #0f172a 100%);
// 背景光斑,避免大面积纯色显得死板
&::before,
&::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(90px);
pointer-events: none;
}
&::before {
top: -120px;
left: -80px;
width: 340px;
height: 340px;
background: rgb(99 102 241 / 45%);
}
&::after {
right: -100px;
bottom: -140px;
width: 380px;
height: 380px;
background: rgb(139 92 246 / 32%);
}
&__card { &__card {
position: relative;
z-index: 1;
width: 100%; width: 100%;
max-width: 400px; max-width: 408px;
padding: 32px 24px; padding: 32px 28px 36px;
background: #fff; background: var(--bg-card);
border-radius: 8px; border-radius: 18px;
box-shadow: 0 4px 20px rgb(0 0 0 / 10%); box-shadow: 0 24px 60px rgb(2 6 23 / 45%);
} }
&__title { &__title {
margin: 0 0 24px; margin: 0 0 26px;
font-size: 22px; font-size: 21px;
font-weight: 600; font-weight: 650;
text-align: center; text-align: center;
letter-spacing: 0.3px;
} }
&__tabs { &__tabs {
display: flex; display: flex;
margin-bottom: 24px; gap: 4px;
border-bottom: 1px solid #eee; margin-bottom: 22px;
padding: 4px;
border-radius: var(--radius);
background: var(--line-soft);
} }
&__tab { &__tab {
flex: 1; flex: 1;
padding: 12px 0; padding: 8px 0;
color: #666; border-radius: var(--radius-sm);
color: var(--ink-500);
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.15s;
&:hover {
color: var(--ink-700);
}
&--active { &--active {
color: #1890ff; background: var(--bg-card);
border-bottom: 2px solid #1890ff; color: var(--brand-600);
font-weight: 500;
box-shadow: var(--shadow-sm);
} }
} }
&__form { &__form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 14px;
} }
&__row { &__row {
...@@ -270,14 +314,21 @@ refreshVerifyImage() ...@@ -270,14 +314,21 @@ refreshVerifyImage()
&__input { &__input {
width: 100%; width: 100%;
height: 44px; height: 44px;
padding: 0 12px; padding: 0 14px;
border: 1px solid #ddd; border: 1px solid var(--line);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px;
outline: none; outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
&::placeholder {
color: var(--ink-400);
}
&:focus { &:focus {
border-color: #1890ff; border-color: var(--brand-500);
box-shadow: 0 0 0 3px var(--brand-100);
} }
} }
...@@ -285,14 +336,16 @@ refreshVerifyImage() ...@@ -285,14 +336,16 @@ refreshVerifyImage()
flex: none; flex: none;
width: 120px; width: 120px;
height: 44px; height: 44px;
border-radius: 4px; border: 1px solid var(--line);
border-radius: var(--radius-sm);
object-fit: cover;
cursor: pointer; cursor: pointer;
&--fallback { &--fallback {
padding: 0; padding: 0;
border: 1px dashed #ddd; border: 1px dashed var(--ink-300);
background: #fafafa; background: var(--line-soft);
color: #999; color: var(--ink-400);
font-size: 12px; font-size: 12px;
line-height: 1.3; line-height: 1.3;
} }
...@@ -302,31 +355,50 @@ refreshVerifyImage() ...@@ -302,31 +355,50 @@ refreshVerifyImage()
flex: none; flex: none;
width: 110px; width: 110px;
height: 44px; height: 44px;
border: 1px solid #1890ff; border: 1px solid var(--brand-200);
border-radius: 4px; border-radius: var(--radius-sm);
background: #fff; background: var(--brand-50);
color: #1890ff; color: var(--brand-600);
font-size: 13px;
cursor: pointer; cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
border-color: var(--brand-500);
background: var(--brand-100);
}
&:disabled { &:disabled {
border-color: #ccc; border-color: var(--line);
color: #ccc; background: var(--line-soft);
color: var(--ink-400);
cursor: not-allowed; cursor: not-allowed;
} }
} }
&__submit { &__submit {
height: 44px; height: 46px;
margin-top: 10px;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
background: #1890ff; background: linear-gradient(135deg, var(--brand-500), var(--brand-700));
color: #fff; color: #fff;
font-size: 16px; font-size: 15px;
font-weight: 500;
cursor: pointer; cursor: pointer;
box-shadow: 0 8px 20px rgb(79 70 229 / 32%);
transition:
box-shadow 0.15s,
opacity 0.15s;
&:hover:not(:disabled) {
box-shadow: 0 10px 26px rgb(79 70 229 / 42%);
}
&:disabled { &:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
box-shadow: none;
} }
} }
} }
......
export { useAuthStore } from './auth' export { useAuthStore } from './auth'
export { useTabsStore } from './tabs'
export type { TabItem } from './tabs'
import { defineStore } from 'pinia'
import { ref } from 'vue'
/** 一个已打开的路由标签页 */
export interface TabItem {
/** 路由 fullPath,标签的唯一标识(带 query 的同一页面算两个标签) */
path: string
/** 标签文案,取 route.meta.title */
title: string
/** 是否可关闭。首页固定不可关闭,避免标签被清空后无处可去 */
closable: boolean
}
/** 关闭全部标签后的兜底落点 */
const FALLBACK_PATH = '/'
export const useTabsStore = defineStore('tabs', () => {
const tabs = ref<TabItem[]>([])
/** 打开(或复用)一个标签 */
function addTab(tab: TabItem) {
if (tabs.value.some(item => item.path === tab.path)) return
tabs.value.push(tab)
}
/**
* 关闭一个标签
*
* @returns 关闭后应该停留的 path —— 关掉的是当前标签时,调用方拿它去跳转
*/
function closeTab(path: string): string {
const index = tabs.value.findIndex(item => item.path === path)
if (index === -1) return path
tabs.value.splice(index, 1)
const next = tabs.value[index] || tabs.value[index - 1]
return next?.path ?? FALLBACK_PATH
}
/** 关闭除 path 之外的标签(不可关闭的标签保留) */
function closeOthers(path: string) {
tabs.value = tabs.value.filter(item => item.path === path || !item.closable)
}
/** 关闭所有可关闭的标签 */
function closeAll(): string {
tabs.value = tabs.value.filter(item => !item.closable)
return tabs.value[0]?.path ?? FALLBACK_PATH
}
return { tabs, addTab, closeTab, closeOthers, closeAll }
})
...@@ -81,15 +81,15 @@ if (typeof document !== 'undefined' && !document.getElementById('toast-styles')) ...@@ -81,15 +81,15 @@ if (typeof document !== 'undefined' && !document.getElementById('toast-styles'))
pointer-events: none; pointer-events: none;
} }
.toast { .toast {
padding: 12px 24px; padding: 11px 20px;
border-radius: 8px; border-radius: 10px;
font-size: 14px; font-size: 14px;
color: #fff; color: #fff;
background: rgba(0, 0, 0, 0.8); background: #0f172a;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 12px 32px rgba(15, 23, 42, 0.22);
opacity: 0; opacity: 0;
transform: translateY(-20px); transform: translateY(-20px);
transition: all 0.3s ease; transition: all 0.25s ease;
white-space: nowrap; white-space: nowrap;
max-width: 80vw; max-width: 80vw;
overflow: hidden; overflow: hidden;
...@@ -100,16 +100,16 @@ if (typeof document !== 'undefined' && !document.getElementById('toast-styles')) ...@@ -100,16 +100,16 @@ if (typeof document !== 'undefined' && !document.getElementById('toast-styles'))
transform: translateY(0); transform: translateY(0);
} }
.toast-success { .toast-success {
background: #52c41a; background: #16a34a;
} }
.toast-error { .toast-error {
background: #ff4d4f; background: #ef4444;
} }
.toast-warning { .toast-warning {
background: #faad14; background: #f59e0b;
} }
.toast-info { .toast-info {
background: #1890ff; background: #4f46e5;
} }
` `
document.head.appendChild(style) document.head.appendChild(style)
......
...@@ -2,4 +2,26 @@ import { defineConfig, presetAttributify, presetUno } from 'unocss' ...@@ -2,4 +2,26 @@ import { defineConfig, presetAttributify, presetUno } from 'unocss'
export default defineConfig({ export default defineConfig({
presets: [presetUno(), presetAttributify()], presets: [presetUno(), presetAttributify()],
theme: {
colors: {
// 品牌色。中性色直接用 presetUno 自带的 slate 系列,不再另造一套
brand: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
500: '#4f46e5',
600: '#4338ca',
700: '#3730a3',
},
},
},
shortcuts: {
/** 内容卡片 */
'card': 'bg-white border border-slate-200 rounded-14px shadow-sm',
/** 主按钮 */
'btn-primary':
'h-9 px-5 border-none rounded-6px bg-brand-500 text-white font-medium transition-colors hover:bg-brand-600 disabled:bg-slate-300 disabled:cursor-not-allowed',
},
}) })
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