feat: 完善平台总后台模块

This commit is contained in:
2026-07-27 00:18:42 +08:00
parent 073eb89762
commit 642980960e
197 changed files with 22356 additions and 1060 deletions

View File

@@ -0,0 +1,68 @@
# Arco Design Pro Vite
基于 [Arco Design Pro](https://arco.design/pro/) 的 Vue 3 中后台模板,使用 Vite 8 + Pinia + TypeScript 构建。
## 环境要求
- Node.js >= 20.19.0
- pnpm
## 常用命令
```bash
pnpm install # 安装依赖
pnpm dev # 开发服务器
pnpm build # 生产构建
pnpm report # 构建并生成 bundle 分析报告
pnpm type:check # TypeScript 检查
pnpm lint # Biome 代码检查
pnpm lint:fix # 自动修复
```
## 目录结构
```
src/
├── api/ # 接口定义(按业务域)
├── assets/ # 静态资源与全局样式
├── components/ # 全局 / 布局级组件
├── directive/ # 自定义指令
├── hooks/ # 组合式函数
├── layout/ # 页面布局
├── locale/ # i18n 入口与全局文案
├── mocks/ # Mock 数据(开发环境)
│ ├── handlers/ # 全局 mock 处理器
│ └── setup.ts # mock 启用与响应包装
├── plugins/ # 应用插件(如 HTTP 拦截器)
├── router/ # 路由与守卫
├── store/ # Pinia 状态
├── types/ # 全局类型
├── utils/ # 工具函数
└── views/ # 页面(每页可含 components/、locale/、mock.ts)
config/
└── vite.config.ts # Vite 配置
public/ # 静态公共资源
```
## Mock 说明
仅在开发环境(`import.meta.env.DEV`)下,`main.ts` 会动态加载 `src/mocks/index.ts`;生产构建不会打入 mockjs。
- 全局 handler 位于 `mocks/handlers/`
- 页面级 mock 保留在 `views/**/mock.ts`,由 `import.meta.glob` 自动注册
## 环境变量
| 变量 | 说明 |
|------|------|
| `VITE_API_BASE_URL` | 后端 API 地址(见 `.env.development`) |
| `VITE_ERROR_REPORT_URL` | 可选,配置后启用前端错误上报(`utils/error-report.ts`) |
## i18n 说明
- 菜单等全局文案:`locale/zh-CN.ts`、`locale/en-US.ts`
- 页面文案:`views/**/locale/` 与 `components/**/locale/`,通过 `import.meta.glob` 自动聚合
## 模板标记
路由与部分功能块带有 `/** simple */` … `/** simple end */` 注释,表示 Arco Pro「精简版 / 完整版」的可选模块边界。

Binary file not shown.

View File

@@ -0,0 +1,51 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"src/**",
"config/**",
"*.ts",
"*.js",
"*.vue",
"components.d.ts"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always",
"quoteProperties": "asNeeded"
}
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"noUnusedVariables": "warn",
"useExhaustiveDependencies": "off"
},
"style": {
"noNonNullAssertion": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"a11y": {
"noSvgWithoutTitle": "off"
}
}
}
}

14
frontend/platform_admin/components.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

View File

@@ -0,0 +1,113 @@
import { vitePluginForArco } from '@arco-plugins/vite-vue';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import { resolve } from 'path';
import visualizer from 'rollup-plugin-visualizer';
import { ArcoResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite';
import { defineConfig, type PluginOption } from 'vite';
import compressPlugin from 'vite-plugin-compression';
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
import svgLoader from 'vite-svg-loader';
const manualChunkGroups: Record<string, string[]> = {
arco: ['@arco-design/web-vue'],
chart: ['echarts', 'vue-echarts'],
vue: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
};
function manualChunks(id: string) {
if (!id.includes('node_modules')) return;
for (const [chunkName, packages] of Object.entries(manualChunkGroups)) {
for (const pkg of packages) {
if (id.includes(`node_modules/${pkg}`)) {
return chunkName;
}
}
}
}
export default defineConfig(({ command, mode }) => {
const isDev = command === 'serve';
const isReport = mode === 'report';
const plugins: PluginOption[] = [
vue(),
vueJsx(),
svgLoader({ svgoConfig: {} }),
vitePluginForArco({}),
];
if (!isDev) {
plugins.push(
Components({
dirs: [],
deep: false,
resolvers: [ArcoResolver()],
}),
compressPlugin({ ext: '.gz' }),
ViteImageOptimizer({
png: { quality: 80 },
jpeg: { quality: 80 },
jpg: { quality: 80 },
webp: { quality: 80 },
}),
);
if (isReport) {
plugins.push(
visualizer({
filename: './node_modules/.cache/visualizer/stats.html',
open: true,
gzipSize: true,
brotliSize: true,
}),
);
}
}
return {
plugins,
resolve: {
alias: [
{ find: '@', replacement: resolve(__dirname, '../src') },
{ find: 'assets', replacement: resolve(__dirname, '../src/assets') },
{
find: 'vue-i18n',
replacement: 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js',
},
{
find: 'vue',
replacement: 'vue/dist/vue.esm-bundler.js',
},
],
extensions: ['.ts', '.js'],
},
css: {
preprocessorOptions: {
less: {
modifyVars: {
hack: `true; @import (reference) "${resolve(
'src/assets/style/breakpoint.less',
)}";`,
},
javascriptEnabled: true,
},
},
},
server: isDev
? {
open: true,
fs: { strict: true },
}
: undefined,
build: isDev
? undefined
: {
rollupOptions: {
output: { manualChunks },
},
chunkSizeWarningLimit: 2000,
},
};
});

View File

@@ -1,12 +1,13 @@
<!doctype html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>可燃气体平台总后台</title>
<title>Arco Design Pro - 开箱即用的中台前端/设计解决方案</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/app/main.ts"></script>
</body>
</html>

View File

@@ -1,20 +1,58 @@
{
"name": "platform-admin",
"version": "0.1.0",
"name": "arco-design-pro-vue",
"description": "Arco Design Pro for Vue",
"version": "1.0.0",
"private": true,
"type": "module",
"author": "ArcoDesign Team",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"type:check": "vue-tsc --noEmit"
"dev": "vite --config ./config/vite.config.ts",
"build": "vue-tsc -p tsconfig.build.json --noEmit && vite build --config ./config/vite.config.ts",
"report": "vite build --config ./config/vite.config.ts --mode report",
"preview": "pnpm run build && vite preview --host",
"type:check": "vue-tsc -p tsconfig.build.json --noEmit --skipLibCheck",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write ."
},
"dependencies": {
"vue": "^3.5.13"
"@arco-design/web-vue": "^2.58.0",
"@vueuse/core": "^13.9.0",
"axios": "^1.8.4",
"dayjs": "^1.11.13",
"echarts": "^6.1.0",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"pinia": "^3.0.1",
"sortablejs": "^1.15.6",
"vue": "^3.5.13",
"vue-echarts": "^8.0.1",
"vue-i18n": "^11.1.2",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@arco-plugins/vite-vue": "^1.4.6",
"@biomejs/biome": "^2.5.0",
"@types/lodash-es": "^4.17.12",
"@types/mockjs": "^1.0.10",
"@types/nprogress": "^0.2.3",
"@types/sortablejs": "^1.15.8",
"@vitejs/plugin-vue": "^6.0.0",
"@vitejs/plugin-vue-jsx": "^5.0.0",
"less": "^4.2.2",
"mockjs": "^1.1.0",
"rollup-plugin-visualizer": "^6.0.3",
"sharp": "^0.34.1",
"typescript": "^5.8.3",
"unplugin-vue-components": "^28.8.0",
"vite": "^8.0.0",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-image-optimizer": "^2.0.0",
"vite-svg-loader": "^5.1.0",
"vue-tsc": "^2.2.8"
},
"engines": {
"node": ">=20.19.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<circle cx="24" cy="24" r="24" fill="#F2F3F5"/>
<circle cx="24" cy="19" r="7" stroke="#86909C" stroke-width="2"/>
<path
d="M10 40c0-7.732 6.268-14 14-14s14 6.268 14 14"
stroke="#86909C"
stroke-width="2"
stroke-linecap="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@@ -0,0 +1,12 @@
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,51 @@
import fs from 'node:fs';
import path from 'node:path';
function walk(dir, acc = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== 'node_modules') {
walk(full, acc);
} else if (/\.(ts|vue)$/.test(entry.name)) {
acc.push(full);
}
}
return acc;
}
const srcFiles = walk('src');
const badPlaceholder = [];
const zhLocales = srcFiles.filter((f) => f.includes(`${path.sep}locale${path.sep}zh-CN.ts`));
for (const file of srcFiles) {
const text = fs.readFileSync(file, 'utf8');
if (text.includes("'???'") || /'(\?\?[^']*)'/.test(text)) {
badPlaceholder.push(file);
}
}
let zhOk = 0;
for (const file of zhLocales) {
if (/[\u4e00-\u9fff]/.test(fs.readFileSync(file, 'utf8'))) zhOk += 1;
}
let mockInDist = false;
if (fs.existsSync('dist/assets')) {
for (const name of fs.readdirSync('dist/assets')) {
if (!name.endsWith('.js')) continue;
const chunk = fs.readFileSync(path.join('dist/assets', name), 'utf8');
if (chunk.includes('mockjs') || chunk.includes('Mock.mock')) {
mockInDist = true;
break;
}
}
}
console.log(JSON.stringify({
badPlaceholder: badPlaceholder.length,
zhLocales: `${zhOk}/${zhLocales.length}`,
mockInDist,
hasGit: fs.existsSync('.env.development') && fs.readFileSync('.env.development', 'utf8').includes('VITE_API_BASE_URL=http'),
settingsHttp: fs.readFileSync('src/locale/zh-CN/settings.ts', 'utf8').includes('http.logout.title'),
rootMenu: fs.readFileSync('src/locale/zh-CN.ts', 'utf8').includes('仪表盘'),
}, null, 2));

View File

