feat: init

This commit is contained in:
ygx
2026-03-05 23:45:39 +08:00
commit 8fab91c5c7
214 changed files with 33682 additions and 0 deletions

26
src/App.vue Normal file
View File

@@ -0,0 +1,26 @@
<template>
<a-config-provider :locale="locale">
<router-view />
<global-setting />
</a-config-provider>
</template>
<script lang="ts" setup>
import GlobalSetting from '@/components/global-setting/index.vue'
import useLocale from '@/hooks/locale'
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'
const { currentLocale } = useLocale()
const locale = computed(() => {
switch (currentLocale.value) {
case 'zh-CN':
return zhCN
case 'en-US':
return enUS
default:
return enUS
}
})
</script>

22
src/api/dashboard.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { TableData } from '@arco-design/web-vue/es/table/interface'
import axios from 'axios'
export interface ContentDataRecord {
x: string
y: number
}
export function queryContentData() {
return axios.get<ContentDataRecord[]>('/api/content-data')
}
export interface PopularRecord {
key: number
clickNumber: string
title: string
increases: number
}
export function queryPopularList(params: { type: string }) {
return axios.get<TableData[]>('/api/popular/list', { params })
}

72
src/api/interceptor.ts Normal file
View File

@@ -0,0 +1,72 @@
import { useUserStore } from '@/store'
import { getToken } from '@/utils/auth'
import { Message, Modal } from '@arco-design/web-vue'
import axios from 'axios'
export interface HttpResponse<T = unknown> {
status: number
msg: string
code: number
data: T
}
if (import.meta.env.VITE_API_BASE_URL) {
axios.defaults.baseURL = import.meta.env.VITE_API_BASE_URL
}
axios.interceptors.request.use(
(config: any) => {
// let each request carry token
// this example using the JWT token
// Authorization is a custom headers key
// please modify it according to the actual situation
const token = getToken()
if (token) {
if (!config.headers) {
config.headers = {}
}
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
// do something
return Promise.reject(error)
}
)
// add response interceptors
axios.interceptors.response.use(
(response: any) => {
const res = response.data
// if the custom code is not 20000, it is judged as an error.
if (res.code !== 20000) {
Message.error({
content: res.msg || 'Error',
duration: 5 * 1000,
})
// 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired;
if ([50008, 50012, 50014].includes(res.code) && response.config.url !== '/api/user/info') {
Modal.error({
title: 'Confirm logout',
content: 'You have been logged out, you can cancel to stay on this page, or log in again',
okText: 'Re-Login',
async onOk() {
const userStore = useUserStore()
await userStore.logout()
window.location.reload()
},
})
}
return Promise.reject(new Error(res.msg || 'Error'))
}
return res
},
(error) => {
Message.error({
content: error.msg || 'Request Error',
duration: 5 * 1000,
})
return Promise.reject(error)
}
)

56
src/api/list.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { DescData } from '@arco-design/web-vue/es/descriptions/interface'
import axios from 'axios'
import qs from 'query-string'
export interface PolicyRecord {
id: string
number: number
name: string
contentType: 'img' | 'horizontalVideo' | 'verticalVideo'
filterType: 'artificial' | 'rules'
count: number
status: 'online' | 'offline'
createdTime: string
}
export interface PolicyParams extends Partial<PolicyRecord> {
current: number
pageSize: number
}
export interface PolicyListRes {
list: PolicyRecord[]
total: number
}
export function queryPolicyList(params: PolicyParams) {
return axios.get<PolicyListRes>('/api/list/policy', {
params,
paramsSerializer: (obj) => {
return qs.stringify(obj)
},
})
}
export interface ServiceRecord {
id: number
title: string
description: string
name?: string
actionType?: string
icon?: string
data?: DescData[]
enable?: boolean
expires?: boolean
}
export function queryInspectionList() {
return axios.get('/api/list/quality-inspection')
}
export function queryTheServiceList() {
return axios.get('/api/list/the-service')
}
export function queryRulesPresetList() {
return axios.get('/api/list/rules-preset')
}

38
src/api/message.ts Normal file
View File

@@ -0,0 +1,38 @@
import axios from 'axios'
export interface MessageRecord {
id: number
type: string
title: string
subTitle: string
avatar?: string
content: string
time: string
status: 0 | 1
messageType?: number
}
export type MessageListType = MessageRecord[]
export function queryMessageList() {
return axios.post<MessageListType>('/api/message/list')
}
interface MessageStatus {
ids: number[]
}
export function setMessageStatus(data: MessageStatus) {
return axios.post<MessageListType>('/api/message/read', data)
}
export interface ChatRecord {
id: number
username: string
content: string
time: string
isCollect: boolean
}
export function queryChatList() {
return axios.post<ChatRecord[]>('/api/chat/list')
}

49
src/api/profile.ts Normal file
View File

@@ -0,0 +1,49 @@
import axios from 'axios'
export interface ProfileBasicRes {
status: number
video: {
mode: string
acquisition: {
resolution: string
frameRate: number
}
encoding: {
resolution: string
rate: {
min: number
max: number
default: number
}
frameRate: number
profile: string
}
}
audio: {
mode: string
acquisition: {
channels: number
}
encoding: {
channels: number
rate: number
profile: string
}
}
}
export function queryProfileBasic() {
return axios.get<ProfileBasicRes>('/api/profile/basic')
}
export type operationLogRes = Array<{
key: string
contentNumber: string
updateContent: string
status: number
updateTime: string
}>
export function queryOperationLog() {
return axios.get<operationLogRes>('/api/operation/log')
}

88
src/api/user-center.ts Normal file
View File

@@ -0,0 +1,88 @@
import axios from 'axios'
export interface MyProjectRecord {
id: number
name: string
description: string
peopleNumber: number
contributors: {
name: string
email: string
avatar: string
}[]
}
export function queryMyProjectList() {
return axios.post('/api/user/my-project/list')
}
export interface MyTeamRecord {
id: number
avatar: string
name: string
peopleNumber: number
}
export function queryMyTeamList() {
return axios.post('/api/user/my-team/list')
}
export interface LatestActivity {
id: number
title: string
description: string
avatar: string
}
export function queryLatestActivity() {
return axios.post<LatestActivity[]>('/api/user/latest-activity')
}
export function saveUserInfo() {
return axios.post('/api/user/save-info')
}
export interface BasicInfoModel {
email: string
nickname: string
countryRegion: string
area: string
address: string
profile: string
}
export interface EnterpriseCertificationModel {
accountType: number
status: number
time: string
legalPerson: string
certificateType: string
authenticationNumber: string
enterpriseName: string
enterpriseCertificateType: string
organizationCode: string
}
export type CertificationRecord = Array<{
certificationType: number
certificationContent: string
status: number
time: string
}>
export interface UnitCertification {
enterpriseInfo: EnterpriseCertificationModel
record: CertificationRecord
}
export function queryCertification() {
return axios.post<UnitCertification>('/api/user/certification')
}
export function userUploadApi(
data: FormData,
config: {
controller: AbortController
onUploadProgress?: (progressEvent: any) => void
}
) {
// const controller = new AbortController();
return axios.post('/api/user/upload', data, config)
}

27
src/api/user.ts Normal file
View File

@@ -0,0 +1,27 @@
import { UserState } from '@/store/modules/user/types'
import axios from 'axios'
import type { RouteRecordNormalized } from 'vue-router'
export interface LoginData {
username: string
password: string
}
export interface LoginRes {
token: string
}
export function login(data: LoginData) {
return axios.post<LoginRes>('/api/user/login', data)
}
export function logout() {
return axios.post<LoginRes>('/api/user/logout')
}
export function getUserInfo() {
return axios.post<UserState>('/api/user/info')
}
export function getMenuList() {
return axios.post<RouteRecordNormalized[]>('/api/user/menu')
}

70
src/api/visualization.ts Normal file
View File

@@ -0,0 +1,70 @@
import axios from 'axios'
import { GeneralChart } from '@/types/global'
export interface ChartDataRecord {
x: string
y: number
name: string
}
export interface DataChainGrowth {
quota: string
}
export interface DataChainGrowthRes {
count: number
growth: number
chartData: {
xAxis: string[]
data: { name: string; value: number[] }
}
}
export function queryDataChainGrowth(data: DataChainGrowth) {
return axios.post<DataChainGrowthRes>('/api/data-chain-growth', data)
}
export interface PopularAuthorRes {
list: {
ranking: number
author: string
contentCount: number
clickCount: number
}[]
}
export function queryPopularAuthor() {
return axios.get<PopularAuthorRes>('/api/popular-author/list')
}
export interface ContentPublishRecord {
x: string[]
y: number[]
name: string
}
export function queryContentPublish() {
return axios.get<ContentPublishRecord[]>('/api/content-publish')
}
export function queryContentPeriodAnalysis() {
return axios.post<GeneralChart>('/api/content-period-analysis')
}
export interface PublicOpinionAnalysis {
quota: string
}
export interface PublicOpinionAnalysisRes {
count: number
growth: number
chartData: ChartDataRecord[]
}
export function queryPublicOpinionAnalysis(data: DataChainGrowth) {
return axios.post<PublicOpinionAnalysisRes>('/api/public-opinion-analysis', data)
}
export interface DataOverviewRes {
xAxis: string[]
data: Array<{ name: string; value: number[]; count: number }>
}
export function queryDataOverview() {
return axios.post<DataOverviewRes>('/api/data-overview')
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

12
src/assets/logo.svg Normal file
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,43 @@
body {
--color-menu-dark-bg: #000c17;
--color-menu-dark-hover: #165dff;
.arco-menu-dark {
.arco-menu-item,
.arco-menu-group-title,
.arco-menu-pop-header,
.arco-menu-inline-header {
color: #fff;
* {
color: #fff;
}
}
.arco-menu-inline-header.arco-menu-selected {
color: #fff;
* {
color: #fff;
}
}
.arco-menu-inline-header:hover {
color: #fff;
* {
color: #fff;
}
}
.arco-menu-item:hover {
color: #fff;
}
.arco-menu-vertical {
.arco-menu-inline-header {
line-height: 46px;
}
}
}
}

View File

@@ -0,0 +1,91 @@
@import './custom.less';
* {
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));
}
}
}

