chore: 添加设计资料和示例工程
This commit is contained in:
22
sample/front/src/api/dashboard.ts
Normal file
22
sample/front/src/api/dashboard.ts
Normal 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 });
|
||||
}
|
||||
21
sample/front/src/api/form.ts
Normal file
21
sample/front/src/api/form.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export interface BaseInfoModel {
|
||||
activityName: string;
|
||||
channelType: string;
|
||||
promotionTime: string[];
|
||||
promoteLink: string;
|
||||
}
|
||||
export interface ChannelInfoModel {
|
||||
advertisingSource: string;
|
||||
advertisingMedia: string;
|
||||
keyword: string[];
|
||||
pushNotify: boolean;
|
||||
advertisingContent: string;
|
||||
}
|
||||
|
||||
export type UnitChannelModel = BaseInfoModel & ChannelInfoModel;
|
||||
|
||||
export function submitChannelForm(data: UnitChannelModel) {
|
||||
return axios.post('/api/channel-form/submit', { data });
|
||||
}
|
||||
50
sample/front/src/api/list.ts
Normal file
50
sample/front/src/api/list.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { DescData } from '@arco-design/web-vue/es/descriptions/interface';
|
||||
import axios from 'axios';
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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
sample/front/src/api/message.ts
Normal file
38
sample/front/src/api/message.ts
Normal 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
sample/front/src/api/profile.ts
Normal file
49
sample/front/src/api/profile.ts
Normal 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');
|
||||
}
|
||||
117
sample/front/src/api/user.ts
Normal file
117
sample/front/src/api/user.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import axios, { type AxiosProgressEvent } from 'axios';
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
import type { UserState } from '@/store/modules/user/types';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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: AxiosProgressEvent) => void;
|
||||
},
|
||||
) {
|
||||
return axios.post('/api/user/upload', data, config);
|
||||
}
|
||||
73
sample/front/src/api/visualization.ts
Normal file
73
sample/front/src/api/visualization.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import axios from 'axios';
|
||||
import type { 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');
|
||||
}
|
||||
24
sample/front/src/app/App.vue
Normal file
24
sample/front/src/app/App.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<a-config-provider :locale="locale">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import enUS from '@arco-design/web-vue/es/locale/lang/en-us';
|
||||
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn';
|
||||
import { computed } from 'vue';
|
||||
import useLocale from '@/hooks/locale';
|
||||
|
||||
const { currentLocale } = useLocale();
|
||||
const locale = computed(() => {
|
||||
switch (currentLocale.value) {
|
||||
case 'zh-CN':
|
||||
return zhCN;
|
||||
case 'en-US':
|
||||
return enUS;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
22
sample/front/src/app/env.d.ts
vendored
Normal file
22
sample/front/src/app/env.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vue/jsx" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
|
||||
const component: DefineComponent<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
unknown
|
||||
>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_ERROR_REPORT_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
38
sample/front/src/app/main.ts
Normal file
38
sample/front/src/app/main.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import ArcoVue from '@arco-design/web-vue';
|
||||
import ArcoVueIcon from '@arco-design/web-vue/es/icon';
|
||||
import { createApp } from 'vue';
|
||||
import globalComponents from '@/components';
|
||||
import '@/assets/style/global.less';
|
||||
import { setupHttp } from '@/plugins/http';
|
||||
import directive from '@/directive';
|
||||
import i18n from '@/locale';
|
||||
import router from '@/router';
|
||||
import store from '@/store';
|
||||
import setupErrorReport from '@/utils/error-report';
|
||||
import App from './App.vue';
|
||||
|
||||
async function bootstrap() {
|
||||
if (import.meta.env.DEV) {
|
||||
await import('@/mocks');
|
||||
}
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(ArcoVue, {});
|
||||
app.use(ArcoVueIcon);
|
||||
app.use(router);
|
||||
app.use(store);
|
||||
app.use(i18n);
|
||||
app.use(globalComponents);
|
||||
app.use(directive);
|
||||
|
||||
setupHttp();
|
||||
setupErrorReport(
|
||||
app,
|
||||
import.meta.env.VITE_ERROR_REPORT_URL?.trim() ?? '',
|
||||
);
|
||||
|
||||
app.mount('#app');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
BIN
sample/front/src/assets/images/login-banner.png
Normal file
BIN
sample/front/src/assets/images/login-banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
12
sample/front/src/assets/logo.svg
Normal file
12
sample/front/src/assets/logo.svg
Normal 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 |
19
sample/front/src/assets/style/breakpoint.less
Normal file
19
sample/front/src/assets/style/breakpoint.less
Normal 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;
|
||||
94
sample/front/src/assets/style/global.less
Normal file
94
sample/front/src/assets/style/global.less
Normal file
@@ -0,0 +1,94 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
background-color: var(--color-bg-1);
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.echarts-tooltip-diy {
|
||||
background: linear-gradient(
|
||||
304.17deg,
|
||||
rgba(253, 254, 255, 0.6) -6.04%,
|
||||
rgba(244, 247, 252, 0.6) 85.2%
|
||||
) !important;
|
||||
border: none !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
/* Note: backdrop-filter has minimal browser support */
|
||||
|
||||
border-radius: 6px !important;
|
||||
.content-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 9px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
width: 164px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
box-shadow: 6px 0px 20px rgba(34, 87, 188, 0.1);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.tooltip-title {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
.tooltip-title,
|
||||
.tooltip-value {
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: right;
|
||||
color: #1d2129;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tooltip-item-icon {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.general-card {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
& > .arco-card-header {
|
||||
height: auto;
|
||||
padding: 20px;
|
||||
border: none;
|
||||
}
|
||||
& > .arco-card-body {
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.split-line {
|
||||
border-color: rgb(var(--gray-2));
|
||||
}
|
||||
|
||||
.arco-table-cell {
|
||||
.circle {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(var(--blue-6));
|
||||
&.pass {
|
||||
background-color: rgb(var(--green-6));
|
||||
}
|
||||
}
|
||||
}
|
||||
13343
sample/front/src/assets/world.json
Normal file
13343
sample/front/src/assets/world.json
Normal file
File diff suppressed because it is too large
Load Diff
35
sample/front/src/components/breadcrumb/index.vue
Normal file
35
sample/front/src/components/breadcrumb/index.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<a-breadcrumb class="container-breadcrumb">
|
||||
<a-breadcrumb-item>
|
||||
<icon-apps />
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item v-for="item in items" :key="item">
|
||||
{{ $t(item) }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array as PropType<string[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container-breadcrumb {
|
||||
margin: 16px 0;
|
||||
:deep(.arco-breadcrumb-item) {
|
||||
color: rgb(var(--gray-6));
|
||||
&:last-child {
|
||||
color: rgb(var(--gray-8));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
sample/front/src/components/chart/index.vue
Normal file
36
sample/front/src/components/chart/index.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<VChart
|
||||
v-if="renderChart"
|
||||
:option="option"
|
||||
autoresize
|
||||
:style="{ width, height }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
|
||||
defineProps({
|
||||
option: {
|
||||
type: Object as () => EChartsOption,
|
||||
default: () => ({}),
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
const renderChart = ref(false);
|
||||
nextTick(() => {
|
||||
renderChart.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
16
sample/front/src/components/footer/index.vue
Normal file
16
sample/front/src/components/footer/index.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<a-layout-footer class="footer">Arco Pro</a-layout-footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
color: var(--color-text-2);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
35
sample/front/src/components/index.ts
Normal file
35
sample/front/src/components/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
|
||||
import {
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import type { App } from 'vue';
|
||||
import Breadcrumb from './breadcrumb/index.vue';
|
||||
import Chart from './chart/index.vue';
|
||||
|
||||
// Manually introduce ECharts modules to reduce packing size
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
]);
|
||||
|
||||
export default {
|
||||
install(Vue: App) {
|
||||
Vue.component('Chart', Chart);
|
||||
Vue.component('Breadcrumb', Breadcrumb);
|
||||
},
|
||||
};
|
||||
160
sample/front/src/components/menu/index.vue
Normal file
160
sample/front/src/components/menu/index.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script lang="tsx">
|
||||
import { compile, computed, defineComponent, h, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { RouteMeta } from 'vue-router';
|
||||
import { type RouteRecordRaw, useRoute, useRouter } from 'vue-router';
|
||||
import { useAppStore } from '@/store';
|
||||
import { openWindow, regexUrl } from '@/utils';
|
||||
import { listenerRouteChange } from '@/utils/route-listener';
|
||||
import useMenuTree from './use-menu-tree';
|
||||
|
||||
export default defineComponent({
|
||||
emit: ['collapse'],
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { menuTree } = useMenuTree();
|
||||
const collapsed = computed({
|
||||
get() {
|
||||
if (appStore.device === 'desktop') return appStore.menuCollapse;
|
||||
return false;
|
||||
},
|
||||
set(value: boolean) {
|
||||
appStore.updateSettings({ menuCollapse: value });
|
||||
},
|
||||
});
|
||||
|
||||
const topMenu = computed(() => appStore.topMenu);
|
||||
const openKeys = ref<string[]>([]);
|
||||
const selectedKey = ref<string[]>([]);
|
||||
|
||||
const goto = (item: RouteRecordRaw) => {
|
||||
// Open external link
|
||||
if (regexUrl.test(item.path)) {
|
||||
openWindow(item.path);
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// Eliminate external link side effects
|
||||
const { hideInMenu, activeMenu } = item.meta as RouteMeta;
|
||||
if (route.name === item.name && !hideInMenu && !activeMenu) {
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// Trigger router change
|
||||
router.push({
|
||||
name: item.name,
|
||||
});
|
||||
};
|
||||
const findMenuOpenKeys = (target: string) => {
|
||||
const result: string[] = [];
|
||||
let isFind = false;
|
||||
const backtrack = (item: RouteRecordRaw, keys: string[]) => {
|
||||
if (item.name === target) {
|
||||
isFind = true;
|
||||
result.push(...keys);
|
||||
return;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
item.children.forEach((el) => {
|
||||
backtrack(el, [...keys, el.name as string]);
|
||||
});
|
||||
}
|
||||
};
|
||||
menuTree.value.forEach((el: RouteRecordRaw) => {
|
||||
if (isFind) return; // Performance optimization
|
||||
backtrack(el, [el.name as string]);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
listenerRouteChange((newRoute) => {
|
||||
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
|
||||
if (requiresAuth && (!hideInMenu || activeMenu)) {
|
||||
const menuOpenKeys = findMenuOpenKeys(
|
||||
(activeMenu || newRoute.name) as string,
|
||||
);
|
||||
|
||||
const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
|
||||
openKeys.value = [...keySet];
|
||||
|
||||
selectedKey.value = [
|
||||
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
|
||||
];
|
||||
}
|
||||
}, true);
|
||||
const setCollapse = (val: boolean) => {
|
||||
if (appStore.device === 'desktop')
|
||||
appStore.updateSettings({ menuCollapse: val });
|
||||
};
|
||||
|
||||
const renderSubMenu = () => {
|
||||
function travel(_route: RouteRecordRaw[], nodes = []) {
|
||||
if (_route) {
|
||||
_route.forEach((element) => {
|
||||
// This is demo, modify nodes as needed
|
||||
const icon = element?.meta?.icon
|
||||
? () => h(compile(`<${element?.meta?.icon}/>`))
|
||||
: null;
|
||||
const node =
|
||||
element?.children && element?.children.length !== 0 ? (
|
||||
<a-sub-menu
|
||||
key={element?.name}
|
||||
v-slots={{
|
||||
icon,
|
||||
title: () => h(compile(t(element?.meta?.locale || ''))),
|
||||
}}
|
||||
>
|
||||
{travel(element?.children)}
|
||||
</a-sub-menu>
|
||||
) : (
|
||||
<a-menu-item
|
||||
key={element?.name}
|
||||
v-slots={{ icon }}
|
||||
onClick={() => goto(element)}
|
||||
>
|
||||
{t(element?.meta?.locale || '')}
|
||||
</a-menu-item>
|
||||
);
|
||||
nodes.push(node as never);
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
return travel(menuTree.value);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<a-menu
|
||||
mode={topMenu.value ? 'horizontal' : 'vertical'}
|
||||
v-model:collapsed={collapsed.value}
|
||||
v-model:open-keys={openKeys.value}
|
||||
show-collapse-button={appStore.device !== 'mobile'}
|
||||
auto-open={false}
|
||||
selected-keys={selectedKey.value}
|
||||
auto-open-selected={true}
|
||||
level-indent={34}
|
||||
style="height: 100%;width:100%;"
|
||||
onCollapse={setCollapse}
|
||||
>
|
||||
{renderSubMenu()}
|
||||
</a-menu>
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.arco-menu-inner) {
|
||||
.arco-menu-inline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.arco-icon {
|
||||
&:not(.arco-icon-down) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
69
sample/front/src/components/menu/use-menu-tree.ts
Normal file
69
sample/front/src/components/menu/use-menu-tree.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { computed } from 'vue';
|
||||
import type { RouteRecordNormalized, RouteRecordRaw } from 'vue-router';
|
||||
import usePermission from '@/hooks/permission';
|
||||
import appClientMenus from '@/router/app-menus';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
export default function useMenuTree() {
|
||||
const permission = usePermission();
|
||||
const appStore = useAppStore();
|
||||
const appRoute = computed(() => {
|
||||
if (appStore.menuFromServer) {
|
||||
return appStore.appAsyncMenus;
|
||||
}
|
||||
return appClientMenus;
|
||||
});
|
||||
const menuTree = computed(() => {
|
||||
const copyRouter = cloneDeep(appRoute.value) as RouteRecordNormalized[];
|
||||
copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => {
|
||||
return (a.meta.order || 0) - (b.meta.order || 0);
|
||||
});
|
||||
function travel(_routes: RouteRecordRaw[], layer: number) {
|
||||
if (!_routes) return null;
|
||||
|
||||
const collector: any = _routes.map((element) => {
|
||||
// no access
|
||||
if (!permission.accessRouter(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// leaf node
|
||||
if (element.meta?.hideChildrenInMenu || !element.children) {
|
||||
element.children = [];
|
||||
return element;
|
||||
}
|
||||
|
||||
// route filter hideInMenu true
|
||||
element.children = element.children.filter(
|
||||
(x) => x.meta?.hideInMenu !== true,
|
||||
);
|
||||
|
||||
// Associated child node
|
||||
const subItem = travel(element.children, layer + 1);
|
||||
|
||||
if (subItem.length) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
// the else logic
|
||||
if (layer > 1) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
|
||||
if (element.meta?.hideInMenu === false) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
return collector.filter(Boolean);
|
||||
}
|
||||
return travel(copyRouter, 0);
|
||||
});
|
||||
|
||||
return {
|
||||
menuTree,
|
||||
};
|
||||
}
|
||||
129
sample/front/src/components/message-box/index.vue
Normal file
129
sample/front/src/components/message-box/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<a-spin style="display: block" :loading="loading">
|
||||
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
|
||||
<a-tab-pane v-for="item in tabList" :key="item.key">
|
||||
<template #title>
|
||||
<span> {{ item.title }}{{ formatUnreadLength(item.key) }} </span>
|
||||
</template>
|
||||
<a-result v-if="!renderList.length" status="404">
|
||||
<template #subtitle> {{ $t('messageBox.noContent') }} </template>
|
||||
</a-result>
|
||||
<List
|
||||
:render-list="renderList"
|
||||
:unread-count="unreadCount"
|
||||
@item-click="handleItemClick"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<template #extra>
|
||||
<a-button type="text" @click="emptyList">
|
||||
{{ $t('messageBox.tab.button') }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-tabs>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, toRefs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
type MessageListType,
|
||||
type MessageRecord,
|
||||
queryMessageList,
|
||||
setMessageStatus,
|
||||
} from '@/api/message';
|
||||
import useLoading from '@/hooks/loading';
|
||||
import List from './list.vue';
|
||||
|
||||
interface TabItem {
|
||||
key: string;
|
||||
title: string;
|
||||
avatar?: string;
|
||||
}
|
||||
const { loading, setLoading } = useLoading(true);
|
||||
const messageType = ref('message');
|
||||
const { t } = useI18n();
|
||||
const messageData = reactive<{
|
||||
renderList: MessageRecord[];
|
||||
messageList: MessageRecord[];
|
||||
}>({
|
||||
renderList: [],
|
||||
messageList: [],
|
||||
});
|
||||
toRefs(messageData);
|
||||
const tabList: TabItem[] = [
|
||||
{
|
||||
key: 'message',
|
||||
title: t('messageBox.tab.title.message'),
|
||||
},
|
||||
{
|
||||
key: 'notice',
|
||||
title: t('messageBox.tab.title.notice'),
|
||||
},
|
||||
{
|
||||
key: 'todo',
|
||||
title: t('messageBox.tab.title.todo'),
|
||||
},
|
||||
];
|
||||
async function fetchSourceData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
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>
|
||||
149
sample/front/src/components/message-box/list.vue
Normal file
149
sample/front/src/components/message-box/list.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<a-list :bordered="false">
|
||||
<a-list-item
|
||||
v-for="item in renderList"
|
||||
:key="item.id"
|
||||
action-layout="vertical"
|
||||
:style="{
|
||||
opacity: item.status ? 0.5 : 1,
|
||||
}"
|
||||
>
|
||||
<template #extra>
|
||||
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>
|
||||
</template>
|
||||
<div class="item-wrap" @click="onItemClick(item)">
|
||||
<a-list-item-meta>
|
||||
<template v-if="item.avatar" #avatar>
|
||||
<a-avatar shape="circle">
|
||||
<img v-if="item.avatar" :src="item.avatar" />
|
||||
<icon-desktop v-else />
|
||||
</a-avatar>
|
||||
</template>
|
||||
<template #title>
|
||||
<a-space :size="4">
|
||||
<span>{{ item.title }}</span>
|
||||
<a-typography-text type="secondary">
|
||||
{{ item.subTitle }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #description>
|
||||
<div>
|
||||
<a-typography-paragraph
|
||||
:ellipsis="{
|
||||
rows: 1,
|
||||
}"
|
||||
>{{ item.content }}</a-typography-paragraph
|
||||
>
|
||||
<a-typography-text
|
||||
v-if="item.type === 'message'"
|
||||
class="time-text"
|
||||
>
|
||||
{{ item.time }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</div>
|
||||
</a-list-item>
|
||||
<template #footer>
|
||||
<a-space
|
||||
fill
|
||||
:size="0"
|
||||
:class="{ 'add-border-top': renderList.length < showMax }"
|
||||
>
|
||||
<div class="footer-wrap">
|
||||
<a-link @click="allRead">{{ $t('messageBox.allRead') }}</a-link>
|
||||
</div>
|
||||
<div class="footer-wrap">
|
||||
<a-link>{{ $t('messageBox.viewMore') }}</a-link>
|
||||
</div>
|
||||
</a-space>
|
||||
</template>
|
||||
<div
|
||||
v-if="renderList.length && renderList.length < 3"
|
||||
:style="{ height: (showMax - renderList.length) * 86 + 'px' }"
|
||||
></div>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
import type { MessageListType, MessageRecord } from '@/api/message';
|
||||
|
||||
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>
|
||||
13
sample/front/src/components/message-box/locale/en-US.ts
Normal file
13
sample/front/src/components/message-box/locale/en-US.ts
Normal 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',
|
||||
};
|
||||
13
sample/front/src/components/message-box/locale/zh-CN.ts
Normal file
13
sample/front/src/components/message-box/locale/zh-CN.ts
Normal 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': '登出登录',
|
||||
};
|
||||
368
sample/front/src/components/navbar/index.vue
Normal file
368
sample/front/src/components/navbar/index.vue
Normal file
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="navbar">
|
||||
<div class="left-side">
|
||||
<a-space>
|
||||
<img
|
||||
alt="logo"
|
||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image"
|
||||
/>
|
||||
<a-typography-title
|
||||
:style="{ margin: 0, fontSize: '18px' }"
|
||||
:heading="5"
|
||||
>
|
||||
Arco Pro
|
||||
</a-typography-title>
|
||||
<icon-menu-fold
|
||||
v-if="!topMenu && appStore.device === 'mobile'"
|
||||
style="font-size: 22px; cursor: pointer"
|
||||
@click="toggleDrawerMenu"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="center-side">
|
||||
<Menu v-if="topMenu" class="center-menu" />
|
||||
<div class="navbar-search">
|
||||
<a-input
|
||||
v-model="searchKeyword"
|
||||
:placeholder="$t('settings.searchPlaceholder')"
|
||||
allow-clear
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
class="navbar-search-btn"
|
||||
@click="handleSearch"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-search />
|
||||
</template>
|
||||
{{ $t('settings.search') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="right-side">
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.language')">
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="setDropDownVisible"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-language />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-dropdown trigger="click" @select="changeLocale as any">
|
||||
<div ref="triggerBtn" class="trigger-btn"></div>
|
||||
<template #content>
|
||||
<a-doption
|
||||
v-for="item in locales"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-check v-show="item.value === currentLocale" />
|
||||
</template>
|
||||
{{ item.label }}
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip
|
||||
:content="
|
||||
theme === 'light'
|
||||
? $t('settings.navbar.theme.toDark')
|
||||
: $t('settings.navbar.theme.toLight')
|
||||
"
|
||||
>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="handleToggleTheme"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-moon-fill v-if="theme === 'dark'" />
|
||||
<icon-sun-fill v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.navbar.alerts')">
|
||||
<div class="message-box-trigger">
|
||||
<a-badge :count="9" dot>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="setPopoverVisible"
|
||||
>
|
||||
<icon-notification />
|
||||
</a-button>
|
||||
</a-badge>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-popover
|
||||
trigger="click"
|
||||
:arrow-style="{ display: 'none' }"
|
||||
:content-style="{ padding: 0, minWidth: '400px' }"
|
||||
content-class="message-popover"
|
||||
>
|
||||
<div ref="refBtn" class="ref-btn"></div>
|
||||
<template #content>
|
||||
<message-box />
|
||||
</template>
|
||||
</a-popover>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip
|
||||
:content="
|
||||
isFullscreen
|
||||
? $t('settings.navbar.screen.toExit')
|
||||
: $t('settings.navbar.screen.toFull')
|
||||
"
|
||||
>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="toggleFullScreen"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-fullscreen-exit v-if="isFullscreen" />
|
||||
<icon-fullscreen v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-dropdown trigger="click">
|
||||
<a-avatar
|
||||
:size="32"
|
||||
:style="{ marginRight: '8px', cursor: 'pointer' }"
|
||||
>
|
||||
<img alt="avatar" :src="avatar" />
|
||||
</a-avatar>
|
||||
<template #content>
|
||||
<a-doption>
|
||||
<a-space @click="switchRoles">
|
||||
<icon-tag />
|
||||
<span>
|
||||
{{ $t('messageBox.switchRoles') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
<a-doption>
|
||||
<a-space @click="$router.push({ name: 'Info' })">
|
||||
<icon-user />
|
||||
<span>
|
||||
{{ $t('messageBox.userCenter') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
<a-doption>
|
||||
<a-space @click="$router.push({ name: 'Setting' })">
|
||||
<icon-settings />
|
||||
<span>
|
||||
{{ $t('messageBox.userSettings') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
<a-doption>
|
||||
<a-space @click="handleLogout">
|
||||
<icon-export />
|
||||
<span>
|
||||
{{ $t('messageBox.logout') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useDark, useFullscreen, useToggle } from '@vueuse/core';
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import Menu from '@/components/menu/index.vue';
|
||||
import { resolveAvatarUrl } from '@/constants/avatar';
|
||||
import useLocale from '@/hooks/locale';
|
||||
import useUser from '@/hooks/user';
|
||||
import { LOCALE_OPTIONS } from '@/locale';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
import MessageBox from '../message-box/index.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const { logout } = useUser();
|
||||
const { changeLocale, currentLocale } = useLocale();
|
||||
const { isFullscreen, toggle: toggleFullScreen } = useFullscreen();
|
||||
const locales = [...LOCALE_OPTIONS];
|
||||
const searchKeyword = ref('');
|
||||
const avatar = computed(() => resolveAvatarUrl(userStore.avatar));
|
||||
const theme = computed(() => {
|
||||
return appStore.theme;
|
||||
});
|
||||
const topMenu = computed(() => appStore.topMenu && appStore.menu);
|
||||
const isDark = useDark({
|
||||
selector: 'body',
|
||||
attribute: 'arco-theme',
|
||||
valueDark: 'dark',
|
||||
valueLight: 'light',
|
||||
storageKey: 'arco-theme',
|
||||
onChanged(dark: boolean) {
|
||||
// overridden default behavior
|
||||
appStore.toggleTheme(dark);
|
||||
},
|
||||
});
|
||||
const toggleTheme = useToggle(isDark);
|
||||
const handleToggleTheme = () => {
|
||||
toggleTheme();
|
||||
};
|
||||
const handleSearch = () => {
|
||||
const keyword = searchKeyword.value.trim();
|
||||
if (!keyword) {
|
||||
return;
|
||||
}
|
||||
Message.info(`${keyword}`);
|
||||
};
|
||||
const refBtn = ref();
|
||||
const triggerBtn = ref();
|
||||
const setPopoverVisible = () => {
|
||||
const event = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
refBtn.value.dispatchEvent(event);
|
||||
};
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
const setDropDownVisible = () => {
|
||||
const event = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
triggerBtn.value.dispatchEvent(event);
|
||||
};
|
||||
const switchRoles = async () => {
|
||||
const res = await userStore.switchRoles();
|
||||
Message.success(res as string);
|
||||
};
|
||||
const toggleDrawerMenu = inject('toggleDrawerMenu') as () => void;
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.left-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.center-side {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.center-menu {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 4px 4px 4px 16px;
|
||||
background-color: var(--color-fill-2);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgb(var(--primary-6));
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.12);
|
||||
}
|
||||
|
||||
:deep(.arco-input-outer) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.arco-input-wrapper) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.navbar-search-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-side {
|
||||
display: flex;
|
||||
padding-right: 20px;
|
||||
list-style: none;
|
||||
:deep(.locale-select) {
|
||||
border-radius: 20px;
|
||||
}
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-1);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-btn {
|
||||
border-color: rgb(var(--gray-2));
|
||||
color: rgb(var(--gray-8));
|
||||
font-size: 16px;
|
||||
}
|
||||
.trigger-btn,
|
||||
.ref-btn {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
}
|
||||
.trigger-btn {
|
||||
margin-left: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.message-popover {
|
||||
.arco-popover-content {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
101
sample/front/src/components/tab-bar/index.vue
Normal file
101
sample/front/src/components/tab-bar/index.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="tab-bar-container">
|
||||
<a-affix ref="affixRef" :offset-top="offsetTop">
|
||||
<div class="tab-bar-box">
|
||||
<div class="tab-bar-scroll">
|
||||
<div class="tags-wrap">
|
||||
<tab-item
|
||||
v-for="(tag, index) in tagList"
|
||||
:key="tag.fullPath"
|
||||
:index="index"
|
||||
:item-data="tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag-bar-operation"></div>
|
||||
</div>
|
||||
</a-affix>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import { useAppStore, useTabBarStore } from '@/store';
|
||||
import {
|
||||
listenerRouteChange,
|
||||
removeRouteListener,
|
||||
} from '@/utils/route-listener';
|
||||
import tabItem from './tab-item.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const affixRef = ref();
|
||||
const tagList = computed(() => {
|
||||
return tabBarStore.getTabList;
|
||||
});
|
||||
const offsetTop = computed(() => {
|
||||
return appStore.navbar ? 60 : 0;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => appStore.navbar,
|
||||
() => {
|
||||
affixRef.value.updatePosition();
|
||||
},
|
||||
);
|
||||
listenerRouteChange((route: RouteLocationNormalized) => {
|
||||
if (
|
||||
!route.meta.noAffix &&
|
||||
!tagList.value.some((tag) => tag.fullPath === route.fullPath)
|
||||
) {
|
||||
tabBarStore.updateTabList(route);
|
||||
}
|
||||
}, true);
|
||||
|
||||
onUnmounted(() => {
|
||||
removeRouteListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tab-bar-container {
|
||||
position: relative;
|
||||
background-color: var(--color-bg-2);
|
||||
.tab-bar-box {
|
||||
display: flex;
|
||||
padding: 0 0 0 20px;
|
||||
background-color: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
.tab-bar-scroll {
|
||||
height: 32px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
.tags-wrap {
|
||||
padding: 4px 0;
|
||||
height: 48px;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
|
||||
:deep(.arco-tag) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
cursor: pointer;
|
||||
&:first-child {
|
||||
.arco-tag-close-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag-bar-operation {
|
||||
width: 100px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
sample/front/src/components/tab-bar/readme.md
Normal file
12
sample/front/src/components/tab-bar/readme.md
Normal file
@@ -0,0 +1,12 @@
|
||||
## 组件说明
|
||||
|
||||
该组件非官方最终设计规范,以单独组件存在。
|
||||
|
||||
同时仅仅提供最基本的功能,后续进行优化及更改。
|
||||
|
||||
|
||||
## Component description
|
||||
|
||||
The component unofficial final design specification exists as a separate component.
|
||||
|
||||
At the same time, only the most basic functions are provided, and subsequent optimizations and changes will be made.
|
||||
199
sample/front/src/components/tab-bar/tab-item.vue
Normal file
199
sample/front/src/components/tab-bar/tab-item.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<a-dropdown
|
||||
trigger="contextMenu"
|
||||
:popup-max-height="false"
|
||||
@select="actionSelect"
|
||||
>
|
||||
<span
|
||||
class="arco-tag arco-tag-size-medium arco-tag-checked"
|
||||
:class="{ 'link-activated': itemData.fullPath === $route.fullPath }"
|
||||
@click="goto(itemData)"
|
||||
>
|
||||
<span class="tag-link">
|
||||
{{ $t(itemData.title) }}
|
||||
</span>
|
||||
<span
|
||||
class="arco-icon-hover arco-tag-icon-hover arco-icon-hover-size-medium arco-tag-close-btn"
|
||||
@click.stop="tagClose(itemData, index)"
|
||||
>
|
||||
<icon-close />
|
||||
</span>
|
||||
</span>
|
||||
<template #content>
|
||||
<a-doption :disabled="disabledReload" :value="TabAction.reload">
|
||||
<icon-refresh />
|
||||
<span>刷新</span>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
class="sperate-line"
|
||||
:disabled="disabledCurrent"
|
||||
:value="TabAction.current"
|
||||
>
|
||||
<icon-close />
|
||||
<span>关闭</span>
|
||||
</a-doption>
|
||||
<a-doption :disabled="disabledLeft" :value="TabAction.left">
|
||||
<icon-to-left />
|
||||
<span>向左靠</span>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
class="sperate-line"
|
||||
:disabled="disabledRight"
|
||||
:value="TabAction.right"
|
||||
>
|
||||
<icon-to-right />
|
||||
<span>向右靠</span>
|
||||
</a-doption>
|
||||
<a-doption :value="TabAction.others">
|
||||
<icon-swap />
|
||||
<span>其它</span>
|
||||
</a-doption>
|
||||
<a-doption :value="TabAction.all">
|
||||
<icon-folder-delete />
|
||||
<span>删除</span>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, type PropType } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { DEFAULT_ROUTE_NAME, REDIRECT_ROUTE_NAME } from '@/router/constants';
|
||||
import { useTabBarStore } from '@/store';
|
||||
import type { TagProps } from '@/store/modules/tab-bar/types';
|
||||
|
||||
enum TabAction {
|
||||
reload = 'reload',
|
||||
current = 'current',
|
||||
left = 'left',
|
||||
right = 'right',
|
||||
others = 'others',
|
||||
all = 'all',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
itemData: {
|
||||
type: Object as PropType<TagProps>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const goto = (tag: TagProps) => {
|
||||
router.push({ ...tag });
|
||||
};
|
||||
const tagList = computed(() => {
|
||||
return tabBarStore.getTabList;
|
||||
});
|
||||
|
||||
const disabledReload = computed(() => {
|
||||
return props.itemData.fullPath !== route.fullPath;
|
||||
});
|
||||
|
||||
const disabledCurrent = computed(() => {
|
||||
return props.index === 0;
|
||||
});
|
||||
|
||||
const disabledLeft = computed(() => {
|
||||
return [0, 1].includes(props.index);
|
||||
});
|
||||
|
||||
const disabledRight = computed(() => {
|
||||
return props.index === tagList.value.length - 1;
|
||||
});
|
||||
|
||||
const tagClose = (tag: TagProps, idx: number) => {
|
||||
tabBarStore.deleteTag(idx, tag);
|
||||
if (props.itemData.fullPath === route.fullPath) {
|
||||
const latest = tagList.value[idx - 1]; // 获取队列的前一个tab
|
||||
router.push({ name: latest.name });
|
||||
}
|
||||
};
|
||||
|
||||
const findCurrentRouteIndex = () => {
|
||||
return tagList.value.findIndex((el) => el.fullPath === route.fullPath);
|
||||
};
|
||||
const actionSelect = async (value: any) => {
|
||||
const { itemData, index } = props;
|
||||
const copyTagList = [...tagList.value];
|
||||
if (value === TabAction.current) {
|
||||
tagClose(itemData, index);
|
||||
} else if (value === TabAction.left) {
|
||||
const currentRouteIdx = findCurrentRouteIndex();
|
||||
copyTagList.splice(1, props.index - 1);
|
||||
|
||||
tabBarStore.freshTabList(copyTagList);
|
||||
if (currentRouteIdx < index) {
|
||||
router.push({ name: itemData.name });
|
||||
}
|
||||
} else if (value === TabAction.right) {
|
||||
const currentRouteIdx = findCurrentRouteIndex();
|
||||
copyTagList.splice(props.index + 1);
|
||||
|
||||
tabBarStore.freshTabList(copyTagList);
|
||||
if (currentRouteIdx > index) {
|
||||
router.push({ name: itemData.name });
|
||||
}
|
||||
} else if (value === TabAction.others) {
|
||||
const filterList = tagList.value.filter((el, idx) => {
|
||||
return idx === 0 || idx === props.index;
|
||||
});
|
||||
tabBarStore.freshTabList(filterList);
|
||||
router.push({ name: itemData.name });
|
||||
} else if (value === TabAction.reload) {
|
||||
tabBarStore.deleteCache(itemData);
|
||||
await router.push({
|
||||
name: REDIRECT_ROUTE_NAME,
|
||||
params: {
|
||||
path: route.fullPath,
|
||||
},
|
||||
});
|
||||
tabBarStore.addCache(itemData.name);
|
||||
} else {
|
||||
tabBarStore.resetTabList();
|
||||
router.push({ name: DEFAULT_ROUTE_NAME });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tag-link {
|
||||
color: var(--color-text-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.link-activated {
|
||||
color: rgb(var(--link-6));
|
||||
.tag-link {
|
||||
color: rgb(var(--link-6));
|
||||
}
|
||||
& + .arco-tag-close-btn {
|
||||
color: rgb(var(--link-6));
|
||||
}
|
||||
}
|
||||
:deep(.arco-dropdown-option-content) {
|
||||
span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
.arco-dropdown-open {
|
||||
.tag-link {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
.arco-tag-close-btn {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
}
|
||||
.sperate-line {
|
||||
border-bottom: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
</style>
|
||||
16
sample/front/src/config/settings.json
Normal file
16
sample/front/src/config/settings.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"theme": "light",
|
||||
"colorWeak": false,
|
||||
"navbar": true,
|
||||
"menu": true,
|
||||
"topMenu": false,
|
||||
"hideMenu": false,
|
||||
"menuCollapse": false,
|
||||
"footer": true,
|
||||
"themeColor": "#165DFF",
|
||||
"menuWidth": 220,
|
||||
"device": "desktop",
|
||||
"tabBar": false,
|
||||
"menuFromServer": false,
|
||||
"serverMenu": []
|
||||
}
|
||||
14
sample/front/src/constants/avatar.ts
Normal file
14
sample/front/src/constants/avatar.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Local default avatar: linear neutral user icon. */
|
||||
export const DEFAULT_USER_AVATAR = '/avatar-default.svg';
|
||||
|
||||
const BROKEN_AVATAR_PATTERN = /pstatp\.com|vcloud\/vadmin/;
|
||||
|
||||
export function resolveAvatarUrl(url?: string) {
|
||||
if (!url || BROKEN_AVATAR_PATTERN.test(url)) {
|
||||
return DEFAULT_USER_AVATAR;
|
||||
}
|
||||
if (url.startsWith('//')) {
|
||||
return `https:${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
8
sample/front/src/directive/index.ts
Normal file
8
sample/front/src/directive/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { App } from 'vue';
|
||||
import permission from './permission';
|
||||
|
||||
export default {
|
||||
install(Vue: App) {
|
||||
Vue.directive('permission', permission);
|
||||
},
|
||||
};
|
||||
30
sample/front/src/directive/permission/index.ts
Normal file
30
sample/front/src/directive/permission/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { DirectiveBinding } from 'vue';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
function checkPermission(el: HTMLElement, binding: DirectiveBinding) {
|
||||
const { value } = binding;
|
||||
const userStore = useUserStore();
|
||||
const { role } = userStore;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
const permissionValues = value;
|
||||
|
||||
const hasPermission = permissionValues.includes(role);
|
||||
if (!hasPermission && el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`need roles! Like v-permission="['admin','user']"`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
updated(el: HTMLElement, binding: DirectiveBinding) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
};
|
||||
25
sample/front/src/hooks/chart-option.ts
Normal file
25
sample/front/src/hooks/chart-option.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { computed } from 'vue';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
// for code hints
|
||||
// import { SeriesOption } from 'echarts';
|
||||
// Because there are so many configuration items, this provides a relatively convenient code hint.
|
||||
// When using vue, pay attention to the reactive issues. It is necessary to ensure that corresponding functions can be triggered, TypeScript does not report errors, and code writing is convenient.
|
||||
type optionsFn = (isDark: boolean) => EChartsOption;
|
||||
|
||||
export default function useChartOption(sourceOption: optionsFn) {
|
||||
const appStore = useAppStore();
|
||||
const isDark = computed(() => {
|
||||
return appStore.theme === 'dark';
|
||||
});
|
||||
// echarts support https://echarts.apache.org/zh/theme-builder.html
|
||||
// It's not used here
|
||||
// TODO echarts themes
|
||||
const chartOption = computed<EChartsOption>(() => {
|
||||
return sourceOption(isDark.value);
|
||||
});
|
||||
return {
|
||||
chartOption,
|
||||
};
|
||||
}
|
||||
16
sample/front/src/hooks/loading.ts
Normal file
16
sample/front/src/hooks/loading.ts
Normal 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
sample/front/src/hooks/locale.ts
Normal file
22
sample/front/src/hooks/locale.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
33
sample/front/src/hooks/permission.ts
Normal file
33
sample/front/src/hooks/permission.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export default function usePermission() {
|
||||
const userStore = useUserStore();
|
||||
return {
|
||||
accessRouter(route: RouteLocationNormalized | RouteRecordRaw) {
|
||||
return (
|
||||
!route.meta?.requiresAuth ||
|
||||
!route.meta?.roles ||
|
||||
route.meta?.roles?.includes('*') ||
|
||||
route.meta?.roles?.includes(userStore.role)
|
||||
);
|
||||
},
|
||||
findFirstPermissionRoute(_routers: any, role = 'admin') {
|
||||
const cloneRouters = [..._routers];
|
||||
while (cloneRouters.length) {
|
||||
const firstElement = cloneRouters.shift();
|
||||
if (
|
||||
firstElement?.meta?.roles?.find((el: string[]) => {
|
||||
return el.includes('*') || el.includes(role);
|
||||
})
|
||||
)
|
||||
return { name: firstElement.name };
|
||||
if (firstElement?.children) {
|
||||
cloneRouters.push(...firstElement.children);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// You can add any rules you want
|
||||
};
|
||||
}
|
||||
26
sample/front/src/hooks/request.ts
Normal file
26
sample/front/src/hooks/request.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import { ref, type UnwrapRef } from 'vue';
|
||||
import type { HttpResponse } from '@/plugins/http';
|
||||
import useLoading from './loading';
|
||||
|
||||
// use to fetch list
|
||||
// Don't use async function. It doesn't work in async function.
|
||||
// Use the bind function to add parameters
|
||||
// example: useRequest(api.bind(null, {}))
|
||||
|
||||
export default function useRequest<T>(
|
||||
api: () => Promise<AxiosResponse<HttpResponse>>,
|
||||
defaultValue = [] as unknown as T,
|
||||
isLoading = true,
|
||||
) {
|
||||
const { loading, setLoading } = useLoading(isLoading);
|
||||
const response = ref<T>(defaultValue);
|
||||
api()
|
||||
.then((res) => {
|
||||
response.value = res.data as unknown as UnwrapRef<T>;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
return { loading, response };
|
||||
}
|
||||
32
sample/front/src/hooks/responsive.ts
Normal file
32
sample/front/src/hooks/responsive.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { onBeforeMount, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { useAppStore } from '@/store';
|
||||
import { addEventListen, removeEventListen } from '@/utils/event';
|
||||
|
||||
const WIDTH = 992; // https://arco.design/vue/component/grid#responsivevalue
|
||||
|
||||
function queryDevice() {
|
||||
const rect = document.body.getBoundingClientRect();
|
||||
return rect.width - 1 < WIDTH;
|
||||
}
|
||||
|
||||
export default function useResponsive(immediate?: boolean) {
|
||||
const appStore = useAppStore();
|
||||
function resizeHandler() {
|
||||
if (!document.hidden) {
|
||||
const isMobile = queryDevice();
|
||||
appStore.toggleDevice(isMobile ? 'mobile' : 'desktop');
|
||||
appStore.toggleMenu(isMobile);
|
||||
}
|
||||
}
|
||||
const debounceFn = useDebounceFn(resizeHandler, 100);
|
||||
onMounted(() => {
|
||||
if (immediate) debounceFn();
|
||||
});
|
||||
onBeforeMount(() => {
|
||||
addEventListen(window, 'resize', debounceFn);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
removeEventListen(window, 'resize', debounceFn);
|
||||
});
|
||||
}
|
||||
12
sample/front/src/hooks/themes.ts
Normal file
12
sample/front/src/hooks/themes.ts
Normal 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
sample/front/src/hooks/user.ts
Normal file
24
sample/front/src/hooks/user.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export default function useUser() {
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const logout = async (logoutTo?: string) => {
|
||||
await userStore.logout();
|
||||
const currentRoute = router.currentRoute.value;
|
||||
Message.success('登出成功');
|
||||
router.push({
|
||||
name: logoutTo && typeof logoutTo === 'string' ? logoutTo : 'login',
|
||||
query: {
|
||||
...router.currentRoute.value.query,
|
||||
redirect: currentRoute.name as string,
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
logout,
|
||||
};
|
||||
}
|
||||
178
sample/front/src/layout/default-layout.vue
Normal file
178
sample/front/src/layout/default-layout.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<a-layout class="layout" :class="{ mobile: appStore.hideMenu }">
|
||||
<div v-if="navbar" class="layout-navbar">
|
||||
<NavBar />
|
||||
</div>
|
||||
<a-layout>
|
||||
<a-layout>
|
||||
<a-layout-sider
|
||||
v-if="renderMenu"
|
||||
v-show="!hideMenu"
|
||||
class="layout-sider"
|
||||
breakpoint="xl"
|
||||
:collapsed="collapsed"
|
||||
:collapsible="true"
|
||||
:width="menuWidth"
|
||||
:style="{ paddingTop: navbar ? '60px' : '' }"
|
||||
:hide-trigger="true"
|
||||
@collapse="setCollapsed"
|
||||
>
|
||||
<div class="menu-wrapper">
|
||||
<Menu />
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
<a-drawer
|
||||
v-if="hideMenu"
|
||||
:visible="drawerVisible"
|
||||
placement="left"
|
||||
:footer="false"
|
||||
mask-closable
|
||||
:closable="false"
|
||||
@cancel="drawerCancel"
|
||||
>
|
||||
<Menu />
|
||||
</a-drawer>
|
||||
<a-layout class="layout-content" :style="paddingStyle">
|
||||
<TabBar v-if="appStore.tabBar" />
|
||||
<a-layout-content>
|
||||
<PageLayout />
|
||||
</a-layout-content>
|
||||
<Footer v-if="footer" />
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, provide, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Footer from '@/components/footer/index.vue';
|
||||
import Menu from '@/components/menu/index.vue';
|
||||
import NavBar from '@/components/navbar/index.vue';
|
||||
import TabBar from '@/components/tab-bar/index.vue';
|
||||
import usePermission from '@/hooks/permission';
|
||||
import useResponsive from '@/hooks/responsive';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
import PageLayout from './page-layout.vue';
|
||||
|
||||
const isInit = ref(false);
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const permission = usePermission();
|
||||
useResponsive(true);
|
||||
const navbarHeight = `60px`;
|
||||
const navbar = computed(() => appStore.navbar);
|
||||
const renderMenu = computed(() => appStore.menu && !appStore.topMenu);
|
||||
const hideMenu = computed(() => appStore.hideMenu);
|
||||
const footer = computed(() => appStore.footer);
|
||||
const menuWidth = computed(() => {
|
||||
return appStore.menuCollapse ? 48 : appStore.menuWidth;
|
||||
});
|
||||
const collapsed = computed(() => {
|
||||
return appStore.menuCollapse;
|
||||
});
|
||||
const paddingStyle = computed(() => {
|
||||
const paddingLeft =
|
||||
renderMenu.value && !hideMenu.value
|
||||
? { paddingLeft: `${menuWidth.value}px` }
|
||||
: {};
|
||||
const paddingTop = navbar.value ? { paddingTop: navbarHeight } : {};
|
||||
return { ...paddingLeft, ...paddingTop };
|
||||
});
|
||||
const setCollapsed = (val: boolean) => {
|
||||
if (!isInit.value) return; // for page initialization menu state problem
|
||||
appStore.updateSettings({ menuCollapse: val });
|
||||
};
|
||||
watch(
|
||||
() => userStore.role,
|
||||
(roleValue) => {
|
||||
if (roleValue && !permission.accessRouter(route))
|
||||
router.push({ name: 'notFound' });
|
||||
},
|
||||
);
|
||||
const drawerVisible = ref(false);
|
||||
const drawerCancel = () => {
|
||||
drawerVisible.value = false;
|
||||
};
|
||||
provide('toggleDrawerMenu', () => {
|
||||
drawerVisible.value = !drawerVisible.value;
|
||||
});
|
||||
onMounted(() => {
|
||||
isInit.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@nav-size-height: 60px;
|
||||
@layout-max-width: 1100px;
|
||||
|
||||
.layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: @nav-size-height;
|
||||
}
|
||||
|
||||
.layout-sider {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 99;
|
||||
height: 100%;
|
||||
transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -1px;
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-color: var(--color-border);
|
||||
content: '';
|
||||
}
|
||||
|
||||
> :deep(.arco-layout-sider-children) {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-wrapper {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
:deep(.arco-menu) {
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 4px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 7px;
|
||||
background-color: var(--color-text-4);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
min-height: 100vh;
|
||||
overflow-y: hidden;
|
||||
background-color: var(--color-fill-2);
|
||||
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
|
||||
}
|
||||
</style>
|
||||
25
sample/front/src/layout/page-layout.vue
Normal file
25
sample/front/src/layout/page-layout.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade" mode="out-in" appear>
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="route.meta.ignoreCache"
|
||||
:key="route.fullPath"
|
||||
/>
|
||||
<keep-alive v-else :include="cacheList">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useTabBarStore } from '@/store';
|
||||
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const cacheList = computed(() => tabBarStore.getCacheList);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
30
sample/front/src/locale/en-US.ts
Normal file
30
sample/front/src/locale/en-US.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { mergeLocaleModules } from './merge-locales';
|
||||
import localeSettings from './en-US/settings';
|
||||
|
||||
const componentLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/components/**/locale/en-US.ts', { eager: true }),
|
||||
);
|
||||
const viewLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/views/**/locale/en-US.ts', { eager: true }),
|
||||
);
|
||||
|
||||
export default {
|
||||
'menu.dashboard': 'Dashboard',
|
||||
'menu.server.dashboard': 'Dashboard-Server',
|
||||
'menu.server.workplace': 'Workplace-Server',
|
||||
'menu.server.monitor': 'Monitor-Server',
|
||||
'menu.list': 'List',
|
||||
'menu.result': 'Result',
|
||||
'menu.exception': 'Exception',
|
||||
'menu.form': 'Form',
|
||||
'menu.profile': 'Profile',
|
||||
'menu.visualization': 'Data Visualization',
|
||||
'menu.user': 'User Center',
|
||||
'menu.arcoWebsite': 'Arco Design',
|
||||
'menu.faq': 'FAQ',
|
||||
'navbar.docs': 'Docs',
|
||||
'navbar.action.locale': 'Switch to English',
|
||||
...localeSettings,
|
||||
...componentLocales,
|
||||
...viewLocales,
|
||||
};
|
||||
16
sample/front/src/locale/en-US/settings.ts
Normal file
16
sample/front/src/locale/en-US/settings.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
'settings.search': 'Search',
|
||||
'settings.searchPlaceholder': 'Search menus, pages, features...',
|
||||
'settings.language': 'Language',
|
||||
'settings.navbar.theme.toLight': 'Click to use light mode',
|
||||
'settings.navbar.theme.toDark': 'Click to use dark mode',
|
||||
'settings.navbar.screen.toFull': 'Click to switch to full screen mode',
|
||||
'settings.navbar.screen.toExit': 'Click to exit the full screen mode',
|
||||
'settings.navbar.alerts': 'alerts',
|
||||
'http.error.default': 'Error',
|
||||
'http.error.request': 'Request Error',
|
||||
'http.logout.title': 'Confirm logout',
|
||||
'http.logout.content':
|
||||
'You have been logged out, you can cancel to stay on this page, or log in again',
|
||||
'http.logout.okText': 'Re-Login',
|
||||
};
|
||||
21
sample/front/src/locale/index.ts
Normal file
21
sample/front/src/locale/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from './en-US';
|
||||
import cn from './zh-CN';
|
||||
|
||||
export const LOCALE_OPTIONS = [
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' },
|
||||
];
|
||||
const defaultLocale = localStorage.getItem('arco-locale') || 'zh-CN';
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: defaultLocale,
|
||||
fallbackLocale: 'en-US',
|
||||
legacy: false,
|
||||
messages: {
|
||||
'en-US': en,
|
||||
'zh-CN': cn,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
10
sample/front/src/locale/merge-locales.ts
Normal file
10
sample/front/src/locale/merge-locales.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
type LocaleModule = { default: Record<string, string> };
|
||||
|
||||
export function mergeLocaleModules(
|
||||
modules: Record<string, LocaleModule>,
|
||||
): Record<string, string> {
|
||||
return Object.values(modules).reduce(
|
||||
(messages, module) => ({ ...messages, ...module.default }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
30
sample/front/src/locale/zh-CN.ts
Normal file
30
sample/front/src/locale/zh-CN.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { mergeLocaleModules } from './merge-locales';
|
||||
import localeSettings from './zh-CN/settings';
|
||||
|
||||
const componentLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/components/**/locale/zh-CN.ts', { eager: true }),
|
||||
);
|
||||
const viewLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/views/**/locale/zh-CN.ts', { eager: true }),
|
||||
);
|
||||
|
||||
export default {
|
||||
'menu.dashboard': '仪表盘',
|
||||
'menu.server.dashboard': '仪表盘-服务端',
|
||||
'menu.server.workplace': '工作台-服务端',
|
||||
'menu.server.monitor': '实时监控-服务端',
|
||||
'menu.list': '列表页',
|
||||
'menu.result': '结果页',
|
||||
'menu.exception': '异常页',
|
||||
'menu.form': '表单页',
|
||||
'menu.profile': '详情页',
|
||||
'menu.visualization': '数据可视化',
|
||||
'menu.user': '个人中心',
|
||||
'menu.arcoWebsite': 'Arco Design',
|
||||
'menu.faq': '常见问题',
|
||||
'navbar.docs': '文档中心',
|
||||
'navbar.action.locale': '切换为中文',
|
||||
...localeSettings,
|
||||
...componentLocales,
|
||||
...viewLocales,
|
||||
};
|
||||
16
sample/front/src/locale/zh-CN/settings.ts
Normal file
16
sample/front/src/locale/zh-CN/settings.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
'settings.search': '搜索',
|
||||
'settings.searchPlaceholder': '搜索菜单、页面、功能...',
|
||||
'settings.language': '语言',
|
||||
'settings.navbar.theme.toLight': '点击切换为亮色模式',
|
||||
'settings.navbar.theme.toDark': '点击切换为暗黑模式',
|
||||
'settings.navbar.screen.toFull': '点击切换全屏模式',
|
||||
'settings.navbar.screen.toExit': '点击退出全屏模式',
|
||||
'settings.navbar.alerts': '消息通知',
|
||||
'http.error.default': '错误',
|
||||
'http.error.request': '请求错误',
|
||||
'http.logout.title': '确认登出',
|
||||
'http.logout.content':
|
||||
'您已被登出,您可以取消以停留在此页面,或重新登录',
|
||||
'http.logout.okText': '重新登录',
|
||||
};
|
||||
88
sample/front/src/mocks/handlers/message-box.ts
Normal file
88
sample/front/src/mocks/handlers/message-box.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import Mock from 'mockjs';
|
||||
import setupMock, { successResponseWrap } from '@/mocks/setup';
|
||||
|
||||
const haveReadIds: number[] = [];
|
||||
const getMessageList = () => {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
type: 'message',
|
||||
title: '\u90d1\u6666\u6708',
|
||||
subTitle: '\u7684\u79c1\u4fe1',
|
||||
avatar:
|
||||
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/8361eeb82904210b4f55fab888fe8416.png~tplv-uwbnlip3yd-webp.webp',
|
||||
content: '\u5ba1\u6279\u8bf7\u6c42\u5df2\u53d1\u9001\uff0c\u8bf7\u67e5\u6536',
|
||||
time: '\u4eca\u5929 12:30:01',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'message',
|
||||
title: '\u5b81\u6ce2',
|
||||
subTitle: '\u7684\u56de\u590d',
|
||||
avatar:
|
||||
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
|
||||
content: '\u6b64\u5904 bug \u5df2\u7ecf\u4fee\u590d',
|
||||
time: '\u4eca\u5929 12:30:01',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'message',
|
||||
title: '\u5b81\u6ce2',
|
||||
subTitle: '\u7684\u56de\u590d',
|
||||
avatar:
|
||||
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
|
||||
content: '\u6b64\u5904 bug \u5df2\u7ecf\u4fee\u590d',
|
||||
time: '\u4eca\u5929 12:20:01',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'notice',
|
||||
title: '\u7eed\u8d39\u901a\u77e5',
|
||||
subTitle: '',
|
||||
avatar: '',
|
||||
content:
|
||||
'\u60a8\u7684\u4ea7\u54c1\u4f7f\u7528\u671f\u9650\u5373\u5c06\u622a\u6b62\uff0c\u5982\u9700\u7ee7\u7eed\u4f7f\u7528\u4ea7\u54c1\u8bf7\u524d\u5f80\u8d2d\u4e70',
|
||||
time: '\u4eca\u5929 12:20:01',
|
||||
messageType: 3,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'notice',
|
||||
title: '\u89c4\u5219\u5f00\u901a\u6210\u529f',
|
||||
subTitle: '',
|
||||
avatar: '',
|
||||
content:
|
||||
'\u5185\u5bb9\u5c4f\u853d\u89c4\u5219\u4e8e 2021-12-01 \u5f00\u901a\u6210\u529f\u5e76\u751f\u6548',
|
||||
time: '\u4eca\u5929 12:20:01',
|
||||
messageType: 1,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: 'todo',
|
||||
title: '\u8d28\u68c0\u961f\u5217\u53d8\u66f4',
|
||||
subTitle: '',
|
||||
avatar: '',
|
||||
content:
|
||||
'\u5185\u5bb9\u8d28\u68c0\u961f\u5217\u4e8e 2021-12-01 19:50:23 \u8fdb\u884c\u53d8\u66f4\uff0c\u8bf7\u91cd\u65b0\u63d0\u4ea4',
|
||||
time: '\u4eca\u5929 12:20:01',
|
||||
messageType: 0,
|
||||
},
|
||||
].map((item) => ({
|
||||
...item,
|
||||
status: haveReadIds.indexOf(item.id) === -1 ? 0 : 1,
|
||||
}));
|
||||
};
|
||||
|
||||
setupMock({
|
||||
setup: () => {
|
||||
Mock.mock(/\/api\/message\/list/, () => {
|
||||
return successResponseWrap(getMessageList());
|
||||
});
|
||||
|
||||
Mock.mock(/\/api\/message\/read/, (params: { body: string }) => {
|
||||
const { ids } = JSON.parse(params.body);
|
||||
haveReadIds.push(...(ids || []));
|
||||
return successResponseWrap(true);
|
||||
});
|
||||
},
|
||||
});
|
||||
104
sample/front/src/mocks/handlers/user.ts
Normal file
104
sample/front/src/mocks/handlers/user.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import Mock from 'mockjs';
|
||||
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
import setupMock, {
|
||||
failResponseWrap,
|
||||
successResponseWrap,
|
||||
} from '@/mocks/setup';
|
||||
|
||||
export interface MockParams {
|
||||
url: string;
|
||||
type: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
setupMock({
|
||||
setup() {
|
||||
Mock.mock(/\/api\/user\/info/, () => {
|
||||
if (isLogin()) {
|
||||
const role = window.localStorage.getItem('userRole') || 'admin';
|
||||
return successResponseWrap({
|
||||
name: 'admin',
|
||||
avatar: DEFAULT_USER_AVATAR,
|
||||
email: 'wangliqun@email.com',
|
||||
job: 'frontend',
|
||||
jobName: '\u524d\u7aef\u827a\u672f\u5bb6',
|
||||
organization: 'Frontend',
|
||||
organizationName: '\u524d\u7aef',
|
||||
location: 'beijing',
|
||||
locationName: '\u5317\u4eac',
|
||||
introduction: '\u4eba\u7206\u723d\uff0c\u6027\u6e29\u7eaf',
|
||||
personalWebsite: 'https://www.arco.design',
|
||||
phone: '150****0000',
|
||||
registrationDate: '2013-05-10 12:10:00',
|
||||
accountId: '15012312300',
|
||||
certification: 1,
|
||||
role,
|
||||
});
|
||||
}
|
||||
return failResponseWrap(null, '\u672a\u767b\u5f55', 50008);
|
||||
});
|
||||
|
||||
Mock.mock(/\/api\/user\/login/, (params: MockParams) => {
|
||||
const { username, password } = JSON.parse(params.body);
|
||||
if (!username) {
|
||||
return failResponseWrap(
|
||||
null,
|
||||
'\u7528\u6237\u540d\u4e0d\u80fd\u4e3a\u7a7a',
|
||||
50000,
|
||||
);
|
||||
}
|
||||
if (!password) {
|
||||
return failResponseWrap(null, '\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a', 50000);
|
||||
}
|
||||
if (username === 'admin' && password === 'admin') {
|
||||
window.localStorage.setItem('userRole', 'admin');
|
||||
return successResponseWrap({ token: '12345' });
|
||||
}
|
||||
if (username === 'user' && password === 'user') {
|
||||
window.localStorage.setItem('userRole', 'user');
|
||||
return successResponseWrap({ token: '54321' });
|
||||
}
|
||||
return failResponseWrap(
|
||||
null,
|
||||
'\u8d26\u53f7\u6216\u8005\u5bc6\u7801\u9519\u8bef',
|
||||
50000,
|
||||
);
|
||||
});
|
||||
|
||||
Mock.mock(/\/api\/user\/logout/, () => successResponseWrap(null));
|
||||
Mock.mock(/\/api\/user\/menu/, () => {
|
||||
const menuList = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
meta: {
|
||||
locale: 'menu.server.dashboard',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-dashboard',
|
||||
order: 1,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'workplace',
|
||||
name: 'Workplace',
|
||||
meta: {
|
||||
locale: 'menu.server.workplace',
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'https://arco.design',
|
||||
name: 'arcoWebsite',
|
||||
meta: {
|
||||
locale: 'menu.arcoWebsite',
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return successResponseWrap(menuList);
|
||||
});
|
||||
},
|
||||
});
|
||||
9
sample/front/src/mocks/index.ts
Normal file
9
sample/front/src/mocks/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import Mock from 'mockjs';
|
||||
import './handlers/user';
|
||||
import './handlers/message-box';
|
||||
|
||||
import.meta.glob('@/views/**/mock.ts', { eager: true });
|
||||
|
||||
Mock.setup({
|
||||
timeout: '600-1000',
|
||||
});
|
||||
27
sample/front/src/mocks/setup.ts
Normal file
27
sample/front/src/mocks/setup.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import debug from '@/utils/env';
|
||||
|
||||
export default ({ mock, setup }: { mock?: boolean; setup: () => void }) => {
|
||||
if (mock !== false && debug) setup();
|
||||
};
|
||||
|
||||
export const successResponseWrap = (data: unknown) => {
|
||||
return {
|
||||
data,
|
||||
status: 'ok',
|
||||
msg: '请求成功',
|
||||
code: 20000,
|
||||
};
|
||||
};
|
||||
|
||||
export const failResponseWrap = (
|
||||
data: unknown,
|
||||
msg: string,
|
||||
code = 50000,
|
||||
) => {
|
||||
return {
|
||||
data,
|
||||
status: 'fail',
|
||||
msg,
|
||||
code,
|
||||
};
|
||||
};
|
||||
78
sample/front/src/plugins/http.ts
Normal file
78
sample/front/src/plugins/http.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
import i18n from '@/locale';
|
||||
import { useUserStore } from '@/store';
|
||||
import { getToken } from '@/utils/auth';
|
||||
|
||||
export interface HttpResponse<T = unknown> {
|
||||
status: number;
|
||||
msg: string;
|
||||
code: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
|
||||
function t(key: string) {
|
||||
return i18n.global.t(key);
|
||||
}
|
||||
|
||||
export function setupHttp() {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL?.trim();
|
||||
if (baseURL) {
|
||||
axios.defaults.baseURL = baseURL;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(response: AxiosResponse<HttpResponse>) => {
|
||||
const res = response.data;
|
||||
if (res.code !== 20000) {
|
||||
Message.error({
|
||||
content: res.msg || t('http.error.default'),
|
||||
duration: 5 * 1000,
|
||||
});
|
||||
if (
|
||||
[50008, 50012, 50014].includes(res.code) &&
|
||||
response.config.url !== '/api/user/info'
|
||||
) {
|
||||
Modal.error({
|
||||
title: t('http.logout.title'),
|
||||
content: t('http.logout.content'),
|
||||
okText: t('http.logout.okText'),
|
||||
async onOk() {
|
||||
const userStore = useUserStore();
|
||||
await userStore.logout();
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(res.msg || t('http.error.default')));
|
||||
}
|
||||
return res as unknown as AxiosResponse<HttpResponse>;
|
||||
},
|
||||
(error) => {
|
||||
Message.error({
|
||||
content: error.msg || t('http.error.request'),
|
||||
duration: 5 * 1000,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
16
sample/front/src/router/app-menus/index.ts
Normal file
16
sample/front/src/router/app-menus/index.ts
Normal 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
sample/front/src/router/constants.ts
Normal file
18
sample/front/src/router/constants.ts
Normal 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
sample/front/src/router/guard/index.ts
Normal file
17
sample/front/src/router/guard/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Router } from 'vue-router';
|
||||
import { setRouteEmitter } from '@/utils/route-listener';
|
||||
import setupPermissionGuard from './permission';
|
||||
import setupUserLoginInfoGuard from './userLoginInfo';
|
||||
|
||||
function setupPageGuard(router: Router) {
|
||||
router.beforeEach(async (to) => {
|
||||
// emit route change
|
||||
setRouteEmitter(to);
|
||||
});
|
||||
}
|
||||
|
||||
export default function createRouteGuard(router: Router) {
|
||||
setupPageGuard(router);
|
||||
setupUserLoginInfoGuard(router);
|
||||
setupPermissionGuard(router);
|
||||
}
|
||||
53
sample/front/src/router/guard/permission.ts
Normal file
53
sample/front/src/router/guard/permission.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import NProgress from 'nprogress'; // progress bar
|
||||
import type { RouteRecordNormalized, Router } from 'vue-router';
|
||||
|
||||
import usePermission from '@/hooks/permission';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
import { NOT_FOUND, WHITE_LIST } from '../constants';
|
||||
import { appRoutes } from '../routes';
|
||||
|
||||
export default function setupPermissionGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const Permission = usePermission();
|
||||
const permissionsAllow = Permission.accessRouter(to);
|
||||
if (appStore.menuFromServer) {
|
||||
// 针对来自服务端的菜单配置进行处理
|
||||
// Handle routing configuration from the server
|
||||
|
||||
// 根据需要自行完善来源于服务端的菜单配置的permission逻辑
|
||||
// Refine the permission logic from the server's menu configuration as needed
|
||||
if (
|
||||
!appStore.appAsyncMenus.length &&
|
||||
!WHITE_LIST.find((el) => el.name === to.name)
|
||||
) {
|
||||
await appStore.fetchServerMenuConfig();
|
||||
}
|
||||
const serverMenuConfig = [...appStore.appAsyncMenus, ...WHITE_LIST];
|
||||
|
||||
let exist = false;
|
||||
while (serverMenuConfig.length && !exist) {
|
||||
const element = serverMenuConfig.shift();
|
||||
if (element?.name === to.name) exist = true;
|
||||
|
||||
if (element?.children) {
|
||||
serverMenuConfig.push(
|
||||
...(element.children as unknown as RouteRecordNormalized[]),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (exist && permissionsAllow) {
|
||||
next();
|
||||
} else next(NOT_FOUND);
|
||||
} else if (permissionsAllow) {
|
||||
next();
|
||||
} else {
|
||||
const destination =
|
||||
Permission.findFirstPermissionRoute(appRoutes, userStore.role) ||
|
||||
NOT_FOUND;
|
||||
next(destination);
|
||||
}
|
||||
NProgress.done();
|
||||
});
|
||||
}
|
||||
43
sample/front/src/router/guard/userLoginInfo.ts
Normal file
43
sample/front/src/router/guard/userLoginInfo.ts
Normal 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
sample/front/src/router/index.ts
Normal file
37
sample/front/src/router/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import NProgress from 'nprogress'; // progress bar
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import 'nprogress/nprogress.css';
|
||||
|
||||
import createRouteGuard from './guard';
|
||||
import { appRoutes } from './routes';
|
||||
import { NOT_FOUND_ROUTE, REDIRECT_MAIN } from './routes/base';
|
||||
|
||||
NProgress.configure({ showSpinner: false }); // NProgress Configuration
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: 'login',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
...appRoutes,
|
||||
REDIRECT_MAIN,
|
||||
NOT_FOUND_ROUTE,
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
createRouteGuard(router);
|
||||
|
||||
export default router;
|
||||
31
sample/front/src/router/routes/base.ts
Normal file
31
sample/front/src/router/routes/base.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import { REDIRECT_ROUTE_NAME } from '@/router/constants';
|
||||
|
||||
export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue');
|
||||
|
||||
export const REDIRECT_MAIN: RouteRecordRaw = {
|
||||
path: '/redirect',
|
||||
name: 'redirectWrapper',
|
||||
component: DEFAULT_LAYOUT,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/redirect/:path',
|
||||
name: REDIRECT_ROUTE_NAME,
|
||||
component: () => import('@/views/redirect/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const NOT_FOUND_ROUTE: RouteRecordRaw = {
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'notFound',
|
||||
component: () => import('@/views/not-found/index.vue'),
|
||||
};
|
||||
10
sample/front/src/router/routes/externalModules/arco.ts
Normal file
10
sample/front/src/router/routes/externalModules/arco.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
path: 'https://arco.design',
|
||||
name: 'arcoWebsite',
|
||||
meta: {
|
||||
locale: 'menu.arcoWebsite',
|
||||
icon: 'icon-link',
|
||||
requiresAuth: true,
|
||||
order: 8,
|
||||
},
|
||||
};
|
||||
10
sample/front/src/router/routes/externalModules/faq.ts
Normal file
10
sample/front/src/router/routes/externalModules/faq.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
path: 'https://arco.design/vue/docs/pro/faq',
|
||||
name: 'faq',
|
||||
meta: {
|
||||
locale: 'menu.faq',
|
||||
icon: 'icon-question-circle',
|
||||
requiresAuth: true,
|
||||
order: 9,
|
||||
},
|
||||
};
|
||||
25
sample/front/src/router/routes/index.ts
Normal file
25
sample/front/src/router/routes/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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,
|
||||
[],
|
||||
);
|
||||
40
sample/front/src/router/routes/modules/dashboard.ts
Normal file
40
sample/front/src/router/routes/modules/dashboard.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { 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;
|
||||
48
sample/front/src/router/routes/modules/exception.ts
Normal file
48
sample/front/src/router/routes/modules/exception.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { 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;
|
||||
38
sample/front/src/router/routes/modules/form.ts
Normal file
38
sample/front/src/router/routes/modules/form.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
|
||||
const FORM: AppRouteRecordRaw = {
|
||||
path: '/form',
|
||||
name: 'form',
|
||||
component: DEFAULT_LAYOUT,
|
||||
meta: {
|
||||
locale: 'menu.form',
|
||||
icon: 'icon-settings',
|
||||
requiresAuth: true,
|
||||
order: 3,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'step',
|
||||
name: 'Step',
|
||||
component: () => import('@/views/form/step/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.form.step',
|
||||
requiresAuth: true,
|
||||
roles: ['admin'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'group',
|
||||
name: 'Group',
|
||||
component: () => import('@/views/form/group/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.form.group',
|
||||
requiresAuth: true,
|
||||
roles: ['admin'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default FORM;
|
||||
38
sample/front/src/router/routes/modules/list.ts
Normal file
38
sample/front/src/router/routes/modules/list.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { 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;
|
||||
28
sample/front/src/router/routes/modules/profile.ts
Normal file
28
sample/front/src/router/routes/modules/profile.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { 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;
|
||||
38
sample/front/src/router/routes/modules/result.ts
Normal file
38
sample/front/src/router/routes/modules/result.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
|
||||
const RESULT: AppRouteRecordRaw = {
|
||||
path: '/result',
|
||||
name: 'result',
|
||||
component: DEFAULT_LAYOUT,
|
||||
meta: {
|
||||
locale: 'menu.result',
|
||||
icon: 'icon-check-circle',
|
||||
requiresAuth: true,
|
||||
order: 5,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'success',
|
||||
name: 'Success',
|
||||
component: () => import('@/views/result/success/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.result.success',
|
||||
requiresAuth: true,
|
||||
roles: ['admin'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'error',
|
||||
name: 'Error',
|
||||
component: () => import('@/views/result/error/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.result.error',
|
||||
requiresAuth: true,
|
||||
roles: ['admin'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default RESULT;
|
||||
38
sample/front/src/router/routes/modules/user.ts
Normal file
38
sample/front/src/router/routes/modules/user.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { 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;
|
||||
39
sample/front/src/router/routes/modules/visualization.ts
Normal file
39
sample/front/src/router/routes/modules/visualization.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { 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;
|
||||
20
sample/front/src/router/routes/types.ts
Normal file
20
sample/front/src/router/routes/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { defineComponent } from 'vue';
|
||||
import type { NavigationGuard, RouteMeta } from 'vue-router';
|
||||
|
||||
export type Component<T = any> =
|
||||
| ReturnType<typeof defineComponent>
|
||||
| (() => Promise<typeof import('*.vue')>)
|
||||
| (() => Promise<T>);
|
||||
|
||||
export interface AppRouteRecordRaw {
|
||||
path: string;
|
||||
name?: string | symbol;
|
||||
meta?: RouteMeta;
|
||||
redirect?: string;
|
||||
component: Component | string;
|
||||
children?: AppRouteRecordRaw[];
|
||||
alias?: string | string[];
|
||||
props?: Record<string, any>;
|
||||
beforeEnter?: NavigationGuard | NavigationGuard[];
|
||||
fullPath?: string;
|
||||
}
|
||||
16
sample/front/src/router/typings.d.ts
vendored
Normal file
16
sample/front/src/router/typings.d.ts
vendored
Normal 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
sample/front/src/store/index.ts
Normal file
9
sample/front/src/store/index.ts
Normal 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;
|
||||
76
sample/front/src/store/modules/app/index.ts
Normal file
76
sample/front/src/store/modules/app/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Notification } from '@arco-design/web-vue';
|
||||
import type { NotificationReturn } from '@arco-design/web-vue/es/notification/interface';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
import { getMenuList } from '@/api/user';
|
||||
import defaultSettings from '@/config/settings.json';
|
||||
import type { AppState } from './types';
|
||||
|
||||
const useAppStore = defineStore('app', {
|
||||
state: (): AppState => ({ ...defaultSettings }),
|
||||
|
||||
getters: {
|
||||
appCurrentSetting(state: AppState): AppState {
|
||||
return { ...state };
|
||||
},
|
||||
appDevice(state: AppState) {
|
||||
return state.device;
|
||||
},
|
||||
appAsyncMenus(state: AppState): RouteRecordNormalized[] {
|
||||
return state.serverMenu as unknown as RouteRecordNormalized[];
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
// Update app settings
|
||||
updateSettings(partial: Partial<AppState>) {
|
||||
// @ts-expect-error-next-line
|
||||
this.$patch(partial);
|
||||
},
|
||||
|
||||
// Change theme color
|
||||
toggleTheme(dark: boolean) {
|
||||
if (dark) {
|
||||
this.theme = 'dark';
|
||||
document.body.setAttribute('arco-theme', 'dark');
|
||||
} else {
|
||||
this.theme = 'light';
|
||||
document.body.removeAttribute('arco-theme');
|
||||
}
|
||||
},
|
||||
toggleDevice(device: string) {
|
||||
this.device = device;
|
||||
},
|
||||
toggleMenu(value: boolean) {
|
||||
this.hideMenu = value;
|
||||
},
|
||||
async fetchServerMenuConfig() {
|
||||
let notifyInstance: NotificationReturn | null = null;
|
||||
try {
|
||||
notifyInstance = Notification.info({
|
||||
id: 'menuNotice', // Keep the instance id the same
|
||||
content: 'loading',
|
||||
closable: true,
|
||||
});
|
||||
const { data } = await getMenuList();
|
||||
this.serverMenu = data;
|
||||
notifyInstance = Notification.success({
|
||||
id: 'menuNotice',
|
||||
content: 'success',
|
||||
closable: true,
|
||||
});
|
||||
} catch {
|
||||
notifyInstance = Notification.error({
|
||||
id: 'menuNotice',
|
||||
content: 'error',
|
||||
closable: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
clearServerMenu() {
|
||||
this.serverMenu = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default useAppStore;
|
||||
19
sample/front/src/store/modules/app/types.ts
Normal file
19
sample/front/src/store/modules/app/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
|
||||
export interface AppState {
|
||||
theme: string;
|
||||
colorWeak: boolean;
|
||||
navbar: boolean;
|
||||
menu: boolean;
|
||||
topMenu: boolean;
|
||||
hideMenu: boolean;
|
||||
menuCollapse: boolean;
|
||||
footer: boolean;
|
||||
themeColor: string;
|
||||
menuWidth: number;
|
||||
device: string;
|
||||
tabBar: boolean;
|
||||
menuFromServer: boolean;
|
||||
serverMenu: RouteRecordNormalized[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
75
sample/front/src/store/modules/tab-bar/index.ts
Normal file
75
sample/front/src/store/modules/tab-bar/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import {
|
||||
DEFAULT_ROUTE,
|
||||
DEFAULT_ROUTE_NAME,
|
||||
REDIRECT_ROUTE_NAME,
|
||||
} from '@/router/constants';
|
||||
import { isString } from '@/utils/is';
|
||||
import type { TabBarState, TagProps } from './types';
|
||||
|
||||
const formatTag = (route: RouteLocationNormalized): TagProps => {
|
||||
const { name, meta, fullPath, query } = route;
|
||||
return {
|
||||
title: meta.locale || '',
|
||||
name: String(name),
|
||||
fullPath,
|
||||
query,
|
||||
ignoreCache: meta.ignoreCache,
|
||||
};
|
||||
};
|
||||
|
||||
const BAN_LIST = [REDIRECT_ROUTE_NAME];
|
||||
|
||||
const useAppStore = defineStore('tabBar', {
|
||||
state: (): TabBarState => ({
|
||||
cacheTabList: new Set([DEFAULT_ROUTE_NAME]),
|
||||
tagList: [DEFAULT_ROUTE],
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getTabList(): TagProps[] {
|
||||
return this.tagList;
|
||||
},
|
||||
getCacheList(): string[] {
|
||||
return Array.from(this.cacheTabList);
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
updateTabList(route: RouteLocationNormalized) {
|
||||
if (BAN_LIST.includes(route.name as string)) return;
|
||||
this.tagList.push(formatTag(route));
|
||||
if (!route.meta.ignoreCache) {
|
||||
this.cacheTabList.add(route.name as string);
|
||||
}
|
||||
},
|
||||
deleteTag(idx: number, tag: TagProps) {
|
||||
this.tagList.splice(idx, 1);
|
||||
this.cacheTabList.delete(tag.name);
|
||||
},
|
||||
addCache(name: string) {
|
||||
if (isString(name) && name !== '') this.cacheTabList.add(name);
|
||||
},
|
||||
deleteCache(tag: TagProps) {
|
||||
this.cacheTabList.delete(tag.name);
|
||||
},
|
||||
freshTabList(tags: TagProps[]) {
|
||||
this.tagList = tags;
|
||||
this.cacheTabList.clear();
|
||||
// 要先判断ignoreCache
|
||||
for (const name of this.tagList
|
||||
.filter((el) => !el.ignoreCache)
|
||||
.map((el) => el.name)) {
|
||||
this.cacheTabList.add(name);
|
||||
}
|
||||
},
|
||||
resetTabList() {
|
||||
this.tagList = [DEFAULT_ROUTE];
|
||||
this.cacheTabList.clear();
|
||||
this.cacheTabList.add(DEFAULT_ROUTE_NAME);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default useAppStore;
|
||||
12
sample/front/src/store/modules/tab-bar/types.ts
Normal file
12
sample/front/src/store/modules/tab-bar/types.ts
Normal 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>;
|
||||
}
|
||||
95
sample/front/src/store/modules/user/index.ts
Normal file
95
sample/front/src/store/modules/user/index.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import {
|
||||
getUserInfo,
|
||||
type LoginData,
|
||||
login as userLogin,
|
||||
logout as userLogout,
|
||||
} from '@/api/user';
|
||||
import { resolveAvatarUrl } from '@/constants/avatar';
|
||||
import { clearToken, setToken } from '@/utils/auth';
|
||||
import { removeRouteListener } from '@/utils/route-listener';
|
||||
import useAppStore from '../app';
|
||||
import type { UserState } from './types';
|
||||
|
||||
const useUserStore = defineStore('user', {
|
||||
state: (): UserState => ({
|
||||
name: undefined,
|
||||
avatar: undefined,
|
||||
job: undefined,
|
||||
organization: undefined,
|
||||
location: undefined,
|
||||
email: undefined,
|
||||
introduction: undefined,
|
||||
personalWebsite: undefined,
|
||||
jobName: undefined,
|
||||
organizationName: undefined,
|
||||
locationName: undefined,
|
||||
phone: undefined,
|
||||
registrationDate: undefined,
|
||||
accountId: undefined,
|
||||
certification: undefined,
|
||||
role: '',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
userInfo(state: UserState): UserState {
|
||||
return { ...state };
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
switchRoles() {
|
||||
return new Promise((resolve) => {
|
||||
this.role = this.role === 'user' ? 'admin' : 'user';
|
||||
resolve(this.role);
|
||||
});
|
||||
},
|
||||
// Set user's information
|
||||
setInfo(partial: Partial<UserState>) {
|
||||
if (partial.avatar !== undefined) {
|
||||
partial.avatar = resolveAvatarUrl(partial.avatar);
|
||||
}
|
||||
this.$patch(partial);
|
||||
},
|
||||
|
||||
// Reset user's information
|
||||
resetInfo() {
|
||||
this.$reset();
|
||||
},
|
||||
|
||||
// Get user's information
|
||||
async info() {
|
||||
const 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;
|
||||
19
sample/front/src/store/modules/user/types.ts
Normal file
19
sample/front/src/store/modules/user/types.ts
Normal 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
sample/front/src/types/echarts.ts
Normal file
10
sample/front/src/types/echarts.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CallbackDataParams } from 'echarts/types/dist/shared';
|
||||
|
||||
export interface ToolTipFormatterParams extends CallbackDataParams {
|
||||
axisDim: string;
|
||||
axisIndex: number;
|
||||
axisType: string;
|
||||
axisId: string;
|
||||
axisValue: string;
|
||||
axisValueLabel: string;
|
||||
}
|
||||
37
sample/front/src/types/global.ts
Normal file
37
sample/front/src/types/global.ts
Normal 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[] }>;
|
||||
}
|
||||
19
sample/front/src/utils/auth.ts
Normal file
19
sample/front/src/utils/auth.ts
Normal 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 };
|
||||
3
sample/front/src/utils/env.ts
Normal file
3
sample/front/src/utils/env.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
const debug = import.meta.env.MODE !== 'production';
|
||||
|
||||
export default debug;
|
||||
19
sample/front/src/utils/error-report.ts
Normal file
19
sample/front/src/utils/error-report.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import axios from 'axios';
|
||||
import type { App, ComponentPublicInstance } from 'vue';
|
||||
|
||||
export default function setupErrorReport(app: App, baseUrl: string) {
|
||||
if (!baseUrl) {
|
||||
return;
|
||||
}
|
||||
app.config.errorHandler = (
|
||||
err: unknown,
|
||||
instance: ComponentPublicInstance | null,
|
||||
info: string,
|
||||
) => {
|
||||
axios.post(`${baseUrl}/report-error`, {
|
||||
err,
|
||||
instance,
|
||||
info,
|
||||
});
|
||||
};
|
||||
}
|
||||
27
sample/front/src/utils/event.ts
Normal file
27
sample/front/src/utils/event.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function addEventListen(
|
||||
target: Window | HTMLElement,
|
||||
event: string,
|
||||
handler: EventListenerOrEventListenerObject,
|
||||
capture = false,
|
||||
) {
|
||||
if (
|
||||
target.addEventListener &&
|
||||
typeof target.addEventListener === 'function'
|
||||
) {
|
||||
target.addEventListener(event, handler, capture);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeEventListen(
|
||||
target: Window | HTMLElement,
|
||||
event: string,
|
||||
handler: EventListenerOrEventListenerObject,
|
||||
capture = false,
|
||||
) {
|
||||
if (
|
||||
target.removeEventListener &&
|
||||
typeof target.removeEventListener === 'function'
|
||||
) {
|
||||
target.removeEventListener(event, handler, capture);
|
||||
}
|
||||
}
|
||||
23
sample/front/src/utils/index.ts
Normal file
23
sample/front/src/utils/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
type TargetContext = '_self' | '_parent' | '_blank' | '_top';
|
||||
|
||||
export const openWindow = (
|
||||
url: string,
|
||||
opts?: { target?: TargetContext; [key: string]: any },
|
||||
) => {
|
||||
const { target = '_blank', ...others } = opts || {};
|
||||
window.open(
|
||||
url,
|
||||
target,
|
||||
Object.entries(others)
|
||||
.reduce((preValue: string[], curValue) => {
|
||||
const [key, value] = curValue;
|
||||
return [...preValue, `${key}=${value}`];
|
||||
}, [])
|
||||
.join(','),
|
||||
);
|
||||
};
|
||||
|
||||
export const regexUrl =
|
||||
/^(?!mailto:)(?:(?:http|https|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))|localhost)(?::\d{2,5})?(?:(\/|\?|#)[^\s]*)?$/i;
|
||||
|
||||
export default null;
|
||||
53
sample/front/src/utils/is.ts
Normal file
53
sample/front/src/utils/is.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
const opt = Object.prototype.toString;
|
||||
|
||||
export function isArray(obj: any): obj is any[] {
|
||||
return opt.call(obj) === '[object Array]';
|
||||
}
|
||||
|
||||
export function isObject(obj: any): obj is { [key: string]: any } {
|
||||
return opt.call(obj) === '[object Object]';
|
||||
}
|
||||
|
||||
export function isString(obj: any): obj is string {
|
||||
return opt.call(obj) === '[object String]';
|
||||
}
|
||||
|
||||
export function isNumber(obj: any): obj is number {
|
||||
return opt.call(obj) === '[object Number]' && !Number.isNaN(obj);
|
||||
}
|
||||
|
||||
export function isRegExp(obj: any) {
|
||||
return opt.call(obj) === '[object RegExp]';
|
||||
}
|
||||
|
||||
export function isFile(obj: any): obj is File {
|
||||
return opt.call(obj) === '[object File]';
|
||||
}
|
||||
|
||||
export function isBlob(obj: any): obj is Blob {
|
||||
return opt.call(obj) === '[object Blob]';
|
||||
}
|
||||
|
||||
export function isUndefined(obj: any): obj is undefined {
|
||||
return obj === undefined;
|
||||
}
|
||||
|
||||
export function isNull(obj: any): obj is null {
|
||||
return obj === null;
|
||||
}
|
||||
|
||||
export function isFunction(obj: any): obj is (...args: any[]) => any {
|
||||
return typeof obj === 'function';
|
||||
}
|
||||
|
||||
export function isEmptyObject(obj: any): boolean {
|
||||
return isObject(obj) && Object.keys(obj).length === 0;
|
||||
}
|
||||
|
||||
export function isExist(obj: any): boolean {
|
||||
return obj || obj === 0;
|
||||
}
|
||||
|
||||
export function isWindow(el: any): el is Window {
|
||||
return el === window;
|
||||
}
|
||||
8
sample/front/src/utils/parse-url-query.ts
Normal file
8
sample/front/src/utils/parse-url-query.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function parseUrlQuery(url: string): Record<string, string> {
|
||||
const { searchParams } = new URL(url, 'http://localhost');
|
||||
const query: Record<string, string> = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
query[key] = value;
|
||||
});
|
||||
return query;
|
||||
}
|
||||
31
sample/front/src/utils/route-listener.ts
Normal file
31
sample/front/src/utils/route-listener.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Listening to routes alone would waste rendering performance.
|
||||
* Use the publish-subscribe model for distribution management.
|
||||
*/
|
||||
import mitt, { type 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);
|
||||
}
|
||||
@@ -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 type { PropType } from 'vue';
|
||||
import type { 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>
|
||||
@@ -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 type { PropType } from 'vue';
|
||||
import type { 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>
|
||||
@@ -0,0 +1,79 @@
|
||||
<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 { type ChatRecord, queryChatList } 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>
|
||||
@@ -0,0 +1,133 @@
|
||||
<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 type {
|
||||
TableColumnData,
|
||||
TableData,
|
||||
} from '@arco-design/web-vue/es/table/interface.d';
|
||||
import { compile, computed, h } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,56 @@
|
||||
<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>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<a-card class="general-card" :title="$t('monitor.title.quickOperation')">
|
||||
<a-space direction="vertical" fill :size="10">
|
||||
<a-button long>
|
||||
{{ $t('monitor.quickOperation.changeClarity') }}
|
||||
<template #icon>
|
||||
<IconTags />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button long>
|
||||
{{ $t('monitor.quickOperation.switchStream') }}
|
||||
<template #icon>
|
||||
<IconSwap />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button long>
|
||||
{{ $t('monitor.quickOperation.removeClarity') }}
|
||||
<template #icon>
|
||||
<IconStop />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button long>
|
||||
{{ $t('monitor.quickOperation.pushFlowGasket') }}
|
||||
<template #icon>
|
||||
<IconArrowRight />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<a-card class="general-card" :title="$t('monitor.title.studioInfo')">
|
||||
<a-form :model="{}" layout="vertical">
|
||||
<a-form-item :label="$t('monitor.studioInfo.label.studioTitle')" required>
|
||||
<a-input
|
||||
:placeholder="`admin${$t(
|
||||
'monitor.studioInfo.placeholder.studioTitle'
|
||||
)}`"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
:label="$t('monitor.studioInfo.label.onlineNotification')"
|
||||
required
|
||||
>
|
||||
<a-textarea />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
:label="$t('monitor.studioInfo.label.studioCategory')"
|
||||
required
|
||||
>
|
||||
<a-input-search />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
:label="$t('monitor.studioInfo.label.studioCategory')"
|
||||
required
|
||||
>
|
||||
<a-input-search />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-button type="primary">{{ $t('monitor.studioInfo.btn.fresh') }}</a-button>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<a-card
|
||||
class="general-card"
|
||||
:title="$t('monitor.studioStatus.title.studioStatus')"
|
||||
>
|
||||
<template #extra>
|
||||
<a-tag color="green">{{ $t('monitor.studioStatus.smooth') }}</a-tag>
|
||||
</template>
|
||||
<a-descriptions layout="horizontal" :data="dataStatus" :column="2">
|
||||
<template #label="{ label }">
|
||||
<span
|
||||
v-if="['mainstream', 'hotStandby', 'coldStandby'].includes(label)"
|
||||
>
|
||||
<a-typography-text style="padding-right: 8px">
|
||||
{{ $t(`monitor.studioStatus.${label}`) }}
|
||||
</a-typography-text>
|
||||
{{ $t('monitor.studioStatus.bitRate') }}
|
||||
</span>
|
||||
<span v-else>{{ label }}</span>
|
||||
</template>
|
||||
</a-descriptions>
|
||||
<a-typography-title style="margin-bottom: 16px" :heading="6">
|
||||
{{ $t('monitor.studioStatus.title.pictureInfo') }}
|
||||
</a-typography-title>
|
||||
<a-descriptions layout="horizontal" :data="dataPicture" :column="2" />
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const dataStatus = computed(() => [
|
||||
{
|
||||
label: 'mainstream',
|
||||
value: '6 Mbps',
|
||||
},
|
||||
{
|
||||
label: t('monitor.studioStatus.frameRate'),
|
||||
value: '60',
|
||||
},
|
||||
{
|
||||
label: 'hotStandby',
|
||||
value: '6 Mbps',
|
||||
},
|
||||
{
|
||||
label: t('monitor.studioStatus.frameRate'),
|
||||
value: '60',
|
||||
},
|
||||
{
|
||||
label: 'coldStandby',
|
||||
value: '6 Mbps',
|
||||
},
|
||||
{
|
||||
label: t('monitor.studioStatus.frameRate'),
|
||||
value: '60',
|
||||
},
|
||||
]);
|
||||
const dataPicture = computed(() => [
|
||||
{
|
||||
label: t('monitor.studioStatus.line'),
|
||||
value: '热备',
|
||||
},
|
||||
{
|
||||
label: 'CDN',
|
||||
value: 'KS',
|
||||
},
|
||||
{
|
||||
label: t('monitor.studioStatus.play'),
|
||||
value: 'FLV',
|
||||
},
|
||||
{
|
||||
label: t('monitor.studioStatus.pictureQuality'),
|
||||
value: '原画',
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.arco-descriptions-item-label) {
|
||||
padding-right: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<a-card class="general-card" :title="$t('monitor.title.studioPreview')">
|
||||
<template #extra>
|
||||
<icon-more />
|
||||
</template>
|
||||
<div class="studio-wrapper">
|
||||
<img
|
||||
src="http://p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/c788fc704d32cf3b1136c7d45afc2669.png~tplv-uwbnlip3yd-webp.webp"
|
||||
class="studio-preview"
|
||||
/>
|
||||
<div class="studio-bar">
|
||||
<div v-if="userInfo">
|
||||
<a-space :size="12">
|
||||
<a-avatar :size="24">
|
||||
<img :src="userInfo.avatar" />
|
||||
</a-avatar>
|
||||
<a-typography-text>
|
||||
{{ userInfo.name }} {{ $t('monitor.studioPreview.studio') }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</div>
|
||||
<a-typography-text type="secondary">
|
||||
36,000 {{ $t('monitor.studioPreview.watching') }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
const userInfo = useUserStore();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.studio {
|
||||
&-preview {
|
||||
display: block;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user