@@ -0,0 +1,67 @@
import fs from 'node:fs';
import path from 'node:path';
const BASE =
'https://raw.githubusercontent.com/arco-design/arco-design-pro-vue/main/arco-design-pro-vite/src';
const vueFiles = [
'views/visualization/multi-dimension-data-analysis/components/content-publishing-source.vue',
'views/user/info/components/my-project.vue',
'views/user/info/components/my-team.vue',
'views/user/setting/components/enterprise-certification.vue',
];
async function download(relPath) {
const res = await fetch(`${BASE}/${relPath}`);
if (!res.ok) throw new Error(`${relPath}: HTTP ${res.status}`);
return res.text();
}
function patchForProject(content, relPath) {
let text = content;
if (relPath.includes('my-project.vue')) {
text = text.replace(
"import { queryMyProjectList, MyProjectRecord } from '@/api/user-center';",
"import { type MyProjectRecord, queryMyProjectList } from '@/api/user';",
);
text = text.replace(/\{\{ project\.contributors \}\}\s*/g, '');
}
if (relPath.includes('my-team.vue')) {
text = text.replace(
"import { queryMyTeamList, MyTeamRecord } from '@/api/user-center';",
"import { type MyTeamRecord, queryMyTeamList } from '@/api/user';",
);
}
if (relPath.includes('enterprise-certification.vue')) {
text = text.replace(
"import { EnterpriseCertificationModel } from '@/api/user-center';",
"import type { EnterpriseCertificationModel } from '@/api/user';",
);
text = text.replace(
/type: Object as PropType<EnterpriseCertificationModel>/,
'type: Object as PropType<EnterpriseCertificationModel>,',
);
}
return text;
}
async function main() {
for (const relPath of vueFiles) {
let content = await download(relPath);
content = patchForProject(content, relPath);
const fullPath = path.join('src', relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
const hasCn = /[\u4e00-\u9fff]/.test(content);
console.log(`OK ${relPath} (cn=${hasCn})`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,109 @@
import fs from 'node:fs';
import path from 'node:path';
const BASE =
'https://raw.githubusercontent.com/arco-design/arco-design-pro-vue/main/arco-design-pro-vite/src';
const localeFiles = [
'locale/zh-CN/settings.ts',
'views/login/locale/zh-CN.ts',
'views/form/group/locale/zh-CN.ts',
'views/form/step/locale/zh-CN.ts',
'views/dashboard/workplace/locale/zh-CN.ts',
'views/dashboard/monitor/locale/zh-CN.ts',
'views/list/card/locale/zh-CN.ts',
'views/list/search-table/locale/zh-CN.ts',
'views/profile/basic/locale/zh-CN.ts',
'views/result/success/locale/zh-CN.ts',
'views/result/error/locale/zh-CN.ts',
'views/exception/403/locale/zh-CN.ts',
'views/exception/404/locale/zh-CN.ts',
'views/user/info/locale/zh-CN.ts',
'views/user/setting/locale/zh-CN.ts',
'views/visualization/data-analysis/locale/zh-CN.ts',
'views/visualization/multi-dimension-data-analysis/locale/zh-CN.ts',
];
const rootZhCN = `import { mergeLocaleModules } from './merge-locales';
import localeSettings from './zh-CN/settings';
const componentLocales = mergeLocaleModules(
import.meta.glob('@/components/**/locale/zh-CN.ts', { eager: true }),
);
const viewLocales = mergeLocaleModules(
import.meta.glob('@/views/**/locale/zh-CN.ts', { eager: true }),
);
export default {
'menu.dashboard': '仪表盘',
'menu.server.dashboard': '仪表盘-服务端',
'menu.server.workplace': '工作台-服务端',
'menu.server.monitor': '实时监控-服务端',
'menu.list': '列表页',
'menu.result': '结果页',
'menu.exception': '异常页',
'menu.form': '表单页',
'menu.profile': '详情页',
'menu.visualization': '数据可视化',
'menu.user': '个人中心',
'menu.arcoWebsite': 'Arco Design',
'menu.faq': '常见问题',
'navbar.docs': '文档中心',
'navbar.action.locale': '切换为中文',
...localeSettings,
...componentLocales,
...viewLocales,
};
`;
async function download(relPath) {
const url = `${BASE}/${relPath}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`${relPath}: HTTP ${res.status}`);
}
return res.text();
}
async function main() {
for (const relPath of localeFiles) {
const content = await download(relPath);
const dest = path.join('src', relPath.replace(/^locale\//, 'locale/'));
const fullPath = path.join('src', relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
const hasCn = /[\u4e00-\u9fff]/.test(content);
console.log(`OK ${relPath} (cn=${hasCn})`);
}
fs.writeFileSync(path.join('src', 'locale/zh-CN.ts'), rootZhCN, 'utf8');
console.log('OK locale/zh-CN.ts (cn=true)');
// search-table column setting label
const stPath = path.join('src', 'views/list/search-table/index.vue');
let st = fs.readFileSync(stPath, 'utf8');
st = st.replace(
"{{ item.title === '#' ? '???' : item.title }}",
"{{ item.title === '#' ? '序列号' : item.title }}",
);
fs.writeFileSync(stPath, st, 'utf8');
console.log('OK search-table/index.vue');
// verify
let bad = 0;
for (const relPath of ['locale/zh-CN.ts', ...localeFiles]) {
const fullPath = path.join('src', relPath);
const text = fs.readFileSync(fullPath, 'utf8');
if (text.includes("'???'") || text.includes("'??'")) {
console.error('STILL BAD:', relPath);
bad += 1;
}
}
if (bad) process.exit(1);
console.log('All locale files verified');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { platformAPI, type DashboardOverview, type IdnAccount, type OrgDeliveryPoint, type OrgGasStation, type OrgServicePerson, type Profile, type SafEvent } from './api/platform';
import { platformAPI, type DashboardOverview, type OrgDeliveryPoint, type OrgGasStation, type OrgServicePerson, type PlatfromAccount, type Profile, type SafEvent } from './api/platform';
type Tab = 'dashboard' | 'station' | 'delivery' | 'person' | 'user' | 'safety' | 'trade' | 'finance' | 'content' | 'track' | 'operation' | 'audit';
type CatalogItem = { title: string; description: string; operations: string[] };
@@ -13,12 +13,11 @@ const overview = ref<DashboardOverview>({});
const stations = ref<OrgGasStation[]>([]);
const deliveryPoints = ref<OrgDeliveryPoint[]>([]);
const servicePeople = ref<OrgServicePerson[]>([]);
const users = ref<IdnAccount[]>([]);
const users = ref<PlatfromAccount[]>([]);
const safetyEvents = ref<SafEvent[]>([]);
const profile = ref<Profile>();
const loginForm = reactive({ username: 'root', password: '' });
const stationForm = reactive({ stationCode: '', name: '', principal: '', serviceArea: '' });
const passwordForm = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' });
const tabs: Array<{ key: Tab; label: string }> = [
{ key: 'dashboard', label: '运营总览' }, { key: 'station', label: '气站管理' }, { key: 'delivery', label: '配送点管理' },
@@ -49,7 +48,7 @@ async function loadData() {
try {
[profile.value, overview.value, stations.value, deliveryPoints.value, servicePeople.value, users.value, safetyEvents.value] = await Promise.all([
platformAPI.getProfile(), platformAPI.getDashboard(), platformAPI.listOrgGasStation(), platformAPI.listOrgDeliveryPoint(),
platformAPI.listOrgServicePerson(), platformAPI.listIdnAccount(), platformAPI.listSafEvent(),
platformAPI.listOrgServicePerson(), platformAPI.listPlatfromAccount(), platformAPI.listSafEvent(),
]);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '加载数据失败';
@@ -75,14 +74,6 @@ async function createStation() {
finally { loading.value = false; }
}
async function changePassword() {
if (passwordForm.newPassword !== passwordForm.confirmPassword) { errorMessage.value = '两次输入的新密码不一致'; return; }
loading.value = true;
try { await platformAPI.changePassword(passwordForm.currentPassword, passwordForm.newPassword); Object.assign(passwordForm, { currentPassword: '', newPassword: '', confirmPassword: '' }); await loadData(); }
catch (error) { errorMessage.value = error instanceof Error ? error.message : '修改密码失败'; }
finally { loading.value = false; }
}
onMounted(loadData);
</script>
@@ -90,7 +81,7 @@ onMounted(loadData);
<main v-if="!loggedIn" class="login-page">
<form class="login-card" @submit.prevent="login">
<span class="brand-mark">阀</span><p class="eyebrow">HEQI PLATFORM</p><h1>可燃气体平台总后台</h1>
<p>使用平台管理员账号登录。首次登录后必须修改初始密码。</p>
<p>使用平台管理员账号登录。</p>
<input v-model.trim="loginForm.username" autocomplete="username" placeholder="账号" required />
<input v-model="loginForm.password" type="password" autocomplete="current-password" placeholder="密码" required />
<p v-if="errorMessage" class="error">{{ errorMessage }}</p><button :disabled="loading" type="submit">{{ loading ? '登录中…' : '登录' }}</button>
@@ -99,7 +90,7 @@ onMounted(loadData);
<main v-else class="shell">
<aside class="sidebar">
<div class="brand"><span class="brand-mark">阀</span><div><strong>可燃气体平台</strong><small>平台总后台</small></div></div>
<div class="brand"><img v-if="profile?.avatar" class="avatar" :src="profile.avatar" :alt="profile.displayName" /><span v-else class="brand-mark">阀</span><div><strong>可燃气体平台</strong><small>平台总后台</small></div></div>
<nav><button v-for="tab in tabs" :key="tab.key" :class="{ active: currentTab === tab.key }" @click="currentTab = tab.key">{{ tab.label }}</button></nav>
<div class="account"><strong>{{ profile?.displayName || '平台管理员' }}</strong><small>{{ profile?.roleCode || '加载中' }}</small><button class="link-button" @click="logout">退出登录</button></div>
</aside>
@@ -107,13 +98,11 @@ onMounted(loadData);
<section class="content">
<header><div><p class="eyebrow">PLATFORM ADMIN</p><h1>{{ activeLabel }}</h1></div><button class="secondary" :disabled="loading" @click="loadData">{{ loading ? '同步中…' : '刷新数据' }}</button></header>
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
<article v-if="profile?.mustChangePassword" class="panel warning"><h2>首次登录安全设置</h2><p>root 初始密码仅可用于首次进入,请立即设置不少于 12 位的新密码。</p><form class="password-form" @submit.prevent="changePassword"><input v-model="passwordForm.currentPassword" type="password" placeholder="当前密码" required /><input v-model="passwordForm.newPassword" type="password" placeholder="新密码(至少 12 位)" required /><input v-model="passwordForm.confirmPassword" type="password" placeholder="确认新密码" required /><button :disabled="loading">修改密码</button></form></article>
<template v-if="currentTab === 'dashboard'"><div class="cards"><article v-for="([label, value]) in overviewCards" :key="label"><span>{{ label }}</span><strong>{{ value }}</strong></article></div><article class="panel"><h2>平台职责边界</h2><p>统一管理所有气站、配送点、服务人员和用户,并对可燃气体业务的安全、订单配送轨迹、资金结算、邀请二维码和高风险操作保留审计闭环。</p></article></template>
<template v-if="currentTab === 'station'"><article class="panel"><h2>创建气站</h2><form @submit.prevent="createStation"><input v-model.trim="stationForm.stationCode" placeholder="气站编码,例如 GS-1002" /><input v-model.trim="stationForm.name" placeholder="气站名称" /><input v-model.trim="stationForm.principal" placeholder="负责人" /><input v-model.trim="stationForm.serviceArea" placeholder="服务区域" /><button :disabled="loading">提交审核</button></form><p>气站可继续管理其配送点、服务人员、用户及专属邀请注册二维码。</p></article><DataTable :headers="['编码', '名称', '负责人', '服务区域', '状态']" :rows="stations.map((item) => [item.stationCode, item.name, item.principal, item.serviceArea, item.status])" /></template>
<template v-if="currentTab === 'delivery'"><article class="panel"><h2>配送点管理边界</h2><p>配送点归属气站,负责管理服务人员、用户、配送班次、订单交付与配送轨迹;平台可跨组织查看、审核、冻结和迁移。</p></article><DataTable :headers="['编码', '配送点', '归属气站', '服务区域', '状态']" :rows="deliveryPoints.map((item) => [item.deliveryCode, item.name, item.gasStationName, item.serviceArea, item.status])" /></template>
<template v-if="currentTab === 'person'"><article class="panel"><h2>服务人员生命周期</h2><p>覆盖安装维修、安检、配送角色、资质、登录设备、接单状态、任务绩效及停用留档。</p></article><DataTable :headers="['姓名', '手机号', '角色', '工作状态', '资质状态']" :rows="servicePeople.map((item) => [item.name, item.phoneMasked, item.roles, item.workStatus, item.credentialStatus])" /></template>
<template v-if="currentTab === 'user'"><article class="panel"><h2>用户 360° 管理</h2><p>统一查看用户资料、地址、智能瓶阀、订单、权益、投诉与风险处置;敏感信息按最小化原则展示。</p></article><DataTable :headers="['手机号', '账户类型', '状态', '服务区域']" :rows="users.map((item) => [item.phoneMasked, item.accountType, item.status, item.serviceArea])" /></template>
<template v-if="currentTab === 'user'"><article class="panel"><h2>平台账户管理</h2><p>统一查看平台账户资料、角色、头像、手机号、状态与操作记录;敏感信息按最小化原则展示。</p></article><DataTable :headers="['账号', '名称', '头像', '手机号', '角色', '状态']" :rows="users.map((item) => [item.username, item.displayName, item.avatar || '未设置', item.phoneMasked, item.roleCode, item.status])" /></template>
<template v-if="currentTab === 'safety'"><article class="panel"><h2>安全运营中心</h2><p>对智能瓶阀告警、安检异常、可燃气体风险、资质临期与订单异常进行分级、派发、升级和复盘。</p></article><DataTable :headers="['事件编码', '等级', '事件说明', '状态', '创建时间']" :rows="safetyEvents.map((item) => [item.eventCode, `${item.level} 级`, item.title, item.status, new Date(item.createdAt).toLocaleString()])" /></template>
<template v-if="catalog[currentTab]"><div class="catalog"><article v-for="item in catalog[currentTab]" :key="item.title" class="panel"><h2>{{ item.title }}</h2><p>{{ item.description }}</p><div class="tags"><span v-for="operation in item.operations" :key="operation">{{ operation }}</span></div></article></div></template>
</section>

View File

@@ -0,0 +1,12 @@
import { request } from './http';
/** 平台登录和当前账号资料的接口模型。 */
export type LoginData = { username: string; password: string };
export type LoginReply = { access_token: string; token_type: string; identity: string; display_name: string; role_code: string };
export type Profile = { identity: string; username: string; display_name: string; avatar: string; role_code: string };
export const authApi = {
login: (data: LoginData) => request<LoginReply>('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
profile: () => request<Profile>('/auth/profile'),
changePassword: (currentPassword: string, newPassword: string) => request<{ changed: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) }),
};

View File

@@ -0,0 +1,5 @@
import { resourceApi } from './resource';
/** 配送点管理接口。 */
export type DeliveryBasic = { identity: string; delivery_code: string; name: string; principal: string; address: string; status: string };
export const deliveryApi = { list: () => resourceApi.list<DeliveryBasic>('/delivery/delivery_basic'), create: (data: Record<string, unknown>) => resourceApi.create<DeliveryBasic>('/delivery/delivery_basic', data) };

View File

@@ -0,0 +1,5 @@
import { resourceApi } from './resource';
/** 可燃气体站管理接口。 */
export type GasBasic = { identity: string; code: string; name: string; principal: string; address: string; status: string };
export const gasApi = { list: () => resourceApi.list<GasBasic>('/gas/gas_basic'), create: (data: Record<string, unknown>) => resourceApi.create<GasBasic>('/gas/gas_basic', data) };

View File

@@ -0,0 +1,21 @@
/** 平台总后台的共享 HTTP 客户端,统一处理响应体和 JWT 请求头。 */
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/platform/v1';
export const tokenStorageKey = 'token';
export type PageResult<T> = { total: number; list: T[] };
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = localStorage.getItem(tokenStorageKey);
const response = await fetch(`${apiBaseURL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: token } : {}),
...(init?.headers ?? {}),
},
});
const payload = (await response.json()) as { code?: number; message?: string; details?: T };
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
return payload.details as T;
}

View File