12822
src/assets/world.json Normal file

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 { 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,42 @@
<template>
<VCharts v-if="renderChart" :option="options" :autoresize="autoResize" :style="{ width, height }" />
</template>
<script lang="ts" setup>
import { nextTick, ref } from 'vue'
import VCharts from 'vue-echarts'
// import { useAppStore } from '@/store';
defineProps({
options: {
type: Object,
default() {
return {}
},
},
autoResize: {
type: Boolean,
default: true,
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '100%',
},
})
// const appStore = useAppStore();
// const theme = computed(() => {
// if (appStore.theme === 'dark') return 'dark';
// return '';
// });
const renderChart = ref(false)
// wait container expand
nextTick(() => {
renderChart.value = true
})
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,27 @@
<template>
<a-layout-footer class="footer">
智能运维管理系统
<span style="margin-left: 3px">© {{ currentYear }}</span>
<p style="margin-left: 10px">
Powered by
<!-- <a target="_blank" style="color: #4d8af0; text-decoration: none">xx</a> -->
</p>
</a-layout-footer>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const currentYear = ref(new Date().getFullYear())
</script>
<style lang="less" scoped>
.footer {
display: flex;
align-items: center;
justify-content: center;
height: 60px;
color: var(--color-text-2);
text-align: center;
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<div class="block">
<h5 class="title">{{ title }}</h5>
<div v-for="option in options" :key="option.name" class="switch-wrapper">
<span>{{ $t(option.name) }}</span>
<form-wrapper :type="option.type || 'switch'" :name="option.key" :default-value="option.defaultVal" @input-change="handleChange" />
</div>
</div>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/store'
import { PropType } from 'vue'
import FormWrapper from './form-wrapper.vue'
interface OptionsProps {
name: string
key: string
type?: string
defaultVal?: boolean | string | number
}
defineProps({
title: {
type: String,
default: '',
},
options: {
type: Array as PropType<OptionsProps[]>,
default() {
return []
},
},
})
const appStore = useAppStore()
const handleChange = async ({ key, value }: { key: string; value: unknown }) => {
if (key === 'colorWeak') {
document.body.style.filter = value ? 'invert(80%)' : 'none'
}
if (key === 'menuFromServer' && value) {
await appStore.fetchServerMenuConfig()
}
if (key === 'topMenu') {
appStore.updateSettings({
menuCollapse: false,
})
}
appStore.updateSettings({ [key]: value })
}
</script>
<style scoped lang="less">
.block {
margin-bottom: 24px;
}
.title {
margin: 10px 0;
padding: 0;
font-size: 14px;
}
.switch-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<a-input-number
v-if="type === 'number'"
:style="{ width: '80px' }"
size="small"
:default-value="defaultValue as number"
@change="handleChange"
/>
<a-switch v-else :default-checked="defaultValue as boolean" size="small" @change="handleChange" />
</template>
<script lang="ts" setup>
const props = defineProps({
type: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
defaultValue: {
type: [String, Boolean, Number],
default: '',
},
})
const emit = defineEmits(['inputChange'])
const handleChange = (value: unknown) => {
emit('inputChange', {
value,
key: props.name,
})
}
</script>

View File

@@ -0,0 +1,98 @@
<template>
<div v-if="!appStore.navbar" class="fixed-settings" @click="setVisible">
<a-button type="primary">
<template #icon>
<icon-settings />
</template>
</a-button>
</div>
<a-drawer
:width="300"
unmount-on-close
:visible="visible"
:cancel-text="$t('settings.close')"
:ok-text="$t('settings.copySettings')"
@ok="copySettings"
@cancel="cancel"
>
<template #title>{{ $t('settings.title') }}</template>
<Block :options="contentOpts" :title="$t('settings.content')" />
<Block :options="othersOpts" :title="$t('settings.otherSettings')" />
<a-alert>{{ $t('settings.alertContent') }}</a-alert>
</a-drawer>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/store'
import { Message } from '@arco-design/web-vue'
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Block from './block.vue'
const emit = defineEmits(['cancel'])
const appStore = useAppStore()
const { t } = useI18n()
const { copy } = useClipboard()
const visible = computed(() => appStore.globalSettings)
const contentOpts = computed(() => [
{ name: 'settings.navbar', key: 'navbar', defaultVal: appStore.navbar },
// {
// name: 'settings.menu',
// key: 'menu',
// defaultVal: appStore.menu,
// },
// {
// name: 'settings.topMenu',
// key: 'topMenu',
// defaultVal: appStore.topMenu,
// },
{ name: 'settings.footer', key: 'footer', defaultVal: appStore.footer },
{ name: 'settings.tabBar', key: 'tabBar', defaultVal: appStore.tabBar },
{
name: 'settings.menuFromServer',
key: 'menuFromServer',
defaultVal: appStore.menuFromServer,
},
{
name: 'settings.menuWidth',
key: 'menuWidth',
defaultVal: appStore.menuWidth,
type: 'number',
},
])
const othersOpts = computed(() => [
{
name: 'settings.colorWeak',
key: 'colorWeak',
defaultVal: appStore.colorWeak,
},
])
const cancel = () => {
appStore.updateSettings({ globalSettings: false })
emit('cancel')
}
const copySettings = async () => {
const text = JSON.stringify(appStore.$state, null, 2)
await copy(text)
Message.success(t('settings.copySettings.message'))
}
const setVisible = () => {
appStore.updateSettings({ globalSettings: true })
}
</script>
<style scoped lang="less">
.fixed-settings {
position: fixed;
top: 280px;
right: 0;
svg {
font-size: 18px;
vertical-align: -4px;
}
}
</style>

29
src/components/index.ts Normal file
View File

@@ -0,0 +1,29 @@
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 { 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,151 @@
<script lang="tsx">
// @ts-nocheck
import { useAppStore } from '@/store'
import { openWindow, regexUrl } from '@/utils'
import { listenerRouteChange } from '@/utils/route-listener'
import { compile, computed, defineComponent, h, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RouteMeta } from 'vue-router'
import { RouteRecordRaw, useRoute, useRouter } from 'vue-router'
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
theme='dark'
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,67 @@
import usePermission from '@/hooks/permission'
import appClientMenus from '@/router/app-menus'
import { useAppStore } from '@/store'
import { cloneDeep } from 'lodash'
import { computed } from 'vue'
import { RouteRecordNormalized, RouteRecordRaw } from 'vue-router'
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,116 @@
<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 { MessageListType, MessageRecord, queryMessageList, setMessageStatus } from '@/api/message'
import useLoading from '@/hooks/loading'
import { computed, reactive, ref, toRefs } from 'vue'
import { useI18n } from 'vue-i18n'
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 {
const { data } = await queryMessageList()
messageData.messageList = data
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
async function readMessage(data: MessageListType) {
const ids = data.map((item) => item.id)
await setMessageStatus({ ids })
fetchSourceData()
}
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,140 @@
<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 { MessageListType, MessageRecord } from '@/api/message'
import { PropType } from 'vue'
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,252 @@
<template>
<div class="navbar">
<div class="left-side">
<a-space>
<icon-menu-fold
v-if="!topMenu && appStore.device === 'mobile'"
style="font-size: 22px; cursor: pointer"
@click="toggleDrawerMenu"
/>
</a-space>
</div>
<ul class="right-side">
<!-- <li>
<a-tooltip :content="$t('settings.search')">
<a-button class="nav-btn" type="outline" :shape="'circle'">
<template #icon>
<icon-search />
</template>
</a-button>
</a-tooltip>
</li> -->
<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-tooltip :content="t('settings.title')">
<a-button class="nav-btn" type="outline" :shape="'circle'" @click="setVisible">
<template #icon>
<icon-settings />
</template>
</a-button>
</a-tooltip>
</li>
<li>
<a-dropdown trigger="click">
<a-avatar :size="32" :style="{ marginRight: '8px' }">
<img alt="avatar" :src="avatar" />
</a-avatar>
<template #content>
<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 useLocale from '@/hooks/locale'
import useUser from '@/hooks/user'
import { LOCALE_OPTIONS } from '@/locale'
import { useAppStore, useUserStore } from '@/store'
import { Message } from '@arco-design/web-vue'
import { useDark, useFullscreen, useToggle } from '@vueuse/core'
import { computed, inject, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import MessageBox from '../message-box/index.vue'
const { t } = useI18n()
const appStore = useAppStore()
const userStore = useUserStore()
const { logout } = useUser()
const { changeLocale, currentLocale }: any = useLocale()
const { isFullscreen, toggle: toggleFullScreen } = useFullscreen()
const locales = [...LOCALE_OPTIONS]
const avatar = computed(() => {
return 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 setVisible = () => {
appStore.updateSettings({ globalSettings: true })
}
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 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);
position: fixed;
top: 0;
right: 0;
left: 0;
height: 60px;
z-index: 99;
}
.left-side {
display: flex;
align-items: center;
padding-left: 20px;
}
.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;
}
}
.arco-dropdown-list-wrapper {
max-height: 100vh !important;
}
</style>

View File

@@ -0,0 +1,90 @@
<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 { useAppStore, useTabBarStore } from '@/store'
import { listenerRouteChange, removeRouteListener } from '@/utils/route-listener'
import { computed, onUnmounted, ref, watch } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'
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,11 @@
## 组件说明
该组件非官方最终设计规范,以单独组件存在。
同时仅仅提供最基本的功能,后续进行优化及更改。
## 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,188 @@
<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="Eaction.reload">
<icon-refresh />
<span>重新加载</span>
</a-doption>
<a-doption class="sperate-line" :disabled="disabledCurrent" :value="Eaction.current">
<icon-close />
<span>关闭当前标签页</span>
</a-doption>
<a-doption :disabled="disabledLeft" :value="Eaction.left">
<icon-to-left />
<span>关闭左侧标签页</span>
</a-doption>
<a-doption class="sperate-line" :disabled="disabledRight" :value="Eaction.right">
<icon-to-right />
<span>关闭右侧标签页</span>
</a-doption>
<a-doption :value="Eaction.others">
<icon-swap />
<span>关闭其它标签页</span>
</a-doption>
<a-doption :value="Eaction.all">
<icon-folder-delete />
<span>关闭全部标签页</span>
</a-doption>
</template>
</a-dropdown>
</template>
<script lang="ts" setup>
import { DEFAULT_ROUTE_NAME, REDIRECT_ROUTE_NAME } from '@/router/constants'
import { useTabBarStore } from '@/store'
import type { TagProps } from '@/store/modules/tab-bar/types'
import { PropType, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
// eslint-disable-next-line no-shadow
enum Eaction {
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 === Eaction.current) {
tagClose(itemData, index)
} else if (value === Eaction.left) {
const currentRouteIdx = findCurrentRouteIndex()
copyTagList.splice(1, props.index - 1)
tabBarStore.freshTabList(copyTagList)
if (currentRouteIdx < index) {
router.push({ name: itemData.name })
}
} else if (value === Eaction.right) {
const currentRouteIdx = findCurrentRouteIndex()
copyTagList.splice(props.index + 1)
tabBarStore.freshTabList(copyTagList)
if (currentRouteIdx > index) {
router.push({ name: itemData.name })
}
} else if (value === Eaction.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 === Eaction.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>

17
src/config/settings.json Normal file
View File

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

8
src/directive/index.ts Normal file
View File

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

View File

@@ -0,0 +1,30 @@
import { 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)
},
}

7
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

27
src/hooks/chart-option.ts Normal file
View File

@@ -0,0 +1,27 @@
import { useAppStore } from '@/store'
import { EChartsOption } from 'echarts'
import { computed } from 'vue'
// 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.
interface 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,
}
}

16
src/hooks/loading.ts Normal file
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,
}
}

