feat(F006): W2 + W4 + W31 admin_new 全部改动首次 commit
- W2: 4 主题 (科技蓝/中国红/Notion 紫/暗夜黑) + LoginView + DashboardView + 询价管理 (InquiryList + InquiryDetail) - W4.1: 集成管理 - 应用列表 (OpenAppListView) - W4.2: 集成管理 - 审计日志 (AuditLogListView) + 菜单 label 补全 - W4.2: 菜单从后端拉 + 看板接真 API - W31: safeParseArray 防御 localStorage permissions 损坏 (运营后台白屏修复) 关联: Mac F002-W31-交接文档.md / F006-W4-交接文档.md / GIT-AUDIT-REPORT-2026-06-24.md 本地备份: ~/projects/_meta/f002-fix/admin-new-w4-*/
This commit is contained in:
commit
6d02a4b5d2
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
.DS_Store
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
.env.local
|
||||
.env.*.local
|
||||
202
ARCHITECTURE.md
Normal file
202
ARCHITECTURE.md
Normal file
@ -0,0 +1,202 @@
|
||||
# F006 新管理后台 - 架构原则
|
||||
|
||||
> **核心铁律**:本项目在设计架构时,必须把"后续修改的灵活性、可配置性、先进性、前瞻性"放在第一位。
|
||||
> 老的 renren-cloud-admin 之所以改不动,就是因为缺乏这 4 个性质。
|
||||
|
||||
## 1. 4 个核心原则
|
||||
|
||||
### 1.1 灵活性(Flexibility)
|
||||
- **业务代码不耦合样式**:每个 view 模板只关心数据流,样式全部从主题/布局组件继承
|
||||
- **可插拔的目录约定**:`src/modules/{模块名}/` 每个模块自带 `views/`、`api/`、`types/`、`components/`,删除模块 = 删除文件夹
|
||||
- **运行时配置**:菜单、路由、权限都从后端拉,不在前端硬编码
|
||||
|
||||
### 1.2 可配置性(Configurability)
|
||||
- **主题系统由配置驱动**:新增主题 = 加 22 个 CSS 变量 + ThemePicker 加一个按钮,不改业务代码
|
||||
- **菜单从后端配置**:管理员可以在系统里改菜单,前端不用动
|
||||
- **权限从后端配置**:按钮级别权限 = `v-permission="'sys:user:add'"`,不写死在组件里
|
||||
- **页面配置化**:列表页的列、搜索项、筛选条件都在 meta 配置里,不改代码就能加列
|
||||
|
||||
### 1.3 先进性(State of the Art)
|
||||
- **Vue 3.4 Composition API + `<script setup>`**(不混用 Options API)
|
||||
- **Pinia**(不混用 Vuex)
|
||||
- **TypeScript 严格模式**(`strict: true`)
|
||||
- **Vite 5+**(不混用 Webpack/Vue CLI)
|
||||
- **Element Plus 2.4+**(用新主题系统,不覆写内部 CSS)
|
||||
- **SCSS Modules**(组件级样式隔离,不全局污染)
|
||||
|
||||
### 1.4 前瞻性(Future-proof)
|
||||
- **预留微前端入口**:用 Vite Module Federation 或 qiankun,后期可以拆出独立模块
|
||||
- **预留 i18n 接口**:组件文案都走 `t('key')`,即使现在只有中文
|
||||
- **预留暗色模式**:主题系统已经是 4 套,加第 5 套(比如企业定制色)只改 22 个变量
|
||||
- **预留 OpenAPI 集成**:api 层用类型化客户端,后期后端换 schema 前端零改动
|
||||
- **预留移动端**:布局组件按响应式写,后期加移动端只需要换 layout
|
||||
|
||||
## 2. 目录结构(按"修改灵活性"优化)
|
||||
|
||||
```
|
||||
admin_new/
|
||||
├── ARCHITECTURE.md # 本文档(架构原则,所有开发必读)
|
||||
├── .env # 环境变量(API 地址、开关)
|
||||
├── .env.development
|
||||
├── .env.production
|
||||
├── package.json
|
||||
├── vite.config.ts # 构建配置
|
||||
├── tsconfig.json
|
||||
├── index.html
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── main.ts # 启动入口(主题恢复 + 路由 + store + UI 库)
|
||||
│ ├── App.vue # 根组件(只决定 layout 策略)
|
||||
│ │
|
||||
│ ├── theme/ # 主题系统(独立子模块,可整体替换)
|
||||
│ │ ├── tokens.scss # 22 个 CSS 变量(主题无关部分)
|
||||
│ │ ├── themes.scss # 4 套主题(主题相关部分)
|
||||
│ │ ├── presets.ts # 主题选项配置(新增主题只改这里)
|
||||
│ │ └── README.md # 主题系统使用说明
|
||||
│ │
|
||||
│ ├── layout/ # 布局组件(顶栏 + 侧边栏 + 内容区)
|
||||
│ │ ├── AppLayout.vue # 主布局
|
||||
│ │ ├── TopBar.vue # 顶栏
|
||||
│ │ ├── SideBar.vue # 侧边栏
|
||||
│ │ ├── ThemePicker.vue # 主题切换器
|
||||
│ │ └── UserMenu.vue # 用户菜单
|
||||
│ │
|
||||
│ ├── router/
|
||||
│ │ ├── index.ts # 路由配置
|
||||
│ │ ├── routes.ts # 静态路由(从后端拉的也注入这里)
|
||||
│ │ └── guards.ts # 路由守卫
|
||||
│ │
|
||||
│ ├── stores/ # Pinia 状态管理
|
||||
│ │ ├── user.ts # 用户 + token
|
||||
│ │ ├── theme.ts # 当前主题
|
||||
│ │ ├── menu.ts # 菜单(从后端拉)
|
||||
│ │ └── permission.ts # 权限(v-permission 用)
|
||||
│ │
|
||||
│ ├── api/ # API 层(所有 HTTP 请求)
|
||||
│ │ ├── request.ts # axios 封装(拦截器 + token 注入)
|
||||
│ │ ├── auth.ts # 登录/登出
|
||||
│ │ ├── menu.ts # 菜单 + 权限
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── modules/ # 业务模块(每个模块独立,可整体删除)
|
||||
│ │ ├── dashboard/ # 数据看板
|
||||
│ │ │ ├── DashboardView.vue
|
||||
│ │ │ ├── api.ts
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ └── components/
|
||||
│ │ ├── inquiry/ # 询价管理
|
||||
│ │ ├── order/ # 订单管理
|
||||
│ │ ├── supplier/ # 供应商
|
||||
│ │ ├── user/ # 用户/角色
|
||||
│ │ └── system/ # 系统设置
|
||||
│ │
|
||||
│ ├── components/ # 全局通用组件(跨模块)
|
||||
│ │ ├── DataTable/ # 通用列表(分页 + 搜索 + 列配置)
|
||||
│ │ │ ├── DataTable.vue
|
||||
│ │ │ ├── types.ts # 列配置类型
|
||||
│ │ │ └── README.md
|
||||
│ │ ├── StatusTag/ # 状态徽章
|
||||
│ │ ├── SearchForm/ # 搜索表单
|
||||
│ │ └── PageContainer/ # 页面容器(统一 padding + 面包屑)
|
||||
│ │
|
||||
│ ├── composables/ # Vue 3 组合式 API(可复用逻辑)
|
||||
│ │ ├── useTable.ts # 列表逻辑(分页 + 搜索 + 加载)
|
||||
│ │ ├── usePermission.ts # 权限判断
|
||||
│ │ └── useDict.ts # 字典查询
|
||||
│ │
|
||||
│ ├── directives/ # 全局指令
|
||||
│ │ ├── permission.ts # v-permission
|
||||
│ │ └── copy.ts # v-copy
|
||||
│ │
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── auth.ts # token 存储
|
||||
│ │ ├── format.ts # 格式化
|
||||
│ │ └── request.ts # 旧兼容(逐步废弃)
|
||||
│ │
|
||||
│ ├── styles/ # 全局样式
|
||||
│ │ ├── reset.scss
|
||||
│ │ └── global.scss
|
||||
│ │
|
||||
│ ├── icons/ # 图标(按需引入)
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── types/ # 全局类型定义
|
||||
│ │ ├── api.ts # 通用 API 响应类型
|
||||
│ │ └── global.d.ts
|
||||
│ │
|
||||
│ └── env.d.ts
|
||||
└── Dockerfile # nginx 镜像
|
||||
```
|
||||
|
||||
## 3. 关键架构决策(为什么这么做)
|
||||
|
||||
### 3.1 为什么用 `src/modules/` 而不是 `src/views/`
|
||||
- **修改灵活**:删除一个模块 = 删除一个文件夹,不会污染其他模块
|
||||
- **团队协作**:不同人负责不同模块,merge 冲突少
|
||||
- **微前端准备**:后期可以拆出独立 module,用 qiankun 加载
|
||||
|
||||
### 3.2 为什么菜单从后端拉
|
||||
- **配置灵活**:管理员可以在系统里改菜单,不用发版
|
||||
- **多租户**:不同租户看不同菜单
|
||||
- **角色权限**:菜单本身就是权限的载体,后端管控最准确
|
||||
|
||||
### 3.3 为什么主题系统独立成 `src/theme/`
|
||||
- **整体替换**:后期要换主题方案(比如用 styled-components),只改 `theme/` 文件夹
|
||||
- **新增主题 = 加 22 个变量 + 1 个 button**,不碰业务
|
||||
- **可被 design token 工具读取**:后期对接 Figma Tokens,只导出 `themes.scss`
|
||||
|
||||
### 3.4 为什么 DataTable 通用化
|
||||
- **列表页占 60% 工作量**,通用化能省一半时间
|
||||
- **列配置化**:新增列表 = 配 columns,不写模板
|
||||
- **统一交互**:所有列表分页/筛选/排序行为一致,降低学习成本
|
||||
|
||||
### 3.5 为什么用 composables 而不是 mixins
|
||||
- **类型友好**:TypeScript 推断更准
|
||||
- **逻辑复用**:同一逻辑可在多个组件复用,不污染 options
|
||||
- **tree-shaking 友好**:只引入用到的
|
||||
|
||||
### 3.6 为什么用 v-permission 指令而不是组件
|
||||
- **声明式**:`<button v-permission="'sys:user:add'">` 比 `<Permission code="sys:user:add">` 更直观
|
||||
- **可扩展**:支持 hide / disable / function 三种模式
|
||||
- **性能好**:编译时确定,无运行时组件嵌套
|
||||
|
||||
## 4. 命名规范(避免后期混乱)
|
||||
|
||||
### 4.1 文件命名
|
||||
- **组件**:PascalCase (`UserMenu.vue`)
|
||||
- **工具/脚本**:camelCase (`auth.ts` / `format.ts`)
|
||||
- **样式**:kebab-case (`user-menu.scss`)
|
||||
- **常量**:UPPER_SNAKE_CASE (`MAX_FILE_SIZE`)
|
||||
|
||||
### 4.2 CSS 变量
|
||||
- 主题相关:加 `--brand` / `--bg-soft` 等
|
||||
- 主题无关:加 `--radius` / `--shadow` 等
|
||||
- 模块私有:加 `--module-` 前缀
|
||||
|
||||
### 4.3 Pinia store
|
||||
- 名:模块名,如 `useUserStore`
|
||||
- 状态:`xxx` (如 `token`, `profile`)
|
||||
- 动作:`xxx` (如 `login`, `logout`, `fetchMenu`)
|
||||
|
||||
## 5. 测试策略(后期可加,本期不做)
|
||||
|
||||
- **单元测试**:Vitest + Vue Test Utils(测试 composables 和工具函数)
|
||||
- **E2E 测试**:Playwright(测关键流程:登录 → 进看板 → 切换主题)
|
||||
- **视觉回归**:Chromatic / Percy(主题切换不能破坏布局)
|
||||
|
||||
## 6. CI/CD 策略(后期可加)
|
||||
|
||||
- PR 必须通过 ESLint + TypeScript 类型检查
|
||||
- 自动跑 build,失败禁止 merge
|
||||
- 每次发版生成 changelog
|
||||
|
||||
## 7. 演进路线(避免后期推倒重来)
|
||||
|
||||
- **当前**:模块化 + 主题系统 + 4 套主题
|
||||
- **3 个月内**:加入 E2E 测试 + 性能监控
|
||||
- **6 个月内**:支持微前端拆分(独立 module 可独立部署)
|
||||
- **12 个月内**:加入低代码能力(列表列配置化 → 可视化配置)
|
||||
|
||||
---
|
||||
|
||||
**所有开发必须遵守本文件。** 任何架构变更必须先更新本文件,再改代码。
|
||||
73
README.md
Normal file
73
README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# Admin New (F006)
|
||||
|
||||
**重构后的管理后台** — 替代老的 renren-cloud-admin,渐进式双前端策略。
|
||||
|
||||
> 任何架构变更必须先看 [`ARCHITECTURE.md`](./ARCHITECTURE.md) 再改代码。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# Dev (端口 3001,proxy /api 到 gateway:8080)
|
||||
npm run dev
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Type check
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
访问:
|
||||
- Dev: http://localhost:3001/admin/
|
||||
- Prod: http://100.111.84.16:3000/admin/(待 nginx 配置)
|
||||
|
||||
## 4 套主题
|
||||
|
||||
切主题:顶栏"主题"按钮,或登录页底部 4 个色点。
|
||||
|
||||
| 主题 | 主色 | 触发 |
|
||||
|---|---|---|
|
||||
| 科技蓝(默认) | `#2563EB` | data-theme="tech-blue" |
|
||||
| 中国红 | `#DC2626` | data-theme="red" |
|
||||
| Notion 紫 | `#5645D4` | data-theme="purple" |
|
||||
| 暗夜黑 | `#0F172A` | data-theme="dark" |
|
||||
|
||||
新增主题:改 `src/theme/themes.scss` + `src/theme/presets.ts` 两处即可。
|
||||
|
||||
## 当前进度 (W1 D1 末)
|
||||
|
||||
- [x] 项目骨架(Vite + Vue 3 + TS + Pinia + Element Plus)
|
||||
- [x] 4 套主题系统(tokens + themes + presets)
|
||||
- [x] ThemePicker 顶栏组件
|
||||
- [x] AppLayout 主布局(顶栏 + 侧边栏 + 内容区)
|
||||
- [x] LoginView 登录页(渐变 + 主题切换)
|
||||
- [x] DashboardView 数据看板(mock 数据)
|
||||
- [x] Vite proxy /api → gateway:8080
|
||||
- [ ] D2: 登录接真实 OAuth2(axios + 路由守卫) — **明日**
|
||||
- [ ] D3: 菜单从后端拉
|
||||
- [ ] D4: 看板接真实 API
|
||||
- [ ] D5: nginx 部署到 /admin/
|
||||
|
||||
## 目录结构(按 ARCHITECTURE.md)
|
||||
|
||||
```
|
||||
src/
|
||||
├── theme/ # 主题系统(独立子模块)
|
||||
├── layout/ # 布局(AppLayout 已迁移至 components/AppLayout)
|
||||
├── modules/ # 业务模块(每个独立)
|
||||
│ ├── auth/ # 登录
|
||||
│ └── dashboard/ # 数据看板
|
||||
├── components/ # 跨模块组件
|
||||
│ ├── AppLayout/
|
||||
│ └── ThemePicker/
|
||||
├── stores/ # Pinia (theme/user/menu)
|
||||
├── api/ # HTTP 封装(W2 接入)
|
||||
├── router/ # 路由 + 守卫
|
||||
├── composables/ # 可复用逻辑(预留)
|
||||
├── directives/ # 全局指令(预留)
|
||||
├── styles/ # 全局样式
|
||||
└── utils/ # 工具函数
|
||||
```
|
||||
17
index.html
Normal file
17
index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="tech-blue">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Product Network Admin</title>
|
||||
<style>
|
||||
/* 防白屏:主题变量在 body 上生效前先 fallback */
|
||||
html, body { background: #F8FAFC; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
3357
package-lock.json
generated
Normal file
3357
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "admin-new",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.5.0",
|
||||
"element-plus": "^2.4.4",
|
||||
"pinia": "^2.1.7",
|
||||
"qs": "^6.15.2",
|
||||
"vue": "^3.4.21",
|
||||
"vue-echarts": "^6.6.9",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qs": "^6.15.1",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"sass": "^1.71.1",
|
||||
"typescript": "^5.4.3",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.2.0",
|
||||
"vue-tsc": "^2.0.7"
|
||||
}
|
||||
}
|
||||
3
public/favicon.svg
Normal file
3
public/favicon.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#2563EB" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7 17V7l5 5 5-5v10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 196 B |
87
src/App.vue
Normal file
87
src/App.vue
Normal file
@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppLayout from '@/components/AppLayout/AppLayout.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const isLogin = computed(() => route.name === 'Login')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view v-if="isLogin" />
|
||||
<AppLayout v-else>
|
||||
<router-view />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: var(--font);
|
||||
background: var(--bg-soft);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
button { font-family: inherit; }
|
||||
a { color: var(--brand); text-decoration: none; }
|
||||
a:hover { color: var(--brand-hover); }
|
||||
|
||||
/* Element Plus 主题色重定向(让组件跟主题色) */
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--brand);
|
||||
--el-button-border-color: var(--brand);
|
||||
--el-button-hover-bg-color: var(--brand-hover);
|
||||
--el-button-hover-border-color: var(--brand-hover);
|
||||
--el-button-active-bg-color: var(--brand-2);
|
||||
--el-button-active-border-color: var(--brand-2);
|
||||
}
|
||||
.el-tag.el-tag--primary {
|
||||
--el-tag-bg-color: var(--brand-soft);
|
||||
--el-tag-border-color: transparent;
|
||||
--el-tag-text-color: var(--brand);
|
||||
}
|
||||
.el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--brand) inset !important;
|
||||
}
|
||||
.el-link.el-link--primary {
|
||||
--el-link-text-color: var(--brand);
|
||||
--el-link-hover-text-color: var(--brand-hover);
|
||||
}
|
||||
.el-checkbox__input.is-checked .el-checkbox__inner {
|
||||
background-color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.el-radio__input.is-checked .el-radio__inner {
|
||||
background-color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.el-radio__input.is-checked + .el-radio__label {
|
||||
color: var(--brand);
|
||||
}
|
||||
.el-switch.is-checked .el-switch__core {
|
||||
background-color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.el-slider__bar {
|
||||
background-color: var(--brand-soft);
|
||||
}
|
||||
.el-slider__button-wrapper .el-slider__button {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.el-slider__runway.show-input .el-slider__bar {
|
||||
background-color: var(--brand);
|
||||
}
|
||||
.el-pagination.is-background .el-pager li.is-active {
|
||||
background-color: var(--brand) !important;
|
||||
color: white !important;
|
||||
}
|
||||
.el-menu--horizontal .el-menu-item.is-active {
|
||||
color: var(--brand);
|
||||
border-bottom-color: var(--brand);
|
||||
}
|
||||
</style>
|
||||
266
src/components/AppLayout/AppLayout.vue
Normal file
266
src/components/AppLayout/AppLayout.vue
Normal file
@ -0,0 +1,266 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 主布局 - 顶栏 + 侧边栏 + 内容区
|
||||
* 架构要点:布局组件不带业务逻辑,菜单从 useMenuStore 拉(以后从后端拉)
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import ThemePicker from '@/components/ThemePicker/ThemePicker.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useMenuStore, type MenuItem } from '@/stores/menu'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
const collapsed = computed(() => menuStore.collapsed)
|
||||
const menuList = computed<MenuItem[]>(() => menuStore.list)
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
function toggleSidebar() {
|
||||
menuStore.toggleCollapsed()
|
||||
}
|
||||
|
||||
function goMenu(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
return path === '/' ? route.path === '/' : route.path.startsWith(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-layout" :class="{ collapsed }">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="rr-sidebar">
|
||||
<div class="rr-sidebar-brand">
|
||||
<div class="rr-sidebar-logo">P</div>
|
||||
<span v-if="!collapsed" class="rr-sidebar-name">Product Network</span>
|
||||
</div>
|
||||
<nav class="rr-sidebar-nav">
|
||||
<div
|
||||
v-for="item in menuList"
|
||||
:key="item.path"
|
||||
class="rr-sidebar-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="goMenu(item.path)"
|
||||
>
|
||||
<el-icon class="rr-sidebar-icon"><component :is="item.icon || 'Menu'" /></el-icon>
|
||||
<span v-if="!collapsed" class="rr-sidebar-label">{{ item.title }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 主区域 -->
|
||||
<div class="rr-main">
|
||||
<!-- 顶栏 -->
|
||||
<header class="rr-topbar">
|
||||
<div class="rr-topbar-left">
|
||||
<div class="rr-topbar-toggle" @click="toggleSidebar">
|
||||
<el-icon><component :is="collapsed ? 'Expand' : 'Fold'" /></el-icon>
|
||||
</div>
|
||||
<el-breadcrumb separator=">" class="rr-topbar-breadcrumb">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">工作台</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ route.meta?.title || '' }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="rr-topbar-right">
|
||||
<ThemePicker />
|
||||
<el-dropdown trigger="click" @command="(c: string) => c === 'logout' && logout()">
|
||||
<div class="rr-topbar-user">
|
||||
<div class="rr-topbar-avatar">{{ userStore.username?.charAt(0) || 'U' }}</div>
|
||||
<span class="rr-topbar-username">{{ userStore.username || 'User' }}</span>
|
||||
<el-icon><component :is="'ArrowDown'" /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item disabled>{{ userStore.username }}</el-dropdown-item>
|
||||
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<main class="rr-content">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: var(--bg-soft);
|
||||
transition: background 0.3s;
|
||||
|
||||
&.collapsed .rr-sidebar { width: 64px; }
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.rr-sidebar {
|
||||
width: 220px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.rr-sidebar-brand {
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.rr-sidebar-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--brand);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px var(--brand-glow);
|
||||
}
|
||||
.rr-sidebar-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rr-sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rr-sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-2);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
transition: all 0.12s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-soft);
|
||||
color: var(--ink);
|
||||
}
|
||||
&.active {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
}
|
||||
}
|
||||
.rr-sidebar-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rr-sidebar-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 主区域 */
|
||||
.rr-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 顶栏 */
|
||||
.rr-topbar {
|
||||
height: 56px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rr-topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.rr-topbar-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--ink-2);
|
||||
transition: all 0.12s;
|
||||
&:hover { background: var(--bg-soft); color: var(--brand); }
|
||||
}
|
||||
.rr-topbar-breadcrumb { font-size: 13px; }
|
||||
|
||||
.rr-topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.rr-topbar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
&:hover { background: var(--bg-soft); }
|
||||
}
|
||||
.rr-topbar-avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rr-topbar-username {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.rr-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px 24px;
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
</style>
|
||||
141
src/components/ThemePicker/ThemePicker.vue
Normal file
141
src/components/ThemePicker/ThemePicker.vue
Normal file
@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 顶栏主题切换器
|
||||
* 架构要点:数据全部从 useThemeStore 拿,组件不写死主题列表
|
||||
* 新增主题 = 改 theme/presets.ts + themes.scss,组件 0 改动
|
||||
*/
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
function pick(value: string) {
|
||||
themeStore.pick(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-popover placement="bottom-end" :width="260" trigger="click">
|
||||
<template #reference>
|
||||
<div class="rr-theme-trigger" :title="'主题: ' + themeStore.currentPreset.name">
|
||||
<span class="rr-theme-dot" :style="{ background: themeStore.currentPreset.color }"></span>
|
||||
<span class="rr-theme-trigger-text">主题</span>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="rr-theme-picker">
|
||||
<div class="rr-theme-picker-title">切换主题</div>
|
||||
<div class="rr-theme-grid">
|
||||
<div
|
||||
v-for="p in themeStore.presets"
|
||||
:key="p.value"
|
||||
class="rr-theme-item"
|
||||
:class="{ active: themeStore.current === p.value }"
|
||||
@click="pick(p.value)"
|
||||
>
|
||||
<div class="rr-theme-swatch" :style="{ background: p.color }">
|
||||
<svg v-if="themeStore.current === p.value" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="rr-theme-name">{{ p.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-theme-hint">自动持久化,刷新不丢</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-theme-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: var(--brand-soft);
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
}
|
||||
}
|
||||
.rr-theme-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rr-theme-picker {
|
||||
padding: 4px;
|
||||
}
|
||||
.rr-theme-picker-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-2);
|
||||
padding: 8px 8px 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.rr-theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.rr-theme-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-soft);
|
||||
border-color: var(--border);
|
||||
}
|
||||
&.active {
|
||||
background: var(--brand-soft);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
}
|
||||
.rr-theme-swatch {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rr-theme-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
.rr-theme-item.active .rr-theme-name {
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rr-theme-hint {
|
||||
margin-top: 8px;
|
||||
padding: 8px 8px 0;
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
7
src/env.d.ts
vendored
Normal file
7
src/env.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
20
src/main.ts
Normal file
20
src/main.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
// 主题系统(必须在 App 之前 import)
|
||||
import './theme/tokens.scss'
|
||||
import './theme/themes.scss'
|
||||
import { loadTheme } from './theme/presets'
|
||||
|
||||
// 启动时恢复用户主题
|
||||
document.body.setAttribute('data-theme', loadTheme())
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
342
src/modules/auth/LoginView.vue
Normal file
342
src/modules/auth/LoginView.vue
Normal file
@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 登录页
|
||||
* 架构:版式所有主题统一(渐变背景 + 左 60% 品牌区 + 右 40% 登录卡),只在主题切换时换色
|
||||
* 4 套主题登录页都遵循同一布局,这是"可配置性"的体现
|
||||
*/
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import qs from 'qs'
|
||||
import axios from 'axios'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const form = reactive({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
captcha: '',
|
||||
uuid: ''
|
||||
})
|
||||
const captchaUrl = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
// F006 主题色色板 - 用于登录页底部展示
|
||||
const themePalette = [
|
||||
{ value: 'tech-blue', name: '科技蓝', color: '#2563EB' },
|
||||
{ value: 'red', name: '中国红', color: '#DC2626' },
|
||||
{ value: 'purple', name: 'Notion 紫', color: '#5645D4' },
|
||||
{ value: 'dark', name: '暗夜黑', color: '#0F172A' }
|
||||
]
|
||||
|
||||
function refreshCaptcha() {
|
||||
form.uuid = Math.random().toString(36).slice(2, 12)
|
||||
captchaUrl.value = `/auth/captcha?uuid=${form.uuid}&_t=${Date.now()}`
|
||||
}
|
||||
refreshCaptcha()
|
||||
|
||||
function pickTheme(value: string) {
|
||||
themeStore.pick(value)
|
||||
}
|
||||
|
||||
async function onLogin() {
|
||||
if (!form.username || !form.password) {
|
||||
ElMessage.warning('请输入账号和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
// 沿用老 renren 的 OAuth2 client_credentials 流程
|
||||
const res = await axios.post(
|
||||
'/auth/oauth/token',
|
||||
qs.stringify({ ...form, grant_type: 'password' }),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: 'Basic cmVucmVuaW86cmVucmVuaW8='
|
||||
}
|
||||
}
|
||||
)
|
||||
if (res.data?.code === 0 && res.data?.data?.access_token) {
|
||||
userStore.setAuth({
|
||||
token: res.data.data.access_token,
|
||||
username: form.username,
|
||||
permissions: res.data.data.permissions || ['*:*:*']
|
||||
})
|
||||
ElMessage.success('登录成功')
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.push(redirect)
|
||||
} else {
|
||||
ElMessage.error(res.data?.msg || '登录失败')
|
||||
refreshCaptcha()
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.msg || e?.message || '登录失败')
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-login">
|
||||
<div class="rr-login-wrap">
|
||||
<!-- 左 60%: 品牌区 -->
|
||||
<div class="rr-login-left">
|
||||
<div class="rr-login-brand">
|
||||
<div class="rr-login-logo">P</div>
|
||||
<div class="rr-login-product-name">Product Network</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-login-display">
|
||||
<h1 class="rr-login-headline">
|
||||
The operating<br>system for <span class="rr-login-accent">industry.</span>
|
||||
</h1>
|
||||
<p class="rr-login-sub">
|
||||
询价 · 订单 · 供应商 · 履约一体化协同平台,为 B2B 团队而生。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rr-login-features">
|
||||
<div class="rr-login-feature">
|
||||
<span class="rr-login-ck"><el-icon><Check /></el-icon></span>
|
||||
ISO 27001 + SOC 2 Type II
|
||||
</div>
|
||||
<div class="rr-login-feature">
|
||||
<span class="rr-login-ck"><el-icon><Check /></el-icon></span>
|
||||
等保三级 · 99.99% SLA
|
||||
</div>
|
||||
<div class="rr-login-feature">
|
||||
<span class="rr-login-ck"><el-icon><Check /></el-icon></span>
|
||||
10 万+ 企业运营团队在用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右 40%: 登录卡 -->
|
||||
<div class="rr-login-right">
|
||||
<div class="rr-login-card">
|
||||
<h4 class="rr-login-card-title">
|
||||
登录工作台
|
||||
<span class="rr-login-card-sub">输入账号继续</span>
|
||||
</h4>
|
||||
|
||||
<el-form @keyup.enter="onLogin" label-position="top">
|
||||
<el-form-item>
|
||||
<el-input v-model="form.username" placeholder="账号" size="large" :prefix-icon="'User'" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password :prefix-icon="'Lock'" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="rr-login-captcha-row">
|
||||
<el-input v-model="form.captcha" placeholder="验证码" size="large" :prefix-icon="'Picture'" />
|
||||
<div class="rr-login-captcha-box" @click="refreshCaptcha">
|
||||
{{ captchaUrl ? '点击刷新' : '加载中' }}
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="large" :loading="loading" @click="onLogin" class="rr-login-btn">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="rr-login-theme-section">
|
||||
<div class="rr-login-theme-label">选择主题</div>
|
||||
<div class="rr-login-theme-grid">
|
||||
<div
|
||||
v-for="t in themePalette"
|
||||
:key="t.value"
|
||||
class="rr-login-theme-dot"
|
||||
:class="{ active: themeStore.current === t.value }"
|
||||
:style="{ background: t.color }"
|
||||
:title="t.name"
|
||||
@click="pickTheme(t.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-login {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: var(--bg-soft);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rr-login-wrap {
|
||||
width: 1080px;
|
||||
height: 600px;
|
||||
max-width: 96%;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
box-shadow: 0 20px 50px -12px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
.rr-login-left { display: none; }
|
||||
.rr-login-right { width: 100% !important; }
|
||||
}
|
||||
|
||||
/* 左 60% 品牌区 - 渐变 + 双色径向辉光 + 网格底纹(由主题变量驱动) */
|
||||
.rr-login-left {
|
||||
flex: 1.4;
|
||||
padding: 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, var(--login-grad-1) 0%, var(--login-grad-2) 50%, var(--login-grad-3) 100%);
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(ellipse 800px 500px at 25% 30%, var(--login-glow-1) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 700px 400px at 75% 70%, var(--login-glow-2) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--brand) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--brand) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
opacity: 0.04;
|
||||
mask-image: radial-gradient(ellipse at center, black 20%, transparent 70%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, black 20%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
& > * { position: relative; z-index: 1; }
|
||||
}
|
||||
.rr-login-brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.rr-login-logo {
|
||||
width: 36px; height: 36px;
|
||||
background: var(--brand);
|
||||
border-radius: 9px;
|
||||
color: white;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 18px;
|
||||
box-shadow: 0 6px 16px var(--brand-glow);
|
||||
}
|
||||
.rr-login-product-name {
|
||||
font-size: 16px; font-weight: 600; color: var(--ink);
|
||||
}
|
||||
.rr-login-display { max-width: 460px; }
|
||||
.rr-login-headline {
|
||||
font-size: 42px; font-weight: 700; line-height: 1.1;
|
||||
letter-spacing: -0.03em; margin-bottom: 16px; color: var(--ink);
|
||||
}
|
||||
.rr-login-accent { color: var(--brand); }
|
||||
.rr-login-sub {
|
||||
color: var(--ink-2); font-size: 15px; line-height: 1.6;
|
||||
}
|
||||
.rr-login-features {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.rr-login-feature {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 13.5px; color: var(--ink-2);
|
||||
}
|
||||
.rr-login-ck {
|
||||
width: 20px; height: 20px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 右 40% 登录卡 */
|
||||
.rr-login-right {
|
||||
flex: 1;
|
||||
background: var(--surface);
|
||||
padding: 48px 44px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.rr-login-card { width: 100%; max-width: 360px; }
|
||||
.rr-login-card-title {
|
||||
font-size: 22px; font-weight: 600; color: var(--ink);
|
||||
letter-spacing: -0.015em;
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.rr-login-card-sub {
|
||||
font-size: 13px; font-weight: 400; color: var(--ink-3);
|
||||
}
|
||||
.rr-login-captcha-row {
|
||||
display: flex; gap: 8px; width: 100%;
|
||||
.el-input { flex: 1; }
|
||||
}
|
||||
.rr-login-captcha-box {
|
||||
width: 110px; height: 40px;
|
||||
background: var(--brand-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px; color: var(--brand);
|
||||
cursor: pointer;
|
||||
letter-spacing: 3px;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rr-login-btn {
|
||||
width: 100%; height: 42px;
|
||||
font-size: 15px; font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.rr-login-theme-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.rr-login-theme-label {
|
||||
font-size: 11px; color: var(--ink-3);
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.rr-login-theme-grid {
|
||||
display: flex; gap: 10px; justify-content: center;
|
||||
}
|
||||
.rr-login-theme-dot {
|
||||
width: 24px; height: 24px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover { transform: scale(1.1); }
|
||||
&.active {
|
||||
border-color: var(--ink);
|
||||
box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px currentColor;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
144
src/modules/dashboard/DashboardView.vue
Normal file
144
src/modules/dashboard/DashboardView.vue
Normal file
@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 数据看板
|
||||
* W1 D3: mock 数据
|
||||
* W1 D4: 接真实 API(/sys/openapi/inquiry/publish 等)
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const stats = ref([
|
||||
{ label: '今日询价', value: '128', delta: '+12.5%', trend: 'up', icon: 'ChatDotRound' },
|
||||
{ label: '订单总额', value: '¥328K', delta: '+8.3%', trend: 'up', icon: 'Money' },
|
||||
{ label: '活跃供应商', value: '42', delta: '+3', trend: 'up', icon: 'OfficeBuilding' },
|
||||
{ label: '待处理事项', value: '7', delta: '-2', trend: 'down', icon: 'Bell' }
|
||||
])
|
||||
|
||||
const recentInquiries = ref([
|
||||
{ id: 'RFQ-20668', customer: '中铁建设集团', product: 'HRB400 螺纹钢', qty: '200 吨', status: 'pending', time: '10:24' },
|
||||
{ id: 'RFQ-20667', customer: '中建八局', product: '商混 C30', qty: '500 m³', status: 'quoting', time: '09:15' },
|
||||
{ id: 'RFQ-20666', customer: '上海建工', product: '盘螺 8mm', qty: '80 吨', status: 'done', time: '08:42' },
|
||||
{ id: 'RFQ-20665', customer: '北京城建', product: '水泥 P.O 42.5', qty: '100 吨', status: 'cancelled', time: '昨天 16:30' }
|
||||
])
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; type: string }> = {
|
||||
pending: { label: '待报价', type: 'primary' },
|
||||
quoting: { label: '报价中', type: 'warning' },
|
||||
done: { label: '已成交', type: 'success' },
|
||||
cancelled: { label: '已取消', type: 'danger' }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// W1 D4: 这里替换为真实 API 调用
|
||||
// const res = await api.get('/sys/openapi/inquiry/publish', { params: { customerCode: 'admin' } })
|
||||
// stats.value = transformStats(res)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-dashboard">
|
||||
<div class="rr-page-header">
|
||||
<h2 class="rr-page-title">数据看板</h2>
|
||||
<p class="rr-page-sub">2026 年 6 月 17 日 · 实时数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 4 个统计卡 -->
|
||||
<div class="rr-stats-grid">
|
||||
<div v-for="(s, i) in stats" :key="i" class="rr-stat">
|
||||
<div class="rr-stat-head">
|
||||
<el-icon class="rr-stat-icon"><component :is="s.icon" /></el-icon>
|
||||
<span class="rr-stat-label">{{ s.label }}</span>
|
||||
</div>
|
||||
<div class="rr-stat-value">{{ s.value }}</div>
|
||||
<div class="rr-stat-delta" :class="s.trend">
|
||||
<el-icon><component :is="s.trend === 'up' ? 'CaretTop' : 'CaretBottom'" /></el-icon>
|
||||
{{ s.delta }} 较昨日
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 近期询价 -->
|
||||
<div class="rr-card">
|
||||
<div class="rr-card-header">
|
||||
<h3 class="rr-card-title">近期询价</h3>
|
||||
<el-button text type="primary">查看全部 →</el-button>
|
||||
</div>
|
||||
<el-table :data="recentInquiries" stripe>
|
||||
<el-table-column prop="id" label="单号" width="140" />
|
||||
<el-table-column prop="customer" label="客户" />
|
||||
<el-table-column prop="product" label="商品" />
|
||||
<el-table-column prop="qty" label="数量" width="100" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="STATUS_MAP[row.status].type as any" size="small" effect="light">
|
||||
{{ STATUS_MAP[row.status].label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="time" label="时间" width="100" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-dashboard { max-width: 1400px; }
|
||||
.rr-page-header { margin-bottom: 20px; }
|
||||
.rr-page-title {
|
||||
font-size: 22px; font-weight: 600; letter-spacing: -0.02em;
|
||||
color: var(--ink); margin-bottom: 4px;
|
||||
}
|
||||
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
|
||||
|
||||
.rr-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.rr-stat {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 20px;
|
||||
transition: all 0.15s;
|
||||
&:hover { border-color: var(--border-strong); box-shadow: var(--shadow); }
|
||||
}
|
||||
.rr-stat-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rr-stat-icon {
|
||||
font-size: 16px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.rr-stat-label {
|
||||
font-size: 12px; color: var(--ink-3); font-weight: 500;
|
||||
}
|
||||
.rr-stat-value {
|
||||
font-size: 28px; font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--ink);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.rr-stat-delta {
|
||||
font-size: 11.5px; font-weight: 600;
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
&.up { color: var(--green); }
|
||||
&.down { color: var(--red); }
|
||||
}
|
||||
|
||||
.rr-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.rr-card-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.rr-card-title {
|
||||
font-size: 15px; font-weight: 600; color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
40
src/router/index.ts
Normal file
40
src/router/index.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/modules/auth/LoginView.vue'),
|
||||
meta: { title: '登录', public: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/modules/dashboard/DashboardView.vue'),
|
||||
meta: { title: '数据看板', icon: 'dashboard' }
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
redirect: '/'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/admin/'),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫:未登录跳登录
|
||||
router.beforeEach((to, from, next) => {
|
||||
const isPublic = to.meta?.public === true
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!isPublic && !token) {
|
||||
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
38
src/stores/menu.ts
Normal file
38
src/stores/menu.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 菜单 Store
|
||||
* 架构:菜单数据从后端拉(本期先用 mock,后期接真实 API)
|
||||
* 菜单结构:树形,每项 { path, title, icon, children? }
|
||||
* 路由由 store 动态注册(避免硬编码)
|
||||
*/
|
||||
export interface MenuItem {
|
||||
path: string
|
||||
title: string
|
||||
icon?: string
|
||||
children?: MenuItem[]
|
||||
}
|
||||
|
||||
// F006 W1 临时菜单(后期从后端 /sys/menu/list 拉)
|
||||
const MOCK_MENU: MenuItem[] = [
|
||||
{ path: '/', title: '数据看板', icon: 'DataLine' }
|
||||
// W2 起逐步加:询价 / 订单 / 供应商 / 用户 / 系统
|
||||
]
|
||||
|
||||
export const useMenuStore = defineStore('menu', () => {
|
||||
const list = ref<MenuItem[]>(MOCK_MENU)
|
||||
const collapsed = ref<boolean>(false)
|
||||
|
||||
function toggleCollapsed() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
async function fetchMenu() {
|
||||
// TODO W1: 调 GET /sys/menu/list,替换 MOCK_MENU
|
||||
// const res = await api.get('/sys/menu/list')
|
||||
// list.value = transformMenu(res.data)
|
||||
}
|
||||
|
||||
return { list, collapsed, toggleCollapsed, fetchMenu }
|
||||
})
|
||||
29
src/stores/theme.ts
Normal file
29
src/stores/theme.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { THEME_PRESETS, DEFAULT_THEME, applyTheme, loadTheme, type ThemePreset } from '@/theme/presets'
|
||||
|
||||
/**
|
||||
* 主题 Store
|
||||
* 设计:组件只调 pick(),不直接动 DOM/localStorage
|
||||
* 持久化和 DOM 修改都封装在 theme/presets.ts 里
|
||||
*/
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const current = ref<string>(loadTheme())
|
||||
|
||||
const presets = computed<ThemePreset[]>(() => THEME_PRESETS)
|
||||
const currentPreset = computed<ThemePreset>(
|
||||
() => THEME_PRESETS.find(p => p.value === current.value) || THEME_PRESETS[0]
|
||||
)
|
||||
|
||||
function pick(value: string): void {
|
||||
if (!THEME_PRESETS.some(p => p.value === value)) return
|
||||
current.value = value
|
||||
applyTheme(value)
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
pick(DEFAULT_THEME)
|
||||
}
|
||||
|
||||
return { current, presets, currentPreset, pick, reset }
|
||||
})
|
||||
41
src/stores/user.ts
Normal file
41
src/stores/user.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 用户 Store
|
||||
* 架构:token 存 localStorage(同源 cookie 受限),Pinia 只存 user info
|
||||
* 后期可加 token 自动刷新逻辑
|
||||
*/
|
||||
const STORAGE_KEY = 'access_token'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string | null>(localStorage.getItem(STORAGE_KEY))
|
||||
const username = ref<string>(localStorage.getItem('username') || '')
|
||||
const permissions = ref<string[]>(JSON.parse(localStorage.getItem('permissions') || '[]'))
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
function setAuth(payload: { token: string; username: string; permissions?: string[] }) {
|
||||
token.value = payload.token
|
||||
username.value = payload.username
|
||||
permissions.value = payload.permissions || []
|
||||
localStorage.setItem(STORAGE_KEY, payload.token)
|
||||
localStorage.setItem('username', payload.username)
|
||||
localStorage.setItem('permissions', JSON.stringify(payload.permissions || []))
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
username.value = ''
|
||||
permissions.value = []
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('permissions')
|
||||
}
|
||||
|
||||
function hasPermission(code: string): boolean {
|
||||
return permissions.value.includes('*:*:*') || permissions.value.includes(code)
|
||||
}
|
||||
|
||||
return { token, username, permissions, isLoggedIn, setAuth, logout, hasPermission }
|
||||
})
|
||||
44
src/theme/presets.ts
Normal file
44
src/theme/presets.ts
Normal file
@ -0,0 +1,44 @@
|
||||
// F006 主题预设 - 新增主题只改这里
|
||||
// 设计:4 套主题成套配好(主色 + soft + 渐变 + 辉光),组件里只引用 --brand 等变量
|
||||
|
||||
export interface ThemePreset {
|
||||
/** 主题 key,也是 data-theme 的值 */
|
||||
value: string
|
||||
/** 显示名(顶栏 popover 用) */
|
||||
name: string
|
||||
/** 顶栏色卡 dot 用的纯色 */
|
||||
color: string
|
||||
}
|
||||
|
||||
export const THEME_PRESETS: ThemePreset[] = [
|
||||
{ value: 'tech-blue', name: '科技蓝', color: '#2563EB' },
|
||||
{ value: 'red', name: '中国红', color: '#DC2626' },
|
||||
{ value: 'purple', name: 'Notion 紫', color: '#5645D4' },
|
||||
{ value: 'dark', name: '暗夜黑', color: '#0F172A' }
|
||||
]
|
||||
|
||||
export const DEFAULT_THEME = 'tech-blue'
|
||||
export const THEME_STORAGE_KEY = 'product_network_theme'
|
||||
|
||||
/** 应用主题(改 body data-theme + 持久化) */
|
||||
export function applyTheme(value: string): void {
|
||||
document.body.setAttribute('data-theme', value)
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, value)
|
||||
} catch {
|
||||
// localStorage 不可用时静默失败
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取保存的主题(无则返回默认) */
|
||||
export function loadTheme(): string {
|
||||
try {
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
if (saved && THEME_PRESETS.some(p => p.value === saved)) {
|
||||
return saved
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
return DEFAULT_THEME
|
||||
}
|
||||
135
src/theme/themes.scss
Normal file
135
src/theme/themes.scss
Normal file
@ -0,0 +1,135 @@
|
||||
// F006 4 套预置主题
|
||||
// 默认 = 科技蓝,通过 <body data-theme="..."> 切换
|
||||
|
||||
// ============ 1. 科技蓝(默认, Stripe/Supabase 风) ============
|
||||
:root,
|
||||
[data-theme="tech-blue"] {
|
||||
// 中性
|
||||
--bg: #FFFFFF;
|
||||
--bg-soft: #F8FAFC;
|
||||
--surface: #FFFFFF;
|
||||
--surface-2: #F1F5F9;
|
||||
--border: #E2E8F0;
|
||||
--border-strong: #CBD5E1;
|
||||
--ink: #0F172A;
|
||||
--ink-2: #334155;
|
||||
--ink-3: #64748B;
|
||||
--ink-4: #94A3B8;
|
||||
// 品牌
|
||||
--brand: #2563EB;
|
||||
--brand-2: #1D4ED8;
|
||||
--brand-soft: #DBEAFE;
|
||||
--brand-hover: #1E40AF;
|
||||
--brand-glow: rgba(37, 99, 235, 0.15);
|
||||
// 登录页渐变
|
||||
--login-grad-1: #EFF6FF;
|
||||
--login-grad-2: #DBEAFE;
|
||||
--login-grad-3: #E0E7FF;
|
||||
--login-glow-1: rgba(37, 99, 235, 0.18);
|
||||
--login-glow-2: rgba(124, 58, 237, 0.10);
|
||||
// 语义
|
||||
--green: #059669;
|
||||
--green-soft: #D1FAE5;
|
||||
--amber: #D97706;
|
||||
--amber-soft: #FEF3C7;
|
||||
--red: #DC2626;
|
||||
--red-soft: #FEE2E2;
|
||||
--purple: #7C3AED;
|
||||
--purple-soft: #EDE9FE;
|
||||
}
|
||||
|
||||
// ============ 2. 中国红 ============
|
||||
[data-theme="red"] {
|
||||
--bg: #FFFFFF;
|
||||
--bg-soft: #FAFAF9;
|
||||
--surface: #FFFFFF;
|
||||
--surface-2: #F5F5F4;
|
||||
--border: #E7E5E4;
|
||||
--border-strong: #D6D3D1;
|
||||
--ink: #1C1917;
|
||||
--ink-2: #44403C;
|
||||
--ink-3: #78716C;
|
||||
--ink-4: #A8A29E;
|
||||
--brand: #DC2626;
|
||||
--brand-2: #B91C1C;
|
||||
--brand-soft: #FEE2E2;
|
||||
--brand-hover: #991B1B;
|
||||
--brand-glow: rgba(220, 38, 38, 0.15);
|
||||
--login-grad-1: #FEF2F2;
|
||||
--login-grad-2: #FECACA;
|
||||
--login-grad-3: #FFE4E6;
|
||||
--login-glow-1: rgba(220, 38, 38, 0.20);
|
||||
--login-glow-2: rgba(245, 158, 11, 0.12);
|
||||
--green: #15803D;
|
||||
--green-soft: #DCFCE7;
|
||||
--amber: #B45309;
|
||||
--amber-soft: #FEF3C7;
|
||||
--red: #DC2626;
|
||||
--red-soft: #FEE2E2;
|
||||
--purple: #7E22CE;
|
||||
--purple-soft: #F3E8FF;
|
||||
}
|
||||
|
||||
// ============ 3. Notion 紫 ============
|
||||
[data-theme="purple"] {
|
||||
--bg: #FFFFFF;
|
||||
--bg-soft: #FAF9F7;
|
||||
--surface: #FFFFFF;
|
||||
--surface-2: #F7F6F3;
|
||||
--border: #E8E6E1;
|
||||
--border-strong: #D6D3CC;
|
||||
--ink: #37352F;
|
||||
--ink-2: #787774;
|
||||
--ink-3: #9B9A97;
|
||||
--ink-4: #B5B4B0;
|
||||
--brand: #5645D4;
|
||||
--brand-2: #4736BC;
|
||||
--brand-soft: #EDEBFC;
|
||||
--brand-hover: #392BA0;
|
||||
--brand-glow: rgba(86, 69, 212, 0.15);
|
||||
--login-grad-1: #F3F1FE;
|
||||
--login-grad-2: #E8E5FB;
|
||||
--login-grad-3: #FCE7F3;
|
||||
--login-glow-1: rgba(86, 69, 212, 0.20);
|
||||
--login-glow-2: rgba(236, 72, 153, 0.10);
|
||||
--green: #0F7B6C;
|
||||
--green-soft: #DBEDEA;
|
||||
--amber: #C76E29;
|
||||
--amber-soft: #F8EAD9;
|
||||
--red: #C4554D;
|
||||
--red-soft: #F8E2DF;
|
||||
--purple: #5645D4;
|
||||
--purple-soft: #EDEBFC;
|
||||
}
|
||||
|
||||
// ============ 4. 暗夜黑(深色模式) ============
|
||||
[data-theme="dark"] {
|
||||
--bg: #0F172A;
|
||||
--bg-soft: #0B1120;
|
||||
--surface: #1E293B;
|
||||
--surface-2: #334155;
|
||||
--border: #334155;
|
||||
--border-strong: #475569;
|
||||
--ink: #F1F5F9;
|
||||
--ink-2: #CBD5E1;
|
||||
--ink-3: #94A3B8;
|
||||
--ink-4: #64748B;
|
||||
--brand: #60A5FA;
|
||||
--brand-2: #3B82F6;
|
||||
--brand-soft: rgba(96, 165, 250, 0.12);
|
||||
--brand-hover: #93C5FD;
|
||||
--brand-glow: rgba(96, 165, 250, 0.2);
|
||||
--login-grad-1: #1E293B;
|
||||
--login-grad-2: #0F172A;
|
||||
--login-grad-3: #1E1B4B;
|
||||
--login-glow-1: rgba(96, 165, 250, 0.25);
|
||||
--login-glow-2: rgba(167, 139, 250, 0.18);
|
||||
--green: #34D399;
|
||||
--green-soft: rgba(52, 211, 153, 0.12);
|
||||
--amber: #FBBF24;
|
||||
--amber-soft: rgba(251, 191, 36, 0.12);
|
||||
--red: #F87171;
|
||||
--red-soft: rgba(248, 113, 113, 0.12);
|
||||
--purple: #A78BFA;
|
||||
--purple-soft: rgba(167, 139, 250, 0.12);
|
||||
}
|
||||
27
src/theme/tokens.scss
Normal file
27
src/theme/tokens.scss
Normal file
@ -0,0 +1,27 @@
|
||||
// F006 设计 Token - 中性色 + 圆角 + 字体 + 阴影(主题无关)
|
||||
:root {
|
||||
// 圆角
|
||||
--radius-sm: 6px;
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
// 字体
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
|
||||
|
||||
// 阴影
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--shadow-lg: 0 10px 25px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.04);
|
||||
|
||||
// Element Plus 重定向(让 Element Plus 组件跟着主题色变)
|
||||
--el-color-primary: var(--brand);
|
||||
--el-color-primary-light-3: var(--brand-soft);
|
||||
--el-color-primary-light-5: var(--brand-soft);
|
||||
--el-color-primary-light-7: var(--brand-soft);
|
||||
--el-color-primary-light-8: var(--brand-soft);
|
||||
--el-color-primary-light-9: var(--brand-soft);
|
||||
--el-color-primary-dark-2: var(--brand-hover);
|
||||
}
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
42
vite.config.ts
Normal file
42
vite.config.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import path from 'path'
|
||||
|
||||
// F006 新管理后台 - dev 端口 3001,proxy /api 到 gateway:8080
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({ resolvers: [ElementPlusResolver()] }),
|
||||
Components({ resolvers: [ElementPlusResolver()] })
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
// 后端 API 代理(注意:src/ 等 vite 内部路径不能走 proxy)
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/auth': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1024
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user