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:
Sisyphus 2026-06-24 11:05:18 +08:00
commit 6d02a4b5d2
24 changed files with 5135 additions and 0 deletions

10
.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View 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
View 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
View 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>

View 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>

View 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
View 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
View 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')

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}
})