22
src/hooks/locale.ts Normal file
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,
}
}

30
src/hooks/permission.ts Normal file
View File

@@ -0,0 +1,30 @@
import { 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
}
}

26
src/hooks/request.ts Normal file
View File

@@ -0,0 +1,26 @@
import { ref, UnwrapRef } from 'vue'
import { AxiosResponse } from 'axios'
import { HttpResponse } from '@/api/interceptor'
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 }
}

32
src/hooks/responsive.ts Normal file
View File

@@ -0,0 +1,32 @@
import { useAppStore } from '@/store'
import { addEventListen, removeEventListen } from '@/utils/event'
import { useDebounceFn } from '@vueuse/core'
import { onBeforeMount, onBeforeUnmount, onMounted } from 'vue'
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)
})
}

12
src/hooks/themes.ts Normal file
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,
}
}

24
src/hooks/user.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
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,
}
}

16
src/hooks/visible.ts Normal file
View File

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

View File

@@ -0,0 +1,222 @@
<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'"
:collapsible="true"
:width="menuWidth"
:style="{ paddingTop: navbar ? '60px' : '' }"
:hide-trigger="true"
@collapse="setCollapsed"
>
<div class="menu-wrapper">
<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>智能运维管理系统</a-typography-title>
</a-space>
</div>
<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 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 { computed, onMounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 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-sider {
background: var(--color-menu-dark-bg);
position: fixed;
top: 0;
left: 0;
z-index: 99;
height: 100%;
padding-top: 0 !important;
.left-side {
display: flex;
align-items: center;
padding-left: 8px;
background: var(--color-menu-dark-bg);
height: 60px;
.arco-typography {
color: #fff;
font-size: 18px;
width: 200px;
}
}
&::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;
top: 0;
}
.menu-wrapper {
height: 100%;
overflow: auto;
overflow-x: hidden;
:deep(.arco-menu) {
height: calc(100% - 60px) !important;
::-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-width: @layout-max-width;
min-height: 100vh;
overflow-y: hidden;
background-color: var(--color-fill-2);
transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
.layout-navbar {
transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
position: fixed;
top: 0;
left: 250px;
z-index: 100;
width: 100%;
min-width: @layout-max-width;
height: @nav-size-height;
}
}
.arco-layout-sider-collapsed {
.left-side {
width: 50px;
.arco-typography {
color: transparent;
}
}
+ .layout-content {
.layout-navbar {
left: 50px !important;
.navbar {
width: calc(100% - 50px) !important;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,21 @@
<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 { useTabBarStore } from '@/store'
import { computed } from 'vue'
const tabBarStore = useTabBarStore()
const cacheList = computed(() => tabBarStore.getCacheList)
</script>
<style scoped lang="less"></style>

57
src/locale/en-US.ts Normal file
View File

@@ -0,0 +1,57 @@
import localeMessageBox from '@/components/message-box/locale/en-US'
import localeLogin from '@/views/login/locale/en-US'
import localeWorkplace from '@/views/dashboard/workplace/locale/en-US'
/** simple */
import localeMonitor from '@/views/dashboard/monitor/locale/en-US'
import localeCardList from '@/views/list/card/locale/en-US'
import localeSearchTable from '@/views/list/search-table/locale/en-US'
import localeBasicProfile from '@/views/profile/basic/locale/en-US'
import localeDataAnalysis from '@/views/visualization/data-analysis/locale/en-US'
import localeMultiDAnalysis from '@/views/visualization/multi-dimension-data-analysis/locale/en-US'
import locale403 from '@/views/exception/403/locale/en-US'
import locale404 from '@/views/exception/404/locale/en-US'
import locale500 from '@/views/exception/500/locale/en-US'
import localeUserInfo from '@/views/user/info/locale/en-US'
import localeUserSetting from '@/views/user/setting/locale/en-US'
/** simple end */
import localeSettings from './en-US/settings'
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.profile': 'Profile',
'menu.visualization': 'Data Visualization',
'menu.user': 'User Center',
'menu.arcoWebsite': '外链',
'menu.faq': 'FAQ',
'navbar.docs': 'Docs',
'navbar.action.locale': 'Switch to English',
...localeSettings,
...localeMessageBox,
...localeLogin,
...localeWorkplace,
/** simple */
...localeMonitor,
...localeSearchTable,
...localeCardList,
...localeBasicProfile,
...localeDataAnalysis,
...localeMultiDAnalysis,
...locale403,
...locale404,
...locale500,
...localeUserInfo,
...localeUserSetting,
/** simple end */
}

View File

@@ -0,0 +1,27 @@
export default {
'settings.title': 'Settings',
'settings.themeColor': 'Theme Color',
'settings.content': 'Content Setting',
'settings.search': 'Search',
'settings.language': 'Language',
'settings.navbar': 'Navbar',
'settings.menuWidth': 'Menu Width (px)',
'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',
'settings.menu': 'Menu',
'settings.topMenu': 'Top Menu',
'settings.tabBar': 'Tab Bar',
'settings.footer': 'Footer',
'settings.otherSettings': 'Other Settings',
'settings.colorWeak': 'Color Weak',
'settings.alertContent':
'After the configuration is only temporarily effective, if you want to really affect the project, click the "Copy Settings" button below and replace the configuration in settings.json.',
'settings.copySettings': 'Copy Settings',
'settings.copySettings.message': 'Copy succeeded, please paste to file src/settings.json.',
'settings.close': 'Close',
'settings.color.tooltip': '10 gradient colors generated according to the theme color',
'settings.menuFromServer': 'Menu From Server',
}

22
src/locale/index.ts Normal file
View File

@@ -0,0 +1,22 @@
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,
allowComposition: true,
messages: {
'en-US': en,
'zh-CN': cn,
},
})
export default i18n

57
src/locale/zh-CN.ts Normal file
View File

@@ -0,0 +1,57 @@
import localeMessageBox from '@/components/message-box/locale/zh-CN'
import localeLogin from '@/views/login/locale/zh-CN'
import localeWorkplace from '@/views/dashboard/workplace/locale/zh-CN'
/** simple */
import localeMonitor from '@/views/dashboard/monitor/locale/zh-CN'
import localeCardList from '@/views/list/card/locale/zh-CN'
import localeSearchTable from '@/views/list/search-table/locale/zh-CN'
import localeBasicProfile from '@/views/profile/basic/locale/zh-CN'
import localeDataAnalysis from '@/views/visualization/data-analysis/locale/zh-CN'
import localeMultiDAnalysis from '@/views/visualization/multi-dimension-data-analysis/locale/zh-CN'
import locale403 from '@/views/exception/403/locale/zh-CN'
import locale404 from '@/views/exception/404/locale/zh-CN'
import locale500 from '@/views/exception/500/locale/zh-CN'
import localeUserInfo from '@/views/user/info/locale/zh-CN'
import localeUserSetting from '@/views/user/setting/locale/zh-CN'
/** simple end */
import localeSettings from './zh-CN/settings'
export default {
'menu.dashboard': '仪表盘',
'menu.server.dashboard': '仪表盘-服务端',
'menu.server.workplace': '工作台-服务端',
'menu.server.monitor': '实时监控-服务端',
'menu.list': '列表页',
'menu.result': '结果页',
'menu.exception': '异常页',
'menu.profile': '详情页',
'menu.visualization': '数据可视化',
'menu.user': '个人中心',
'menu.arcoWebsite': '外链',
'menu.faq': '常见问题',
'navbar.docs': '文档中心',
'navbar.action.locale': '切换为中文',
...localeSettings,
...localeMessageBox,
...localeLogin,
...localeWorkplace,
/** simple */
...localeMonitor,
...localeSearchTable,
...localeCardList,
...localeBasicProfile,
...localeDataAnalysis,
...localeMultiDAnalysis,
...locale403,
...locale404,
...locale500,
...localeUserInfo,
...localeUserSetting,
/** simple end */
}

View File

@@ -0,0 +1,26 @@
export default {
'settings.title': '页面配置',
'settings.themeColor': '主题色',
'settings.content': '内容区域',
'settings.search': '搜索',
'settings.language': '语言',
'settings.navbar': '导航栏',
'settings.menuWidth': '菜单宽度 (px)',
'settings.navbar.theme.toLight': '点击切换为亮色模式',
'settings.navbar.theme.toDark': '点击切换为暗黑模式',
'settings.navbar.screen.toFull': '点击切换全屏模式',
'settings.navbar.screen.toExit': '点击退出全屏模式',
'settings.navbar.alerts': '消息通知',
'settings.menu': '菜单栏',
'settings.topMenu': '顶部菜单栏',
'settings.tabBar': '多页签',
'settings.footer': '底部',
'settings.otherSettings': '其他设置',
'settings.colorWeak': '色弱模式',
'settings.alertContent': '配置之后仅是临时生效,要想真正作用于项目,点击下方的 "复制配置" 按钮,将配置替换到 settings.json 中即可。',
'settings.copySettings': '复制配置',
'settings.copySettings.message': '复制成功,请粘贴到 src/settings.json 文件中',
'settings.close': '关闭',
'settings.color.tooltip': '根据主题颜色生成的 10 个梯度色(将配置复制到项目中,主题色才能对亮色 / 暗黑模式同时生效)',
'settings.menuFromServer': '菜单来源于后台',
}

28
src/main.ts Normal file
View File

@@ -0,0 +1,28 @@
import globalComponents from '@/components'
import ArcoVue from '@arco-design/web-vue'
import ArcoVueIcon from '@arco-design/web-vue/es/icon'
import { createApp } from 'vue'
import App from './App.vue'
import directive from './directive'
import i18n from './locale'
import './mock'
import router from './router'
import store from './store'
// Styles are imported via arco-plugin. See config/plugin/arcoStyleImport.ts in the directory for details
// 样式通过 arco-plugin 插件导入。详见目录文件 config/plugin/arcoStyleImport.ts
// https://arco.design/docs/designlab/use-theme-package
import '@/api/interceptor'
import '@/assets/style/global.less'
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)
app.mount('#app')

24
src/mock/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import Mock from 'mockjs'
import './user'
import './message-box'
import '@/views/dashboard/workplace/mock'
/** simple */
import '@/views/dashboard/monitor/mock'
import '@/views/list/card/mock'
import '@/views/list/search-table/mock'
import '@/views/profile/basic/mock'
import '@/views/visualization/data-analysis/mock'
import '@/views/visualization/multi-dimension-data-analysis/mock'
import '@/views/user/info/mock'
import '@/views/user/setting/mock'
/** simple end */
Mock.setup({
timeout: '600-1000',
})

82
src/mock/message-box.ts Normal file
View File

@@ -0,0 +1,82 @@
import Mock from 'mockjs'
import setupMock, { successResponseWrap } from '@/utils/setup-mock'
const haveReadIds: number[] = []
const getMessageList = () => {
return [
{
id: 1,
type: 'message',
title: '郑曦月',
subTitle: '的私信',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/8361eeb82904210b4f55fab888fe8416.png~tplv-uwbnlip3yd-webp.webp',
content: '审批请求已发送,请查收',
time: '今天 12:30:01',
},
{
id: 2,
type: 'message',
title: '宁波',
subTitle: '的回复',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
content: '此处 bug 已经修复',
time: '今天 12:30:01',
},
{
id: 3,
type: 'message',
title: '宁波',
subTitle: '的回复',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
content: '此处 bug 已经修复',
time: '今天 12:20:01',
},
{
id: 4,
type: 'notice',
title: '续费通知',
subTitle: '',
avatar: '',
content: '您的产品使用期限即将截止,如需继续使用产品请前往购…',
time: '今天 12:20:01',
messageType: 3,
},
{
id: 5,
type: 'notice',
title: '规则开通成功',
subTitle: '',
avatar: '',
content: '内容屏蔽规则于 2021-12-01 开通成功并生效',
time: '今天 12:20:01',
messageType: 1,
},
{
id: 6,
type: 'todo',
title: '质检队列变更',
subTitle: '',
avatar: '',
content: '内容质检队列于 2021-12-01 19:50:23 进行变更,请重新…',
time: '今天 12:20:01',
messageType: 0,
},
].map((item) => ({
...item,
status: haveReadIds.indexOf(item.id) === -1 ? 0 : 1,
}))
}
setupMock({
setup: () => {
Mock.mock(new RegExp('/api/message/list'), () => {
return successResponseWrap(getMessageList())
})
Mock.mock(new RegExp('/api/message/read'), (params: { body: string }) => {
const { ids } = JSON.parse(params.body)
haveReadIds.push(...(ids || []))
return successResponseWrap(true)
})
},
})

101
src/mock/user.ts Normal file
View File

@@ -0,0 +1,101 @@
import setupMock, { failResponseWrap, successResponseWrap } from '@/utils/setup-mock'
import Mock from 'mockjs'
import { MockParams } from '@/types/mock'
import { isLogin } from '@/utils/auth'
setupMock({
setup() {
// Mock.XHR.prototype.withCredentials = true;
// 用户信息
Mock.mock(new RegExp('/api/user/info'), () => {
if (isLogin()) {
const role = window.localStorage.getItem('userRole') || 'admin'
return successResponseWrap({
name: '王立群',
avatar: 'https://i.gtimg.cn/club/item/face/img/2/15922_100.gif',
email: 'xxx.com',
job: 'frontend',
jobName: 'xxx',
organization: 'Frontend',
organizationName: 'xx',
location: 'beijing',
locationName: '北京',
introduction: '人潇洒,性温存',
personalWebsite: 'wwww.baidu.com',
phone: '150****0000',
registrationDate: '2013-05-10 12:10:00',
accountId: 'xxxxx',
certification: 1,
role,
})
}
return failResponseWrap(null, '未登录', 50008)
})
// 登录
Mock.mock(new RegExp('/api/user/login'), (params: MockParams) => {
const { username, password } = JSON.parse(params.body)
if (!username) {
return failResponseWrap(null, '用户名不能为空', 50000)
}
if (!password) {
return failResponseWrap(null, '密码不能为空', 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, '账号或者密码错误', 50000)
})
// 登出
Mock.mock(new RegExp('/api/user/logout'), () => {
return successResponseWrap(null)
})
// 用户的服务端菜单
Mock.mock(new RegExp('/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,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

18
src/router/constants.ts Normal file
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',
}

17
src/router/guard/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import { setRouteEmitter } from '@/utils/route-listener'
import type { Router } from 'vue-router'
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,48 @@
import NProgress from 'nprogress' // progress bar
import type { Router, RouteRecordNormalized } 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 {
// eslint-disable-next-line no-lonely-if
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,
})
}
})
}

37
src/router/index.ts Normal file
View File

@@ -0,0 +1,37 @@
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css'
import { createRouter, createWebHashHistory } from 'vue-router'
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: createWebHashHistory('./'),
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

31
src/router/routes/base.ts Normal file
View File

@@ -0,0 +1,31 @@
import { REDIRECT_ROUTE_NAME } from '@/router/constants'
import type { RouteRecordRaw } from 'vue-router'
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: 'www.baidu.com',
name: 'faq',
meta: {
locale: 'menu.faq',
icon: 'icon-question-circle',
requiresAuth: true,
order: 9,
},
}

View File

@@ -0,0 +1,20 @@
import type { RouteRecordNormalized } from 'vue-router'
const modules = import.meta.glob('./modules/*.ts', { eager: true })
const externalModules = import.meta.glob('./externalModules/*.ts', {
eager: true,
})
function formatModules(_modules: any, result: RouteRecordNormalized[]) {
Object.keys(_modules).forEach((key) => {
const defaultModule = _modules[key].default
if (!defaultModule) return
const moduleList = Array.isArray(defaultModule) ? [...defaultModule] : [defaultModule]
result.push(...moduleList)
})
return result
}
export const appRoutes: RouteRecordNormalized[] = formatModules(modules, [])
export const appExternalRoutes: RouteRecordNormalized[] = formatModules(externalModules, [])

View File

@@ -0,0 +1,40 @@
import { DEFAULT_LAYOUT } from '../base'
import { AppRouteRecordRaw } from '../types'
const DASHBOARD: AppRouteRecordRaw = {
path: '/dashboard',
name: 'dashboard',
component: DEFAULT_LAYOUT,
meta: {
locale: 'menu.dashboard',
requiresAuth: true,
icon: 'icon-dashboard',
order: 0,
},
children: [
{
path: 'workplace',
name: 'Workplace',
component: () => import('@/views/dashboard/workplace/index.vue'),
meta: {
locale: 'menu.dashboard.workplace',
requiresAuth: true,
roles: ['*'],
},
},
/** simple */
{
path: 'monitor',
name: 'Monitor',
component: () => import('@/views/dashboard/monitor/index.vue'),
meta: {
locale: 'menu.dashboard.monitor',
requiresAuth: true,
roles: ['admin'],
},
},
/** simple end */
],
}
export default DASHBOARD

View File

@@ -0,0 +1,48 @@
import { DEFAULT_LAYOUT } from '../base'
import { AppRouteRecordRaw } from '../types'
const EXCEPTION: AppRouteRecordRaw = {
path: '/exception',
name: 'exception',
component: DEFAULT_LAYOUT,
meta: {
locale: 'menu.exception',
requiresAuth: true,
icon: 'icon-exclamation-circle',
order: 6,
},
children: [
{
path: '403',
name: '403',
component: () => import('@/views/exception/403/index.vue'),
meta: {
locale: 'menu.exception.403',
requiresAuth: true,
roles: ['admin'],
},
},
{
path: '404',
name: '404',
component: () => import('@/views/exception/404/index.vue'),
meta: {
locale: 'menu.exception.404',
requiresAuth: true,
roles: ['*'],
},
},
{
path: '500',
name: '500',
component: () => import('@/views/exception/500/index.vue'),
meta: {
locale: 'menu.exception.500',
requiresAuth: true,
roles: ['*'],
},
},
],
}
export default EXCEPTION

View File

@@ -0,0 +1,38 @@
import { DEFAULT_LAYOUT } from '../base'
import { AppRouteRecordRaw } from '../types'
const LIST: AppRouteRecordRaw = {
path: '/list',
name: 'list',
component: DEFAULT_LAYOUT,
meta: {
locale: 'menu.list',
requiresAuth: true,
icon: 'icon-list',
order: 2,
},
children: [
{
path: 'search-table', // The midline path complies with SEO specifications
name: 'SearchTable',
component: () => import('@/views/list/search-table/index.vue'),
meta: {
locale: 'menu.list.searchTable',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'card',
name: 'Card',
component: () => import('@/views/list/card/index.vue'),
meta: {
locale: 'menu.list.cardList',
requiresAuth: true,
roles: ['*'],
},
},
],
}
export default LIST

View File

@@ -0,0 +1,28 @@
import { DEFAULT_LAYOUT } from '../base'
import { AppRouteRecordRaw } from '../types'
const PROFILE: AppRouteRecordRaw = {
path: '/profile',
name: 'profile',
component: DEFAULT_LAYOUT,
meta: {
locale: 'menu.profile',
requiresAuth: true,
icon: 'icon-file',
order: 4,
},
children: [
{
path: 'basic',
name: 'Basic',
component: () => import('@/views/profile/basic/index.vue'),
meta: {
locale: 'menu.profile.basic',
requiresAuth: true,
roles: ['admin'],
},
},
],
}
export default PROFILE

View File

@@ -0,0 +1,38 @@
import { DEFAULT_LAYOUT } from '../base'
import { AppRouteRecordRaw } from '../types'
const USER: AppRouteRecordRaw = {
path: '/user',
name: 'user',
component: DEFAULT_LAYOUT,
meta: {
locale: 'menu.user',
icon: 'icon-user',
requiresAuth: true,
order: 7,
},
children: [
{
path: 'info',
name: 'Info',
component: () => import('@/views/user/info/index.vue'),
meta: {
locale: 'menu.user.info',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'setting',
name: 'Setting',
component: () => import('@/views/user/setting/index.vue'),
meta: {
locale: 'menu.user.setting',
requiresAuth: true,
roles: ['*'],
},
},
],
}
export default USER

View File

@@ -0,0 +1,38 @@
import { DEFAULT_LAYOUT } from '../base'
import { AppRouteRecordRaw } from '../types'
const VISUALIZATION: AppRouteRecordRaw = {
path: '/visualization',
name: 'visualization',
component: DEFAULT_LAYOUT,
meta: {
locale: 'menu.visualization',
requiresAuth: true,
icon: 'icon-apps',
order: 1,
},
children: [
{
path: 'data-analysis',
name: 'DataAnalysis',
component: () => import('@/views/visualization/data-analysis/index.vue'),
meta: {
locale: 'menu.visualization.dataAnalysis',
requiresAuth: true,
roles: ['admin'],
},
},
{
path: 'multi-dimension-data-analysis',
name: 'MultiDimensionDataAnalysis',
component: () => import('@/views/visualization/multi-dimension-data-analysis/index.vue'),
meta: {
locale: 'menu.visualization.multiDimensionDataAnalysis',
requiresAuth: true,
roles: ['admin'],
},
},
],
}
export default VISUALIZATION

View File

@@ -0,0 +1,17 @@
import { 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
}

16
src/router/typings.d.ts vendored Normal file
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
}
}

9
src/store/index.ts Normal file
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,77 @@
import { defineStore } from 'pinia'
import { Notification } from '@arco-design/web-vue'
import type { NotificationReturn } from '@arco-design/web-vue/es/notification/interface'
import type { RouteRecordNormalized } from 'vue-router'
import defaultSettings from '@/config/settings.json'
import { getMenuList } from '@/api/user'
import { 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-ignore-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,
})
const { data } = await getMenuList()
this.serverMenu = data
notifyInstance = Notification.success({
id: 'menuNotice',
content: 'success',
closable: true,
})
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
notifyInstance = Notification.error({
id: 'menuNotice',
content: 'error',
closable: true,
})
}
},
clearServerMenu() {
this.serverMenu = []
},
},
})
export default useAppStore