@@ -1,62 +1,14 @@
// 平台总后台 API 客户端;JWT 使用 BSM 中间件要求的原始令牌值。
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/v1';
const developmentJWT = import.meta.env.VITE_DEV_JWT?.trim();
const tokenStorageKey = 'heqi.platform_admin.access_token';
import { request } from './http';
import { resourceApi } from './resource';
export type OrgGasStation = { identity: string; stationCode: string; name: string; principal: string; serviceArea: string; status: string; createdAt: string };
export type OrgDeliveryPoint = { identity: string; deliveryCode: string; name: string; gasStationName: string; serviceArea: string; status: string };
export type OrgServicePerson = { identity: string; name: string; phoneMasked: string; roles: string; workStatus: string; credentialStatus: string };
export type IdnAccount = { identity: string; phoneMasked: string; accountType: string; status: string; serviceArea: string };
export type SafEvent = { identity: string; eventCode: string; level: number; title: string; status: string; createdAt: string };
export type DashboardOverview = Record<string, number>;
export type LoginReply = { accessToken: string; tokenType: string; identity: string; displayName: string; roleCode: string; mustChangePassword: boolean };
export type Profile = { identity: string; username: string; displayName: string; roleCode: string; mustChangePassword: boolean; mfaEnabled: boolean };
/** 平台角色、菜单、账号和工作台接口。 */
export type PlatformRole = { identity: string; role_code: string; name: string; data_scope: string; is_system: boolean; status: string };
export type PlatformMenu = { id: number; identity: string; parent_id: number; menu_code: string; name: string; icon: string; path: string; sort_no: number };
type ListReply<T> = { total: number; list: T[] };
type RawOrgDeliveryPoint = { identity: string; delivery_code: string; name: string; gas_station_name?: string; service_area: string; status: string };
type RawOrgServicePerson = { identity: string; name: string; phone_masked?: string; roles: string; work_status: string; credential_status: string };
type RawIdnAccount = { identity: string; phone_masked: string; account_type: string; status: string; service_area: string };
type RawSafEvent = { identity: string; event_code: string; level: number; title: string; status: string; created_at: string };
function currentToken(): string | undefined {
return localStorage.getItem(tokenStorageKey) || developmentJWT;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const accessToken = currentToken();
const response = await fetch(`${apiBaseURL}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...(accessToken ? { Authorization: accessToken } : {}), ...(init?.headers ?? {}) },
});
const payload = (await response.json()) as { code?: number; message?: string; details?: T };
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
return payload.details as T;
}
export const platformAPI = {
hasSession: () => Boolean(currentToken()),
clearSession: () => localStorage.removeItem(tokenStorageKey),
login: async (username: string, password: string) => {
const item = await request<{ access_token: string; token_type: string; identity: string; display_name: string; role_code: string; must_change_password: boolean }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
localStorage.setItem(tokenStorageKey, item.access_token);
return { accessToken: item.access_token, tokenType: item.token_type, identity: item.identity, displayName: item.display_name, roleCode: item.role_code, mustChangePassword: item.must_change_password } satisfies LoginReply;
},
getProfile: async () => {
const item = await request<{ identity: string; username: string; display_name: string; role_code: string; must_change_password: boolean; mfa_enabled: boolean }>('/auth/profile');
return { identity: item.identity, username: item.username, displayName: item.display_name, roleCode: item.role_code, mustChangePassword: item.must_change_password, mfaEnabled: item.mfa_enabled } satisfies Profile;
},
changePassword: (currentPassword: string, newPassword: string) => request<{ changed: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) }),
getDashboard: async () => {
const item = await request<{ gas_station_count: number; delivery_point_count: number; service_person_count: number; user_count: number; pending_safety_count: number }>('/dashboard/overview');
return { gasStationCount: item.gas_station_count, deliveryPointCount: item.delivery_point_count, servicePersonCount: item.service_person_count, userCount: item.user_count, pendingSafetyCount: item.pending_safety_count };
},
listOrgGasStation: async () => {
const reply = await request<ListReply<{ identity: string; station_code: string; name: string; principal: string; service_area: string; status: string; created_at: string }>>('/organization/org_gas_station');
return reply.list.map((item) => ({ identity: item.identity, stationCode: item.station_code, name: item.name, principal: item.principal, serviceArea: item.service_area, status: item.status, createdAt: item.created_at }));
},
createOrgGasStation: (body: Pick<OrgGasStation, 'stationCode' | 'name' | 'principal' | 'serviceArea'>) => request<OrgGasStation>('/organization/org_gas_station', { method: 'POST', body: JSON.stringify({ station_code: body.stationCode, name: body.name, principal: body.principal, service_area: body.serviceArea }) }),
listOrgDeliveryPoint: async () => (await request<ListReply<RawOrgDeliveryPoint>>('/organization/org_delivery_point')).list.map((item) => ({ identity: item.identity, deliveryCode: item.delivery_code, name: item.name, gasStationName: item.gas_station_name ?? '未关联', serviceArea: item.service_area, status: item.status })),
listOrgServicePerson: async () => (await request<ListReply<RawOrgServicePerson>>('/organization/org_service_person')).list.map((item) => ({ identity: item.identity, name: item.name, phoneMasked: item.phone_masked ?? '***', roles: item.roles, workStatus: item.work_status, credentialStatus: item.credential_status })),
listIdnAccount: async () => (await request<ListReply<RawIdnAccount>>('/identity/idn_account')).list.map((item) => ({ identity: item.identity, phoneMasked: item.phone_masked, accountType: item.account_type, status: item.status, serviceArea: item.service_area })),
listSafEvent: async () => (await request<ListReply<RawSafEvent>>('/safety/saf_event')).list.map((item) => ({ identity: item.identity, eventCode: item.event_code, level: item.level, title: item.title, status: item.status, createdAt: item.created_at })),
export const platformApi = {
overview: () => request<Record<string, number>>('/dashboard/overview'),
listAccount: () => request<{ total: number; list: Record<string, unknown>[] }>('/platform/platfrom_account'),
listRole: () => resourceApi.list<PlatformRole>('/platform/platform_role'),
createRole: (data: Record<string, unknown>) => resourceApi.create<PlatformRole>('/platform/platform_role', data),
listMenu: () => request<{ total: number; list: PlatformMenu[] }>('/platform/platform_menu'),
};

View File

@@ -0,0 +1,11 @@
import { request, type PageResult } from './http';
/** 所有标准 CRUD 资源共享的调用方法。 */
export const resourceApi = {
list: <T>(resource: string, page = 1, size = 20) => request<PageResult<T>>(`${resource}?page=${page}&size=${size}`),
detail: <T>(resource: string, identity: string) => request<T>(`${resource}/${identity}`),
create: <T>(resource: string, data: Record<string, unknown>) => request<T>(resource, { method: 'POST', body: JSON.stringify(data) }),
update: <T>(resource: string, identity: string, data: Record<string, unknown>) => request<T>(`${resource}/${identity}`, { method: 'PUT', body: JSON.stringify(data) }),
updateStatus: (resource: string, identity: string, status: string) => request<{ updated: boolean }>(`${resource}/${identity}/status`, { method: 'PATCH', body: JSON.stringify({ status }) }),
archive: (resource: string, identity: string) => request<{ updated: boolean }>(`${resource}/${identity}`, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,5 @@
import { resourceApi } from './resource';
/** 服务人员管理接口。 */
export type Staff = { identity: string; name: string; phone: string; role_code: string; work_status: string; status: string };
export const staffApi = { list: () => resourceApi.list<Staff>('/staff/staff'), create: (data: Record<string, unknown>) => resourceApi.create<Staff>('/staff/staff', data) };

View File

@@ -0,0 +1,5 @@
import { resourceApi } from './resource';
/** 业主客户管理接口。 */
export type User = { identity: string; name: string; phone: string; real_name: string; status: string };
export const userApi = { list: () => resourceApi.list<User>('/user/user'), create: (data: Record<string, unknown>) => resourceApi.create<User>('/user/user', data) };

View File

@@ -0,0 +1,24 @@
<template>
<a-config-provider :locale="locale">
<router-view />
</a-config-provider>
</template>
<script lang="ts" setup>
import enUS from '@arco-design/web-vue/es/locale/lang/en-us';
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn';
import { computed } from 'vue';
import useLocale from '@/hooks/locale';
const { currentLocale } = useLocale();
const locale = computed(() => {
switch (currentLocale.value) {
case 'zh-CN':
return zhCN;
case 'en-US':
return enUS;
default:
return enUS;
}
});
</script>

View File

@@ -0,0 +1,22 @@
/// <reference types="vite/client" />
/// <reference types="vue/jsx" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<
Record<string, unknown>,
Record<string, unknown>,
unknown
>;
export default component;
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_ERROR_REPORT_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,38 @@
import ArcoVue from '@arco-design/web-vue';
import ArcoVueIcon from '@arco-design/web-vue/es/icon';
import { createApp } from 'vue';
import globalComponents from '@/components';
import '@/assets/style/global.less';
import { setupHttp } from '@/plugins/http';
import directive from '@/directive';
import i18n from '@/locale';
import router from '@/router';
import store from '@/store';
import setupErrorReport from '@/utils/error-report';
import App from './App.vue';
async function bootstrap() {
if (import.meta.env.DEV) {
await import('@/mocks');
}
const app = createApp(App);
app.use(ArcoVue, {});
app.use(ArcoVueIcon);
app.use(router);
app.use(store);
app.use(i18n);
app.use(globalComponents);
app.use(directive);
setupHttp();
setupErrorReport(
app,
import.meta.env.VITE_ERROR_REPORT_URL?.trim() ?? '',
);
app.mount('#app');
}
bootstrap();

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -0,0 +1,12 @@
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,19 @@
// ==============breakpoint============
// Extra small screen / phone
@screen-xs: 480px;
// Small screen / tablet
@screen-sm: 576px;
// Medium screen / desktop
@screen-md: 768px;
// Large screen / wide desktop
@screen-lg: 992px;
// Extra large screen / full hd
@screen-xl: 1200px;
// Extra extra large screen / large desktop
@screen-xxl: 1600px;

View File

@@ -0,0 +1,94 @@
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-size: 14px;
background-color: var(--color-bg-1);
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
}
.echarts-tooltip-diy {
background: linear-gradient(
304.17deg,
rgba(253, 254, 255, 0.6) -6.04%,
rgba(244, 247, 252, 0.6) 85.2%
) !important;
border: none !important;
backdrop-filter: blur(10px) !important;
/* Note: backdrop-filter has minimal browser support */
border-radius: 6px !important;
.content-panel {
display: flex;
justify-content: space-between;
padding: 0 9px;
background: rgba(255, 255, 255, 0.8);
width: 164px;
height: 32px;
line-height: 32px;
box-shadow: 6px 0px 20px rgba(34, 87, 188, 0.1);
border-radius: 4px;
margin-bottom: 4px;
}
.tooltip-title {
margin: 0 0 10px 0;
}
p {
margin: 0;
}
.tooltip-title,
.tooltip-value {
font-size: 13px;
line-height: 15px;
display: flex;
align-items: center;
text-align: right;
color: #1d2129;
font-weight: bold;
}
.tooltip-item-icon {
display: inline-block;
margin-right: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
}
}
.general-card {
border-radius: 4px;
border: none;
& > .arco-card-header {
height: auto;
padding: 20px;
border: none;
}
& > .arco-card-body {
padding: 0 20px 20px 20px;
}
}
.split-line {
border-color: rgb(var(--gray-2));
}
.arco-table-cell {
.circle {
display: inline-block;
margin-right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: rgb(var(--blue-6));
&.pass {
background-color: rgb(var(--green-6));
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
<template>
<a-breadcrumb class="container-breadcrumb">
<a-breadcrumb-item>
<icon-apps />
</a-breadcrumb-item>
<a-breadcrumb-item v-for="item in items" :key="item">
{{ $t(item) }}
</a-breadcrumb-item>
</a-breadcrumb>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
defineProps({
items: {
type: Array as PropType<string[]>,
default() {
return [];
},
},
});
</script>
<style scoped lang="less">
.container-breadcrumb {
margin: 16px 0;
:deep(.arco-breadcrumb-item) {
color: rgb(var(--gray-6));
&:last-child {
color: rgb(var(--gray-8));
}
}
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<VChart
v-if="renderChart"
:option="option"
autoresize
:style="{ width, height }"
/>
</template>
<script lang="ts" setup>
import type { EChartsOption } from 'echarts';
import { nextTick, ref } from 'vue';
import VChart from 'vue-echarts';
defineProps({
option: {
type: Object as () => EChartsOption,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '100%',
},
});
const renderChart = ref(false);
nextTick(() => {
renderChart.value = true;
});
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,16 @@
<template>
<a-layout-footer class="footer">Arco Pro</a-layout-footer>
</template>
<script lang="ts" setup></script>
<style lang="less" scoped>
.footer {
display: flex;
align-items: center;
justify-content: center;
height: 40px;
color: var(--color-text-2);
text-align: center;
}
</style>

View File

@@ -0,0 +1,35 @@
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
import {
DataZoomComponent,
GraphicComponent,
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import type { App } from 'vue';
import Breadcrumb from './breadcrumb/index.vue';
import Chart from './chart/index.vue';
// Manually introduce ECharts modules to reduce packing size
use([
CanvasRenderer,
BarChart,
LineChart,
PieChart,
RadarChart,
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
GraphicComponent,
]);
export default {
install(Vue: App) {
Vue.component('Chart', Chart);
Vue.component('Breadcrumb', Breadcrumb);
},
};

View File

@@ -0,0 +1,160 @@
<script lang="tsx">
import { compile, computed, defineComponent, h, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { RouteMeta } from 'vue-router';
import { type RouteRecordRaw, useRoute, useRouter } from 'vue-router';
import { useAppStore } from '@/store';
import { openWindow, regexUrl } from '@/utils';
import { listenerRouteChange } from '@/utils/route-listener';
import useMenuTree from './use-menu-tree';
export default defineComponent({
emit: ['collapse'],
setup() {
const { t } = useI18n();
const appStore = useAppStore();
const router = useRouter();
const route = useRoute();
const { menuTree } = useMenuTree();
const collapsed = computed({
get() {
if (appStore.device === 'desktop') return appStore.menuCollapse;
return false;
},
set(value: boolean) {
appStore.updateSettings({ menuCollapse: value });
},
});
const topMenu = computed(() => appStore.topMenu);
const openKeys = ref<string[]>([]);
const selectedKey = ref<string[]>([]);
const goto = (item: RouteRecordRaw) => {
// Open external link
if (regexUrl.test(item.path)) {
openWindow(item.path);
selectedKey.value = [item.name as string];
return;
}
// Eliminate external link side effects
const { hideInMenu, activeMenu } = item.meta as RouteMeta;
if (route.name === item.name && !hideInMenu && !activeMenu) {
selectedKey.value = [item.name as string];
return;
}
// Trigger router change
router.push({
name: item.name,
});
};
const findMenuOpenKeys = (target: string) => {
const result: string[] = [];
let isFind = false;
const backtrack = (item: RouteRecordRaw, keys: string[]) => {
if (item.name === target) {
isFind = true;
result.push(...keys);
return;
}
if (item.children?.length) {
item.children.forEach((el) => {
backtrack(el, [...keys, el.name as string]);
});
}
};
menuTree.value.forEach((el: RouteRecordRaw) => {
if (isFind) return; // Performance optimization
backtrack(el, [el.name as string]);
});
return result;
};
listenerRouteChange((newRoute) => {
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
if (requiresAuth && (!hideInMenu || activeMenu)) {
const menuOpenKeys = findMenuOpenKeys(
(activeMenu || newRoute.name) as string,
);
const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
openKeys.value = [...keySet];
selectedKey.value = [
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
];
}
}, true);
const setCollapse = (val: boolean) => {
if (appStore.device === 'desktop')
appStore.updateSettings({ menuCollapse: val });
};
const renderSubMenu = () => {
function travel(_route: RouteRecordRaw[], nodes = []) {
if (_route) {
_route.forEach((element) => {
// This is demo, modify nodes as needed
const icon = element?.meta?.icon
? () => h(compile(`<${element?.meta?.icon}/>`))
: null;
const node =
element?.children && element?.children.length !== 0 ? (
<a-sub-menu
key={element?.name}
v-slots={{
icon,
title: () => h(compile(t(element?.meta?.locale || ''))),
}}
>
{travel(element?.children)}
</a-sub-menu>
) : (
<a-menu-item
key={element?.name}
v-slots={{ icon }}
onClick={() => goto(element)}
>
{t(element?.meta?.locale || '')}
</a-menu-item>
);
nodes.push(node as never);
});
}
return nodes;
}
return travel(menuTree.value);
};
return () => (
<a-menu
mode={topMenu.value ? 'horizontal' : 'vertical'}
v-model:collapsed={collapsed.value}
v-model:open-keys={openKeys.value}
show-collapse-button={appStore.device !== 'mobile'}
auto-open={false}
selected-keys={selectedKey.value}
auto-open-selected={true}
level-indent={34}
style="height: 100%;width:100%;"
onCollapse={setCollapse}
>
{renderSubMenu()}
</a-menu>
);
},
});
</script>
<style lang="less" scoped>
:deep(.arco-menu-inner) {
.arco-menu-inline-header {
display: flex;
align-items: center;
}
.arco-icon {
&:not(.arco-icon-down) {
font-size: 18px;
}
}
}
</style>

View File

@@ -0,0 +1,69 @@
import { cloneDeep } from 'lodash-es';
import { computed } from 'vue';
import type { RouteRecordNormalized, RouteRecordRaw } from 'vue-router';
import usePermission from '@/hooks/permission';
import appClientMenus from '@/router/app-menus';
import { useAppStore } from '@/store';
export default function useMenuTree() {
const permission = usePermission();
const appStore = useAppStore();
const appRoute = computed(() => {
if (appStore.menuFromServer) {
return appStore.appAsyncMenus;
}
return appClientMenus;
});
const menuTree = computed(() => {
const copyRouter = cloneDeep(appRoute.value) as RouteRecordNormalized[];
copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => {
return (a.meta.order || 0) - (b.meta.order || 0);
});
function travel(_routes: RouteRecordRaw[], layer: number) {
if (!_routes) return null;
const collector: any = _routes.map((element) => {
// no access
if (!permission.accessRouter(element)) {
return null;
}
// leaf node
if (element.meta?.hideChildrenInMenu || !element.children) {
element.children = [];
return element;
}
// route filter hideInMenu true
element.children = element.children.filter(
(x) => x.meta?.hideInMenu !== true,
);
// Associated child node
const subItem = travel(element.children, layer + 1);
if (subItem.length) {
element.children = subItem;
return element;
}
// the else logic
if (layer > 1) {
element.children = subItem;
return element;
}
if (element.meta?.hideInMenu === false) {
return element;
}
return null;
});
return collector.filter(Boolean);
}
return travel(copyRouter, 0);
});
return {
menuTree,
};
}

View File

@@ -0,0 +1,122 @@
<template>
<a-spin style="display: block" :loading="loading">
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
<a-tab-pane v-for="item in tabList" :key="item.key">
<template #title>
<span> {{ item.title }}{{ formatUnreadLength(item.key) }} </span>
</template>
<a-result v-if="!renderList.length" status="404">
<template #subtitle> {{ $t('messageBox.noContent') }} </template>
</a-result>
<List
:render-list="renderList"
:unread-count="unreadCount"
@item-click="handleItemClick"
/>
</a-tab-pane>
<template #extra>
<a-button type="text" @click="emptyList">
{{ $t('messageBox.tab.button') }}
</a-button>
</template>
</a-tabs>
</a-spin>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, toRefs } from 'vue';
import { useI18n } from 'vue-i18n';
import type { MessageListType, MessageRecord } from './types';
import useLoading from '@/hooks/loading';
import List from './list.vue';
interface TabItem {
key: string;
title: string;
avatar?: string;
}
const { loading, setLoading } = useLoading(true);
const messageType = ref('message');
const { t } = useI18n();
const messageData = reactive<{
renderList: MessageRecord[];
messageList: MessageRecord[];
}>({
renderList: [],
messageList: [],
});
toRefs(messageData);
const tabList: TabItem[] = [
{
key: 'message',
title: t('messageBox.tab.title.message'),
},
{
key: 'notice',
title: t('messageBox.tab.title.notice'),
},
{
key: 'todo',
title: t('messageBox.tab.title.todo'),
},
];
async function fetchSourceData() {
setLoading(true);
try {
messageData.messageList = [];
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false);
}
}
async function readMessage(data: MessageListType) {
const ids = data.map((item) => item.id);
messageData.messageList = messageData.messageList.map((item) => ids.includes(item.id) ? { ...item, status: true } : item);
}
const renderList = computed(() => {
return messageData.messageList.filter(
(item) => messageType.value === item.type,
);
});
const unreadCount = computed(() => {
return renderList.value.filter((item) => !item.status).length;
});
const getUnreadList = (type: string) => {
const list = messageData.messageList.filter(
(item) => item.type === type && !item.status,
);
return list;
};
const formatUnreadLength = (type: string) => {
const list = getUnreadList(type);
return list.length ? `(${list.length})` : ``;
};
const handleItemClick = (items: MessageListType) => {
if (renderList.value.length) readMessage([...items]);
};
const emptyList = () => {
messageData.messageList = [];
};
fetchSourceData();
</script>
<style scoped lang="less">
:deep(.arco-popover-popup-content) {
padding: 0;
}
:deep(.arco-list-item-meta) {
align-items: flex-start;
}
:deep(.arco-tabs-nav) {
padding: 14px 0 12px 16px;
border-bottom: 1px solid var(--color-neutral-3);
}
:deep(.arco-tabs-content) {
padding-top: 0;
.arco-result-subtitle {
color: rgb(var(--gray-6));
}
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<a-list :bordered="false">
<a-list-item
v-for="item in renderList"
:key="item.id"
action-layout="vertical"
:style="{
opacity: item.status ? 0.5 : 1,
}"
>
<template #extra>
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag>
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>
</template>
<div class="item-wrap" @click="onItemClick(item)">
<a-list-item-meta>
<template v-if="item.avatar" #avatar>
<a-avatar shape="circle">
<img v-if="item.avatar" :src="item.avatar" />
<icon-desktop v-else />
</a-avatar>
</template>
<template #title>
<a-space :size="4">
<span>{{ item.title }}</span>
<a-typography-text type="secondary">
{{ item.subTitle }}
</a-typography-text>
</a-space>
</template>
<template #description>
<div>
<a-typography-paragraph
:ellipsis="{
rows: 1,
}"
>{{ item.content }}</a-typography-paragraph
>
<a-typography-text
v-if="item.type === 'message'"
class="time-text"
>
{{ item.time }}
</a-typography-text>
</div>
</template>
</a-list-item-meta>
</div>
</a-list-item>
<template #footer>
<a-space
fill
:size="0"
:class="{ 'add-border-top': renderList.length < showMax }"
>
<div class="footer-wrap">
<a-link @click="allRead">{{ $t('messageBox.allRead') }}</a-link>
</div>
<div class="footer-wrap">
<a-link>{{ $t('messageBox.viewMore') }}</a-link>
</div>
</a-space>
</template>
<div
v-if="renderList.length && renderList.length < 3"
:style="{ height: (showMax - renderList.length) * 86 + 'px' }"
></div>
</a-list>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
import type { MessageListType, MessageRecord } from './types';
const props = defineProps({
renderList: {
type: Array as PropType<MessageListType>,
required: true,
},
unreadCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['itemClick']);
const allRead = () => {
emit('itemClick', [...props.renderList]);
};
const onItemClick = (item: MessageRecord) => {
if (!item.status) {
emit('itemClick', [item]);
}
};
const showMax = 3;
</script>
<style scoped lang="less">
:deep(.arco-list) {
.arco-list-item {
min-height: 86px;
border-bottom: 1px solid rgb(var(--gray-3));
}
.arco-list-item-extra {
position: absolute;
right: 20px;
}
.arco-list-item-meta-content {
flex: 1;
}
.item-wrap {
cursor: pointer;
}
.time-text {
font-size: 12px;
color: rgb(var(--gray-6));
}
.arco-empty {
display: none;
}
.arco-list-footer {
padding: 0;
height: 50px;
line-height: 50px;
border-top: none;
.arco-space-item {
width: 100%;
border-right: 1px solid rgb(var(--gray-3));
&:last-child {
border-right: none;
}
}
.add-border-top {
border-top: 1px solid rgb(var(--gray-3));
}
}
.footer-wrap {
text-align: center;
}
.arco-typography {
margin-bottom: 0;
}
.add-border {
border-top: 1px solid rgb(var(--gray-3));
}
}
</style>

View File

@@ -0,0 +1,13 @@
export default {
'messageBox.tab.title.message': 'Message',
'messageBox.tab.title.notice': 'Notice',
'messageBox.tab.title.todo': 'Todo',
'messageBox.tab.button': 'empty',
'messageBox.allRead': 'All Read',
'messageBox.viewMore': 'View More',
'messageBox.noContent': 'No Content',
'messageBox.switchRoles': 'Switch Roles',
'messageBox.userCenter': 'User Center',
'messageBox.userSettings': 'User Settings',
'messageBox.logout': 'Logout',
};

View File

@@ -0,0 +1,13 @@
export default {
'messageBox.tab.title.message': '消息',
'messageBox.tab.title.notice': '通知',
'messageBox.tab.title.todo': '待办',
'messageBox.tab.button': '清空',
'messageBox.allRead': '全部已读',
'messageBox.viewMore': '查看更多',
'messageBox.noContent': '暂无内容',
'messageBox.switchRoles': '切换角色',
'messageBox.userCenter': '用户中心',
'messageBox.userSettings': '用户设置',
'messageBox.logout': '登出登录',
};

View File

@@ -0,0 +1,3 @@
/** 顶部消息展示的本地类型;通知模块接入后由业务 API 替换。 */
export type MessageRecord = { id: string; type: 'message' | 'notice' | 'todo'; status: boolean; title: string; subTitle?: string; content: string; time?: string; avatar?: string; messageType?: number };
export type MessageListType = MessageRecord[];

View File

@@ -0,0 +1,368 @@
<template>
<div class="navbar">
<div class="left-side">
<a-space>
<img
alt="logo"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image"
/>
<a-typography-title
:style="{ margin: 0, fontSize: '18px' }"
:heading="5"
>
Arco Pro
</a-typography-title>
<icon-menu-fold
v-if="!topMenu && appStore.device === 'mobile'"
style="font-size: 22px; cursor: pointer"
@click="toggleDrawerMenu"
/>
</a-space>
</div>
<div class="center-side">
<Menu v-if="topMenu" class="center-menu" />
<div class="navbar-search">
<a-input
v-model="searchKeyword"
:placeholder="$t('settings.searchPlaceholder')"
allow-clear
@press-enter="handleSearch"
/>
<a-button
type="primary"
class="navbar-search-btn"
@click="handleSearch"
>
<template #icon>
<icon-search />
</template>
{{ $t('settings.search') }}
</a-button>
</div>
</div>
<ul class="right-side">
<li>
<a-tooltip :content="$t('settings.language')">
<a-button
class="nav-btn"
type="outline"
:shape="'circle'"
@click="setDropDownVisible"
>
<template #icon>
<icon-language />
</template>
</a-button>
</a-tooltip>
<a-dropdown trigger="click" @select="changeLocale as any">
<div ref="triggerBtn" class="trigger-btn"></div>
<template #content>
<a-doption
v-for="item in locales"
:key="item.value"
:value="item.value"
>
<template #icon>
<icon-check v-show="item.value === currentLocale" />
</template>
{{ item.label }}
</a-doption>
</template>
</a-dropdown>
</li>
<li>
<a-tooltip
:content="
theme === 'light'
? $t('settings.navbar.theme.toDark')
: $t('settings.navbar.theme.toLight')
"
>
<a-button
class="nav-btn"
type="outline"
:shape="'circle'"
@click="handleToggleTheme"
>
<template #icon>
<icon-moon-fill v-if="theme === 'dark'" />
<icon-sun-fill v-else />
</template>
</a-button>
</a-tooltip>
</li>
<li>
<a-tooltip :content="$t('settings.navbar.alerts')">
<div class="message-box-trigger">
<a-badge :count="9" dot>
<a-button
class="nav-btn"
type="outline"
:shape="'circle'"
@click="setPopoverVisible"
>
<icon-notification />
</a-button>
</a-badge>
</div>
</a-tooltip>
<a-popover
trigger="click"
:arrow-style="{ display: 'none' }"
:content-style="{ padding: 0, minWidth: '400px' }"
content-class="message-popover"
>
<div ref="refBtn" class="ref-btn"></div>
<template #content>
<message-box />
</template>
</a-popover>
</li>
<li>
<a-tooltip
:content="
isFullscreen
? $t('settings.navbar.screen.toExit')
: $t('settings.navbar.screen.toFull')
"
>
<a-button
class="nav-btn"
type="outline"
:shape="'circle'"
@click="toggleFullScreen"
>
<template #icon>
<icon-fullscreen-exit v-if="isFullscreen" />
<icon-fullscreen v-else />
</template>
</a-button>
</a-tooltip>
</li>
<li>
<a-dropdown trigger="click">
<a-avatar
:size="32"
:style="{ marginRight: '8px', cursor: 'pointer' }"
>
<img alt="avatar" :src="avatar" />
</a-avatar>
<template #content>
<a-doption>
<a-space @click="switchRoles">
<icon-tag />
<span>
{{ $t('messageBox.switchRoles') }}
</span>
</a-space>
</a-doption>
<a-doption>
<a-space @click="$router.push({ name: 'Info' })">
<icon-user />
<span>
{{ $t('messageBox.userCenter') }}
</span>
</a-space>
</a-doption>
<a-doption>
<a-space @click="$router.push({ name: 'Setting' })">
<icon-settings />
<span>
{{ $t('messageBox.userSettings') }}
</span>
</a-space>
</a-doption>
<a-doption>
<a-space @click="handleLogout">
<icon-export />
<span>
{{ $t('messageBox.logout') }}
</span>
</a-space>
</a-doption>
</template>
</a-dropdown>
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import { Message } from '@arco-design/web-vue';
import { useDark, useFullscreen, useToggle } from '@vueuse/core';
import { computed, inject, ref } from 'vue';
import Menu from '@/components/menu/index.vue';
import { resolveAvatarUrl } from '@/constants/avatar';
import useLocale from '@/hooks/locale';
import useUser from '@/hooks/user';
import { LOCALE_OPTIONS } from '@/locale';
import { useAppStore, useUserStore } from '@/store';
import MessageBox from '../message-box/index.vue';
const appStore = useAppStore();
const userStore = useUserStore();
const { logout } = useUser();
const { changeLocale, currentLocale } = useLocale();
const { isFullscreen, toggle: toggleFullScreen } = useFullscreen();
const locales = [...LOCALE_OPTIONS];
const searchKeyword = ref('');
const avatar = computed(() => resolveAvatarUrl(userStore.avatar));
const theme = computed(() => {
return appStore.theme;
});
const topMenu = computed(() => appStore.topMenu && appStore.menu);
const isDark = useDark({
selector: 'body',
attribute: 'arco-theme',
valueDark: 'dark',
valueLight: 'light',
storageKey: 'arco-theme',
onChanged(dark: boolean) {
// overridden default behavior
appStore.toggleTheme(dark);
},
});
const toggleTheme = useToggle(isDark);
const handleToggleTheme = () => {
toggleTheme();
};
const handleSearch = () => {
const keyword = searchKeyword.value.trim();
if (!keyword) {
return;
}
Message.info(`${keyword}`);
};
const refBtn = ref();
const triggerBtn = ref();
const setPopoverVisible = () => {
const event = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
});
refBtn.value.dispatchEvent(event);
};
const handleLogout = () => {
logout();
};
const setDropDownVisible = () => {
const event = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
});
triggerBtn.value.dispatchEvent(event);
};
const switchRoles = async () => {
const res = await userStore.switchRoles();
Message.success(res as string);
};
const toggleDrawerMenu = inject('toggleDrawerMenu') as () => void;
</script>
<style scoped lang="less">
.navbar {
display: flex;
justify-content: space-between;
height: 100%;
background-color: var(--color-bg-2);
border-bottom: 1px solid var(--color-border);
}
.left-side {
display: flex;
align-items: center;
padding-left: 20px;
}
.center-side {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
min-width: 0;
padding: 0 16px;
}
.center-menu {
flex-shrink: 0;
}
.navbar-search {
display: flex;
align-items: center;
width: 100%;
max-width: 420px;
padding: 4px 4px 4px 16px;
background-color: var(--color-fill-2);
border: 1px solid var(--color-border-2);
border-radius: 999px;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
&:focus-within {
border-color: rgb(var(--primary-6));
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.12);
}
:deep(.arco-input-outer) {
flex: 1;
min-width: 0;
}
:deep(.arco-input-wrapper) {
background: transparent;
border: none;
box-shadow: none;
}
.navbar-search-btn {
flex-shrink: 0;
height: 32px;
padding: 0 16px;
border-radius: 999px;
}
}
.right-side {
display: flex;
padding-right: 20px;
list-style: none;
:deep(.locale-select) {
border-radius: 20px;
}
li {
display: flex;
align-items: center;
padding: 0 10px;
}
a {
color: var(--color-text-1);
text-decoration: none;
}
.nav-btn {
border-color: rgb(var(--gray-2));
color: rgb(var(--gray-8));
font-size: 16px;
}
.trigger-btn,
.ref-btn {
position: absolute;
bottom: 14px;
}
.trigger-btn {
margin-left: 14px;
}
}
</style>
<style lang="less">
.message-popover {
.arco-popover-content {
margin-top: 0;
}
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div class="tab-bar-container">
<a-affix ref="affixRef" :offset-top="offsetTop">
<div class="tab-bar-box">
<div class="tab-bar-scroll">
<div class="tags-wrap">
<tab-item
v-for="(tag, index) in tagList"
:key="tag.fullPath"
:index="index"
:item-data="tag"
/>
</div>
</div>
<div class="tag-bar-operation"></div>
</div>
</a-affix>
</div>
</template>
<script lang="ts" setup>
import { computed, onUnmounted, ref, watch } from 'vue';
import type { RouteLocationNormalized } from 'vue-router';
import { useAppStore, useTabBarStore } from '@/store';
import {
listenerRouteChange,
removeRouteListener,
} from '@/utils/route-listener';
import tabItem from './tab-item.vue';
const appStore = useAppStore();
const tabBarStore = useTabBarStore();
const affixRef = ref();
const tagList = computed(() => {
return tabBarStore.getTabList;
});
const offsetTop = computed(() => {
return appStore.navbar ? 60 : 0;
});
watch(
() => appStore.navbar,
() => {
affixRef.value.updatePosition();
},
);
listenerRouteChange((route: RouteLocationNormalized) => {
if (
!route.meta.noAffix &&
!tagList.value.some((tag) => tag.fullPath === route.fullPath)
) {
tabBarStore.updateTabList(route);
}
}, true);
onUnmounted(() => {
removeRouteListener();
});
</script>
<style scoped lang="less">
.tab-bar-container {
position: relative;
background-color: var(--color-bg-2);
.tab-bar-box {
display: flex;
padding: 0 0 0 20px;
background-color: var(--color-bg-2);
border-bottom: 1px solid var(--color-border);
.tab-bar-scroll {
height: 32px;
flex: 1;
overflow: hidden;
.tags-wrap {
padding: 4px 0;
height: 48px;
white-space: nowrap;
overflow-x: auto;
:deep(.arco-tag) {
display: inline-flex;
align-items: center;
margin-right: 6px;
cursor: pointer;
&:first-child {
.arco-tag-close-btn {
display: none;
}
}
}
}
}
}
.tag-bar-operation {
width: 100px;
height: 32px;
}
}
</style>

View File

@@ -0,0 +1,12 @@
## 组件说明
该组件非官方最终设计规范,以单独组件存在。
同时仅仅提供最基本的功能,后续进行优化及更改。
## Component description
The component unofficial final design specification exists as a separate component.
At the same time, only the most basic functions are provided, and subsequent optimizations and changes will be made.

View File

@@ -0,0 +1,199 @@
<template>
<a-dropdown
trigger="contextMenu"
:popup-max-height="false"
@select="actionSelect"
>
<span
class="arco-tag arco-tag-size-medium arco-tag-checked"
:class="{ 'link-activated': itemData.fullPath === $route.fullPath }"
@click="goto(itemData)"
>
<span class="tag-link">
{{ $t(itemData.title) }}
</span>
<span
class="arco-icon-hover arco-tag-icon-hover arco-icon-hover-size-medium arco-tag-close-btn"
@click.stop="tagClose(itemData, index)"
>
<icon-close />
</span>
</span>
<template #content>
<a-doption :disabled="disabledReload" :value="TabAction.reload">
<icon-refresh />
<span>刷新</span>
</a-doption>
<a-doption
class="sperate-line"
:disabled="disabledCurrent"
:value="TabAction.current"
>
<icon-close />
<span>关闭</span>
</a-doption>
<a-doption :disabled="disabledLeft" :value="TabAction.left">
<icon-to-left />
<span>向左靠</span>
</a-doption>
<a-doption
class="sperate-line"
:disabled="disabledRight"
:value="TabAction.right"
>
<icon-to-right />
<span>向右靠</span>
</a-doption>
<a-doption :value="TabAction.others">
<icon-swap />
<span>其它</span>
</a-doption>
<a-doption :value="TabAction.all">
<icon-folder-delete />
<span>删除</span>
</a-doption>
</template>
</a-dropdown>
</template>
<script lang="ts" setup>
import { computed, type PropType } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { DEFAULT_ROUTE_NAME, REDIRECT_ROUTE_NAME } from '@/router/constants';
import { useTabBarStore } from '@/store';
import type { TagProps } from '@/store/modules/tab-bar/types';
enum TabAction {
reload = 'reload',
current = 'current',
left = 'left',
right = 'right',
others = 'others',
all = 'all',
}
const props = defineProps({
itemData: {
type: Object as PropType<TagProps>,
default() {
return [];
},
},
index: {
type: Number,
default: 0,
},
});
const router = useRouter();
const route = useRoute();
const tabBarStore = useTabBarStore();
const goto = (tag: TagProps) => {
router.push({ ...tag });
};
const tagList = computed(() => {
return tabBarStore.getTabList;
});
const disabledReload = computed(() => {
return props.itemData.fullPath !== route.fullPath;
});
const disabledCurrent = computed(() => {
return props.index === 0;
});
const disabledLeft = computed(() => {
return [0, 1].includes(props.index);
});
const disabledRight = computed(() => {
return props.index === tagList.value.length - 1;
});
const tagClose = (tag: TagProps, idx: number) => {
tabBarStore.deleteTag(idx, tag);
if (props.itemData.fullPath === route.fullPath) {
const latest = tagList.value[idx - 1]; // 获取队列的前一个tab
router.push({ name: latest.name });
}
};
const findCurrentRouteIndex = () => {
return tagList.value.findIndex((el) => el.fullPath === route.fullPath);
};
const actionSelect = async (value: any) => {
const { itemData, index } = props;
const copyTagList = [...tagList.value];
if (value === TabAction.current) {
tagClose(itemData, index);
} else if (value === TabAction.left) {
const currentRouteIdx = findCurrentRouteIndex();
copyTagList.splice(1, props.index - 1);
tabBarStore.freshTabList(copyTagList);
if (currentRouteIdx < index) {
router.push({ name: itemData.name });
}
} else if (value === TabAction.right) {
const currentRouteIdx = findCurrentRouteIndex();
copyTagList.splice(props.index + 1);
tabBarStore.freshTabList(copyTagList);
if (currentRouteIdx > index) {
router.push({ name: itemData.name });
}
} else if (value === TabAction.others) {
const filterList = tagList.value.filter((el, idx) => {
return idx === 0 || idx === props.index;
});
tabBarStore.freshTabList(filterList);
router.push({ name: itemData.name });
} else if (value === TabAction.reload) {
tabBarStore.deleteCache(itemData);
await router.push({
name: REDIRECT_ROUTE_NAME,
params: {
path: route.fullPath,
},
});
tabBarStore.addCache(itemData.name);
} else {
tabBarStore.resetTabList();
router.push({ name: DEFAULT_ROUTE_NAME });
}
};
</script>
<style scoped lang="less">
.tag-link {
color: var(--color-text-2);
text-decoration: none;
}
.link-activated {
color: rgb(var(--link-6));
.tag-link {
color: rgb(var(--link-6));
}
& + .arco-tag-close-btn {
color: rgb(var(--link-6));
}
}
:deep(.arco-dropdown-option-content) {
span {
margin-left: 10px;
}
}
.arco-dropdown-open {
.tag-link {
color: rgb(var(--danger-6));
}
.arco-tag-close-btn {
color: rgb(var(--danger-6));
}
}
.sperate-line {
border-bottom: 1px solid var(--color-neutral-3);
}
</style>

View File

@@ -0,0 +1,16 @@
{
"theme": "light",
"colorWeak": false,
"navbar": true,
"menu": true,
"topMenu": false,
"hideMenu": false,
"menuCollapse": false,
"footer": true,
"themeColor": "#165DFF",
"menuWidth": 220,
"device": "desktop",
"tabBar": false,
"menuFromServer": false,
"serverMenu": []
}

View File

@@ -0,0 +1,14 @@
/** Local default avatar: linear neutral user icon. */
export const DEFAULT_USER_AVATAR = '/avatar-default.svg';
const BROKEN_AVATAR_PATTERN = /pstatp\.com|vcloud\/vadmin/;
export function resolveAvatarUrl(url?: string) {
if (!url || BROKEN_AVATAR_PATTERN.test(url)) {
return DEFAULT_USER_AVATAR;
}
if (url.startsWith('//')) {
return `https:${url}`;
}
return url;
}

View File

@@ -0,0 +1,8 @@
import type { App } from 'vue';
import permission from './permission';
export default {
install(Vue: App) {
Vue.directive('permission', permission);
},
};

View File

@@ -0,0 +1,30 @@
import type { DirectiveBinding } from 'vue';
import { useUserStore } from '@/store';
function checkPermission(el: HTMLElement, binding: DirectiveBinding) {
const { value } = binding;
const userStore = useUserStore();
const { role } = userStore;
if (Array.isArray(value)) {
if (value.length > 0) {
const permissionValues = value;
const hasPermission = permissionValues.includes(role);
if (!hasPermission && el.parentNode) {
el.parentNode.removeChild(el);
}
}
} else {
throw new Error(`need roles! Like v-permission="['admin','user']"`);
}
}
export default {
mounted(el: HTMLElement, binding: DirectiveBinding) {
checkPermission(el, binding);
},
updated(el: HTMLElement, binding: DirectiveBinding) {
checkPermission(el, binding);
},
};

View File

@@ -0,0 +1,25 @@
import type { EChartsOption } from 'echarts';
import { computed } from 'vue';
import { useAppStore } from '@/store';
// for code hints
// import { SeriesOption } from 'echarts';
// Because there are so many configuration items, this provides a relatively convenient code hint.
// When using vue, pay attention to the reactive issues. It is necessary to ensure that corresponding functions can be triggered, TypeScript does not report errors, and code writing is convenient.
type optionsFn = (isDark: boolean) => EChartsOption;
export default function useChartOption(sourceOption: optionsFn) {
const appStore = useAppStore();
const isDark = computed(() => {
return appStore.theme === 'dark';
});
// echarts support https://echarts.apache.org/zh/theme-builder.html
// It's not used here
// TODO echarts themes
const chartOption = computed<EChartsOption>(() => {
return sourceOption(isDark.value);
});
return {
chartOption,
};
}

View File

@@ -0,0 +1,16 @@
import { ref } from 'vue';
export default function useLoading(initValue = false) {
const loading = ref(initValue);
const setLoading = (value: boolean) => {
loading.value = value;
};
const toggle = () => {
loading.value = !loading.value;
};
return {
loading,
setLoading,
toggle,
};
}

View File

@@ -0,0 +1,22 @@
import { Message } from '@arco-design/web-vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
export default function useLocale() {
const i18 = useI18n();
const currentLocale = computed(() => {
return i18.locale.value;
});
const changeLocale = (value: string) => {
if (i18.locale.value === value) {
return;
}
i18.locale.value = value;
localStorage.setItem('arco-locale', value);
Message.success(i18.t('navbar.action.locale'));
};
return {
currentLocale,
changeLocale,
};
}

View File

@@ -0,0 +1,33 @@
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
import { useUserStore } from '@/store';
export default function usePermission() {
const userStore = useUserStore();
return {
accessRouter(route: RouteLocationNormalized | RouteRecordRaw) {
return (
!route.meta?.requiresAuth ||
!route.meta?.roles ||
route.meta?.roles?.includes('*') ||
route.meta?.roles?.includes(userStore.role)
);
},
findFirstPermissionRoute(_routers: any, role = 'admin') {
const cloneRouters = [..._routers];
while (cloneRouters.length) {
const firstElement = cloneRouters.shift();
if (
firstElement?.meta?.roles?.find((el: string[]) => {
return el.includes('*') || el.includes(role);
})
)
return { name: firstElement.name };
if (firstElement?.children) {
cloneRouters.push(...firstElement.children);
}
}
return null;
},
// You can add any rules you want
};
}

View File

@@ -0,0 +1,26 @@
import type { AxiosResponse } from 'axios';
import { ref, type UnwrapRef } from 'vue';
import type { HttpResponse } from '@/plugins/http';
import useLoading from './loading';
// use to fetch list
// Don't use async function. It doesn't work in async function.
// Use the bind function to add parameters
// example: useRequest(api.bind(null, {}))
export default function useRequest<T>(
api: () => Promise<AxiosResponse<HttpResponse>>,
defaultValue = [] as unknown as T,
isLoading = true,
) {
const { loading, setLoading } = useLoading(isLoading);
const response = ref<T>(defaultValue);
api()
.then((res) => {
response.value = res.data as unknown as UnwrapRef<T>;
})
.finally(() => {
setLoading(false);
});
return { loading, response };
}

View File

@@ -0,0 +1,32 @@
import { useDebounceFn } from '@vueuse/core';
import { onBeforeMount, onBeforeUnmount, onMounted } from 'vue';
import { useAppStore } from '@/store';
import { addEventListen, removeEventListen } from '@/utils/event';
const WIDTH = 992; // https://arco.design/vue/component/grid#responsivevalue
function queryDevice() {
const rect = document.body.getBoundingClientRect();
return rect.width - 1 < WIDTH;
}
export default function useResponsive(immediate?: boolean) {
const appStore = useAppStore();
function resizeHandler() {
if (!document.hidden) {
const isMobile = queryDevice();
appStore.toggleDevice(isMobile ? 'mobile' : 'desktop');
appStore.toggleMenu(isMobile);
}
}
const debounceFn = useDebounceFn(resizeHandler, 100);
onMounted(() => {
if (immediate) debounceFn();
});
onBeforeMount(() => {
addEventListen(window, 'resize', debounceFn);
});
onBeforeUnmount(() => {
removeEventListen(window, 'resize', debounceFn);
});
}

View File

@@ -0,0 +1,12 @@
import { computed } from 'vue';
import { useAppStore } from '@/store';
export default function useThemes() {
const appStore = useAppStore();
const isDark = computed(() => {
return appStore.theme === 'dark';
});
return {
isDark,
};
}

View File

@@ -0,0 +1,24 @@
import { Message } from '@arco-design/web-vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/store';
export default function useUser() {
const router = useRouter();
const userStore = useUserStore();
const logout = async (logoutTo?: string) => {
await userStore.logout();
const currentRoute = router.currentRoute.value;
Message.success('登出成功');
router.push({
name: logoutTo && typeof logoutTo === 'string' ? logoutTo : 'login',
query: {
...router.currentRoute.value.query,
redirect: currentRoute.name as string,
},
});
};
return {
logout,
};
}

View File

@@ -0,0 +1,178 @@
<template>
<a-layout class="layout" :class="{ mobile: appStore.hideMenu }">
<div v-if="navbar" class="layout-navbar">
<NavBar />
</div>
<a-layout>
<a-layout>
<a-layout-sider
v-if="renderMenu"
v-show="!hideMenu"
class="layout-sider"
breakpoint="xl"
:collapsed="collapsed"
:collapsible="true"
:width="menuWidth"
:style="{ paddingTop: navbar ? '60px' : '' }"
:hide-trigger="true"
@collapse="setCollapsed"
>
<div class="menu-wrapper">
<Menu />
</div>
</a-layout-sider>
<a-drawer
v-if="hideMenu"
:visible="drawerVisible"
placement="left"
:footer="false"
mask-closable
:closable="false"
@cancel="drawerCancel"
>
<Menu />
</a-drawer>
<a-layout class="layout-content" :style="paddingStyle">
<TabBar v-if="appStore.tabBar" />
<a-layout-content>
<PageLayout />
</a-layout-content>
<Footer v-if="footer" />
</a-layout>
</a-layout>
</a-layout>
</a-layout>
</template>
<script lang="ts" setup>
import { computed, onMounted, provide, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Footer from '@/components/footer/index.vue';
import Menu from '@/components/menu/index.vue';
import NavBar from '@/components/navbar/index.vue';
import TabBar from '@/components/tab-bar/index.vue';
import usePermission from '@/hooks/permission';
import useResponsive from '@/hooks/responsive';
import { useAppStore, useUserStore } from '@/store';
import PageLayout from './page-layout.vue';
const isInit = ref(false);
const appStore = useAppStore();
const userStore = useUserStore();
const router = useRouter();
const route = useRoute();
const permission = usePermission();
useResponsive(true);
const navbarHeight = `60px`;
const navbar = computed(() => appStore.navbar);
const renderMenu = computed(() => appStore.menu && !appStore.topMenu);
const hideMenu = computed(() => appStore.hideMenu);
const footer = computed(() => appStore.footer);
const menuWidth = computed(() => {
return appStore.menuCollapse ? 48 : appStore.menuWidth;
});
const collapsed = computed(() => {
return appStore.menuCollapse;
});
const paddingStyle = computed(() => {
const paddingLeft =
renderMenu.value && !hideMenu.value
? { paddingLeft: `${menuWidth.value}px` }
: {};
const paddingTop = navbar.value ? { paddingTop: navbarHeight } : {};
return { ...paddingLeft, ...paddingTop };
});
const setCollapsed = (val: boolean) => {
if (!isInit.value) return; // for page initialization menu state problem
appStore.updateSettings({ menuCollapse: val });
};
watch(
() => userStore.role,
(roleValue) => {
if (roleValue && !permission.accessRouter(route))
router.push({ name: 'notFound' });
},
);
const drawerVisible = ref(false);
const drawerCancel = () => {
drawerVisible.value = false;
};
provide('toggleDrawerMenu', () => {
drawerVisible.value = !drawerVisible.value;
});
onMounted(() => {
isInit.value = true;
});
</script>
<style scoped lang="less">
@nav-size-height: 60px;
@layout-max-width: 1100px;
.layout {
width: 100%;
height: 100%;
}
.layout-navbar {
position: fixed;
top: 0;
left: 0;
z-index: 100;
width: 100%;
height: @nav-size-height;
}
.layout-sider {
position: fixed;
top: 0;
left: 0;
z-index: 99;
height: 100%;
transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
&::after {
position: absolute;
top: 0;
right: -1px;
display: block;
width: 1px;
height: 100%;
background-color: var(--color-border);
content: '';
}
> :deep(.arco-layout-sider-children) {
overflow-y: hidden;
}
}
.menu-wrapper {
height: 100%;
overflow: auto;
overflow-x: hidden;
:deep(.arco-menu) {
::-webkit-scrollbar {
width: 12px;
height: 4px;
}
::-webkit-scrollbar-thumb {
border: 4px solid transparent;
background-clip: padding-box;
border-radius: 7px;
background-color: var(--color-text-4);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-text-3);
}
}
}
.layout-content {
min-height: 100vh;
overflow-y: hidden;
background-color: var(--color-fill-2);
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
}
</style>

View File

@@ -0,0 +1,25 @@
<template>
<router-view v-slot="{ Component, route }">
<transition name="fade" mode="out-in" appear>
<component
:is="Component"
v-if="route.meta.ignoreCache"
:key="route.fullPath"
/>
<keep-alive v-else :include="cacheList">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useTabBarStore } from '@/store';
const tabBarStore = useTabBarStore();
const cacheList = computed(() => tabBarStore.getCacheList);
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,30 @@
import { mergeLocaleModules } from './merge-locales';
import localeSettings from './en-US/settings';
const componentLocales = mergeLocaleModules(
import.meta.glob('@/components/**/locale/en-US.ts', { eager: true }),
);
const viewLocales = mergeLocaleModules(
import.meta.glob('@/views/**/locale/en-US.ts', { eager: true }),
);
export default {
'menu.dashboard': 'Dashboard',
'menu.server.dashboard': 'Dashboard-Server',
'menu.server.workplace': 'Workplace-Server',
'menu.server.monitor': 'Monitor-Server',
'menu.list': 'List',
'menu.result': 'Result',
'menu.exception': 'Exception',
'menu.form': 'Form',
'menu.profile': 'Profile',
'menu.visualization': 'Data Visualization',
'menu.user': 'User Center',
'menu.arcoWebsite': 'Arco Design',
'menu.faq': 'FAQ',
'navbar.docs': 'Docs',
'navbar.action.locale': 'Switch to English',
...localeSettings,
...componentLocales,
...viewLocales,
};

View File

@@ -0,0 +1,16 @@
export default {
'settings.search': 'Search',
'settings.searchPlaceholder': 'Search menus, pages, features...',
'settings.language': 'Language',
'settings.navbar.theme.toLight': 'Click to use light mode',
'settings.navbar.theme.toDark': 'Click to use dark mode',
'settings.navbar.screen.toFull': 'Click to switch to full screen mode',
'settings.navbar.screen.toExit': 'Click to exit the full screen mode',
'settings.navbar.alerts': 'alerts',
'http.error.default': 'Error',
'http.error.request': 'Request Error',
'http.logout.title': 'Confirm logout',
'http.logout.content':
'You have been logged out, you can cancel to stay on this page, or log in again',
'http.logout.okText': 'Re-Login',
};

View File

@@ -0,0 +1,21 @@
import { createI18n } from 'vue-i18n';
import en from './en-US';
import cn from './zh-CN';
export const LOCALE_OPTIONS = [
{ label: '中文', value: 'zh-CN' },
{ label: 'English', value: 'en-US' },
];
const defaultLocale = localStorage.getItem('arco-locale') || 'zh-CN';
const i18n = createI18n({
locale: defaultLocale,
fallbackLocale: 'en-US',
legacy: false,
messages: {
'en-US': en,
'zh-CN': cn,
},
});
export default i18n;

View File

@@ -0,0 +1,10 @@
type LocaleModule = { default: Record<string, string> };
export function mergeLocaleModules(
modules: Record<string, LocaleModule>,
): Record<string, string> {
return Object.values(modules).reduce(
(messages, module) => ({ ...messages, ...module.default }),
{},
);
}

View File

@@ -0,0 +1,43 @@
import { mergeLocaleModules } from './merge-locales';
import localeSettings from './zh-CN/settings';
const componentLocales = mergeLocaleModules(
import.meta.glob('@/components/**/locale/zh-CN.ts', { eager: true }),
);
const viewLocales = mergeLocaleModules(
import.meta.glob('@/views/**/locale/zh-CN.ts', { eager: true }),
);
export default {
'menu.platform.dashboard': '运营概览',
'menu.platform.gas': '可燃气体站管理',
'menu.platform.gas.basic': '可燃气体站',
'menu.platform.delivery': '配送管理',
'menu.platform.delivery.basic': '配送点',
'menu.platform.staff': '服务人员',
'menu.platform.staff.list': '人员档案',
'menu.platform.user': '业主客户',
'menu.platform.user.list': '客户档案',
'menu.platform.config': '平台配置',
'menu.platform.config.account': '平台账号',
'menu.platform.config.role': '角色管理',
'menu.platform.config.menu': '菜单管理',
'menu.dashboard': '仪表盘',
'menu.server.dashboard': '仪表盘-服务端',
'menu.server.workplace': '工作台-服务端',
'menu.server.monitor': '实时监控-服务端',
'menu.list': '列表页',
'menu.result': '结果页',
'menu.exception': '异常页',
'menu.form': '表单页',
'menu.profile': '详情页',
'menu.visualization': '数据可视化',
'menu.user': '个人中心',
'menu.arcoWebsite': 'Arco Design',
'menu.faq': '常见问题',
'navbar.docs': '文档中心',
'navbar.action.locale': '切换为中文',
...localeSettings,
...componentLocales,
...viewLocales,
};

View File

@@ -0,0 +1,16 @@
export default {
'settings.search': '搜索',
'settings.searchPlaceholder': '搜索菜单、页面、功能...',
'settings.language': '语言',
'settings.navbar.theme.toLight': '点击切换为亮色模式',
'settings.navbar.theme.toDark': '点击切换为暗黑模式',
'settings.navbar.screen.toFull': '点击切换全屏模式',
'settings.navbar.screen.toExit': '点击退出全屏模式',
'settings.navbar.alerts': '消息通知',
'http.error.default': '错误',
'http.error.request': '请求错误',
'http.logout.title': '确认登出',
'http.logout.content':
'您已被登出,您可以取消以停留在此页面,或重新登录',
'http.logout.okText': '重新登录',
};

View File

@@ -0,0 +1,88 @@
import Mock from 'mockjs';
import setupMock, { successResponseWrap } from '@/mocks/setup';
const haveReadIds: number[] = [];
const getMessageList = () => {
return [
{
id: 1,
type: 'message',
title: '\u90d1\u6666\u6708',
subTitle: '\u7684\u79c1\u4fe1',
avatar:
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/8361eeb82904210b4f55fab888fe8416.png~tplv-uwbnlip3yd-webp.webp',
content: '\u5ba1\u6279\u8bf7\u6c42\u5df2\u53d1\u9001\uff0c\u8bf7\u67e5\u6536',
time: '\u4eca\u5929 12:30:01',
},
{
id: 2,
type: 'message',
title: '\u5b81\u6ce2',
subTitle: '\u7684\u56de\u590d',
avatar:
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
content: '\u6b64\u5904 bug \u5df2\u7ecf\u4fee\u590d',
time: '\u4eca\u5929 12:30:01',
},
{
id: 3,
type: 'message',
title: '\u5b81\u6ce2',
subTitle: '\u7684\u56de\u590d',
avatar:
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
content: '\u6b64\u5904 bug \u5df2\u7ecf\u4fee\u590d',
time: '\u4eca\u5929 12:20:01',
},
{
id: 4,
type: 'notice',
title: '\u7eed\u8d39\u901a\u77e5',
subTitle: '',
avatar: '',
content:
'\u60a8\u7684\u4ea7\u54c1\u4f7f\u7528\u671f\u9650\u5373\u5c06\u622a\u6b62\uff0c\u5982\u9700\u7ee7\u7eed\u4f7f\u7528\u4ea7\u54c1\u8bf7\u524d\u5f80\u8d2d\u4e70',
time: '\u4eca\u5929 12:20:01',
messageType: 3,
},
{
id: 5,
type: 'notice',
title: '\u89c4\u5219\u5f00\u901a\u6210\u529f',
subTitle: '',
avatar: '',
content:
'\u5185\u5bb9\u5c4f\u853d\u89c4\u5219\u4e8e 2021-12-01 \u5f00\u901a\u6210\u529f\u5e76\u751f\u6548',
time: '\u4eca\u5929 12:20:01',
messageType: 1,
},
{
id: 6,
type: 'todo',
title: '\u8d28\u68c0\u961f\u5217\u53d8\u66f4',
subTitle: '',
avatar: '',
content:
'\u5185\u5bb9\u8d28\u68c0\u961f\u5217\u4e8e 2021-12-01 19:50:23 \u8fdb\u884c\u53d8\u66f4\uff0c\u8bf7\u91cd\u65b0\u63d0\u4ea4',
time: '\u4eca\u5929 12:20:01',
messageType: 0,
},
].map((item) => ({
...item,
status: haveReadIds.indexOf(item.id) === -1 ? 0 : 1,
}));
};
setupMock({
setup: () => {
Mock.mock(/\/api\/message\/list/, () => {
return successResponseWrap(getMessageList());
});
Mock.mock(/\/api\/message\/read/, (params: { body: string }) => {
const { ids } = JSON.parse(params.body);
haveReadIds.push(...(ids || []));
return successResponseWrap(true);
});
},
});

View File

@@ -0,0 +1,104 @@
import Mock from 'mockjs';
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
import { isLogin } from '@/utils/auth';
import setupMock, {
failResponseWrap,
successResponseWrap,
} from '@/mocks/setup';
export interface MockParams {
url: string;
type: string;
body: string;
}
setupMock({
setup() {
Mock.mock(/\/api\/user\/info/, () => {
if (isLogin()) {
const role = window.localStorage.getItem('userRole') || 'admin';
return successResponseWrap({
name: 'admin',
avatar: DEFAULT_USER_AVATAR,
email: 'wangliqun@email.com',
job: 'frontend',
jobName: '\u524d\u7aef\u827a\u672f\u5bb6',
organization: 'Frontend',
organizationName: '\u524d\u7aef',
location: 'beijing',
locationName: '\u5317\u4eac',
introduction: '\u4eba\u7206\u723d\uff0c\u6027\u6e29\u7eaf',
personalWebsite: 'https://www.arco.design',
phone: '150****0000',
registrationDate: '2013-05-10 12:10:00',
accountId: '15012312300',
certification: 1,
role,
});
}
return failResponseWrap(null, '\u672a\u767b\u5f55', 50008);
});
Mock.mock(/\/api\/user\/login/, (params: MockParams) => {
const { username, password } = JSON.parse(params.body);
if (!username) {
return failResponseWrap(
null,
'\u7528\u6237\u540d\u4e0d\u80fd\u4e3a\u7a7a',
50000,
);
}
if (!password) {
return failResponseWrap(null, '\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a', 50000);
}
if (username === 'admin' && password === 'admin') {
window.localStorage.setItem('userRole', 'admin');
return successResponseWrap({ token: '12345' });
}
if (username === 'user' && password === 'user') {
window.localStorage.setItem('userRole', 'user');
return successResponseWrap({ token: '54321' });
}
return failResponseWrap(
null,
'\u8d26\u53f7\u6216\u8005\u5bc6\u7801\u9519\u8bef',
50000,
);
});
Mock.mock(/\/api\/user\/logout/, () => successResponseWrap(null));
Mock.mock(/\/api\/user\/menu/, () => {
const menuList = [
{
path: '/dashboard',
name: 'dashboard',
meta: {
locale: 'menu.server.dashboard',
requiresAuth: true,
icon: 'icon-dashboard',
order: 1,
},
children: [
{
path: 'workplace',
name: 'Workplace',
meta: {
locale: 'menu.server.workplace',
requiresAuth: true,
},
},
{
path: 'https://arco.design',
name: 'arcoWebsite',
meta: {
locale: 'menu.arcoWebsite',
requiresAuth: true,
},
},
],
},
];
return successResponseWrap(menuList);
});
},
});

View File

@@ -0,0 +1,9 @@
import Mock from 'mockjs';
import './handlers/user';
import './handlers/message-box';
import.meta.glob('@/views/**/mock.ts', { eager: true });
Mock.setup({
timeout: '600-1000',
});

View File

@@ -0,0 +1,27 @@
import debug from '@/utils/env';
export default ({ mock, setup }: { mock?: boolean; setup: () => void }) => {
if (mock !== false && debug) setup();
};
export const successResponseWrap = (data: unknown) => {
return {
data,
status: 'ok',
msg: '请求成功',
code: 20000,
};
};
export const failResponseWrap = (
data: unknown,
msg: string,
code = 50000,
) => {
return {
data,
status: 'fail',
msg,
code,
};
};

View File

@@ -0,0 +1,78 @@
import { Message, Modal } from '@arco-design/web-vue';
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import axios from 'axios';
import i18n from '@/locale';
import { useUserStore } from '@/store';
import { getToken } from '@/utils/auth';
export interface HttpResponse<T = unknown> {
status: number;
msg: string;
code: number;
data: T;
}
let initialized = false;
function t(key: string) {
return i18n.global.t(key);
}
export function setupHttp() {
if (initialized) {
return;
}
initialized = true;
const baseURL = import.meta.env.VITE_API_BASE_URL?.trim();
if (baseURL) {
axios.defaults.baseURL = baseURL;
}
axios.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error),
);
axios.interceptors.response.use(
(response: AxiosResponse<HttpResponse>) => {
const res = response.data;
if (res.code !== 20000) {
Message.error({
content: res.msg || t('http.error.default'),
duration: 5 * 1000,
});
if (
[50008, 50012, 50014].includes(res.code) &&
response.config.url !== '/api/user/info'
) {
Modal.error({
title: t('http.logout.title'),
content: t('http.logout.content'),
okText: t('http.logout.okText'),
async onOk() {
const userStore = useUserStore();
await userStore.logout();
window.location.reload();
},
});
}
return Promise.reject(new Error(res.msg || t('http.error.default')));
}
return res as unknown as AxiosResponse<HttpResponse>;
},
(error) => {
Message.error({
content: error.msg || t('http.error.request'),
duration: 5 * 1000,
});
return Promise.reject(error);
},
);
}

View File

@@ -0,0 +1,16 @@
import { appExternalRoutes, appRoutes } from '../routes';
const mixinRoutes = [...appRoutes, ...appExternalRoutes];
const appClientMenus = mixinRoutes.map((el) => {
const { name, path, meta, redirect, children } = el;
return {
name,
path,
meta,
redirect,
children,
};
});
export default appClientMenus;

View File

@@ -0,0 +1,18 @@
export const WHITE_LIST = [
{ name: 'notFound', children: [] },
{ name: 'login', children: [] },
];
export const NOT_FOUND = {
name: 'notFound',
};
export const REDIRECT_ROUTE_NAME = 'Redirect';
export const DEFAULT_ROUTE_NAME = 'Workplace';
export const DEFAULT_ROUTE = {
title: 'menu.dashboard.workplace',
name: DEFAULT_ROUTE_NAME,
fullPath: '/dashboard/workplace',
};

View File

@@ -0,0 +1,17 @@
import type { Router } from 'vue-router';
import { setRouteEmitter } from '@/utils/route-listener';
import setupPermissionGuard from './permission';
import setupUserLoginInfoGuard from './userLoginInfo';
function setupPageGuard(router: Router) {
router.beforeEach(async (to) => {
// emit route change
setRouteEmitter(to);
});
}
export default function createRouteGuard(router: Router) {
setupPageGuard(router);
setupUserLoginInfoGuard(router);
setupPermissionGuard(router);
}

View File

@@ -0,0 +1,53 @@
import NProgress from 'nprogress'; // progress bar
import type { RouteRecordNormalized, Router } from 'vue-router';
import usePermission from '@/hooks/permission';
import { useAppStore, useUserStore } from '@/store';
import { NOT_FOUND, WHITE_LIST } from '../constants';
import { appRoutes } from '../routes';
export default function setupPermissionGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
const appStore = useAppStore();
const userStore = useUserStore();
const Permission = usePermission();
const permissionsAllow = Permission.accessRouter(to);
if (appStore.menuFromServer) {
// 针对来自服务端的菜单配置进行处理
// Handle routing configuration from the server
// 根据需要自行完善来源于服务端的菜单配置的permission逻辑
// Refine the permission logic from the server's menu configuration as needed
if (
!appStore.appAsyncMenus.length &&
!WHITE_LIST.find((el) => el.name === to.name)
) {
await appStore.fetchServerMenuConfig();
}
const serverMenuConfig = [...appStore.appAsyncMenus, ...WHITE_LIST];
let exist = false;
while (serverMenuConfig.length && !exist) {
const element = serverMenuConfig.shift();
if (element?.name === to.name) exist = true;
if (element?.children) {
serverMenuConfig.push(
...(element.children as unknown as RouteRecordNormalized[]),
);
}
}
if (exist && permissionsAllow) {
next();
} else next(NOT_FOUND);
} else if (permissionsAllow) {
next();
} else {
const destination =
Permission.findFirstPermissionRoute(appRoutes, userStore.role) ||
NOT_FOUND;
next(destination);
}
NProgress.done();
});
}

View File

@@ -0,0 +1,43 @@
import NProgress from 'nprogress'; // progress bar
import type { LocationQueryRaw, Router } from 'vue-router';
import { useUserStore } from '@/store';
import { isLogin } from '@/utils/auth';
export default function setupUserLoginInfoGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
NProgress.start();
const userStore = useUserStore();
if (isLogin()) {
if (userStore.role) {
next();
} else {
try {
await userStore.info();
next();
} catch (error) {
await userStore.logout();
next({
name: 'login',
query: {
redirect: to.name,
...to.query,
} as LocationQueryRaw,
});
}
}
} else {
if (to.name === 'login') {
next();
return;
}
next({
name: 'login',
query: {
redirect: to.name,
...to.query,
} as LocationQueryRaw,
});
}
});
}

View File

@@ -0,0 +1,37 @@
import NProgress from 'nprogress'; // progress bar
import { createRouter, createWebHistory } from 'vue-router';
import 'nprogress/nprogress.css';
import createRouteGuard from './guard';
import { appRoutes } from './routes';
import { NOT_FOUND_ROUTE, REDIRECT_MAIN } from './routes/base';
NProgress.configure({ showSpinner: false }); // NProgress Configuration
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
redirect: 'login',
},
{
path: '/login',
name: 'login',
component: () => import('@/views/login/index.vue'),
meta: {
requiresAuth: false,
},
},
...appRoutes,
REDIRECT_MAIN,
NOT_FOUND_ROUTE,
],
scrollBehavior() {
return { top: 0 };
},
});
createRouteGuard(router);
export default router;

View File

@@ -0,0 +1,31 @@
import type { RouteRecordRaw } from 'vue-router';
import { REDIRECT_ROUTE_NAME } from '@/router/constants';
export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue');
export const REDIRECT_MAIN: RouteRecordRaw = {
path: '/redirect',
name: 'redirectWrapper',
component: DEFAULT_LAYOUT,
meta: {
requiresAuth: true,
hideInMenu: true,
},
children: [
{
path: '/redirect/:path',
name: REDIRECT_ROUTE_NAME,
component: () => import('@/views/redirect/index.vue'),
meta: {
requiresAuth: true,
hideInMenu: true,
},
},
],
};
export const NOT_FOUND_ROUTE: RouteRecordRaw = {
path: '/:pathMatch(.*)*',
name: 'notFound',
component: () => import('@/views/not-found/index.vue'),
};

View File

@@ -0,0 +1,10 @@
export default {
path: 'https://arco.design',
name: 'arcoWebsite',
meta: {
locale: 'menu.arcoWebsite',
icon: 'icon-link',
requiresAuth: true,
order: 8,
},
};

View File

@@ -0,0 +1,10 @@
export default {
path: 'https://arco.design/vue/docs/pro/faq',
name: 'faq',
meta: {
locale: 'menu.faq',
icon: 'icon-question-circle',
requiresAuth: true,
order: 9,
},
};

View File

@@ -0,0 +1,8 @@
import type { RouteRecordNormalized } from 'vue-router';
import platformRoutes from './modules/platform';
/** 平台总后台仅注册已实现的业务路由;示例页面文件保留在工程中但不参与生产菜单。 */
export const appRoutes: RouteRecordNormalized[] = platformRoutes as unknown as RouteRecordNormalized[];
/** 本期没有外部菜单。 */
export const appExternalRoutes: RouteRecordNormalized[] = [];

View File

@@ -0,0 +1,13 @@
import { DEFAULT_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types';
const PLATFORM: AppRouteRecordRaw[] = [
{ path: '/dashboard', name: 'dashboard', component: DEFAULT_LAYOUT, meta: { locale: 'menu.dashboard', requiresAuth: true, icon: 'icon-dashboard', order: 0 }, children: [{ path: 'overview', name: 'DashboardOverview', component: () => import('@/views/dashboard/DashboardPage.vue'), meta: { locale: 'menu.platform.dashboard', requiresAuth: true, roles: ['*'] } }] },
{ path: '/gas', name: 'gas', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.gas', requiresAuth: true, icon: 'icon-fire', order: 10 }, children: [{ path: 'basic', name: 'GasBasic', component: () => import('@/views/gas/basic/ListPage.vue'), meta: { locale: 'menu.platform.gas.basic', requiresAuth: true, roles: ['*'] } }] },
{ path: '/delivery', name: 'delivery', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.delivery', requiresAuth: true, icon: 'icon-car', order: 11 }, children: [{ path: 'basic', name: 'DeliveryBasic', component: () => import('@/views/delivery/basic/ListPage.vue'), meta: { locale: 'menu.platform.delivery.basic', requiresAuth: true, roles: ['*'] } }] },
{ path: '/staff', name: 'staff', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.staff', requiresAuth: true, icon: 'icon-user', order: 12 }, children: [{ path: 'list', name: 'Staff', component: () => import('@/views/staff/list/ListPage.vue'), meta: { locale: 'menu.platform.staff.list', requiresAuth: true, roles: ['*'] } }] },
{ path: '/user', name: 'user', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.user', requiresAuth: true, icon: 'icon-user-group', order: 13 }, children: [{ path: 'list', name: 'User', component: () => import('@/views/user/list/ListPage.vue'), meta: { locale: 'menu.platform.user.list', requiresAuth: true, roles: ['*'] } }] },
{ path: '/platform', name: 'platform', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.config', requiresAuth: true, icon: 'icon-settings', order: 14 }, children: [{ path: 'account', name: 'PlatformAccount', component: () => import('@/views/platform/account/ListPage.vue'), meta: { locale: 'menu.platform.config.account', requiresAuth: true, roles: ['*'] } }, { path: 'role', name: 'PlatformRole', component: () => import('@/views/platform/role/ListPage.vue'), meta: { locale: 'menu.platform.config.role', requiresAuth: true, roles: ['*'] } }, { path: 'menu', name: 'PlatformMenu', component: () => import('@/views/platform/menu/TreePage.vue'), meta: { locale: 'menu.platform.config.menu', requiresAuth: true, roles: ['*'] } }] },
];
export default PLATFORM;

View File

@@ -0,0 +1,20 @@
import type { defineComponent } from 'vue';
import type { NavigationGuard, RouteMeta } from 'vue-router';
export type Component<T = any> =
| ReturnType<typeof defineComponent>
| (() => Promise<typeof import('*.vue')>)
| (() => Promise<T>);
export interface AppRouteRecordRaw {
path: string;
name?: string | symbol;
meta?: RouteMeta;
redirect?: string;
component: Component | string;
children?: AppRouteRecordRaw[];
alias?: string | string[];
props?: Record<string, any>;
beforeEnter?: NavigationGuard | NavigationGuard[];
fullPath?: string;
}

View File

@@ -0,0 +1,16 @@
import 'vue-router';
declare module 'vue-router' {
interface RouteMeta {
roles?: string[]; // Controls roles that have access to the page
requiresAuth: boolean; // Whether login is required to access the current page (every route must declare)
icon?: string; // The icon show in the side menu
locale?: string; // The locale name show in side menu and breadcrumb
hideInMenu?: boolean; // If true, it is not displayed in the side menu
hideChildrenInMenu?: boolean; // if set true, the children are not displayed in the side menu
activeMenu?: string; // if set name, the menu will be highlighted according to the name you set
order?: number; // Sort routing menu items. If set key, the higher the value, the more forward it is
noAffix?: boolean; // if set true, the tag will not affix in the tab-bar
ignoreCache?: boolean; // if set true, the page will not be cached
}
}

View File

@@ -0,0 +1,9 @@
import { createPinia } from 'pinia';
import useAppStore from './modules/app';
import useTabBarStore from './modules/tab-bar';
import useUserStore from './modules/user';
const pinia = createPinia();
export { useAppStore, useTabBarStore, useUserStore };
export default pinia;

View File

@@ -0,0 +1,74 @@
import { Notification } from '@arco-design/web-vue';
import type { NotificationReturn } from '@arco-design/web-vue/es/notification/interface';
import { defineStore } from 'pinia';
import type { RouteRecordNormalized } from 'vue-router';
import defaultSettings from '@/config/settings.json';
import type { AppState } from './types';
const useAppStore = defineStore('app', {
state: (): AppState => ({ ...defaultSettings }),
getters: {
appCurrentSetting(state: AppState): AppState {
return { ...state };
},
appDevice(state: AppState) {
return state.device;
},
appAsyncMenus(state: AppState): RouteRecordNormalized[] {
return state.serverMenu as unknown as RouteRecordNormalized[];
},
},
actions: {
// Update app settings
updateSettings(partial: Partial<AppState>) {
// @ts-expect-error-next-line
this.$patch(partial);
},
// Change theme color
toggleTheme(dark: boolean) {
if (dark) {
this.theme = 'dark';
document.body.setAttribute('arco-theme', 'dark');
} else {
this.theme = 'light';
document.body.removeAttribute('arco-theme');
}
},
toggleDevice(device: string) {
this.device = device;
},
toggleMenu(value: boolean) {
this.hideMenu = value;
},
async fetchServerMenuConfig() {
let notifyInstance: NotificationReturn | null = null;
try {
notifyInstance = Notification.info({
id: 'menuNotice', // Keep the instance id the same
content: 'loading',
closable: true,
});
this.serverMenu = [];
notifyInstance = Notification.success({
id: 'menuNotice',
content: 'success',
closable: true,
});
} catch {
notifyInstance = Notification.error({
id: 'menuNotice',
content: 'error',
closable: true,
});
}
},
clearServerMenu() {
this.serverMenu = [];
},
},
});
export default useAppStore;

View File

@@ -0,0 +1,19 @@
import type { RouteRecordNormalized } from 'vue-router';
export interface AppState {
theme: string;
colorWeak: boolean;
navbar: boolean;
menu: boolean;
topMenu: boolean;
hideMenu: boolean;
menuCollapse: boolean;
footer: boolean;
themeColor: string;
menuWidth: number;
device: string;
tabBar: boolean;
menuFromServer: boolean;
serverMenu: RouteRecordNormalized[];
[key: string]: unknown;
}

View File

@@ -0,0 +1,75 @@
import { defineStore } from 'pinia';
import type { RouteLocationNormalized } from 'vue-router';
import {
DEFAULT_ROUTE,
DEFAULT_ROUTE_NAME,
REDIRECT_ROUTE_NAME,
} from '@/router/constants';
import { isString } from '@/utils/is';
import type { TabBarState, TagProps } from './types';
const formatTag = (route: RouteLocationNormalized): TagProps => {
const { name, meta, fullPath, query } = route;
return {
title: meta.locale || '',
name: String(name),
fullPath,
query,
ignoreCache: meta.ignoreCache,
};
};
const BAN_LIST = [REDIRECT_ROUTE_NAME];
const useAppStore = defineStore('tabBar', {
state: (): TabBarState => ({
cacheTabList: new Set([DEFAULT_ROUTE_NAME]),
tagList: [DEFAULT_ROUTE],
}),
getters: {
getTabList(): TagProps[] {
return this.tagList;
},
getCacheList(): string[] {
return Array.from(this.cacheTabList);
},
},
actions: {
updateTabList(route: RouteLocationNormalized) {
if (BAN_LIST.includes(route.name as string)) return;
this.tagList.push(formatTag(route));
if (!route.meta.ignoreCache) {
this.cacheTabList.add(route.name as string);
}
},
deleteTag(idx: number, tag: TagProps) {
this.tagList.splice(idx, 1);
this.cacheTabList.delete(tag.name);
},
addCache(name: string) {
if (isString(name) && name !== '') this.cacheTabList.add(name);
},
deleteCache(tag: TagProps) {
this.cacheTabList.delete(tag.name);
},
freshTabList(tags: TagProps[]) {
this.tagList = tags;
this.cacheTabList.clear();
// 要先判断ignoreCache
for (const name of this.tagList
.filter((el) => !el.ignoreCache)
.map((el) => el.name)) {
this.cacheTabList.add(name);
}
},
resetTabList() {
this.tagList = [DEFAULT_ROUTE];
this.cacheTabList.clear();
this.cacheTabList.add(DEFAULT_ROUTE_NAME);
},
},
});
export default useAppStore;

View File

@@ -0,0 +1,12 @@
export interface TagProps {
title: string;
name: string;
fullPath: string;
query?: any;
ignoreCache?: boolean;
}
export interface TabBarState {
tagList: TagProps[];
cacheTabList: Set<string>;
}

View File

@@ -0,0 +1,89 @@
import { defineStore } from 'pinia';
import { authApi, type LoginData } from '@/api/auth';
import { resolveAvatarUrl } from '@/constants/avatar';
import { clearToken, setToken } from '@/utils/auth';
import { removeRouteListener } from '@/utils/route-listener';
import useAppStore from '../app';
import type { UserState } from './types';
const useUserStore = defineStore('user', {
state: (): UserState => ({
name: undefined,
avatar: undefined,
job: undefined,
organization: undefined,
location: undefined,
email: undefined,
introduction: undefined,
personalWebsite: undefined,
jobName: undefined,
organizationName: undefined,
locationName: undefined,
phone: undefined,
registrationDate: undefined,
accountId: undefined,
certification: undefined,
role: '',
}),
getters: {
userInfo(state: UserState): UserState {
return { ...state };
},
},
actions: {
switchRoles() {
return new Promise((resolve) => {
this.role = this.role === 'user' ? 'admin' : 'user';
resolve(this.role);
});
},
// Set user's information
setInfo(partial: Partial<UserState>) {
if (partial.avatar !== undefined) {
partial.avatar = resolveAvatarUrl(partial.avatar);
}
this.$patch(partial);
},
// Reset user's information
resetInfo() {
this.$reset();
},
// Get user's information
async info() {
const profile = await authApi.profile();
this.setInfo({ name: profile.display_name || profile.username, avatar: profile.avatar, accountId: profile.identity, role: '*' });
},
// Login
async login(loginForm: LoginData) {
try {
const res = await authApi.login(loginForm);
setToken(res.access_token);
} catch (err) {
clearToken();
throw err;
}
},
logoutCallBack() {
const appStore = useAppStore();
this.resetInfo();
clearToken();
removeRouteListener();
appStore.clearServerMenu();
},
// Logout
async logout() {
try {
await Promise.resolve();
} finally {
this.logoutCallBack();
}
},
},
});
export default useUserStore;

View File

@@ -0,0 +1,19 @@
export type RoleType = '' | '*' | 'admin' | 'user';
export interface UserState {
name?: string;
avatar?: string;
job?: string;
organization?: string;
location?: string;
email?: string;
introduction?: string;
personalWebsite?: string;
jobName?: string;
organizationName?: string;
locationName?: string;
phone?: string;
registrationDate?: string;
accountId?: string;
certification?: number;
role: RoleType;
}

View File

@@ -9,6 +9,7 @@ button, input { font: inherit; }
.sidebar { background: #102a43; color: #e8f1fb; padding: 28px 16px; display: flex; flex-direction: column; }
.brand { display: flex; align-items: center; gap: 12px; padding: 0 10px 30px; }
.brand-mark { background: #33a474; display: grid; place-items: center; border-radius: 10px; height: 38px; width: 38px; font-weight: 800; }
.avatar { width: 38px; height: 38px; border-radius: 10px; object-fit: cover; background: #e8f1fb; }
.brand strong, .brand small { display: block; }.brand small { color: #a8c2dc; margin-top: 3px; }
nav { display: grid; gap: 6px; } nav button { border: 0; border-radius: 8px; background: transparent; color: inherit; text-align: left; padding: 11px 14px; cursor: pointer; } nav button:hover, nav button.active { background: #1e4b70; }
.mock-note { font-size: 12px; color: #a8c2dc; line-height: 1.6; margin-top: auto; padding: 12px; background: #173b59; border-radius: 8px; }

View File

@@ -0,0 +1,10 @@
import type { CallbackDataParams } from 'echarts/types/dist/shared';
export interface ToolTipFormatterParams extends CallbackDataParams {
axisDim: string;
axisIndex: number;
axisType: string;
axisId: string;
axisValue: string;
axisValueLabel: string;
}

View File

@@ -0,0 +1,37 @@
export interface AnyObject {
[key: string]: unknown;
}
export interface Options {
value: unknown;
label: string;
}
export interface NodeOptions extends Options {
children?: NodeOptions[];
}
export interface GetParams {
body: null;
type: string;
url: string;
}
export interface PostData {
body: string;
type: string;
url: string;
}
export interface Pagination {
current: number;
pageSize: number;
total?: number;
}
export type TimeRanger = [string, string];
export interface GeneralChart {
xAxis: string[];
data: Array<{ name: string; value: number[] }>;
}

View File

@@ -0,0 +1,19 @@
const TOKEN_KEY = 'token';
const isLogin = () => {
return !!localStorage.getItem(TOKEN_KEY);
};
const getToken = () => {
return localStorage.getItem(TOKEN_KEY);
};
const setToken = (token: string) => {
localStorage.setItem(TOKEN_KEY, token);
};
const clearToken = () => {
localStorage.removeItem(TOKEN_KEY);
};
export { clearToken, getToken, isLogin, setToken };

View File

@@ -0,0 +1,3 @@
const debug = import.meta.env.MODE !== 'production';
export default debug;

View File

@@ -0,0 +1,19 @@
import axios from 'axios';
import type { App, ComponentPublicInstance } from 'vue';
export default function setupErrorReport(app: App, baseUrl: string) {
if (!baseUrl) {
return;
}
app.config.errorHandler = (
err: unknown,
instance: ComponentPublicInstance | null,
info: string,
) => {
axios.post(`${baseUrl}/report-error`, {
err,
instance,
info,
});
};
}

View File

@@ -0,0 +1,27 @@
export function addEventListen(
target: Window | HTMLElement,
event: string,
handler: EventListenerOrEventListenerObject,
capture = false,
) {
if (
target.addEventListener &&
typeof target.addEventListener === 'function'
) {
target.addEventListener(event, handler, capture);
}
}
export function removeEventListen(
target: Window | HTMLElement,
event: string,
handler: EventListenerOrEventListenerObject,
capture = false,
) {
if (
target.removeEventListener &&
typeof target.removeEventListener === 'function'
) {
target.removeEventListener(event, handler, capture);
}
}

View File

@@ -0,0 +1,23 @@
type TargetContext = '_self' | '_parent' | '_blank' | '_top';
export const openWindow = (
url: string,
opts?: { target?: TargetContext; [key: string]: any },
) => {
const { target = '_blank', ...others } = opts || {};
window.open(
url,
target,
Object.entries(others)
.reduce((preValue: string[], curValue) => {
const [key, value] = curValue;
return [...preValue, `${key}=${value}`];
}, [])
.join(','),
);
};
export const regexUrl =
/^(?!mailto:)(?:(?:http|https|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))|localhost)(?::\d{2,5})?(?:(\/|\?|#)[^\s]*)?$/i;
export default null;

View File

@@ -0,0 +1,53 @@
const opt = Object.prototype.toString;
export function isArray(obj: any): obj is any[] {
return opt.call(obj) === '[object Array]';
}
export function isObject(obj: any): obj is { [key: string]: any } {
return opt.call(obj) === '[object Object]';
}
export function isString(obj: any): obj is string {
return opt.call(obj) === '[object String]';
}
export function isNumber(obj: any): obj is number {
return opt.call(obj) === '[object Number]' && !Number.isNaN(obj);
}
export function isRegExp(obj: any) {
return opt.call(obj) === '[object RegExp]';
}
export function isFile(obj: any): obj is File {
return opt.call(obj) === '[object File]';
}
export function isBlob(obj: any): obj is Blob {
return opt.call(obj) === '[object Blob]';
}
export function isUndefined(obj: any): obj is undefined {
return obj === undefined;
}
export function isNull(obj: any): obj is null {
return obj === null;
}
export function isFunction(obj: any): obj is (...args: any[]) => any {
return typeof obj === 'function';
}
export function isEmptyObject(obj: any): boolean {
return isObject(obj) && Object.keys(obj).length === 0;
}
export function isExist(obj: any): boolean {
return obj || obj === 0;
}
export function isWindow(el: any): el is Window {
return el === window;
}

View File

@@ -0,0 +1,8 @@
export function parseUrlQuery(url: string): Record<string, string> {
const { searchParams } = new URL(url, 'http://localhost');
const query: Record<string, string> = {};
searchParams.forEach((value, key) => {
query[key] = value;
});
return query;
}

Some files were not shown because too many files have changed in this diff Show More