View File

@@ -0,0 +1,20 @@
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
globalSettings: boolean
device: string
tabBar: boolean
menuFromServer: boolean
serverMenu: RouteRecordNormalized[]
[key: string]: unknown
}

View File

@@ -0,0 +1,70 @@
import type { RouteLocationNormalized } from 'vue-router'
import { defineStore } from 'pinia'
import { DEFAULT_ROUTE, DEFAULT_ROUTE_NAME, REDIRECT_ROUTE_NAME } from '@/router/constants'
import { isString } from '@/utils/is'
import { 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
this.tagList
.filter((el) => !el.ignoreCache)
.map((el) => el.name)
.forEach((x) => this.cacheTabList.add(x))
},
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,86 @@
import { LoginData, getUserInfo, login as userLogin, logout as userLogout } from '@/api/user'
import { clearToken, setToken } from '@/utils/auth'
import { removeRouteListener } from '@/utils/route-listener'
import { defineStore } from 'pinia'
import useAppStore from '../app'
import { 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>) {
this.$patch(partial)
},
// Reset user's information
resetInfo() {
this.$reset()
},
// Get user's information
async info() {
const res = await getUserInfo()
this.setInfo(res.data)
},
// Login
async login(loginForm: LoginData) {
try {
const res = await userLogin(loginForm)
setToken(res.data.token)
} catch (err) {
clearToken()
throw err
}
},
logoutCallBack() {
const appStore = useAppStore()
this.resetInfo()
clearToken()
removeRouteListener()
appStore.clearServerMenu()
},
// Logout
async logout() {
try {
await userLogout()
} 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
}

10
src/types/echarts.ts Normal file
View File

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

11
src/types/env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
// 在这里可以继续补充其他 VITE_ 前缀的环境变量
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

37
src/types/global.ts Normal file
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[] }>
}

5
src/types/mock.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface MockParams {
url: string
type: string
body: string
}

9
src/types/shims.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module '@arco-design/web-vue/es/form' {
export * from '@arco-design/web-vue'
}
declare module '*.png' {
const src: string
export default src
}

9
src/types/vue-i18n.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { Composer } from 'vue-i18n'
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$t: any
}
}
export {}

19
src/utils/auth.ts Normal file
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 }

4
src/utils/env.ts Normal file
View File

@@ -0,0 +1,4 @@
// const debug = import.meta.env.MODE !== 'production'
const debug = true
export default debug

16
src/utils/event.ts Normal file
View File

@@ -0,0 +1,16 @@
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)
}
}

22
src/utils/index.ts Normal file
View File

@@ -0,0 +1,22 @@
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 = new RegExp(
'^(?!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

53
src/utils/is.ts Normal file
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]' && obj === obj // eslint-disable-line
}
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
}

24
src/utils/monitor.ts Normal file
View File

@@ -0,0 +1,24 @@
import { App, ComponentPublicInstance } from 'vue'
import axios from 'axios'
export default function handleError(Vue: App, baseUrl: string) {
if (!baseUrl) {
return
}
Vue.config.errorHandler = (err: unknown, instance: ComponentPublicInstance | null, info: string) => {
// send error info
axios.post(`${baseUrl}/report-error`, {
err,
instance,
info,
// location: window.location.href,
// message: err.message,
// stack: err.stack,
// browserInfo: getBrowserInfo(),
// user info
// dom info
// url info
// ...
})
}
}

View File

@@ -0,0 +1,28 @@
/**
* Listening to routes alone would waste rendering performance. Use the publish-subscribe model for distribution management
* 单独监听路由会浪费渲染性能。使用发布订阅模式去进行分发管理。
*/
import mitt, { Handler } from 'mitt'
import type { RouteLocationNormalized } from 'vue-router'
const emitter = mitt()
const key = Symbol('ROUTE_CHANGE')
let latestRoute: RouteLocationNormalized
export function setRouteEmitter(to: RouteLocationNormalized) {
emitter.emit(key, to)
latestRoute = to
}
export function listenerRouteChange(handler: (route: RouteLocationNormalized) => void, immediate = true) {
emitter.on(key, handler as Handler)
if (immediate && latestRoute) {
handler(latestRoute)
}
}
export function removeRouteListener() {
emitter.off(key)
}

23
src/utils/setup-mock.ts Normal file
View File

@@ -0,0 +1,23 @@
import debug from './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,94 @@
<template>
<div :class="['chat-item', itemData.isCollect ? 'chat-item-collected' : '']">
<a-space :size="4" direction="vertical" fill>
<a-typography-text type="warning">
{{ itemData.username }}
</a-typography-text>
<a-typography-text>{{ itemData.content }}</a-typography-text>
<div class="chat-item-footer">
<div class="chat-item-time">
<a-typography-text type="secondary">
{{ itemData.time }}
</a-typography-text>
</div>
<div class="chat-item-actions">
<div class="chat-item-actions-item">
<icon-command />
</div>
<div class="chat-item-actions-item chat-item-actions-collect">
<icon-star />
</div>
</div>
</div>
</a-space>
</div>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import { ChatRecord } from '@/api/message'
defineProps({
itemData: {
type: Object as PropType<ChatRecord>,
default() {
return {}
},
},
})
</script>
<style scoped lang="less">
.chat-item {
padding: 8px;
font-size: 12px;
line-height: 20px;
border-radius: 2px;
&-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
&-actions {
display: flex;
opacity: 0;
&-item {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 4px;
color: var(--color-text-3);
font-size: 14px;
border-radius: 50%;
cursor: pointer;
&:hover {
background-color: rgb(var(--gray-3));
}
&:last-child {
margin-right: 0;
}
}
}
&-collected {
.chat-item-actions-collect {
color: rgb(var(--gold-6));
}
}
&:hover {
background-color: rgb(var(--gray-2));
.chat-item-actions {
opacity: 1;
}
}
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="chat-list">
<ChatItem v-for="item in renderList" :key="item.id" :item-data="item" />
<a-result v-if="!renderList.length" status="404" />
</div>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import { ChatRecord } from '@/api/message'
import ChatItem from './chat-item.vue'
defineProps({
renderList: {
type: Array as PropType<ChatRecord[]>,
default() {
return []
},
},
})
</script>
<style scoped lang="less">
.chat-item {
padding: 8px;
font-size: 12px;
line-height: 20px;
border-radius: 2px;
&-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
&-actions {
display: flex;
opacity: 0;
&-item {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 4px;
color: var(--color-text-3);
font-size: 14px;
border-radius: 50%;
cursor: pointer;
&:hover {
background-color: rgb(var(--gray-3));
}
&:last-child {
margin-right: 0;
}
}
}
&-collected {
.message-item-actions-collect {
color: rgb(var(--gold-6));
}
}
&:hover {
background-color: rgb(var(--gray-2));
.message-item-actions {
opacity: 1;
}
}
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<a-card
class="general-card chat-panel"
:title="$t('monitor.title.chatPanel')"
:bordered="false"
:header-style="{ paddingBottom: '0' }"
:body-style="{
height: '100%',
paddingTop: '16px',
display: 'flex',
flexFlow: 'column',
}"
>
<a-space :size="8">
<a-select style="width: 86px" default-value="all">
<a-option value="all">
{{ $t('monitor.chat.options.all') }}
</a-option>
</a-select>
<a-input-search :placeholder="$t('monitor.chat.placeholder.searchCategory')" />
<a-button type="text">
<icon-download />
</a-button>
</a-space>
<div class="chat-panel-content">
<a-spin :loading="loading" style="width: 100%">
<ChatList :render-list="chatData" />
</a-spin>
</div>
<div class="chat-panel-footer">
<a-space :size="8">
<a-Input>
<template #suffix>
<icon-face-smile-fill />
</template>
</a-Input>
<a-button type="primary">{{ $t('monitor.chat.update') }}</a-button>
</a-space>
</div>
</a-card>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { queryChatList, ChatRecord } from '@/api/message'
import useLoading from '@/hooks/loading'
import ChatList from './chat-list.vue'
const { loading, setLoading } = useLoading(true)
const chatData = ref<ChatRecord[]>([])
const fetchData = async () => {
try {
const { data } = await queryChatList()
chatData.value = data
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
fetchData()
</script>
<style scoped lang="less">
.chat-panel {
display: flex;
flex-direction: column;
height: 100%;
// padding: 20px;
background-color: var(--color-bg-2);
&-content {
flex: 1;
margin: 20px 0;
}
}
</style>

View File

@@ -0,0 +1,117 @@
<template>
<div>
<a-table
:columns="columns"
:data="data"
row-key="id"
:row-selection="{
type: 'checkbox',
showCheckedAll: true,
}"
:border="false"
:pagination="false"
/>
<a-typography-text type="secondary" class="data-statistic-list-tip">
{{ $t('monitor.list.tip.rotations') }} {{ data.length }}
{{ $t('monitor.list.tip.rest') }}
</a-typography-text>
</div>
</template>
<script lang="ts" setup>
import { computed, h, compile } from 'vue'
import { useI18n } from 'vue-i18n'
import type { TableColumnData, TableData } from '@arco-design/web-vue/es/table/interface.d'
interface PreviewRecord {
cover: string
name: string
duration: string
id: string
status: number
}
const { t } = useI18n()
const data: PreviewRecord[] = [
{
cover: 'http://p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/c788fc704d32cf3b1136c7d45afc2669.png~tplv-uwbnlip3yd-webp.webp',
name: '视频直播',
duration: '00:05:19',
id: '54e23ade',
status: -1,
},
]
const renderTag = (status: number) => {
if (status === -1) {
return `<a-tag color="red" class='data-statistic-list-cover-tag'>
${t('monitor.list.tag.auditFailed')}
</a-tag>`
}
return ''
}
// Using the Render function is more flexible than using templates.
// But, cannot bind context and local scopes are also lost
const columns = computed(() => {
return [
{
title: t('monitor.list.title.order'),
render({ rowIndex }: { record: TableData; column: TableColumnData; rowIndex: number }) {
const tmp = `<span>${rowIndex + 1}</span>`
return h(compile(tmp))
},
},
{
title: t('monitor.list.title.cover'),
render({ record }: { record: TableData; column: TableColumnData; rowIndex: number }) {
const tmp = `<div class='data-statistic-list-cover-wrapper'>
<img src=${record.cover} />
${renderTag(record.status)}
</div>`
return h(compile(tmp))
},
},
{
title: t('monitor.list.title.name'),
dataIndex: 'name',
},
{
dataIndex: 'duration',
title: t('monitor.list.title.duration'),
},
{
dataIndex: 'id',
title: t('monitor.list.title.id'),
},
]
})
</script>
<style lang="less">
// Warning: Here is the global style
.data-statistic {
&-list {
&-cover {
&-wrapper {
position: relative;
height: 68px;
img {
height: 100%;
}
}
&-tag {
position: absolute;
top: 6px;
left: 6px;
}
}
&-tip {
display: block;
margin-top: 16px;
text-align: center;
}
}
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<a-card :bordered="false" :body-style="{ padding: '20px' }">
<a-tabs default-active-tab="liveMethod">
<a-tab-pane key="liveMethod" :title="$t('monitor.tab.title.liveMethod')" />
<a-tab-pane key="onlinePopulation" :title="$t('monitor.tab.title.onlinePopulation')" />
</a-tabs>
<div class="data-statistic-content">
<a-radio-group :default-value="3" type="button">
<a-radio :value="1">{{ $t('monitor.liveMethod.normal') }}</a-radio>
<a-radio :value="2">{{ $t('monitor.liveMethod.flowControl') }}</a-radio>
<a-radio :value="3">{{ $t('monitor.liveMethod.video') }}</a-radio>
<a-radio :value="4">{{ $t('monitor.liveMethod.web') }}</a-radio>
</a-radio-group>
<div class="data-statistic-list-wrapper">
<div class="data-statistic-list-header">
<a-button type="text">{{ $t('monitor.editCarousel') }}</a-button>
<a-button disabled>{{ $t('monitor.startCarousel') }}</a-button>
</div>
<div class="data-statistic-list-content">
<DataStatisticList />
</div>
</div>
</div>
</a-card>
</template>
<script lang="ts" setup>
import DataStatisticList from './data-statistic-list.vue'
</script>
<style scoped lang="less">
.data-statistic {
&-content {
padding: 20px 0;
}
&-list {
&-header {
display: flex;
justify-content: space-between;
margin-top: 16px;
}
&-content {
margin-top: 16px;
}
}
}
</style>

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