This commit is contained in:
ygx
2026-03-07 20:11:25 +08:00
parent 8fab91c5c7
commit f7bbb5ee46
74 changed files with 6540 additions and 2636 deletions

22
.env.development Normal file
View File

@@ -0,0 +1,22 @@
# 开发配置文件
# 应用工作空间名称
VITE_APP_WORKSPACE=ops
# 应用标题
VITE_APP_TITLE=标准管理系统
VITE_APP_DESCRIPTION="default standard template"
# API 基础URL
VITE_API_BASE_URL=https://ops-api.apinb.com
# 应用版本
VITE_APP_VERSION=1.0.0
# 是否启用调试模式
VITE_DEBUG=true
# 主题配置
VITE_DEFAULT_LANGUAGE=cn

19
.env.production Normal file
View File

@@ -0,0 +1,19 @@
# 生产环境变量
# 应用工作空间名称
VITE_APP_WORKSPACE=ops
VITE_APP_TITLE=标准管理系统
VITE_APP_DESCRIPTION="default standard template"
# API 基础URL
VITE_API_BASE_URL=https://ops-api.apinb.com
# 应用版本
VITE_APP_VERSION=1.0.0
# 是否启用调试模式
VITE_DEBUG=false
# 主题配置
VITE_DEFAULT_LANGUAGE=cn

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="shortcut icon" type="image/x-icon" href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue Admin Arco - 开箱即用的中台前端/设计解决方案</title>
<title>智能运维管理系统</title>
</head>
<body>
<div id="app"></div>

View File

@@ -18,6 +18,7 @@
},
"dependencies": {
"@arco-design/web-vue": "^2.57.0",
"@tabler/icons-vue": "^3.40.0",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.6",
"dayjs": "^1.11.19",
@@ -28,6 +29,7 @@
"pinia": "^3.0.4",
"query-string": "^9.3.1",
"sortablejs": "^1.15.7",
"uuid": "^13.0.0",
"vue": "^3.5.29",
"vue-echarts": "^8.0.1",
"vue-i18n": "^11.2.8",

35
src/api/module/factor.ts Normal file
View File

@@ -0,0 +1,35 @@
import { request } from "@/api/request";
/** 批次列表 */
export const fetchLoanBatch = (data: { page: number, size: number }) => request.post("/factor/v1/fetch/loan_batch", data);
/** 上报列表 */
export const fetchTransferWatch = (data: { page: number, size: number }) => request.post("/factor/v1/fetch/transfer_watch", data);
/** 回盘列表 */
export const fetchTransferDown = (data: { page: number, size: number }) => request.post("/factor/v1/fetch/transfer_down", data);
/** 放款数据列表 */
export const fetchDataFile = (data: { page: number, size: number }) => request.post("/factor/v1/fetch/data_file", data);
/** 回盘数据列表 */
export const fetchDataReply = (data: { page: number, size: number }) => request.post("/factor/v1/fetch/data_reply", data);
/** 贷款支付数据列表 */
export const fetchLoanPayment = (data: { page: number, size: number, status?: number }) => request.post("/factor/v1/fetch/loan_payment", data);
/** 获取日报数据 */
export const fetchOverviewReports = (data: { page: number, size: number }) => request.post("/factor/v1/reports/overview", data);
/** 获取日报数据 */
export const fetchDailyReports = (data: { page: number, size: number }) => request.post("/factor/v1/reports/days", data);
/** 获取周报数据 */
export const fetchWeeklyReports = (data: { page: number, size: number }) => request.post("/factor/v1/reports/weeks", data);
/** 获取月报数据 */
export const fetchMonthlyReports = (data: { page: number, size: number }) => request.post("/factor/v1/reports/months", data);
/** 获取批次详情 */
export const getLoanBatchDetail = (data: { batch_id: number }) => request.post(`/factor/v1/get/batch`,data);

39
src/api/module/pmn.ts Normal file
View File

@@ -0,0 +1,39 @@
import { request } from "@/api/request";
export interface MenuItem {
id?: number;
identity?: string;
code?: string;
description?: string;
menu_icon?: string;
menu_path?: string;
parent_id?: number | null;
title?: string;
title_en?: string;
type?: number;
sort_key?: number;
order?: number; // 排序字段(用于本地排序)
app_id?: number;
created_at?: string;
children?: MenuItem[];
is_web_page?: boolean; // 是否为网页
web_url?: string; // 嵌入的网页URL
}
/** 获取菜单 */
export const fetchMenu = (data: { page: number, size: number }) => request.post("/rbac2/v1/pmn/fetch", data, { needWorkspace: true });
/** 创建菜单 */
export const createMenu = (data: MenuItem) => request.post("/rbac2/v1/pmn/create", data, { needWorkspace: true });
/** 修改菜单 */
export const modifyMenu = (data: MenuItem) => request.post("/rbac2/v1/pmn/modify", data, { needWorkspace: true });
/** 删除菜单 */
export const deleteMenu = (data: { id: MenuItem['id'] }) => request.post("/rbac2/v1/pmn/del", data, { needWorkspace: true });
/** 更新菜单排序 */
export const updateMenuOrder = (data: { pmn_id: number, sort_key: number }[]) =>
request.post("/rbac2/v1/pmn/sort", data);
/** 用户-给指定用户设置权限 */
export const userPmn = (data: { code: string; list: { id: number, pmn_id: number }[] }) => request.post("/rbac2/v1/pmn/user_pmn", data, { needWorkspace: true });
/** 用户-给指定用户设置权限 */
export const userSetPmn = (data: { list: { id: number, pmn_id: number }[] }) => request.post("/rbac2/v1/pmn/user/set_pmn", data, { needWorkspace: true });

35
src/api/module/report.ts Normal file
View File

@@ -0,0 +1,35 @@
import { request } from "@/api/request";
/** 获取报表 */
export const fetchReports = (data: { page: number, size: number, keyword?: string }) => request.get("/api/v1/report/basics", { params: data });
/** 创建报表任务 */
export const fetchReportNewTask = (keyName:string,data: any) => request.post("/api/v1/report/task/new/"+keyName, data);
/** 获取报表基础信息详情 */
export const fetchReportBasicInfo = (id: string) => request.get(`/api/v1/report/basics/${id}`);
/** 获取指定报表的调度列表 */
export const fetchReportSchedules = (report_basic_id: string) => request.get(`/api/v1/report/schedules/${report_basic_id}`);
/** 批量创建报表调度 */
export const fetchReportSchedulesBatch = (data: any) => request.post("/api/v1/report/schedules/batch", data);
/** 获取指定报表的历史记录列表 */
export const fetchReportHistories = (report_basic_id: string) => request.get(`/api/v1/report/histories/${report_basic_id}?status=2`);
/** 获取报表历史记录列表 */
export const fetchReportHistoriesList = (data: { page: number, size: number, keyword?: string }) => request.get("/api/v1/report/histories", { params: data });
/** 获取邮件推送记录列表 */
export enum PushStatus {
/** 发送成功 */
Success = 1,
/** 发送失败 */
Failed = 2
}
export const fetchReportPushesList = (data: { page: number, size: number, keyword?: string, status?: PushStatus }) => request.get("/api/v1/report/pushes", { params: data });
/** 重新发送邮件 */
export const fetchReportPushesResend = (id: number) => request.post(`/api/v1/report/pushes/${id}`);

41
src/api/module/user.ts Normal file
View File

@@ -0,0 +1,41 @@
import { request } from "@/api/request";
import { LoginRequest, UserItem } from "../types";
/** 登录 */
export const login = (data: LoginRequest) => request.post("/rbac2/v1/login", data);
/** 登出 */
export const logout = () => request.post("/rbac2/v1/logout");
/** 获取菜单列表 */
export const getMenuList = () => request.post("/rbac2/v1/user/menu");
/** 创建用户 */
export const createUser = (data: UserItem) => request.post("/rbac2/v1/user/create", data, { needWorkspace: true });
/** 获取用户详情 */
export const getUserDetail = (data: UserItem) => request.post("/rbac2/v1/user/detail", data);
/** 获取用户列表 */
export const fetchUserList = (data: { page: number, size: number, keyword?: string }) => request.post("/rbac2/v1/user/fetch", data);
/** 修改用户 */
export const modifyUser = (data: UserItem) => request.post("/rbac2/v1/user/modify", data);
/** 删除用户 */
export const deleteUser = (data: { id: UserItem['id'] }) => request.post("/rbac2/v1/user/del", data);
/** 获取所有权限【树形】 */
export const getUserPmnTree = (data: { id: UserItem['id'] }) => request.post("/rbac2/v1/user/pmn_tree", data);
/** 用户-获取所有权限【平面】 */
export const userPmn = (data: { id: UserItem['id'], workspace: string }) => request.post("/rbac2/v1/user/pmn", data, { needWorkspace: true });
/** 用户-给指定用户设置权限 */
export const userSetPmn = (data: { list: { id: number, pmn_id: number }[] }) => request.post("/rbac2/v1/user/set_pmn", data, { needWorkspace: true });
/** 用户-给指定用户编辑权限 */
export const userModifyPmn = (data: { id: UserItem['id'], list: number[] }) => request.post("/rbac2/v1/user/modify_pmn", data, { needWorkspace: true });
/** 重置密码(短信) */
export const resetUserPassword = (data: {
/** 账号 */
account: string;
/** 验证码 */
code: string;
/** 密码 */
password: string;
/** 手机号 */
phone: string;
}) => request.post("/rbac2/v1/reset", data);

91
src/api/request.ts Normal file
View File

@@ -0,0 +1,91 @@
import axios, {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig
} from "axios";
import { v4 as uuidv4 } from 'uuid';
import SafeStorage, { AppStorageKey } from "@/utils/safeStorage";
console.log('import.meta.env.VITE_API_BASE_UR:', import.meta.env.VITE_API_BASE_URL)
// 1. 创建axios实例
const instance: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
headers: {
"Content-Type": "application/json",
"Workspace": import.meta.env.VITE_APP_WORKSPACE,
"Request-Id": uuidv4(),
},
});
// 2. 请求拦截器
instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 添加认证token示例
const token = SafeStorage.get(AppStorageKey.TOKEN);
if (token) {
config.headers.Authorization = token as string;
}
return config;
},
(error) => Promise.reject(error)
);
// 3. 响应拦截器
instance.interceptors.response.use(
(response: AxiosResponse) => {
// 统一处理响应数据格式[2](@ref)
if (response.data.status === 401) {
// token过期处理
SafeStorage.clearAppStorage();
window.location.href = "/auth/login";
}
return response.data; // 直接返回核心数据[1](@ref)
},
(error) => {
if (error?.response?.data?.error === 'Token has expired') {
// token过期处理
SafeStorage.clearAppStorage();
window.location.href = "/auth/login";
}
// 统一错误处理
console.error("API Error:", error.message);
return Promise.reject(error);
}
);
// 4. 封装核心请求方法
interface RequestConfig extends AxiosRequestConfig {
data?: unknown;
needWorkspace?: boolean;
}
export const request = {
get<T = any>(url: string, config?: RequestConfig): Promise<T> {
return instance.get(url, config);
},
post<T = any>(url: string, data = {}, config?: RequestConfig): Promise<T> {
let params: any
if (config?.needWorkspace) {
params = { workspace: import.meta.env.VITE_APP_WORKSPACE, ...data };
} else {
params = data;
}
return instance.post(url, params, config);
},
put<T = any>(url: string, data = {}, config?: RequestConfig): Promise<T> {
let params: any
if (config?.needWorkspace) {
params = { workspace: import.meta.env.VITE_APP_WORKSPACE, ...data };
} else {
params = data;
}
return instance.put(url, params, config);
},
delete<T = any>(url: string, config?: RequestConfig): Promise<T> {
return instance.delete(url, config);
}
};
export default instance;

39
src/api/types.ts Normal file
View File

@@ -0,0 +1,39 @@
/** 登录请求参数 */
export interface LoginRequest {
/** 账号 */
account: string
/** 密码 */
password: string
}
/** 登录表单数据 */
export interface LoginData {
account: string
password: string
}
/** 用户信息 */
export interface UserItem {
id?: number
username?: string
nickname?: string
email?: string
phone?: string
avatar?: string
status?: number
created_at?: string
updated_at?: string
}
/** 登录响应 */
export interface LoginResponse {
token: string
user?: UserItem
}
/** 通用响应结构 */
export interface ApiResponse<T = any> {
status: number
data: T
message?: string
}

View File

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

View File

@@ -0,0 +1,299 @@
<template>
<div class="data-table">
<!-- 工具栏 -->
<a-row v-if="showToolbar" style="margin-bottom: 16px">
<a-col :span="12">
<a-space>
<slot name="toolbar-left" />
</a-space>
</a-col>
<a-col :span="12" style="display: flex; align-items: center; justify-content: end">
<slot name="toolbar-right" />
<a-button v-if="showDownload" @click="handleDownload">
<template #icon>
<icon-download />
</template>
{{ downloadButtonText }}
</a-button>
<a-tooltip v-if="showRefresh" :content="refreshTooltipText">
<div class="action-icon" @click="handleRefresh"><icon-refresh size="18" /></div>
</a-tooltip>
<a-dropdown v-if="showDensity" @select="handleSelectDensity">
<a-tooltip :content="densityTooltipText">
<div class="action-icon"><icon-line-height size="18" /></div>
</a-tooltip>
<template #content>
<a-doption v-for="item in densityList" :key="item.value" :value="item.value" :class="{ active: item.value === size }">
<span>{{ item.name }}</span>
</a-doption>
</template>
</a-dropdown>
<a-tooltip v-if="showColumnSetting" :content="columnSettingTooltipText">
<a-popover trigger="click" position="bl" @popup-visible-change="popupVisibleChange">
<div class="action-icon"><icon-settings size="18" /></div>
<template #content>
<div ref="columnSettingRef" class="column-setting-container">
<div v-for="(item, idx) in showColumns" :key="item.dataIndex" class="setting">
<div style="margin-right: 4px; cursor: move">
<icon-drag-arrow />
</div>
<div>
<a-checkbox v-model="item.checked" @change="handleChange($event, item, idx)"></a-checkbox>
</div>
<div class="title">
{{ item.title === '#' ? '序列号' : item.title }}
</div>
</div>
</div>
</template>
</a-popover>
</a-tooltip>
</a-col>
</a-row>
<!-- 表格 -->
<a-table
row-key="id"
:loading="loading"
:pagination="pagination"
:columns="cloneColumns"
:data="data"
:bordered="bordered"
:size="size"
@page-change="onPageChange"
>
<!-- 动态插槽根据 columns slotName 动态渲染 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</a-table>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch, nextTick, onUnmounted, PropType } from 'vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import cloneDeep from 'lodash/cloneDeep'
import Sortable from 'sortablejs'
type SizeProps = 'mini' | 'small' | 'medium' | 'large'
type Column = TableColumnData & { checked?: boolean }
const props = defineProps({
data: {
type: Array as PropType<any[]>,
default: () => [],
},
columns: {
type: Array as PropType<TableColumnData[]>,
required: true,
},
loading: {
type: Boolean,
default: false,
},
pagination: {
type: Object as PropType<{
current: number
pageSize: number
total?: number
}>,
default: () => ({
current: 1,
pageSize: 20,
}),
},
bordered: {
type: Boolean,
default: false,
},
showToolbar: {
type: Boolean,
default: true,
},
showDownload: {
type: Boolean,
default: true,
},
showRefresh: {
type: Boolean,
default: true,
},
showDensity: {
type: Boolean,
default: true,
},
showColumnSetting: {
type: Boolean,
default: true,
},
downloadButtonText: {
type: String,
default: '下载',
},
refreshTooltipText: {
type: String,
default: '刷新',
},
densityTooltipText: {
type: String,
default: '密度',
},
columnSettingTooltipText: {
type: String,
default: '列设置',
},
})
const emit = defineEmits<{
(e: 'page-change', current: number): void
(e: 'refresh'): void
(e: 'download'): void
(e: 'density-change', size: SizeProps): void
(e: 'column-change', columns: TableColumnData[]): void
}>()
const size = ref<SizeProps>('medium')
const cloneColumns = ref<Column[]>([])
const showColumns = ref<Column[]>([])
const columnSettingRef = ref<HTMLElement | null>(null)
// Sortable 实例缓存
let sortableInstance: Sortable | null = null
// 密度列表
const densityList = [
{ name: '迷你', value: 'mini' as SizeProps },
{ name: '偏小', value: 'small' as SizeProps },
{ name: '中等', value: 'medium' as SizeProps },
{ name: '偏大', value: 'large' as SizeProps },
]
// 计算需要插槽的列(只在 columns 变化时重新计算)
const slotColumns = computed(() => {
return props.columns.filter(col => col.slotName)
})
const onPageChange = (current: number) => {
emit('page-change', current)
}
const handleRefresh = () => {
emit('refresh')
}
const handleDownload = () => {
emit('download')
}
const handleSelectDensity = (val: string | number | Record<string, any> | undefined) => {
size.value = val as SizeProps
emit('density-change', size.value)
}
const handleChange = (checked: boolean | (string | boolean | number)[], column: Column, index: number) => {
if (!checked) {
cloneColumns.value = showColumns.value.filter((item) => item.dataIndex !== column.dataIndex)
} else {
cloneColumns.value.splice(index, 0, column)
}
emit('column-change', cloneColumns.value)
}
const exchangeArray = <T extends Array<any>>(array: T, beforeIdx: number, newIdx: number): T => {
if (beforeIdx > -1 && newIdx > -1 && beforeIdx !== newIdx) {
const temp = array[beforeIdx]
array.splice(beforeIdx, 1)
array.splice(newIdx, 0, temp)
}
return array
}
const popupVisibleChange = (val: boolean) => {
if (val) {
nextTick(() => {
if (columnSettingRef.value && !sortableInstance) {
sortableInstance = new Sortable(columnSettingRef.value, {
animation: 150,
onEnd(e: any) {
const { oldIndex, newIndex } = e
if (oldIndex !== undefined && newIndex !== undefined) {
exchangeArray(cloneColumns.value, oldIndex, newIndex)
exchangeArray(showColumns.value, oldIndex, newIndex)
emit('column-change', cloneColumns.value)
}
},
})
}
})
}
}
// 初始化列配置
const initColumns = () => {
const cols = props.columns.map(item => ({
...item,
checked: true,
}))
cloneColumns.value = cols
showColumns.value = cloneDeep(cols)
}
// 监听列配置变化
watch(
() => props.columns,
(val, oldVal) => {
if (val !== oldVal || cloneColumns.value.length === 0) {
initColumns()
}
},
{ immediate: true }
)
// 组件卸载时销毁 Sortable 实例
onUnmounted(() => {
if (sortableInstance) {
sortableInstance.destroy()
sortableInstance = null
}
})
</script>
<script lang="ts">
export default {
name: 'DataTable',
}
</script>
<style scoped lang="less">
.data-table {
:deep(.arco-table-th) {
&:last-child {
.arco-table-th-item-title {
margin-left: 16px;
}
}
}
}
.action-icon {
margin-left: 12px;
cursor: pointer;
}
.active {
color: #0960bd;
background-color: #e3f4fc;
}
.column-setting-container {
max-height: 300px;
overflow-y: auto;
}
.setting {
display: flex;
align-items: center;
width: 200px;
.title {
margin-left: 12px;
cursor: pointer;
}
}
</style>

View File

@@ -5,6 +5,9 @@ import { CanvasRenderer } from 'echarts/renderers'
import { App } from 'vue'
import Breadcrumb from './breadcrumb/index.vue'
import Chart from './chart/index.vue'
import SearchForm from './search-form/index.vue'
import DataTable from './data-table/index.vue'
import SearchTable from './search-table/index.vue'
// Manually introduce ECharts modules to reduce packing size
@@ -25,5 +28,8 @@ export default {
install(Vue: App) {
Vue.component('Chart', Chart)
Vue.component('Breadcrumb', Breadcrumb)
Vue.component('SearchForm', SearchForm)
Vue.component('DataTable', DataTable)
Vue.component('SearchTable', SearchTable)
},
}

View File

@@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'
import type { RouteMeta } from 'vue-router'
import { RouteRecordRaw, useRoute, useRouter } from 'vue-router'
import useMenuTree from './use-menu-tree'
import { COMMON_ICONS } from '@/views/ops/pages/system-settings/menu-management/menuIcons'
export default defineComponent({
emit: ['collapse'],
@@ -85,12 +86,28 @@ export default defineComponent({
if (appStore.device === 'desktop') appStore.updateSettings({ menuCollapse: val })
}
// 获取图标组件 - 支持 Arco Design 图标和 @tabler/icons-vue
const getIconComponent = (iconName: string) => {
if (!iconName) return null
// 检查是否是 Tabler 图标(不以 'icon-' 开头)
if (!iconName.startsWith('icon-')) {
const IconComponent = COMMON_ICONS[iconName]
if (IconComponent) {
return () => h(IconComponent, { size: 18 })
}
}
// 回退到 Arco Design 图标
return () => h(compile(`<${iconName}/>`))
}
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 icon = element?.meta?.icon ? getIconComponent(element?.meta?.icon as string) : null
const node =
element?.children && element?.children.length !== 0 ? (
<a-sub-menu

View File

@@ -0,0 +1,136 @@
<template>
<a-row>
<a-col :flex="1">
<a-form
:model="localModel"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 18 }"
label-align="left"
>
<a-row :gutter="16">
<a-col v-for="item in formItems" :key="item.field" :span="item.span || 8">
<a-form-item :field="item.field" :label="item.label">
<!-- 输入框 -->
<a-input
v-if="item.type === 'input'"
v-model="localModel[item.field]"
:placeholder="item.placeholder"
allow-clear
/>
<!-- 选择框 -->
<a-select
v-else-if="item.type === 'select'"
v-model="localModel[item.field]"
:options="item.options"
:placeholder="item.placeholder || '请选择'"
allow-clear
/>
<!-- 日期范围选择器 -->
<a-range-picker
v-else-if="item.type === 'dateRange'"
v-model="localModel[item.field]"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-col>
<a-divider v-if="showButtons" style="height: 84px" direction="vertical" />
<a-col v-if="showButtons" :flex="'86px'" style="text-align: right">
<a-space direction="vertical" :size="18">
<a-button type="primary" @click="handleSearch">
<template #icon>
<icon-search />
</template>
{{ searchButtonText }}
</a-button>
<a-button @click="handleReset">
<template #icon>
<icon-refresh />
</template>
{{ resetButtonText }}
</a-button>
</a-space>
</a-col>
</a-row>
</template>
<script lang="ts" setup>
import { PropType, reactive, watch } from 'vue'
import type { FormItem } from './types'
const props = defineProps({
modelValue: {
type: Object as PropType<Record<string, any>>,
required: true,
},
formItems: {
type: Array as PropType<FormItem[]>,
default: () => [],
},
showButtons: {
type: Boolean,
default: true,
},
searchButtonText: {
type: String,
default: '查询',
},
resetButtonText: {
type: String,
default: '重置',
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search'): void
(e: 'reset'): void
}>()
// 使用本地响应式副本,避免直接修改 props
const localModel = reactive<Record<string, any>>({})
// 初始化本地模型
const initLocalModel = () => {
Object.keys(localModel).forEach(key => delete localModel[key])
Object.assign(localModel, props.modelValue)
}
// 监听外部值变化
watch(
() => props.modelValue,
(val) => {
Object.keys(localModel).forEach(key => delete localModel[key])
Object.assign(localModel, val)
},
{ immediate: true, deep: true }
)
// 监听本地值变化,同步到外部
watch(
localModel,
(val) => {
emit('update:modelValue', { ...val })
},
{ deep: true }
)
const handleSearch = () => {
emit('search')
}
const handleReset = () => {
emit('reset')
}
</script>
<script lang="ts">
export default {
name: 'SearchForm',
}
</script>
<style scoped lang="less">
</style>

View File

@@ -0,0 +1,10 @@
import type { SelectOptionData } from '@arco-design/web-vue/es/select/interface'
export interface FormItem {
field: string
label: string
type: 'input' | 'select' | 'dateRange' | 'slot'
span?: number
placeholder?: string
options?: SelectOptionData[]
}

View File

@@ -0,0 +1,217 @@
<template>
<div class="search-table-container">
<a-card class="general-card" :title="title">
<!-- 搜索表单 -->
<SearchForm
:model-value="formModel"
:form-items="formItems"
:show-buttons="showSearchButtons"
:search-button-text="searchButtonText"
:reset-button-text="resetButtonText"
@update:model-value="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
/>
<a-divider style="margin-top: 0" />
<!-- 数据表格 -->
<DataTable
:data="data"
:columns="columns"
:loading="loading"
:pagination="pagination"
:bordered="bordered"
:show-toolbar="showToolbar"
:show-download="showDownload"
:show-refresh="showRefresh"
:show-density="showDensity"
:show-column-setting="showColumnSetting"
:download-button-text="downloadButtonText"
:refresh-tooltip-text="refreshTooltipText"
:density-tooltip-text="densityTooltipText"
:column-setting-tooltip-text="columnSettingTooltipText"
@page-change="handlePageChange"
@refresh="handleRefresh"
@download="handleDownload"
@density-change="handleDensityChange"
@column-change="handleColumnChange"
>
<template #toolbar-left>
<slot name="toolbar-left" />
</template>
<template #toolbar-right>
<slot name="toolbar-right" />
</template>
<!-- 动态插槽透传 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</DataTable>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { computed, PropType } from 'vue'
import SearchForm from '../search-form/index.vue'
import type { FormItem } from '../search-form/types'
import DataTable from '../data-table/index.vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
type SizeProps = 'mini' | 'small' | 'medium' | 'large'
const props = defineProps({
// 表单相关
formModel: {
type: Object as PropType<Record<string, any>>,
required: true,
},
formItems: {
type: Array as PropType<FormItem[]>,
default: () => [],
},
showSearchButtons: {
type: Boolean,
default: true,
},
searchButtonText: {
type: String,
default: '查询',
},
resetButtonText: {
type: String,
default: '重置',
},
// 表格相关
data: {
type: Array as PropType<any[]>,
default: () => [],
},
columns: {
type: Array as PropType<TableColumnData[]>,
required: true,
},
loading: {
type: Boolean,
default: false,
},
pagination: {
type: Object as PropType<{
current: number
pageSize: number
total?: number
}>,
default: () => ({
current: 1,
pageSize: 20,
}),
},
bordered: {
type: Boolean,
default: false,
},
// 工具栏相关
showToolbar: {
type: Boolean,
default: true,
},
showDownload: {
type: Boolean,
default: true,
},
showRefresh: {
type: Boolean,
default: true,
},
showDensity: {
type: Boolean,
default: true,
},
showColumnSetting: {
type: Boolean,
default: true,
},
// 文本配置
title: {
type: String,
default: '',
},
downloadButtonText: {
type: String,
default: '下载',
},
refreshTooltipText: {
type: String,
default: '刷新',
},
densityTooltipText: {
type: String,
default: '密度',
},
columnSettingTooltipText: {
type: String,
default: '列设置',
},
})
const emit = defineEmits<{
(e: 'update:formModel', value: Record<string, any>): void
(e: 'search'): void
(e: 'reset'): void
(e: 'page-change', current: number): void
(e: 'refresh'): void
(e: 'download'): void
(e: 'density-change', size: SizeProps): void
(e: 'column-change', columns: TableColumnData[]): void
}>()
// 计算需要插槽的列(动态插槽透传)
const slotColumns = computed(() => {
return props.columns.filter(col => col.slotName)
})
const handleFormModelUpdate = (value: Record<string, any>) => {
emit('update:formModel', value)
}
const handleSearch = () => {
emit('search')
}
const handleReset = () => {
emit('reset')
}
const handlePageChange = (current: number) => {
emit('page-change', current)
}
const handleRefresh = () => {
emit('refresh')
}
const handleDownload = () => {
emit('download')
}
const handleDensityChange = (size: SizeProps) => {
emit('density-change', size)
}
const handleColumnChange = (columns: TableColumnData[]) => {
emit('column-change', columns)
}
</script>
<script lang="ts">
export default {
name: 'SearchTable',
}
</script>
<style scoped lang="less">
.search-table-container {
padding: 0 20px 20px 20px;
}
</style>

View File

@@ -12,6 +12,6 @@
"globalSettings": false,
"device": "desktop",
"tabBar": true,
"menuFromServer": false,
"menuFromServer": true,
"serverMenu": []
}

View File

@@ -5,7 +5,6 @@ import localeWorkplace from '@/views/dashboard/workplace/locale/en-US'
/** simple */
import localeMonitor from '@/views/dashboard/monitor/locale/en-US'
import localeCardList from '@/views/list/card/locale/en-US'
import localeSearchTable from '@/views/list/search-table/locale/en-US'
import localeBasicProfile from '@/views/profile/basic/locale/en-US'
@@ -17,8 +16,6 @@ import locale403 from '@/views/exception/403/locale/en-US'
import locale404 from '@/views/exception/404/locale/en-US'
import locale500 from '@/views/exception/500/locale/en-US'
import localeUserInfo from '@/views/user/info/locale/en-US'
import localeUserSetting from '@/views/user/setting/locale/en-US'
/** simple end */
import localeSettings from './en-US/settings'
@@ -28,6 +25,57 @@ export default {
'menu.server.workplace': 'Workplace-Server',
'menu.server.monitor': 'Monitor-Server',
'menu.list': 'List',
'menu.ops': 'Operations',
'menu.ops.systemSettings': 'System Settings',
'menu.ops.systemSettings.menuManagement': 'Menu Management',
'menu.ops.systemSettings.systemLogs': 'System Logs',
'menu.management': 'Menu Management',
'menu.addRoot': 'Add Root Menu',
'menu.tip': 'Click a menu item to edit, hover to show action buttons',
'menu.empty': 'No menu data',
'menu.selectTip': 'Please select a menu item from the left to edit',
'menu.group': 'Group',
'menu.addChild': 'Add Child Menu',
'menu.delete': 'Delete',
'menu.edit': 'Edit',
'menu.addChildFor': 'Add child for',
'menu.add': 'Add',
'menu.details': 'Details',
'menu.parent': 'Parent Menu',
'menu.deleteConfirm': 'Delete Confirmation',
'menu.deleteConfirmText': 'Are you sure you want to delete menu "{name}"?',
'menu.deleteChildrenWarning': 'Warning: This menu has child menus, they will also be deleted!',
'menu.deleteSuccess': 'Deleted successfully',
'menu.saveSuccess': 'Saved successfully',
'menu.basicInfo': 'Basic Information',
'menu.code': 'Menu Code',
'menu.codeRequired': 'Please enter menu code',
'menu.codePlaceholder': 'Enter menu code, e.g.: dashboard',
'menu.type': 'Menu Type',
'menu.submenu': 'Submenu',
'menu.rootMenu': 'Root Menu',
'menu.title': 'Menu Title',
'menu.titleRequired': 'Please enter menu title',
'menu.titlePlaceholder': 'Enter Chinese title',
'menu.titleEn': 'English Title',
'menu.titleEnRequired': 'Please enter English title',
'menu.titleEnPlaceholder': 'Enter English title',
'menu.path': 'Menu Path',
'menu.pathRequired': 'Please enter menu path',
'menu.pathPlaceholder': 'Enter menu path, e.g.: /dashboard',
'menu.icon': 'Menu Icon',
'menu.iconRequired': 'Please select menu icon',
'menu.iconPlaceholder': 'Click to select icon',
'menu.description': 'Description',
'menu.descriptionPlaceholder': 'Enter menu description',
'menu.webConfig': 'Web Page Configuration',
'menu.isWebPage': 'Is Embedded Web Page',
'menu.webUrl': 'Web URL',
'menu.webUrlPlaceholder': 'Enter the URL to embed',
'menu.save': 'Save',
'menu.cancel': 'Cancel',
'menu.selectIcon': 'Select Icon',
'menu.iconSearch': 'Search icon',
'menu.result': 'Result',
'menu.exception': 'Exception',
'menu.profile': 'Profile',
@@ -44,14 +92,11 @@ export default {
/** simple */
...localeMonitor,
...localeSearchTable,
...localeCardList,
...localeBasicProfile,
...localeDataAnalysis,
...localeMultiDAnalysis,
...locale403,
...locale404,
...locale500,
...localeUserInfo,
...localeUserSetting,
/** simple end */
}

View File

@@ -5,7 +5,6 @@ import localeWorkplace from '@/views/dashboard/workplace/locale/zh-CN'
/** simple */
import localeMonitor from '@/views/dashboard/monitor/locale/zh-CN'
import localeCardList from '@/views/list/card/locale/zh-CN'
import localeSearchTable from '@/views/list/search-table/locale/zh-CN'
import localeBasicProfile from '@/views/profile/basic/locale/zh-CN'
@@ -17,8 +16,6 @@ import locale403 from '@/views/exception/403/locale/zh-CN'
import locale404 from '@/views/exception/404/locale/zh-CN'
import locale500 from '@/views/exception/500/locale/zh-CN'
import localeUserInfo from '@/views/user/info/locale/zh-CN'
import localeUserSetting from '@/views/user/setting/locale/zh-CN'
/** simple end */
import localeSettings from './zh-CN/settings'
@@ -28,6 +25,57 @@ export default {
'menu.server.workplace': '工作台-服务端',
'menu.server.monitor': '实时监控-服务端',
'menu.list': '列表页',
'menu.ops': '运维管理',
'menu.ops.systemSettings': '系统设置',
'menu.ops.systemSettings.menuManagement': '菜单管理',
'menu.ops.systemSettings.systemLogs': '系统日志',
'menu.management': '菜单管理',
'menu.addRoot': '添加根菜单',
'menu.tip': '点击菜单项可编辑,悬停显示操作按钮',
'menu.empty': '暂无菜单数据',
'menu.selectTip': '请从左侧选择一个菜单项进行编辑',
'menu.group': '分组',
'menu.addChild': '添加子菜单',
'menu.delete': '删除',
'menu.edit': '编辑',
'menu.addChildFor': '为',
'menu.add': '添加',
'menu.details': '详情',
'menu.parent': '父菜单',
'menu.deleteConfirm': '删除确认',
'menu.deleteConfirmText': '确定要删除菜单 "{name}" 吗?',
'menu.deleteChildrenWarning': '注意:该菜单下有子菜单,删除后子菜单也会被删除!',
'menu.deleteSuccess': '删除成功',
'menu.saveSuccess': '保存成功',
'menu.basicInfo': '基本信息',
'menu.code': '菜单编码',
'menu.codeRequired': '请输入菜单编码',
'menu.codePlaceholder': '请输入菜单编码dashboard',
'menu.type': '菜单类型',
'menu.submenu': '子菜单',
'menu.rootMenu': '根菜单',
'menu.title': '菜单标题',
'menu.titleRequired': '请输入菜单标题',
'menu.titlePlaceholder': '请输入中文标题',
'menu.titleEn': '英文标题',
'menu.titleEnRequired': '请输入英文标题',
'menu.titleEnPlaceholder': '请输入英文标题',
'menu.path': '菜单路径',
'menu.pathRequired': '请输入菜单路径',
'menu.pathPlaceholder': '请输入菜单路径,如:/dashboard',
'menu.icon': '菜单图标',
'menu.iconRequired': '请选择菜单图标',
'menu.iconPlaceholder': '点击选择图标',
'menu.description': '描述',
'menu.descriptionPlaceholder': '请输入菜单描述',
'menu.webConfig': '网页嵌入配置',
'menu.isWebPage': '是否为嵌入网页',
'menu.webUrl': '网页地址',
'menu.webUrlPlaceholder': '请输入要嵌入的网页URL',
'menu.save': '保存',
'menu.cancel': '取消',
'menu.selectIcon': '选择图标',
'menu.iconSearch': '搜索图标',
'menu.result': '结果页',
'menu.exception': '异常页',
'menu.profile': '详情页',
@@ -44,14 +92,11 @@ export default {
/** simple */
...localeMonitor,
...localeSearchTable,
...localeCardList,
...localeBasicProfile,
...localeDataAnalysis,
...localeMultiDAnalysis,
...locale403,
...locale404,
...locale500,
...localeUserInfo,
...localeUserSetting,
/** simple end */
}

View File

@@ -7,7 +7,6 @@ import '@/views/dashboard/workplace/mock'
/** simple */
import '@/views/dashboard/monitor/mock'
import '@/views/list/card/mock'
import '@/views/list/search-table/mock'
import '@/views/profile/basic/mock'
@@ -15,8 +14,6 @@ import '@/views/profile/basic/mock'
import '@/views/visualization/data-analysis/mock'
import '@/views/visualization/multi-dimension-data-analysis/mock'
import '@/views/user/info/mock'
import '@/views/user/setting/mock'
/** simple end */
Mock.setup({

View File

@@ -0,0 +1,932 @@
import type { MenuItem } from '@/api/module/pmn'
// 本地菜单数据(扁平格式)- 接口未准备好时使用
// 这是从 fetchMenu 接口返回的原始扁平数据格式
export const localMenuFlatItems: MenuItem[] = [
{
id: 8,
identity: '019b591d-0064-7dcc-9c78-59b5f4aa31a7',
title: '系统概况',
title_en: 'Home',
code: 'ops:系统概况',
description: '首页菜单',
app_id: 2,
menu_path: '/overview',
menu_icon: 'Home',
type: 1,
sort_key: 1,
created_at: '2025-12-26T13:23:51.54067+08:00',
},
{
id: 13,
identity: '019b591d-00c3-7955-aa1b-80b5a0c8d6bd',
title: '可视化大屏管理',
title_en: 'Visual Dashboard Management',
code: 'ops:可视化大屏管理',
description: '可视化大屏管理菜单',
app_id: 2,
menu_path: '/visual/',
menu_icon: 'Virus',
type: 1,
sort_key: 2,
created_at: '2025-12-26T13:23:51.62748+08:00',
},
{
id: 14,
identity: '019b591d-00dc-7486-aa93-51e798d3253a',
title: '大屏管理',
title_en: 'My Components',
code: 'ops:可视化大屏管理:我的组件',
description: '可视化大屏管理 - 我的组件',
app_id: 2,
parent_id: 13,
menu_path: '/visual/component',
menu_icon: 'appstore',
type: 1,
sort_key: 3,
is_web_page: true,
web_url: 'https://ops.apinb.com/view/#/project/items',
created_at: '2025-12-26T13:23:51.644296+08:00',
},
{
id: 85,
identity: '019bf309-a961-7bba-a03d-83eb78a82437',
title: '已发布大屏列表',
title_en: 'View Publish',
code: '已发布大屏列表',
app_id: 2,
parent_id: 13,
menu_path: '/visual/view-publish',
type: 1,
sort_key: 4,
is_web_page: true,
web_url: 'https://ops.apinb.com/view/#/project/management',
created_at: '2026-01-25T10:44:15.33024+08:00',
},
{
id: 16,
identity: '019b591d-00f4-73a0-bbdb-aa7da79ed390',
title: '服务器及PC管理',
title_en: '服务器及pc管理',
code: 'ops:服务器及pc管理',
description: '服务器及PC管理菜单',
app_id: 2,
menu_path: '/dc/',
menu_icon: 'Building',
type: 1,
sort_key: 5,
created_at: '2025-12-26T13:23:51.675908+08:00',
},
{
id: 18,
identity: '019b591d-011a-7dca-b85c-4802ad0e5d05',
title: '办公PC管理',
title_en: '办公pc管理',
code: 'ops:服务器及pc管理:办公pc管理',
description: '服务器及PC管理 - 办公PC管理',
app_id: 2,
parent_id: 16,
menu_path: '/dc/pc',
menu_icon: 'appstore',
type: 1,
sort_key: 6,
created_at: '2025-12-26T13:23:51.706903+08:00',
},
{
id: 17,
identity: '019b591d-010b-790f-adbc-401ee42c20ae',
title: '服务器管理',
title_en: '服务器管理',
code: 'ops:服务器及pc管理:服务器管理',
description: '服务器及PC管理 - 服务器管理',
app_id: 2,
parent_id: 16,
menu_path: '/dc/server',
menu_icon: 'appstore',
type: 1,
sort_key: 7,
created_at: '2025-12-26T13:23:51.691593+08:00',
},
{
id: 19,
identity: '019b591d-0122-7959-a3c3-f5226d0bf278',
title: '集群采集控制中心',
title_en: 'Cluster Collection Control Center',
code: 'ops:集群采集控制中心',
description: '集群采集控制中心菜单',
app_id: 2,
menu_path: '/dc/',
menu_icon: 'Adjustments',
type: 1,
sort_key: 8,
created_at: '2025-12-26T13:23:51.715137+08:00',
},
{
id: 20,
identity: '019b591d-013a-7c89-be2d-5f03e5d7c5da',
title: '数据库采集管理',
title_en: 'Database Collection Management',
code: 'ops:集群采集控制中心:数据库采集管理',
description: '集群采集控制中心 - 数据库采集管理',
app_id: 2,
parent_id: 19,
menu_path: '/dc/database',
menu_icon: 'appstore',
type: 1,
sort_key: 9,
created_at: '2025-12-26T13:23:51.738821+08:00',
},
{
id: 21,
identity: '019b591d-014a-710a-8b3f-30713e516d70',
title: '中间件采集管理',
title_en: 'Middleware Collection Management',
code: 'ops:集群采集控制中心:中间件采集管理',
description: '集群采集控制中心 - 中间件采集管理',
app_id: 2,
parent_id: 19,
menu_path: '/dc/middleware',
menu_icon: 'appstore',
type: 1,
sort_key: 10,
created_at: '2025-12-26T13:23:51.754068+08:00',
},
{
id: 22,
identity: '019b591d-0159-7e46-a5e0-fb69c6b62a25',
title: '网络设备采集管理',
title_en: 'Network Device Collection Management',
code: 'ops:集群采集控制中心:网络设备采集管理',
description: '集群采集控制中心 - 网络设备采集管理',
app_id: 2,
parent_id: 19,
menu_path: '/dc/network',
menu_icon: 'appstore',
type: 1,
sort_key: 11,
created_at: '2025-12-26T13:23:51.769935+08:00',
},
{
id: 23,
identity: '019b591d-0160-736e-a42d-180fef3fb3be',
title: '综合监控',
title_en: 'Comprehensive Monitoring',
code: 'ops:综合监控',
description: '综合监控菜单',
app_id: 2,
menu_path: '/monitor/',
menu_icon: 'Desktop',
type: 1,
sort_key: 12,
created_at: '2025-12-26T13:23:51.77834+08:00',
},
{
id: 31,
identity: '019b591d-01e3-7adc-b10f-26550a6e3700',
title: '日志监控',
title_en: 'Log Monitoring',
code: 'ops:综合监控:日志监控',
description: '综合监控 - 日志监控',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/log',
menu_icon: 'appstore',
type: 1,
sort_key: 13,
created_at: '2025-12-26T13:23:51.907711+08:00',
},
{
id: 30,
identity: '019b591d-01d4-78b0-89ea-a6ba12914379',
title: '虚拟化监控',
title_en: 'Virtualization Monitoring',
code: 'ops:综合监控:虚拟化监控',
description: '综合监控 - 虚拟化监控',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/virtualization',
menu_icon: 'appstore',
type: 1,
sort_key: 14,
created_at: '2025-12-26T13:23:51.892569+08:00',
},
{
id: 27,
identity: '019b591d-01a5-776f-ac4b-3cd896dd3f48',
title: '存储设备监控',
title_en: 'Storage Device Monitoring',
code: 'ops:综合监控:存储设备监控',
description: '综合监控 - 存储设备监控',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/storage',
menu_icon: 'appstore',
type: 1,
sort_key: 15,
created_at: '2025-12-26T13:23:51.845487+08:00',
},
{
id: 25,
identity: '019b591d-0187-7a06-888f-0e924c11544c',
title: '网络设备监控',
title_en: 'Network Device Monitoring',
code: 'ops:综合监控:网络设备监控',
description: '综合监控 - 网络设备监控',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/network',
menu_icon: 'appstore',
type: 1,
sort_key: 16,
created_at: '2025-12-26T13:23:51.815656+08:00',
},
{
id: 33,
identity: '019b591d-0202-7c99-b768-68f674b1daaa',
title: '电力/UPS/空调/温湿度',
title_en: 'Power/UPS/AC/Temperature',
code: 'ops:综合监控:电力_ups_空调_温湿度',
description: '综合监控 - 电力/UPS/空调/温湿度',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/power',
menu_icon: 'appstore',
type: 1,
sort_key: 17,
created_at: '2025-12-26T13:23:51.938825+08:00',
},
{
id: 32,
identity: '019b591d-01f3-7698-9aad-9f9dbb3be203',
title: 'URL监控',
title_en: 'URL Monitoring',
code: 'ops:综合监控:url监控',
description: '综合监控 - URL监控',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/url',
menu_icon: 'appstore',
type: 1,
sort_key: 18,
created_at: '2025-12-26T13:23:51.923432+08:00',
},
{
id: 34,
identity: '019b591d-0210-7e32-ad40-ebbf2f48282f',
title: '消防/门禁/漏水/有害气体',
title_en: 'Fire/Access/Leakage/Gas',
code: 'ops:综合监控:消防_门禁_漏水_有害气体',
description: '综合监控 - 消防/门禁/漏水/有害气体',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/guard',
menu_icon: 'appstore',
type: 1,
sort_key: 19,
created_at: '2025-12-26T13:23:51.95293+08:00',
},
{
id: 26,
identity: '019b591d-0194-7bde-8437-1cd85a507548',
title: '安全设备监控',
title_en: 'Security Device Monitoring',
code: 'ops:综合监控:安全设备监控',
description: '综合监控 - 安全设备监控',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/security',
menu_icon: 'appstore',
type: 1,
sort_key: 20,
created_at: '2025-12-26T13:23:51.828777+08:00',
},
{
id: 35,
identity: '019b591d-021a-74a3-8092-c15b990f3c7e',
title: '网络架构管理',
title_en: 'Network Architecture Management',
code: 'ops:网络架构管理',
description: '网络架构管理菜单',
app_id: 2,
menu_path: '/netarch/',
menu_icon: 'Laptop',
type: 1,
sort_key: 21,
created_at: '2025-12-26T13:23:51.969818+08:00',
},
{
id: 83,
identity: '019b8e92-3714-7376-936c-570b015b653b',
title: '自动感知拓扑图',
title_en: 'Auto Topo',
code: 'AutoTopo',
app_id: 2,
parent_id: 35,
menu_path: '/netarch/auto-topo',
type: 1,
sort_key: 22,
created_at: '2026-01-05T22:31:45.684645+08:00',
},
{
id: 36,
identity: '019b591d-0231-7667-a9fc-cfeb05da5aab',
title: '拓扑管理',
title_en: 'Topology Management',
code: 'ops:网络架构管理:拓扑管理',
description: '网络架构管理 - 拓扑管理',
app_id: 2,
parent_id: 35,
menu_path: '/netarch/topo-group',
menu_icon: 'appstore',
type: 1,
sort_key: 23,
created_at: '2025-12-26T13:23:51.985419+08:00',
},
{
id: 37,
identity: '019b591d-0240-7d6d-90b8-a0a6303665dc',
title: '流量分析管理',
title_en: 'Traffic Analysis Management',
code: 'ops:网络架构管理:流量分析管理',
description: '网络架构管理 - 流量分析管理',
app_id: 2,
parent_id: 35,
menu_path: '/netarch/traffic',
menu_icon: 'appstore',
type: 1,
sort_key: 24,
created_at: '2025-12-26T13:23:52.000879+08:00',
},
{
id: 38,
identity: '019b591d-024c-7564-942d-cf33e0ed6204',
title: 'IP地址管理',
title_en: 'IP Address Management',
code: 'ops:网络架构管理:ip地址管理',
description: '网络架构管理 - IP地址管理',
app_id: 2,
parent_id: 35,
menu_path: '/netarch/ip',
menu_icon: 'appstore',
type: 1,
sort_key: 25,
created_at: '2025-12-26T13:23:52.012353+08:00',
},
{
id: 39,
identity: '019b591d-0258-7316-9b58-d664678bb3af',
title: '告警管理',
title_en: 'Alert Management',
code: 'ops:告警管理',
description: '告警管理菜单',
app_id: 2,
menu_path: '/alert/',
menu_icon: 'ShieldLock',
type: 1,
sort_key: 26,
created_at: '2025-12-26T13:23:52.032311+08:00',
},
{
id: 45,
identity: '019b591d-02be-713f-91fe-5f5d668380c8',
title: '告警策略管理',
title_en: 'Alert Policy Management',
code: 'ops:告警管理:告警策略管理',
description: '告警管理 - 告警策略管理',
app_id: 2,
parent_id: 39,
menu_path: '/alert/setting',
menu_icon: 'appstore',
type: 1,
sort_key: 27,
created_at: '2025-12-26T13:23:52.126081+08:00',
},
{
id: 43,
identity: '019b591d-029e-7c52-ac1d-d94263e00f8e',
title: '告警受理处理',
title_en: 'Alert Handling',
code: 'ops:告警管理:告警受理处理',
description: '告警管理 - 告警受理处理',
app_id: 2,
parent_id: 39,
menu_path: '/alert/tackle',
menu_icon: 'appstore',
type: 1,
sort_key: 28,
created_at: '2025-12-26T13:23:52.094807+08:00',
},
{
id: 44,
identity: '019b591d-02ae-7587-9e11-7c3d395ea256',
title: '告警历史',
title_en: 'Alert History',
code: 'ops:告警管理:告警历史',
description: '告警管理 - 告警历史',
app_id: 2,
parent_id: 39,
menu_path: '/alert/history',
menu_icon: 'appstore',
type: 1,
sort_key: 29,
created_at: '2025-12-26T13:23:52.110362+08:00',
},
{
id: 40,
identity: '019b591d-026f-785d-b473-ac804133e251',
title: '告警模版',
title_en: 'Alert Template',
code: 'ops:告警管理:告警模版',
description: '告警管理 - 告警模版',
app_id: 2,
parent_id: 39,
menu_path: '/alert/template',
menu_icon: 'appstore',
type: 1,
sort_key: 30,
created_at: '2025-12-26T13:23:52.047548+08:00',
},
{
id: 41,
identity: '019b591d-027d-7eae-b9b9-23fd1b7ece75',
title: '告警通知设置',
title_en: 'Alert Notification Settings',
code: 'ops:告警管理:告警通知设置',
description: '告警管理 - 告警通知设置',
app_id: 2,
parent_id: 39,
menu_path: '/alert/notice',
menu_icon: 'appstore',
type: 1,
sort_key: 31,
created_at: '2025-12-26T13:23:52.061962+08:00',
},
{
id: 42,
identity: '019b591d-028e-7bb4-acf3-8bcb0c5b4b75',
title: '告警级别管理',
title_en: 'Alert Level Management',
code: 'ops:告警管理:告警级别管理',
description: '告警管理 - 告警级别管理',
app_id: 2,
parent_id: 39,
menu_path: '/alert/level',
menu_icon: 'appstore',
type: 1,
sort_key: 32,
created_at: '2025-12-26T13:23:52.078767+08:00',
},
{
id: 46,
identity: '019b591d-02c5-7ba6-ac39-847b5bbdc9c6',
title: '工单管理',
title_en: 'Ticket Management',
code: 'ops:工单管理',
description: '工单管理菜单',
app_id: 2,
menu_path: '/feedback/',
menu_icon: 'Layout',
type: 1,
sort_key: 33,
created_at: '2025-12-26T13:23:52.142086+08:00',
},
{
id: 48,
identity: '019b591d-02ec-7e47-adab-c1968a136eae',
title: '所有工单',
title_en: 'All Tickets',
code: 'ops:工单管理:所有工单',
description: '工单管理 - 所有工单',
app_id: 2,
parent_id: 46,
menu_path: '/feedback/all',
menu_icon: 'appstore',
type: 1,
sort_key: 34,
created_at: '2025-12-26T13:23:52.172935+08:00',
},
{
id: 47,
identity: '019b591d-02dd-75c9-b365-097ef1d5bca3',
title: '我的工单',
title_en: 'Pending Tickets',
code: 'ops:工单管理:待处理工单',
description: '工单管理 - 待处理工单',
app_id: 2,
parent_id: 46,
menu_path: '/feedback/undo',
menu_icon: 'appstore',
type: 1,
sort_key: 35,
created_at: '2025-12-26T13:23:52.157379+08:00',
},
{
id: 49,
identity: '019b591d-02f4-7781-8ef4-3993952290d3',
title: '数据中心管理',
title_en: 'Data Center Management',
code: 'ops:数据中心管理',
description: '数据中心管理菜单',
app_id: 2,
menu_path: '/datacenter/',
menu_icon: 'Storage',
type: 1,
sort_key: 36,
created_at: '2025-12-26T13:23:52.189288+08:00',
},
{
id: 52,
identity: '019b591d-0324-7b16-be4a-d0256a4a66a9',
title: '机柜管理',
title_en: 'Rack Management',
code: 'ops:数据中心管理:机柜管理',
description: '数据中心管理 - 机柜管理',
app_id: 2,
parent_id: 49,
menu_path: '/datacenter/rack',
menu_icon: 'appstore',
type: 1,
sort_key: 37,
created_at: '2025-12-26T13:23:52.228726+08:00',
},
{
id: 50,
identity: '019b591d-0305-75ac-89be-d0c5dd863383',
title: '数据中心',
title_en: 'Data Center',
code: 'ops:数据中心管理:数据中心',
description: '数据中心管理 - 数据中心',
app_id: 2,
parent_id: 49,
menu_path: '/datacenter/house',
menu_icon: 'appstore',
type: 1,
sort_key: 38,
created_at: '2025-12-26T13:23:52.197371+08:00',
},
{
id: 51,
identity: '019b591d-031c-726e-a7ac-0dff6f236d6c',
title: '楼层管理',
title_en: 'Floor Management',
code: 'ops:数据中心管理:楼层管理',
description: '数据中心管理 - 楼层管理',
app_id: 2,
parent_id: 49,
menu_path: '/datacenter/floor',
menu_icon: 'appstore',
type: 1,
sort_key: 39,
created_at: '2025-12-26T13:23:52.220159+08:00',
},
{
id: 54,
identity: '019b591d-0343-7ce7-91bd-d82497ea0a11',
title: '资产管理',
title_en: 'Asset Management',
code: 'ops:资产管理',
description: '资产管理菜单',
app_id: 2,
menu_path: '/assets/',
menu_icon: 'Social',
type: 1,
sort_key: 40,
created_at: '2025-12-26T13:23:52.268069+08:00',
},
{
id: 56,
identity: '019b591d-036b-7cb0-8678-4e1a7c73442f',
title: '设备分类管理',
title_en: 'Device Category Management',
code: 'ops:资产管理:设备分类管理',
description: '资产管理 - 设备分类管理',
app_id: 2,
parent_id: 54,
menu_path: '/assets/classify',
menu_icon: 'appstore',
type: 1,
sort_key: 41,
created_at: '2025-12-26T13:23:52.299831+08:00',
},
{
id: 57,
identity: '019b591d-037b-7248-8227-2ae17b0210fb',
title: '设备管理',
title_en: 'Device Management',
code: 'ops:资产管理:设备管理',
description: '资产管理 - 设备管理',
app_id: 2,
parent_id: 54,
menu_path: '/assets/device',
menu_icon: 'appstore',
type: 1,
sort_key: 42,
created_at: '2025-12-26T13:23:52.315149+08:00',
},
{
id: 55,
identity: '019b591d-035b-766f-9acf-a8e8dbcc5882',
title: '供应商管理',
title_en: 'Supplier Management',
code: 'ops:资产管理:供应商管理',
description: '资产管理 - 供应商管理',
app_id: 2,
parent_id: 54,
menu_path: '/assets/supplier',
menu_icon: 'appstore',
type: 1,
sort_key: 43,
created_at: '2025-12-26T13:23:52.283421+08:00',
},
{
id: 63,
identity: '019b591d-03d1-74e6-9547-a4321b0c0328',
title: '知识库管理',
title_en: 'Knowledge Base Management',
code: 'ops:知识库管理',
description: '知识库管理菜单',
app_id: 2,
menu_path: '/kb/',
menu_icon: 'FilePdf',
type: 1,
sort_key: 44,
created_at: '2025-12-26T13:23:52.40915+08:00',
},
{
id: 65,
identity: '019b591d-03f8-78a9-ba28-c41ff1873569',
title: '知识管理',
title_en: 'Knowledge Base',
code: 'ops:知识库管理:知识库',
description: '知识库管理 - 知识库',
app_id: 2,
parent_id: 63,
menu_path: '/kb/items',
menu_icon: 'appstore',
type: 1,
sort_key: 45,
created_at: '2025-12-26T13:23:52.440567+08:00',
},
{
id: 64,
identity: '019b591d-03e8-7d4b-b8cf-7a5142320c61',
title: '标签管理',
title_en: 'Tag Management',
code: 'ops:知识库管理:标签管理',
description: '知识库管理 - 标签管理',
app_id: 2,
parent_id: 63,
menu_path: '/kb/tags',
menu_icon: 'appstore',
type: 1,
sort_key: 46,
created_at: '2025-12-26T13:23:52.424871+08:00',
},
{
id: 67,
identity: '019b591d-0410-7c89-a545-98ca76421c41',
title: '我的审核',
title_en: 'Review',
code: 'ops:知识库管理:审核',
description: '知识库管理 - 审核',
app_id: 2,
parent_id: 63,
menu_path: '/kb/review',
menu_icon: 'appstore',
type: 1,
sort_key: 47,
created_at: '2025-12-26T13:23:52.464821+08:00',
},
{
id: 68,
identity: '019b591d-0427-7292-b964-ea747ac59748',
title: '我的收藏',
title_en: 'My Favorites',
code: 'ops:知识库管理:我的收藏',
description: '知识库管理 - 我的收藏',
app_id: 2,
parent_id: 63,
menu_path: '/kb/favorite',
menu_icon: 'appstore',
type: 1,
sort_key: 48,
created_at: '2025-12-26T13:23:52.487168+08:00',
},
{
id: 66,
identity: '019b591d-0408-748f-a43e-3fc6aaabe397',
title: '回收站',
title_en: 'Recycle Bin',
code: 'ops:知识库管理:回收站',
description: '知识库管理 - 回收站',
app_id: 2,
parent_id: 63,
menu_path: '/kb/recycle',
menu_icon: 'appstore',
type: 1,
sort_key: 49,
created_at: '2025-12-26T13:23:52.456298+08:00',
},
{
id: 69,
identity: '019b591d-042e-7d67-98c6-432768a6fd97',
title: '报告管理',
title_en: 'Report Management',
code: 'ops:报告管理',
description: '报表管理菜单',
app_id: 2,
menu_path: '/report/',
menu_icon: 'Git',
type: 1,
sort_key: 50,
created_at: '2025-12-26T13:23:52.502439+08:00',
},
{
id: 73,
identity: '019b591d-0475-7b37-929a-2b652b7de8cd',
title: '服务器报告',
title_en: 'Server Report',
code: 'ops:报表管理:服务器报表',
description: '报表管理 - 服务器报表',
app_id: 2,
parent_id: 69,
menu_path: '/report/host',
menu_icon: 'appstore',
type: 1,
sort_key: 51,
created_at: '2025-12-26T13:23:52.565735+08:00',
},
{
id: 72,
identity: '019b591d-0465-769b-8eca-a3ed252243d8',
title: '故障报告',
title_en: 'Fault Report',
code: 'ops:报表管理:故障报告',
description: '报表管理 - 故障报告',
app_id: 2,
parent_id: 69,
menu_path: '/report/fault',
menu_icon: 'appstore',
type: 1,
sort_key: 52,
created_at: '2025-12-26T13:23:52.549432+08:00',
},
{
id: 74,
identity: '019b591d-0484-7ca3-8535-5d87dd16305a',
title: '网络设备报告',
title_en: 'Network Device Report',
code: 'ops:报表管理:网络设备报表',
description: '报表管理 - 网络设备报表',
app_id: 2,
parent_id: 69,
menu_path: '/report/device',
menu_icon: 'appstore',
type: 1,
sort_key: 53,
created_at: '2025-12-26T13:23:52.580828+08:00',
},
{
id: 71,
identity: '019b591d-0455-7a93-9386-7825fa1e2aee',
title: '流量统计报告',
title_en: 'Traffic Statistics',
code: 'ops:报表管理:流量统计',
description: '报表管理 - 流量统计',
app_id: 2,
parent_id: 69,
menu_path: '/report/traffic',
menu_icon: 'appstore',
type: 1,
sort_key: 54,
created_at: '2025-12-26T13:23:52.533693+08:00',
},
{
id: 75,
identity: '019b591d-0495-7891-8893-5f93b073c4ba',
title: '历史报告',
title_en: 'History Report',
code: 'ops:报表管理:历史报表',
description: '报表管理 - 历史报表',
app_id: 2,
parent_id: 69,
menu_path: '/report/history',
menu_icon: 'appstore',
type: 1,
sort_key: 55,
created_at: '2025-12-26T13:23:52.597561+08:00',
},
{
id: 70,
identity: '019b591d-0446-7270-b3a6-b0a0e4623962',
title: '统计报告',
title_en: 'Statistics Report',
code: 'ops:报表管理:统计报告',
description: '报表管理 - 统计报告',
app_id: 2,
parent_id: 69,
menu_path: '/report/statistics',
menu_icon: 'appstore',
type: 1,
sort_key: 56,
created_at: '2025-12-26T13:23:52.518159+08:00',
},
{
id: 78,
identity: '019b5dc5-d82f-7290-b805-f549db554120',
title: '系统设置',
title_en: 'System Settings',
code: 'system-settings',
app_id: 2,
menu_path: '/system-settings/',
menu_icon: 'Settings',
type: 1,
sort_key: 57,
created_at: '2025-12-27T11:06:45.679492+08:00',
},
{
id: 82,
identity: '019b5e13-a5c0-7be8-88ff-7cc000325fbe',
title: '系统监控',
title_en: 'System Monitoring',
code: 'SystemSettingsSystemMonitoring',
description: '系统监控',
app_id: 2,
parent_id: 78,
menu_path: '/system-settings/system-monitoring',
menu_icon: 'icon-file',
type: 1,
sort_key: 58,
created_at: '2025-12-27T12:31:44.577167+08:00',
},
{
id: 81,
identity: '019b5e13-019b-7e4c-9466-3173e25d8c57',
title: '系统日志',
title_en: 'System Logs',
code: 'SystemSettingsSystemLogs',
description: '系统日志',
app_id: 2,
parent_id: 78,
menu_path: '/system-settings/system-logs',
type: 1,
sort_key: 59,
created_at: '2025-12-27T12:31:02.556301+08:00',
},
{
id: 80,
identity: '019b5e12-8192-7d79-9dfc-b193e1ef53a0',
title: '用户管理',
title_en: 'Account Management',
code: 'SystemSettingsAccountManagement',
description: '用户管理',
app_id: 2,
parent_id: 78,
menu_path: '/system-settings/account-management',
type: 1,
sort_key: 60,
created_at: '2025-12-27T12:30:29.779261+08:00',
},
{
id: 79,
identity: '019b5e12-0315-7d3a-b85c-1a0a557d6853',
title: '菜单设置',
title_en: 'Menu Management',
code: 'SystemSettingsMenuManagement',
description: '菜单设置',
app_id: 2,
parent_id: 78,
menu_path: '/system-settings/menu-management',
type: 1,
sort_key: 61,
created_at: '2025-12-27T12:29:57.398258+08:00',
},
{
id: 84,
identity: '019be0c6-2c0c-7c15-b003-9b9b4384f422',
title: '许可授权中心',
title_en: 'LicenseCenter',
code: 'LicenseCenter',
app_id: 2,
parent_id: 78,
menu_path: '/system-settings/license-center',
type: 1,
sort_key: 62,
created_at: '2026-01-21T21:37:22.445144+08:00',
},
{
id: 86,
identity: '019cc7bf-9bd4-78ed-bbcb-c5169236ca72',
title: '帮助中心',
title_en: 'Help Center',
code: 'ops:帮助中心',
app_id: 2,
menu_path: '/help',
menu_icon: 'icon-bulb',
type: 1,
sort_key: 63,
created_at: '2026-03-07T18:02:29.205051+08:00',
},
]
export default localMenuFlatItems

File diff suppressed because it is too large Load Diff

736
src/router/menu-data.ts Normal file
View File

@@ -0,0 +1,736 @@
import { DEFAULT_LAYOUT } from './routes/base'
import type { AppRouteRecordRaw } from './routes/types'
// 本地菜单数据 - 接口未准备好时使用
export const localMenuData: AppRouteRecordRaw[] = [{
path: '/dashboard',
name: 'dashboard',
component: DEFAULT_LAYOUT,
meta: {
locale: '仪表盘',
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 */
],
},
{
path: '/overview',
name: 'Overview',
component: DEFAULT_LAYOUT,
meta: {
locale: '系统概况',
requiresAuth: true,
icon: 'icon-home',
order: 1,
hideChildrenInMenu: true,
},
children: [
{
path: '',
name: 'OverviewIndex',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '系统概况',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/visual',
name: 'VisualDashboard',
component: DEFAULT_LAYOUT,
meta: {
locale: '可视化大屏管理',
requiresAuth: true,
icon: 'icon-apps',
order: 2,
},
children: [
{
path: 'component',
name: 'VisualComponent',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '大屏管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'view-publish',
name: 'ViewPublish',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '已发布大屏列表',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/dc',
name: 'DataCenter',
component: DEFAULT_LAYOUT,
meta: {
locale: '服务器及PC管理',
requiresAuth: true,
icon: 'icon-storage',
order: 3,
},
children: [
{
path: 'pc',
name: 'OfficePC',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '办公PC管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'server',
name: 'ServerManagement',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '服务器管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/dc/cluster',
name: 'ClusterCollection',
component: DEFAULT_LAYOUT,
meta: {
locale: '集群采集控制中心',
requiresAuth: true,
icon: 'icon-settings',
order: 4,
},
children: [
{
path: 'database',
name: 'DatabaseCollection',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '数据库采集管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'middleware',
name: 'MiddlewareCollection',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '中间件采集管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'network',
name: 'NetworkDeviceCollection',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '网络设备采集管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/monitor',
name: 'Monitor',
component: DEFAULT_LAYOUT,
meta: {
locale: '综合监控',
requiresAuth: true,
icon: 'icon-desktop',
order: 5,
},
children: [
{
path: 'log',
name: 'LogMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '日志监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'virtualization',
name: 'VirtualizationMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '虚拟化监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'storage',
name: 'StorageMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '存储设备监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'network',
name: 'NetworkMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '网络设备监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'power',
name: 'PowerMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '电力/UPS/空调/温湿度',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'url',
name: 'URLMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: 'URL监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'guard',
name: 'GuardMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '消防/门禁/漏水/有害气体',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'security',
name: 'SecurityMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '安全设备监控',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/netarch',
name: 'NetworkArchitecture',
component: DEFAULT_LAYOUT,
meta: {
locale: '网络架构管理',
requiresAuth: true,
icon: 'icon-nav',
order: 6,
},
children: [
{
path: 'auto-topo',
name: 'AutoTopo',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '自动感知拓扑图',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'topo-group',
name: 'TopoGroup',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '拓扑管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'traffic',
name: 'TrafficAnalysis',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '流量分析管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'ip',
name: 'IPAddress',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: 'IP地址管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/alert',
name: 'Alert',
component: DEFAULT_LAYOUT,
meta: {
locale: '告警管理',
requiresAuth: true,
icon: 'icon-bulb',
order: 7,
},
children: [
{
path: 'setting',
name: 'AlertSetting',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警策略管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'tackle',
name: 'AlertTackle',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警受理处理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'history',
name: 'AlertHistory',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警历史',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'template',
name: 'AlertTemplate',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警模版',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'notice',
name: 'AlertNotice',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警通知设置',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'level',
name: 'AlertLevel',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警级别管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/feedback',
name: 'Feedback',
component: DEFAULT_LAYOUT,
meta: {
locale: '工单管理',
requiresAuth: true,
icon: 'icon-list',
order: 8,
},
children: [
{
path: 'all',
name: 'AllTickets',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '所有工单',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'undo',
name: 'PendingTickets',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '我的工单',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/datacenter',
name: 'DataCenterManagement',
component: DEFAULT_LAYOUT,
meta: {
locale: '数据中心管理',
requiresAuth: true,
icon: 'icon-drive-file',
order: 9,
},
children: [
{
path: 'rack',
name: 'RackManagement',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '机柜管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'house',
name: 'DataCenterHouse',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '数据中心',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'floor',
name: 'FloorManagement',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '楼层管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/assets',
name: 'Assets',
component: DEFAULT_LAYOUT,
meta: {
locale: '资产管理',
requiresAuth: true,
icon: 'icon-apps',
order: 10,
},
children: [
{
path: 'classify',
name: 'AssetClassify',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '设备分类管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'device',
name: 'AssetDevice',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '设备管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'supplier',
name: 'AssetSupplier',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '供应商管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/kb',
name: 'KnowledgeBase',
component: DEFAULT_LAYOUT,
meta: {
locale: '知识库管理',
requiresAuth: true,
icon: 'icon-file',
order: 11,
},
children: [
{
path: 'items',
name: 'KnowledgeItems',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '知识管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'tags',
name: 'KnowledgeTags',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '标签管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'review',
name: 'KnowledgeReview',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '我的审核',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'favorite',
name: 'KnowledgeFavorite',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '我的收藏',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'recycle',
name: 'KnowledgeRecycle',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '回收站',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/report',
name: 'Report',
component: DEFAULT_LAYOUT,
meta: {
locale: '报告管理',
requiresAuth: true,
icon: 'icon-nav',
order: 12,
},
children: [
{
path: 'host',
name: 'ServerReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '服务器报告',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'fault',
name: 'FaultReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '故障报告',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'device',
name: 'DeviceReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '网络设备报告',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'traffic',
name: 'TrafficReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '流量统计报告',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'history',
name: 'HistoryReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '历史报告',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'statistics',
name: 'StatisticsReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '统计报告',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/system-settings',
name: 'SystemSettings',
component: DEFAULT_LAYOUT,
meta: {
locale: '系统设置',
requiresAuth: true,
icon: 'icon-settings',
order: 13,
},
children: [
{
path: 'system-monitoring',
name: 'SystemMonitoring',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '系统监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'system-logs',
name: 'SystemLogs',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '系统日志',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'account-management',
name: 'AccountManagement',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '用户管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'menu-management',
name: 'MenuManagement',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '菜单设置',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'license-center',
name: 'LicenseCenter',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '许可授权中心',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/help',
name: 'HelpCenter',
component: DEFAULT_LAYOUT,
meta: {
locale: '帮助中心',
requiresAuth: true,
icon: 'icon-bulb',
order: 14,
hideChildrenInMenu: true,
},
children: [
{
path: '',
name: 'HelpCenterIndex',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '帮助中心',
requiresAuth: true,
roles: ['*'],
},
},
],
},
]
export default localMenuData

View File

@@ -6,7 +6,7 @@ const DASHBOARD: AppRouteRecordRaw = {
name: 'dashboard',
component: DEFAULT_LAYOUT,
meta: {
locale: 'menu.dashboard',
locale: '仪表盘',
requiresAuth: true,
icon: 'icon-dashboard',
order: 0,

View File

@@ -13,21 +13,11 @@ const LIST: AppRouteRecordRaw = {
},
children: [
{
path: 'search-table', // The midline path complies with SEO specifications
name: 'SearchTable',
component: () => import('@/views/list/search-table/index.vue'),
path: 'search-table-demo',
name: 'SearchTableDemo',
component: () => import('@/views/list/search-table/demo.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',
locale: '公共组件Demo',
requiresAuth: true,
roles: ['*'],
},

View File

@@ -0,0 +1,38 @@
import { DEFAULT_LAYOUT } from '../base'
import { AppRouteRecordRaw } from '../types'
const OPS: AppRouteRecordRaw = {
path: '/ops',
name: 'ops',
component: DEFAULT_LAYOUT,
meta: {
locale: 'menu.ops',
requiresAuth: true,
icon: 'icon-settings',
order: 3,
},
children: [
{
path: 'menu-management',
name: 'MenuManagement',
component: () => import('@/views/ops/pages/system-settings/menu-management/index.vue'),
meta: {
locale: 'menu.ops.systemSettings.menuManagement',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'system-logs',
name: 'SystemLogs',
component: () => import('@/views/ops/pages/system-settings/system-logs/index.vue'),
meta: {
locale: 'menu.ops.systemSettings.systemLogs',
requiresAuth: true,
roles: ['*'],
},
},
],
}
export default OPS

View File

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

View File

@@ -3,9 +3,13 @@ import { Notification } from '@arco-design/web-vue'
import type { NotificationReturn } from '@arco-design/web-vue/es/notification/interface'
import type { RouteRecordNormalized } from 'vue-router'
import defaultSettings from '@/config/settings.json'
import { getMenuList } from '@/api/user'
import { userPmn } from '@/api/module/user'
import { localMenuData } from '@/router/menu-data'
import { buildTree } from '@/utils/tree'
import SafeStorage, { AppStorageKey } from "@/utils/safeStorage";
import { AppState } from './types'
const useAppStore = defineStore('app', {
state: (): AppState => ({ ...defaultSettings }),
@@ -45,27 +49,24 @@ const useAppStore = defineStore('app', {
this.hideMenu = value
},
async fetchServerMenuConfig() {
const userInfo = SafeStorage.get(AppStorageKey.USER_INFO) as any
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,
})
// 使用本地菜单数据(接口未准备好)
// TODO: 接口准备好后,取消下面的注释,使用真实接口数据
const res = await userPmn({ id: userInfo.user_id, workspace: import.meta.env.VITE_APP_WORKSPACE })
console.log('res', res)
if (res.code === 0 && res?.details?.length) {
console.log('buildTree', buildTree(res.details[0].permissions))
}
// this.serverMenu = data
// 使用本地数据
this.serverMenu = localMenuData as unknown as RouteRecordNormalized[]
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
notifyInstance = Notification.error({
id: 'menuNotice',
content: 'error',
closable: true,
})
}
},
clearServerMenu() {

View File

@@ -1,7 +1,10 @@
import { LoginData, getUserInfo, login as userLogin, logout as userLogout } from '@/api/user'
import { login, logout } from '@/api/module/user'
import { LoginData } from '@/api/types'
import { request } from '@/api/request'
import { clearToken, setToken } from '@/utils/auth'
import { removeRouteListener } from '@/utils/route-listener'
import { defineStore } from 'pinia'
import SafeStorage, { AppStorageKey } from "@/utils/safeStorage";
import useAppStore from '../app'
import { UserState } from './types'
@@ -23,6 +26,7 @@ const useUserStore = defineStore('user', {
accountId: undefined,
certification: undefined,
role: '',
userInfo: SafeStorage.get(AppStorageKey.USER_INFO)
}),
getters: {
@@ -50,16 +54,22 @@ const useUserStore = defineStore('user', {
// Get user's information
async info() {
const res = await getUserInfo()
this.setInfo(res.data)
// const res = await request.post('/rbac2/v1/user/info') as any
// request 拦截器已经返回 response.data所以 res 就是用户信息
// this.setInfo(res.data || res)
},
// Login
async login(loginForm: LoginData) {
try {
const res = await userLogin(loginForm)
setToken(res.data.token)
const { code, details } = await login(loginForm as any) as any
if (code === 0 && details?.token) {
setToken(details.token)
SafeStorage.set(AppStorageKey.USER_INFO, details)
this.userInfo = details
} else {
throw new Error('登录失败:未获取到 token')
}
} catch (err) {
clearToken()
throw err
@@ -71,11 +81,12 @@ const useUserStore = defineStore('user', {
clearToken()
removeRouteListener()
appStore.clearServerMenu()
SafeStorage.clearAppStorage()
},
// Logout
async logout() {
try {
await userLogout()
// await logout()
} finally {
this.logoutCallBack()
}

View File

@@ -16,4 +16,5 @@ export interface UserState {
accountId?: string
certification?: number
role: RoleType
userInfo?: any
}

View File

@@ -1,19 +1,15 @@
const TOKEN_KEY = 'token'
import SafeStorage, { AppStorageKey } from "@/utils/safeStorage";
const isLogin = () => {
return !!localStorage.getItem(TOKEN_KEY)
}
const isLogin = () => !!SafeStorage.get(AppStorageKey.TOKEN)
const getToken = () => {
return localStorage.getItem(TOKEN_KEY)
}
const getToken = () => SafeStorage.get(AppStorageKey.TOKEN)
const setToken = (token: string) => {
localStorage.setItem(TOKEN_KEY, token)
SafeStorage.set(AppStorageKey.TOKEN, token)
}
const clearToken = () => {
localStorage.removeItem(TOKEN_KEY)
SafeStorage.remove(AppStorageKey.TOKEN)
}
export { clearToken, getToken, isLogin, setToken }

158
src/utils/safeStorage.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* 类型安全的 localStorage 封装,具备严格的 key 管理
*/
// 1. 定义应用程序使用的存储键名(防止使用任意字符串作为 key
export enum AppStorageKey {
PASSPORT_DATA = 'passportData',
PASSPORT_TOKEN = 'passportToken',
MENU_DATA = 'menuData', // 全部的菜单数据
SLIDER_MENU = 'sliderMenu', // 侧边栏菜单数据
USER_INFO = 'userInfo',
TOKEN = 'token',
MODE = 'mode', // 日间夜间模式
}
// 2. 存储值类型定义(用于内部处理)
type StorageValue<T> = {
__data: T; // 实际存储的数据
__expiry?: number; // 可选的过期时间戳
};
// 3. 主存储类
class SafeStorage {
/**
* 存储数据(自动处理 JSON 序列化)
* @param key 预定义的存储键
* @param value 要存储的值(支持所有 JSON 安全类型)
* @param ttl 可选的时间有效期(毫秒)
*/
static set<T>(key: AppStorageKey, value: T, ttl?: number): void {
if (typeof window === "undefined") return;
try {
// 创建存储对象(包含数据值和过期时间)
const storageValue: StorageValue<T> = {
__data: value,
};
// 设置过期时间(如果提供)
if (ttl !== undefined && ttl > 0) {
storageValue.__expiry = Date.now() + ttl;
}
// 自动序列化并存储
localStorage.setItem(key, JSON.stringify(storageValue));
} catch (error) {
console.error(`[存储] 保存失败 (key: ${key})`, error);
this.handleStorageError(key, error);
}
}
/**
* 获取数据(自动反序列化并检查过期)
* @param key 预定义的存储键
* @returns 存储的值或 null如果不存在或过期
*/
static get<T>(key: AppStorageKey): T | null {
if (typeof window === "undefined") return null;
try {
// 获取原始数据
const rawData = localStorage.getItem(key);
if (!rawData) return null;
// 解析为内部存储结构
const storageValue = JSON.parse(rawData) as StorageValue<T>;
// 检查是否过期
if (this.isExpired(storageValue)) {
this.remove(key);
return null;
}
// 返回实际数据
return storageValue.__data;
} catch (error) {
console.error(`[存储] 解析失败 (key: ${key})`, error);
this.remove(key); // 移除无效数据
return null;
}
}
/**
* 删除指定存储项
* @param key 要删除的存储键
*/
static remove(key: AppStorageKey): void {
if (typeof window === "undefined") return;
localStorage.removeItem(key);
}
/**
* 清除应用相关的所有存储
*/
static clearAppStorage(): void {
if (typeof window === "undefined") return;
// 遍历所有预定义的 key 进行删除
Object.values(AppStorageKey).forEach(key => {
localStorage.removeItem(key);
});
}
// ----------------------------- 私有辅助方法 -----------------------------
/**
* 检查存储值是否过期
*/
private static isExpired<T>(value: StorageValue<T>): boolean {
return value.__expiry !== undefined && Date.now() > value.__expiry;
}
/**
* 处理存储错误(配额不足等)
*/
private static handleStorageError(key: AppStorageKey, error: unknown): void {
// 处理存储空间不足错误
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
console.warn('存储空间不足,尝试清理过期数据');
this.clearExpiredItems();
// 尝试重新存储(最多尝试一次)
try {
const raw = localStorage.getItem(key);
if (raw) {
const value = JSON.parse(raw) as StorageValue<unknown>;
if (!this.isExpired(value)) {
localStorage.setItem(key, raw);
return;
}
}
} catch (retryError) {
console.error('重试存储失败', retryError);
}
}
}
/**
* 清理所有过期的存储项
*/
private static clearExpiredItems(): void {
Object.values(AppStorageKey).forEach(key => {
try {
const raw = localStorage.getItem(key);
if (raw) {
const value = JSON.parse(raw) as StorageValue<unknown>;
if (this.isExpired(value)) {
localStorage.removeItem(key);
}
}
} catch (e) {
// 忽略无效数据
}
});
}
}
export default SafeStorage;

223
src/utils/tree.ts Normal file
View File

@@ -0,0 +1,223 @@
/**
* 树结构相关工具函数
*/
/**
* 树节点基础接口
*/
export interface TreeNodeBase {
id?: number | string
parent_id?: number | string | null
children?: TreeNodeBase[]
[key: string]: any
}
/**
* 构建树结构的选项
*/
export interface BuildTreeOptions<T extends TreeNodeBase> {
/** ID 字段名,默认 'id' */
idKey?: keyof T
/** 父ID 字段名,默认 'parent_id' */
parentKey?: keyof T
/** 子节点字段名,默认 'children' */
childrenKey?: string
/** 排序字段名,可选 */
orderKey?: keyof T
}
/**
* 构建树结构的结果
*/
export interface BuildTreeResult<T extends TreeNodeBase> {
/** 根节点列表 */
rootItems: T[]
/** 节点映射表 (id -> node) */
itemMap: Map<number | string, T>
}
/**
* 将扁平数组构建为树状结构
* @param items 扁平数组
* @param options 构建选项
* @returns 包含根节点列表和节点映射的对象
*/
export function buildTree<T extends TreeNodeBase>(
items: T[],
options: BuildTreeOptions<T> = {}
): BuildTreeResult<T> {
const {
idKey = 'id' as keyof T,
parentKey = 'parent_id' as keyof T,
childrenKey = 'children',
orderKey,
} = options
const itemMap = new Map<number | string, T>()
const rootItems: T[] = []
// 创建节点映射
items.forEach((item) => {
const id = item[idKey]
if (id !== undefined && id !== null) {
// 创建带有空 children 数组的节点副本
itemMap.set(id as number | string, {
...item,
[childrenKey]: []
} as T)
}
})
// 构建树结构
itemMap.forEach((item) => {
const parentId = item[parentKey]
if (parentId && itemMap.has(parentId as number | string)) {
const parent = itemMap.get(parentId as number | string)!
const parentChildren = parent[childrenKey] as T[]
parentChildren.push(item)
} else {
rootItems.push(item)
}
})
// 排序函数
if (orderKey) {
const sortByOrder = (a: T, b: T) => {
const orderA = (a[orderKey] as number) || 0
const orderB = (b[orderKey] as number) || 0
return orderA - orderB
}
rootItems.sort(sortByOrder)
itemMap.forEach((item) => {
const children = item[childrenKey] as T[]
if (children && children.length > 0) {
children.sort(sortByOrder)
}
})
}
return { rootItems, itemMap }
}
/**
* 递归遍历树节点
* @param nodes 节点列表
* @param callback 回调函数
* @param childrenKey 子节点字段名
*/
export function traverseTree<T extends TreeNodeBase>(
nodes: T[],
callback: (node: T, depth: number, parent: T | null) => void | boolean,
childrenKey: string = 'children',
depth: number = 0,
parent: T | null = null
): void {
for (const node of nodes) {
const result = callback(node, depth, parent)
// 如果回调返回 false停止遍历
if (result === false) return
const children = node[childrenKey] as T[]
if (children && children.length > 0) {
traverseTree(children, callback, childrenKey, depth + 1, node)
}
}
}
/**
* 在树中查找节点
* @param nodes 节点列表
* @param predicate 判断条件
* @param childrenKey 子节点字段名
* @returns 找到的节点或 null
*/
export function findInTree<T extends TreeNodeBase>(
nodes: T[],
predicate: (node: T) => boolean,
childrenKey: string = 'children'
): T | null {
for (const node of nodes) {
if (predicate(node)) {
return node
}
const children = node[childrenKey] as T[]
if (children && children.length > 0) {
const found = findInTree(children, predicate, childrenKey)
if (found) return found
}
}
return null
}
/**
* 获取节点的所有父节点路径
* @param nodeId 目标节点ID
* @param itemMap 节点映射表
* @param parentKey 父ID字段名
* @returns 从根到目标节点的路径(不包含目标节点本身)
*/
export function getAncestors<T extends TreeNodeBase>(
nodeId: number | string,
itemMap: Map<number | string, T>,
parentKey: keyof T = 'parent_id' as keyof T
): T[] {
const ancestors: T[] = []
let current = itemMap.get(nodeId)
while (current) {
const parentId = current[parentKey]
if (parentId && itemMap.has(parentId as number | string)) {
const parent = itemMap.get(parentId as number | string)!
ancestors.unshift(parent)
current = parent
} else {
break
}
}
return ancestors
}
/**
* 获取节点的所有子孙节点
* @param node 目标节点
* @param childrenKey 子节点字段名
* @returns 所有子孙节点(扁平数组)
*/
export function getDescendants<T extends TreeNodeBase>(
node: T,
childrenKey: string = 'children'
): T[] {
const descendants: T[] = []
const children = node[childrenKey] as T[]
if (children && children.length > 0) {
for (const child of children) {
descendants.push(child)
descendants.push(...getDescendants(child, childrenKey))
}
}
return descendants
}
/**
* 将树结构扁平化为数组
* @param nodes 节点列表
* @param childrenKey 子节点字段名
* @returns 扁平化后的数组(移除 children 属性)
*/
export function flattenTree<T extends TreeNodeBase>(
nodes: T[],
childrenKey: string = 'children'
): Omit<T, 'children'>[] {
const result: Omit<T, 'children'>[] = []
traverseTree(nodes, (node) => {
const { [childrenKey]: _, ...rest } = node
result.push(rest as Omit<T, 'children'>)
}, childrenKey)
return result
}

View File

@@ -1,196 +0,0 @@
<template>
<div class="card-wrap">
<a-card v-if="loading" :bordered="false" hoverable>
<slot name="skeleton"></slot>
</a-card>
<a-card v-else :bordered="false" hoverable>
<a-space align="start">
<a-avatar v-if="icon" :size="24" style="margin-right: 8px; background-color: #626aea">
<icon-filter />
</a-avatar>
<a-card-meta>
<template #title>
<a-typography-text style="margin-right: 10px">
{{ title }}
</a-typography-text>
<template v-if="showTag">
<a-tag v-if="open && isExpires === false" size="small" color="green">
<template #icon>
<icon-check-circle-fill />
</template>
<span>{{ tagText }}</span>
</a-tag>
<a-tag v-else-if="isExpires" size="small" color="red">
<template #icon>
<icon-check-circle-fill />
</template>
<span>{{ expiresTagText }}</span>
</a-tag>
</template>
</template>
<template #description>
{{ description }}
<slot></slot>
</template>
</a-card-meta>
</a-space>
<template #actions>
<a-switch v-if="actionType === 'switch'" v-model="open" />
<a-space v-else-if="actionType === 'button'">
<template v-if="isExpires">
<a-button type="outline" @click="renew">
{{ expiresText }}
</a-button>
</template>
<template v-else>
<a-button v-if="open" @click="handleToggle">
{{ closeTxt }}
</a-button>
<a-button v-else-if="!open" type="outline" @click="handleToggle">
{{ openTxt }}
</a-button>
</template>
</a-space>
<div v-else>
<a-space>
<a-button @click="toggle(false)">
{{ closeTxt }}
</a-button>
<a-button type="primary" @click="toggle(true)">
{{ openTxt }}
</a-button>
</a-space>
</div>
</template>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useToggle } from '@vueuse/core'
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
actionType: {
type: String,
default: '',
},
defaultValue: {
type: Boolean,
default: false,
},
openTxt: {
type: String,
default: '',
},
closeTxt: {
type: String,
default: '',
},
expiresText: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
showTag: {
type: Boolean,
default: true,
},
tagText: {
type: String,
default: '',
},
expires: {
type: Boolean,
default: false,
},
expiresTagText: {
type: String,
default: '',
},
})
const [open, toggle] = useToggle(props.defaultValue)
const handleToggle = () => {
toggle()
}
const isExpires = ref(props.expires)
const renew = () => {
isExpires.value = false
}
</script>
<style scoped lang="less">
.card-wrap {
height: 100%;
transition: all 0.3s;
border: 1px solid var(--color-neutral-3);
border-radius: 4px;
&:hover {
transform: translateY(-4px);
// box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.1);
}
:deep(.arco-card) {
height: 100%;
border-radius: 4px;
.arco-card-body {
height: 100%;
.arco-space {
width: 100%;
height: 100%;
.arco-space-item {
height: 100%;
&:last-child {
flex: 1;
}
.arco-card-meta {
height: 100%;
display: flex;
flex-flow: column;
.arco-card-meta-content {
flex: 1;
.arco-card-meta-description {
margin-top: 8px;
color: rgb(var(--gray-6));
line-height: 20px;
font-size: 12px;
}
}
.arco-card-meta-footer {
margin-top: 0;
}
}
}
}
}
}
:deep(.arco-card-meta-title) {
display: flex;
align-items: center;
// To prevent the shaking
line-height: 28px;
}
:deep(.arco-skeleton-line) {
&:last-child {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
}
}
</style>

View File

@@ -1,86 +0,0 @@
<template>
<div class="list-wrap">
<a-typography-title class="block-title" :heading="6">
{{ $t('cardList.tab.title.content') }}
</a-typography-title>
<a-row class="list-row" :gutter="24">
<a-col :xs="12" :sm="12" :md="12" :lg="6" :xl="6" :xxl="6" class="list-col">
<div class="card-wrap empty-wrap">
<a-card :bordered="false" hoverable>
<a-result :status="null" :title="$t('cardList.content.action')">
<template #icon>
<icon-plus style="font-size: 20px" />
</template>
</a-result>
</a-card>
</div>
</a-col>
<a-col v-for="item in renderData" :key="item.id" class="list-col" :xs="12" :sm="12" :md="12" :lg="6" :xl="6" :xxl="6">
<CardWrap
:loading="loading"
:title="item.title"
:description="item.description"
:default-value="item.enable"
:action-type="item.actionType"
:icon="item.icon"
:open-txt="$t('cardList.content.inspection')"
:close-txt="$t('cardList.content.delete')"
:show-tag="false"
>
<a-descriptions style="margin-top: 16px" :data="item.data" layout="inline-horizontal" :column="2" />
<template #skeleton>
<a-skeleton :animation="true">
<a-skeleton-line :widths="['50%', '50%', '100%', '40%']" :rows="4" />
<a-skeleton-line :widths="['40%']" :rows="1" />
</a-skeleton>
</template>
</CardWrap>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { queryInspectionList, ServiceRecord } from '@/api/list'
import useRequest from '@/hooks/request'
import CardWrap from './card-wrap.vue'
const defaultValue: ServiceRecord[] = new Array(3).fill({})
const { loading, response: renderData } = useRequest<ServiceRecord[]>(queryInspectionList, defaultValue)
</script>
<style scoped lang="less">
.card-wrap {
height: 100%;
transition: all 0.3s;
border: 1px solid var(--color-neutral-3);
&:hover {
transform: translateY(-4px);
}
:deep(.arco-card-meta-description) {
color: rgb(var(--gray-6));
.arco-descriptions-item-label-inline {
font-weight: normal;
font-size: 12px;
color: rgb(var(--gray-6));
}
.arco-descriptions-item-value-inline {
color: rgb(var(--gray-8));
}
}
}
.empty-wrap {
height: 200px;
border-radius: 4px;
:deep(.arco-card) {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
.arco-result-title {
color: rgb(var(--gray-6));
}
}
}
</style>

View File

@@ -1,48 +0,0 @@
<template>
<div class="list-wrap">
<a-typography-title class="block-title" :heading="6">
{{ $t('cardList.tab.title.preset') }}
</a-typography-title>
<a-row class="list-row" :gutter="24">
<a-col
v-for="item in renderData"
:key="item.id"
:xs="12"
:sm="12"
:md="12"
:lg="6"
:xl="6"
:xxl="6"
class="list-col"
style="min-height: 140px"
>
<CardWrap
:loading="loading"
:title="item.title"
:description="item.description"
:default-value="item.enable"
:action-type="item.actionType"
:tag-text="$t('cardList.preset.tag')"
>
<template #skeleton>
<a-skeleton :animation="true">
<a-skeleton-line :widths="['100%', '40%']" :rows="2" />
<a-skeleton-line :widths="['40%']" :rows="1" />
</a-skeleton>
</template>
</CardWrap>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { queryRulesPresetList, ServiceRecord } from '@/api/list'
import useRequest from '@/hooks/request'
import CardWrap from './card-wrap.vue'
const defaultValue: ServiceRecord[] = new Array(6).fill({})
const { loading, response: renderData } = useRequest<ServiceRecord[]>(queryRulesPresetList, defaultValue)
</script>
<style scoped lang="less"></style>

View File

@@ -1,54 +0,0 @@
<template>
<div class="list-wrap">
<a-typography-title class="block-title" :heading="6">
{{ $t('cardList.tab.title.service') }}
</a-typography-title>
<a-row class="list-row" :gutter="24">
<a-col
v-for="item in renderData"
:key="item.id"
:xs="12"
:sm="12"
:md="12"
:lg="6"
:xl="6"
:xxl="6"
class="list-col"
style="min-height: 162px"
>
<CardWrap
:loading="loading"
:title="item.title"
:description="item.description"
:default-value="item.enable"
:action-type="item.actionType"
:expires="item.expires"
:open-txt="$t('cardList.service.open')"
:close-txt="$t('cardList.service.cancel')"
:expires-text="$t('cardList.service.renew')"
:tag-text="$t('cardList.service.tag')"
:expires-tag-text="$t('cardList.service.expiresTag')"
:icon="item.icon"
>
<template #skeleton>
<a-skeleton :animation="true">
<a-skeleton-line :widths="['100%', '40%', '100%']" :rows="3" />
<a-skeleton-line :widths="['40%']" :rows="1" />
</a-skeleton>
</template>
</CardWrap>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { queryTheServiceList, ServiceRecord } from '@/api/list'
import useRequest from '@/hooks/request'
import CardWrap from './card-wrap.vue'
const defaultValue: ServiceRecord[] = new Array(4).fill({})
const { loading, response: renderData } = useRequest<ServiceRecord[]>(queryTheServiceList, defaultValue)
</script>
<style scoped lang="less"></style>

View File

@@ -1,92 +0,0 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.list', 'menu.list.cardList']" />
<a-row :gutter="20" align="stretch">
<a-col :span="24">
<a-card class="general-card" :title="$t('menu.list.cardList')">
<a-row justify="space-between">
<a-col :span="24">
<a-tabs :default-active-tab="1" type="rounded">
<a-tab-pane key="1" :title="$t('cardList.tab.title.all')">
<QualityInspection />
<TheService />
<RulesPreset />
</a-tab-pane>
<a-tab-pane key="2" :title="$t('cardList.tab.title.content')">
<QualityInspection />
</a-tab-pane>
<a-tab-pane key="3" :title="$t('cardList.tab.title.service')">
<TheService />
</a-tab-pane>
<a-tab-pane key="4" :title="$t('cardList.tab.title.preset')">
<RulesPreset />
</a-tab-pane>
</a-tabs>
</a-col>
<a-input-search
:placeholder="$t('cardList.searchInput.placeholder')"
style="width: 240px; position: absolute; top: 60px; right: 20px"
/>
</a-row>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import QualityInspection from './components/quality-inspection.vue'
import TheService from './components/the-service.vue'
import RulesPreset from './components/rules-preset.vue'
</script>
<script lang="ts">
export default {
name: 'Card',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
:deep(.arco-list-content) {
overflow-x: hidden;
}
:deep(.arco-card-meta-title) {
font-size: 14px;
}
}
:deep(.arco-list-col) {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
:deep(.arco-list-item) {
width: 33%;
}
:deep(.block-title) {
margin: 0 0 12px 0;
font-size: 14px;
}
:deep(.list-wrap) {
// min-height: 140px;
.list-row {
align-items: stretch;
.list-col {
margin-bottom: 16px;
}
}
:deep(.arco-space) {
width: 100%;
.arco-space-item {
&:last-child {
flex: 1;
}
}
}
}
</style>

View File

@@ -1,19 +0,0 @@
export default {
'menu.list.cardList': 'Card List',
'cardList.tab.title.all': 'All',
'cardList.tab.title.content': 'Quality Inspection',
'cardList.tab.title.service': 'The service',
'cardList.tab.title.preset': 'Rules Preset',
'cardList.searchInput.placeholder': 'Search',
'cardList.enable': 'Enable',
'cardList.disable': 'Disable',
'cardList.content.delete': 'Delete',
'cardList.content.inspection': 'Inspection',
'cardList.content.action': 'Click Create Qc Content queue',
'cardList.service.open': 'Open',
'cardList.service.cancel': 'Cancel',
'cardList.service.renew': 'Contract of service',
'cardList.service.tag': 'Opened',
'cardList.service.expiresTag': 'Expired',
'cardList.preset.tag': 'Enable',
}

View File

@@ -1,19 +0,0 @@
export default {
'menu.list.cardList': '卡片列表',
'cardList.tab.title.all': '全部',
'cardList.tab.title.content': '内容质检',
'cardList.tab.title.service': '开通服务',
'cardList.tab.title.preset': '规则预置',
'cardList.searchInput.placeholder': '搜索',
// 'cardList.statistic.enable': '已启用',
// 'cardList.statistic.disable': '未启用',
'cardList.content.delete': '删除',
'cardList.content.inspection': '质检',
'cardList.content.action': '点击创建质检内容队列',
'cardList.service.open': '开通服务',
'cardList.service.cancel': '取消服务',
'cardList.service.renew': '续约服务',
'cardList.service.tag': '已开通',
'cardList.service.expiresTag': '已过期',
'cardList.preset.tag': '已启用',
}

View File

@@ -1,179 +0,0 @@
import Mock from 'mockjs'
import setupMock, { successResponseWrap } from '@/utils/setup-mock'
import { ServiceRecord } from '@/api/list'
const qualityInspectionList: ServiceRecord[] = [
{
id: 1,
name: 'quality',
title: '视频类-历史导入',
description: '2021-10-12 00:00:00',
data: [
{
label: '待质检数',
value: '120',
},
{
label: '积压时长',
value: '60s',
},
{
label: '待抽检数',
value: '0',
},
],
},
{
id: 2,
name: 'quality',
title: '图文类-图片版权',
description: '2021-12-11 18:30:00',
data: [
{
label: '待质检数',
value: '120',
},
{
label: '积压时长',
value: '60s',
},
{
label: '待抽检数',
value: '0',
},
],
},
{
id: 3,
name: 'quality',
title: '图文类-高清图片',
description: '2021-10-15 08:10:00',
data: [
{
label: '待质检数',
value: '120',
},
{
label: '积压时长',
value: '60s',
},
{
label: '待抽检数',
value: '0',
},
],
},
]
const theServiceList: ServiceRecord[] = [
{
id: 1,
icon: 'code',
title: '漏斗分析',
description: '用户行为分析之漏斗分析模型是企业实现精细化运营、进行用户行为分析的重要数据分析模型。',
enable: true,
actionType: 'button',
},
{
id: 2,
icon: 'edit',
title: '用户分布',
description: '快速诊断用户人群,地域细分情况,了解数据分布的集中度,以及主要的数据分布的区间段是什么。',
enable: true,
actionType: 'button',
expires: true,
},
{
id: 3,
icon: 'user',
title: '资源分发',
description: '移动端动态化资源分发解决方案。提供稳定大流量服务支持、灵活定制的分发圈选规则,通过离线化预加载。',
enable: false,
actionType: 'button',
},
{
id: 4,
icon: 'user',
title: '用户画像分析',
description: '用户画像就是将典型用户信息标签化,根据用户特征、业务场景和用户行为等信息,构建一个标签化的用户模型。',
enable: true,
actionType: 'button',
},
]
const rulesPresetList: ServiceRecord[] = [
{
id: 1,
title: '内容屏蔽规则',
description: '用户在执行特定的内容分发任务时,可使用内容屏蔽规则根据特定标签,过滤内容集合。',
enable: true,
actionType: 'switch',
},
{
id: 2,
title: '内容置顶规则',
description: '该规则支持用户在执行特定内容分发任务时,对固定的几条内容置顶。',
enable: true,
actionType: 'switch',
},
{
id: 3,
title: '内容加权规则',
description: '选定内容加权规则后可自定义从不同内容集合获取内容的概率。',
enable: false,
actionType: 'switch',
},
{
id: 4,
title: '内容分发规则',
description: '内容分发时对某些内容需要固定在C端展示的位置。',
enable: true,
actionType: 'switch',
},
{
id: 5,
title: '违禁内容识别',
description: '精准识别赌博、刀枪、毒品、造假、贩假等违规物品和违规行为。',
enable: false,
actionType: 'switch',
},
{
id: 6,
title: '多语言文字符号识别',
description: '精准识别英语、维语、藏语、蒙古语、朝鲜语等多种语言以及emoji表情形态的语义识别。',
enable: false,
actionType: 'switch',
},
]
setupMock({
setup() {
// Quality Inspection
Mock.mock(new RegExp('/api/list/quality-inspection'), () => {
return successResponseWrap(
qualityInspectionList.map((_, index) => ({
...qualityInspectionList[index % qualityInspectionList.length],
id: Mock.Random.guid(),
}))
)
})
// the service
Mock.mock(new RegExp('/api/list/the-service'), () => {
return successResponseWrap(
theServiceList.map((_, index) => ({
...theServiceList[index % theServiceList.length],
id: Mock.Random.guid(),
}))
)
})
// rules preset
Mock.mock(new RegExp('/api/list/rules-preset'), () => {
return successResponseWrap(
rulesPresetList.map((_, index) => ({
...rulesPresetList[index % rulesPresetList.length],
id: Mock.Random.guid(),
}))
)
})
},
})

View File

@@ -0,0 +1,326 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.list', '公共组件Demo']" />
<!-- 使用 SearchTable 公共组件 -->
<SearchTable
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="公共组件演示"
search-button-text="查询"
reset-button-text="重置"
download-button-text="导出"
refresh-tooltip-text="刷新数据"
density-tooltip-text="表格密度"
column-setting-tooltip-text="列设置"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
@download="handleDownload"
>
<!-- 工具栏左侧按钮 -->
<template #toolbar-left>
<a-button type="primary" @click="handleAdd">
<template #icon>
<icon-plus />
</template>
新增
</a-button>
<a-button status="success" @click="handleBatchDelete">
<template #icon>
<icon-delete />
</template>
批量删除
</a-button>
</template>
<!-- 表格自定义列序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 表格自定义列状态 -->
<template #status="{ record }">
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
{{ record.status === 'active' ? '启用' : '禁用' }}
</a-tag>
</template>
<!-- 表格自定义列头像 -->
<template #avatar="{ record }">
<a-avatar :style="{ backgroundColor: record.avatarColor }">
{{ record.name.charAt(0) }}
</a-avatar>
</template>
<!-- 表格自定义列操作 -->
<template #operations="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleView(record)">
查看
</a-button>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-popconfirm content="确定要删除吗?" @ok="handleDelete(record)">
<a-button type="text" size="small" status="danger">
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</SearchTable>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormItem } from '@/components/search-form/types'
// 定义表格数据类型
interface UserRecord {
id: number
name: string
email: string
department: string
role: string
status: 'active' | 'inactive'
avatarColor: string
createdAt: string
}
// 模拟数据生成
const generateMockData = (count: number): UserRecord[] => {
const departments = ['技术部', '产品部', '运营部', '市场部', '财务部']
const roles = ['管理员', '普通用户', '访客']
const names = ['张三', '李四', '王五', '赵六', '钱七', '孙八', '周九', '吴十']
const colors = ['#165DFF', '#0FC6C2', '#722ED1', '#F53F3F', '#FF7D00', '#00B42A']
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: names[i % names.length] + (Math.floor(i / names.length) || ''),
email: `user${i + 1}@example.com`,
department: departments[i % departments.length],
role: roles[i % roles.length],
status: i % 3 === 0 ? 'inactive' : 'active',
avatarColor: colors[i % colors.length],
createdAt: `2024-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
}))
}
// 状态管理
const loading = ref(false)
const tableData = ref<UserRecord[]>([])
const formModel = ref({
name: '',
department: '',
status: '',
email: '',
})
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'name',
label: '用户名',
type: 'input',
placeholder: '请输入用户名',
},
{
field: 'email',
label: '邮箱',
type: 'input',
placeholder: '请输入邮箱',
},
{
field: 'department',
label: '部门',
type: 'select',
placeholder: '请选择部门',
options: [
{ label: '技术部', value: '技术部' },
{ label: '产品部', value: '产品部' },
{ label: '运营部', value: '运营部' },
{ label: '市场部', value: '市场部' },
{ label: '财务部', value: '财务部' },
],
},
{
field: 'status',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: [
{ label: '启用', value: 'active' },
{ label: '禁用', value: 'inactive' },
],
},
])
// 表格列配置
const columns = computed<TableColumnData[]>(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '头像',
dataIndex: 'avatar',
slotName: 'avatar',
width: 80,
},
{
title: '用户名',
dataIndex: 'name',
width: 120,
},
{
title: '邮箱',
dataIndex: 'email',
width: 200,
},
{
title: '部门',
dataIndex: 'department',
width: 120,
},
{
title: '角色',
dataIndex: 'role',
width: 100,
},
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
width: 100,
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 120,
},
{
title: '操作',
dataIndex: 'operations',
slotName: 'operations',
width: 200,
fixed: 'right',
},
])
// 模拟异步获取数据
const fetchData = async () => {
loading.value = true
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
let data = generateMockData(86)
// 根据搜索条件过滤
if (formModel.value.name) {
data = data.filter(item => item.name.includes(formModel.value.name))
}
if (formModel.value.email) {
data = data.filter(item => item.email.includes(formModel.value.email))
}
if (formModel.value.department) {
data = data.filter(item => item.department === formModel.value.department)
}
if (formModel.value.status) {
data = data.filter(item => item.status === formModel.value.status)
}
// 更新分页
pagination.total = data.length
// 分页截取
const start = (pagination.current - 1) * pagination.pageSize
const end = start + pagination.pageSize
tableData.value = data.slice(start, end)
loading.value = false
}
// 事件处理
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleReset = () => {
formModel.value = {
name: '',
department: '',
status: '',
email: '',
}
pagination.current = 1
fetchData()
}
const handlePageChange = (current: number) => {
pagination.current = current
fetchData()
}
const handleRefresh = () => {
fetchData()
Message.success('数据已刷新')
}
const handleDownload = () => {
Message.info('导出功能开发中...')
}
const handleAdd = () => {
Message.info('新增功能开发中...')
}
const handleBatchDelete = () => {
Message.warning('请先选择要删除的数据')
}
const handleView = (record: UserRecord) => {
Message.info(`查看用户:${record.name}`)
}
const handleEdit = (record: UserRecord) => {
Message.info(`编辑用户:${record.name}`)
}
const handleDelete = (record: UserRecord) => {
Message.success(`已删除用户:${record.name}`)
}
// 初始化加载数据
fetchData()
</script>
<script lang="ts">
export default {
name: 'SearchTableDemo',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
</style>

View File

@@ -1,198 +1,95 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.list', 'menu.list.searchTable']" />
<a-card class="general-card" :title="$t('menu.list.searchTable')">
<a-row>
<a-col :flex="1">
<a-form :model="formModel" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
<a-row :gutter="16">
<a-col :span="8">
<a-form-item field="number" :label="$t('searchTable.form.number')">
<a-input v-model="formModel.number" :placeholder="$t('searchTable.form.number.placeholder')" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="name" :label="$t('searchTable.form.name')">
<a-input v-model="formModel.name" :placeholder="$t('searchTable.form.name.placeholder')" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="contentType" :label="$t('searchTable.form.contentType')">
<a-select
v-model="formModel.contentType"
:options="contentTypeOptions"
:placeholder="$t('searchTable.form.selectDefault')"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="filterType" :label="$t('searchTable.form.filterType')">
<a-select
v-model="formModel.filterType"
:options="filterTypeOptions"
:placeholder="$t('searchTable.form.selectDefault')"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="createdTime" :label="$t('searchTable.form.createdTime')">
<a-range-picker v-model="formModel.createdTime" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="status" :label="$t('searchTable.form.status')">
<a-select v-model="formModel.status" :options="statusOptions" :placeholder="$t('searchTable.form.selectDefault')" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-col>
<a-divider style="height: 84px" direction="vertical" />
<a-col :flex="'86px'" style="text-align: right">
<a-space direction="vertical" :size="18">
<a-button type="primary" @click="search">
<template #icon>
<icon-search />
</template>
{{ $t('searchTable.form.search') }}
<SearchTable
:form-model="formModel"
:form-items="formItems"
:data="renderData"
:columns="columns"
:loading="loading"
:pagination="pagination"
:title="$t('menu.list.searchTable')"
:search-button-text="$t('searchTable.form.search')"
:reset-button-text="$t('searchTable.form.reset')"
:download-button-text="$t('searchTable.operation.download')"
:refresh-tooltip-text="$t('searchTable.actions.refresh')"
:density-tooltip-text="$t('searchTable.actions.density')"
:column-setting-tooltip-text="$t('searchTable.actions.columnSetting')"
@search="search"
@reset="reset"
@page-change="onPageChange"
@refresh="search"
>
<!-- 工具栏左侧插槽 -->
<template #toolbar-left>
<a-button type="primary">
<template #icon>
<icon-plus />
</template>
{{ $t('searchTable.operation.create') }}
</a-button>
<a-upload action="/">
<template #upload-button>
<a-button>
{{ $t('searchTable.operation.import') }}
</a-button>
<a-button @click="reset">
<template #icon>
<icon-refresh />
</template>
{{ $t('searchTable.form.reset') }}
</a-button>
</a-space>
</a-col>
</a-row>
<a-divider style="margin-top: 0" />
<a-row style="margin-bottom: 16px">
<a-col :span="12">
<a-space>
<a-button type="primary">
<template #icon>
<icon-plus />
</template>
{{ $t('searchTable.operation.create') }}
</a-button>
<a-upload action="/">
<template #upload-button>
<a-button>
{{ $t('searchTable.operation.import') }}
</a-button>
</template>
</a-upload>
</a-space>
</a-col>
<a-col :span="12" style="display: flex; align-items: center; justify-content: end">
<a-button>
<template #icon>
<icon-download />
</template>
{{ $t('searchTable.operation.download') }}
</a-button>
<a-tooltip :content="$t('searchTable.actions.refresh')">
<div class="action-icon" @click="search"><icon-refresh size="18" /></div>
</a-tooltip>
<a-dropdown @select="handleSelectDensity">
<a-tooltip :content="$t('searchTable.actions.density')">
<div class="action-icon"><icon-line-height size="18" /></div>
</a-tooltip>
<template #content>
<a-doption v-for="item in densityList" :key="item.value" :value="item.value" :class="{ active: item.value === size }">
<span>{{ item.name }}</span>
</a-doption>
</template>
</a-dropdown>
<a-tooltip :content="$t('searchTable.actions.columnSetting')">
<a-popover trigger="click" position="bl" @popup-visible-change="popupVisibleChange">
<div class="action-icon"><icon-settings size="18" /></div>
<template #content>
<div id="tableSetting">
<div v-for="(item, index) in showColumns" :key="item.dataIndex" class="setting">
<div style="margin-right: 4px; cursor: move">
<icon-drag-arrow />
</div>
<div>
<a-checkbox v-model="item.checked" @change="handleChange($event, item as TableColumnData, index)"></a-checkbox>
</div>
<div class="title">
{{ item.title === '#' ? '序列号' : item.title }}
</div>
</div>
</div>
</template>
</a-popover>
</a-tooltip>
</a-col>
</a-row>
<a-table
row-key="id"
:loading="loading"
:pagination="pagination"
:columns="cloneColumns as TableColumnData[]"
:data="renderData"
:bordered="false"
:size="size"
@page-change="onPageChange"
>
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<template #contentType="{ record }">
<a-space>
<a-avatar v-if="record.contentType === 'img'" :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/581b17753093199839f2e327e726b157.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
<a-avatar v-else-if="record.contentType === 'horizontalVideo'" :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/77721e365eb2ab786c889682cbc721c1.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
<a-avatar v-else :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/ea8b09190046da0ea7e070d83c5d1731.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
{{ $t(`searchTable.form.contentType.${record.contentType}`) }}
</a-space>
</template>
<template #filterType="{ record }">
{{ $t(`searchTable.form.filterType.${record.filterType}`) }}
</template>
<template #status="{ record }">
<span v-if="record.status === 'offline'" class="circle"></span>
<span v-else class="circle pass"></span>
{{ $t(`searchTable.form.status.${record.status}`) }}
</template>
<template #operations>
<a-button v-permission="['admin']" type="text" size="small">
{{ $t('searchTable.columns.operations.view') }}
</a-button>
</template>
</a-table>
</a-card>
</template>
</a-upload>
</template>
<!-- 表格自定义列插槽 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<template #contentType="{ record }">
<a-space>
<a-avatar v-if="record.contentType === 'img'" :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/581b17753093199839f2e327e726b157.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
<a-avatar v-else-if="record.contentType === 'horizontalVideo'" :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/77721e365eb2ab786c889682cbc721c1.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
<a-avatar v-else :size="16" shape="square">
<img
alt="avatar"
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/ea8b09190046da0ea7e070d83c5d1731.svg~tplv-49unhts6dw-image.image"
/>
</a-avatar>
{{ $t(`searchTable.form.contentType.${record.contentType}`) }}
</a-space>
</template>
<template #filterType="{ record }">
{{ $t(`searchTable.form.filterType.${record.filterType}`) }}
</template>
<template #status="{ record }">
<span v-if="record.status === 'offline'" class="circle"></span>
<span v-else class="circle pass"></span>
{{ $t(`searchTable.form.status.${record.status}`) }}
</template>
<template #operations>
<a-button v-permission="['admin']" type="text" size="small">
{{ $t('searchTable.columns.operations.view') }}
</a-button>
</template>
</SearchTable>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, reactive, watch, nextTick } from 'vue'
import { computed, ref, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import useLoading from '@/hooks/loading'
import { queryPolicyList, PolicyRecord, PolicyParams } from '@/api/list'
import { Pagination } from '@/types/global'
import type { SelectOptionData } from '@arco-design/web-vue/es/select/interface'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import cloneDeep from 'lodash/cloneDeep'
import Sortable from 'sortablejs'
type SizeProps = 'mini' | 'small' | 'medium' | 'large'
type Column = TableColumnData & { checked?: true }
import type { FormItem } from '@/components/search-form/types'
const generateFormModel = () => {
return {
@@ -208,10 +105,6 @@ const { loading, setLoading } = useLoading(true)
const { t } = useI18n()
const renderData = ref<PolicyRecord[]>([])
const formModel = ref(generateFormModel())
const cloneColumns = ref<Column[]>([])
const showColumns = ref<Column[]>([])
const size = ref<SizeProps>('medium')
const basePagination: Pagination = {
current: 1,
@@ -220,66 +113,7 @@ const basePagination: Pagination = {
const pagination = reactive({
...basePagination,
})
const densityList = computed(() => [
{
name: t('searchTable.size.mini'),
value: 'mini',
},
{
name: t('searchTable.size.small'),
value: 'small',
},
{
name: t('searchTable.size.medium'),
value: 'medium',
},
{
name: t('searchTable.size.large'),
value: 'large',
},
])
const columns = computed<TableColumnData[]>(() => [
{
title: t('searchTable.columns.index'),
dataIndex: 'index',
slotName: 'index',
},
{
title: t('searchTable.columns.number'),
dataIndex: 'number',
},
{
title: t('searchTable.columns.name'),
dataIndex: 'name',
},
{
title: t('searchTable.columns.contentType'),
dataIndex: 'contentType',
slotName: 'contentType',
},
{
title: t('searchTable.columns.filterType'),
dataIndex: 'filterType',
},
{
title: t('searchTable.columns.count'),
dataIndex: 'count',
},
{
title: t('searchTable.columns.createdTime'),
dataIndex: 'createdTime',
},
{
title: t('searchTable.columns.status'),
dataIndex: 'status',
slotName: 'status',
},
{
title: t('searchTable.columns.operations'),
dataIndex: 'operations',
slotName: 'operations',
},
])
const contentTypeOptions = computed<SelectOptionData[]>(() => [
{
label: t('searchTable.form.contentType.img'),
@@ -314,6 +148,91 @@ const statusOptions = computed<SelectOptionData[]>(() => [
value: 'offline',
},
])
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'number',
label: t('searchTable.form.number'),
type: 'input',
placeholder: t('searchTable.form.number.placeholder'),
},
{
field: 'name',
label: t('searchTable.form.name'),
type: 'input',
placeholder: t('searchTable.form.name.placeholder'),
},
{
field: 'contentType',
label: t('searchTable.form.contentType'),
type: 'select',
options: contentTypeOptions.value,
},
{
field: 'filterType',
label: t('searchTable.form.filterType'),
type: 'select',
options: filterTypeOptions.value,
},
{
field: 'createdTime',
label: t('searchTable.form.createdTime'),
type: 'dateRange',
},
{
field: 'status',
label: t('searchTable.form.status'),
type: 'select',
options: statusOptions.value,
},
])
// 表格列配置
const columns = computed<TableColumnData[]>(() => [
{
title: t('searchTable.columns.index'),
dataIndex: 'index',
slotName: 'index',
},
{
title: t('searchTable.columns.number'),
dataIndex: 'number',
},
{
title: t('searchTable.columns.name'),
dataIndex: 'name',
},
{
title: t('searchTable.columns.contentType'),
dataIndex: 'contentType',
slotName: 'contentType',
},
{
title: t('searchTable.columns.filterType'),
dataIndex: 'filterType',
slotName: 'filterType',
},
{
title: t('searchTable.columns.count'),
dataIndex: 'count',
},
{
title: t('searchTable.columns.createdTime'),
dataIndex: 'createdTime',
},
{
title: t('searchTable.columns.status'),
dataIndex: 'status',
slotName: 'status',
},
{
title: t('searchTable.columns.operations'),
dataIndex: 'operations',
slotName: 'operations',
},
])
const fetchData = async (params: PolicyParams = { current: 1, pageSize: 20 }) => {
setLoading(true)
try {
@@ -342,54 +261,6 @@ fetchData()
const reset = () => {
formModel.value = generateFormModel()
}
const handleSelectDensity = (val: string | number | Record<string, any> | undefined, e: Event) => {
size.value = val as SizeProps
}
const handleChange = (checked: boolean | (string | boolean | number)[], column: Column, index: number) => {
if (!checked) {
cloneColumns.value = showColumns.value.filter((item) => item.dataIndex !== column.dataIndex)
} else {
cloneColumns.value.splice(index, 0, column)
}
}
const exchangeArray = <T extends Array<any>>(array: T, beforeIdx: number, newIdx: number, isDeep = false): T => {
const newArray = isDeep ? cloneDeep(array) : array
if (beforeIdx > -1 && newIdx > -1) {
// 先替换后面的,然后拿到替换的结果替换前面的
newArray.splice(beforeIdx, 1, newArray.splice(newIdx, 1, newArray[beforeIdx]).pop())
}
return newArray
}
const popupVisibleChange = (val: boolean) => {
if (val) {
nextTick(() => {
const el = document.getElementById('tableSetting') as HTMLElement
const sortable = new Sortable(el, {
onEnd(e: any) {
const { oldIndex, newIndex } = e
exchangeArray(cloneColumns.value, oldIndex, newIndex)
exchangeArray(showColumns.value, oldIndex, newIndex)
},
})
})
}
}
watch(
() => columns.value,
(val) => {
cloneColumns.value = cloneDeep(val)
cloneColumns.value.forEach((item, index) => {
item.checked = true
})
showColumns.value = cloneDeep(cloneColumns.value)
},
{ deep: true, immediate: true }
)
</script>
<script lang="ts">
@@ -402,28 +273,4 @@ export default {
.container {
padding: 0 20px 20px 20px;
}
:deep(.arco-table-th) {
&:last-child {
.arco-table-th-item-title {
margin-left: 16px;
}
}
}
.action-icon {
margin-left: 12px;
cursor: pointer;
}
.active {
color: #0960bd;
background-color: #e3f4fc;
}
.setting {
display: flex;
align-items: center;
width: 200px;
.title {
margin-left: 12px;
cursor: pointer;
}
}
</style>

View File

@@ -5,12 +5,12 @@
<div class="login-form-error-msg">{{ errorMessage }}</div>
<a-form ref="loginForm" :model="userInfo" class="login-form" layout="vertical" @submit="handleSubmit">
<a-form-item
field="username"
:rules="[{ required: true, message: $t('login.form.userName.errMsg') }]"
field="account"
:rules="[{ required: true, message: '请输入账号' }]"
:validate-trigger="['change', 'blur']"
hide-label
>
<a-input v-model="userInfo.username" :placeholder="$t('login.form.userName.placeholder')">
<a-input v-model="userInfo.account" placeholder="账号">
<template #prefix>
<icon-user />
</template>
@@ -18,11 +18,11 @@
</a-form-item>
<a-form-item
field="password"
:rules="[{ required: true, message: $t('login.form.password.errMsg') }]"
:rules="[{ required: true, message: '请输入密码' }]"
:validate-trigger="['change', 'blur']"
hide-label
>
<a-input-password v-model="userInfo.password" :placeholder="$t('login.form.password.placeholder')" allow-clear>
<a-input-password v-model="userInfo.password" placeholder="密码" allow-clear>
<template #prefix>
<icon-lock />
</template>
@@ -44,7 +44,7 @@
</template>
<script lang="ts" setup>
import type { LoginData } from '@/api/user'
import type { LoginData } from '@/api/types'
import useLoading from '@/hooks/loading'
import { useUserStore } from '@/store'
import { Message } from '@arco-design/web-vue'
@@ -62,11 +62,11 @@ const userStore = useUserStore()
const loginConfig = useStorage('login-config', {
rememberPassword: true,
username: 'admin', // 演示默认值
password: 'admin', // demo default value
account: '',
password: '',
})
const userInfo = reactive({
username: loginConfig.value.username,
account: loginConfig.value.account,
password: loginConfig.value.password,
})
@@ -85,10 +85,10 @@ const handleSubmit = async ({ errors, values }: { errors: Record<string, Validat
})
Message.success(t('login.form.login.success'))
const { rememberPassword } = loginConfig.value
const { username, password } = values
const { account, password } = values
// 实际生产环境需要进行加密存储。
// The actual production environment requires encrypted storage.
loginConfig.value.username = rememberPassword ? username : ''
loginConfig.value.account = rememberPassword ? account : ''
loginConfig.value.password = rememberPassword ? password : ''
} catch (err) {
errorMessage.value = (err as Error).message

View File

@@ -1,25 +1,6 @@
<template>
<div class="login-bg">
<div class="container">
<div class="scan-login-btn" @click="showQr = true">
<svg class="scan-icon" viewBox="0 0 24 24" width="20" height="20">
<rect x="3" y="3" width="7" height="7" rx="2" fill="none" stroke="#00308f" stroke-width="2" />
<rect x="14" y="3" width="7" height="7" rx="2" fill="none" stroke="#00308f" stroke-width="2" />
<rect x="14" y="14" width="7" height="7" rx="2" fill="none" stroke="#00308f" stroke-width="2" />
<rect x="3" y="14" width="7" height="7" rx="2" fill="none" stroke="#00308f" stroke-width="2" />
</svg>
<span>扫码登录</span>
</div>
<a-modal v-model:visible="showQr" title="扫码登录" :footer="false" width="320px">
<div class="qr-modal-content">
<img
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=login-demo"
alt="二维码"
style="width: 200px; height: 200px; display: block; margin: 0 auto"
/>
<div style="text-align: center; margin-top: 12px; color: #888">请使用微信/钉钉等扫码登录</div>
</div>
</a-modal>
<div class="logo">
<img alt="logo" src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image" />
<div class="logo-text">智能运维管理系统</div>
@@ -128,32 +109,4 @@ export default defineComponent({
background: transparent;
}
}
.scan-login-btn {
position: absolute;
top: 24px;
right: 32px;
z-index: 10;
display: flex;
align-items: center;
gap: 6px;
background: #f4f8ff;
color: #00308f;
border-radius: 18px;
padding: 6px 16px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 2px 8px 0 rgba(31, 38, 135, 0.08);
transition: background 0.2s;
&:hover {
background: #e0e7ff;
}
.scan-icon {
display: inline-block;
vertical-align: middle;
}
}
.qr-modal-content {
padding: 12px 0 0 0;
}
</style>

View File

@@ -4,8 +4,8 @@ export default {
'login.form.password.errMsg': '密码不能为空',
'login.form.login.errMsg': '登录出错,轻刷新重试',
'login.form.login.success': '欢迎使用',
'login.form.userName.placeholder': '用户名admin',
'login.form.password.placeholder': '密码admin',
'login.form.userName.placeholder': '请输入用户名',
'login.form.password.placeholder': '请输入密码',
'login.form.rememberPassword': '记住密码',
'login.form.forgetPassword': '忘记密码',
'login.form.login': '登录',

View File

@@ -1,8 +1,8 @@
<template>
<div class="content">
<a-result class="result" status="404" :subtitle="'not found'"></a-result>
<a-result class="result" status="404" :subtitle="'未找到'"></a-result>
<div class="operation-row">
<a-button key="back" type="primary" @click="back">back</a-button>
<a-button key="back" type="primary" @click="back">返回</a-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,351 @@
<template>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
layout="vertical"
class="menu-form"
@submit-success="handleSubmit"
>
<!-- 基本信息 -->
<a-divider orientation="left">基本信息</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="code" label="菜单编码" :rules="[{ required: true, message: '请输入菜单编码' }]">
<a-input v-model="formData.code" placeholder="请输入菜单编码,如: menu_management" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="type" label="菜单类型">
<a-select v-model="formData.type">
<a-option :value="1">{{ formData.parent_id ? '子菜单' : '根菜单' }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="title" label="菜单名称" :rules="[{ required: true, message: '请输入菜单名称' }]">
<a-input v-model="formData.title" placeholder="请输入中文菜单名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="title_en" label="英文名称" :rules="[{ required: true, message: '请输入英文名称' }]">
<a-input v-model="formData.title_en" placeholder="请输入英文菜单名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="menu_path" label="路由路径" :rules="[{ required: true, message: '请输入路由路径' }]">
<a-input v-model="formData.menu_path" placeholder="请输入路由路径,如: /ops/menu-management" />
</a-form-item>
</a-col>
<!-- 根菜单才显示图标选择 -->
<a-col v-if="!parentId" :span="12">
<a-form-item field="menu_icon" label="菜单图标" :rules="[{ required: true, message: '请选择菜单图标' }]">
<a-input
v-model="formData.menu_icon"
placeholder="点击选择图标"
readonly
@click="iconPickerVisible = true"
>
<template #prefix>
<component v-if="formData.menu_icon" :is="getIconComponent(formData.menu_icon)" :size="18" />
</template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item field="description" label="菜单描述">
<a-textarea v-model="formData.description" placeholder="请输入菜单描述" :auto-size="{ minRows: 3, maxRows: 5 }" />
</a-form-item>
</a-col>
</a-row>
<!-- 网页嵌入配置 -->
<a-divider orientation="left">网页嵌入配置</a-divider>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item field="is_web_page">
<a-switch v-model="formData.is_web_page" />
<span class="switch-label">是否为嵌入网页</span>
</a-form-item>
</a-col>
<a-col v-if="formData.is_web_page" :span="24">
<a-form-item field="web_url" label="网页地址">
<a-input v-model="formData.web_url" placeholder="请输入要嵌入的网页URL" />
</a-form-item>
</a-col>
</a-row>
<!-- 操作按钮 -->
<div class="form-actions">
<a-space>
<a-button type="primary" html-type="submit" :loading="loading">
保存
</a-button>
<a-button @click="$emit('cancel')">
取消
</a-button>
</a-space>
</div>
<!-- 图标选择器 -->
<a-modal
v-model:visible="iconPickerVisible"
title="选择图标"
:footer="false"
width="600px"
>
<div class="icon-search">
<a-input v-model="iconSearch" placeholder="搜索图标" allow-clear>
<template #prefix><icon-search /></template>
</a-input>
</div>
<div class="icon-grid">
<div
v-for="icon in filteredIcons"
:key="icon"
class="icon-item"
:class="{ 'is-selected': formData.menu_icon === icon }"
@click="selectIcon(icon)"
>
<component :is="getIconComponent(icon)" :size="24" class="icon-preview" />
<span class="icon-name">{{ icon }}</span>
</div>
</div>
</a-modal>
</a-form>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue'
import type { FormInstance } from '@arco-design/web-vue'
import type { MenuRouteRequest, MenuNode } from '../types'
import { COMMON_ICONS, iconNames, getIconComponent } from '../menuIcons'
import { IconSearch } from '@tabler/icons-vue'
const props = defineProps<{
mode: 'add' | 'edit' | 'add_child'
parentId?: number | null
initialValues?: MenuNode | null
parentName?: string
}>()
const emit = defineEmits<{
(e: 'save', data: MenuRouteRequest): void
(e: 'cancel'): void
}>()
const formRef = ref<FormInstance>()
const loading = ref(false)
const iconPickerVisible = ref(false)
const iconSearch = ref('')
// 表单数据
const formData = ref<MenuRouteRequest>({
code: '',
description: '',
menu_icon: '',
menu_path: '',
parent_id: undefined,
title: '',
title_en: '',
type: 1,
is_web_page: false,
web_url: '',
// 编辑时需要的额外字段
id: undefined,
identity: undefined,
app_id: undefined,
sort_key: undefined,
created_at: undefined,
})
// 图标列表
const iconList = iconNames
// 过滤图标
const filteredIcons = computed(() => {
if (!iconSearch.value) return iconList
return iconList.filter(icon =>
icon.toLowerCase().includes(iconSearch.value.toLowerCase())
)
})
// 表单验证规则
const rules = {
code: [{ required: true, message: '请输入菜单编码' }],
title: [{ required: true, message: '请输入菜单名称' }],
title_en: [{ required: true, message: '请输入英文名称' }],
menu_path: [{ required: true, message: '请输入路由路径' }],
}
// 重置表单
const resetForm = () => {
formData.value = {
code: '',
description: '',
menu_icon: '',
menu_path: '',
parent_id: props.parentId || undefined,
title: '',
title_en: '',
type: 1,
is_web_page: false,
web_url: '',
id: undefined,
identity: undefined,
app_id: undefined,
sort_key: undefined,
created_at: undefined,
}
formRef.value?.clearValidate()
}
// 监听 mode 和 initialValues 变化
watch(
() => [props.mode, props.initialValues] as const,
([mode, initialValues]) => {
if (mode === 'add' || mode === 'add_child') {
resetForm()
} else if (mode === 'edit' && initialValues) {
// 编辑模式:保留所有原始数据,只更新表单字段
formData.value = {
id: initialValues.id,
identity: initialValues.identity,
app_id: initialValues.app_id ?? 2,
sort_key: initialValues.sort_key,
created_at: initialValues.created_at,
code: initialValues.code || '',
description: initialValues.description || '',
menu_icon: initialValues.menu_icon || '',
menu_path: initialValues.menu_path || '',
parent_id: initialValues.parent_id,
title: initialValues.title || '',
title_en: initialValues.title_en || '',
type: initialValues.type || 1,
is_web_page: initialValues.is_web_page || false,
web_url: initialValues.web_url || '',
}
}
},
{ immediate: true }
)
// 监听父ID变化
watch(() => props.parentId, (val) => {
if (props.mode === 'add_child') {
formData.value.parent_id = val || undefined
}
}, { immediate: true })
// 选择图标
const selectIcon = (icon: string) => {
formData.value.menu_icon = icon
iconPickerVisible.value = false
iconSearch.value = ''
}
// 提交表单
const handleSubmit = async () => {
try {
loading.value = true
const valid = await formRef.value?.validate()
if (valid) return
emit('save', { ...formData.value })
} catch (error) {
console.error('Form validation error:', error)
} finally {
loading.value = false
}
}
</script>
<script lang="ts">
export default {
name: 'MenuForm',
}
</script>
<style scoped lang="less">
.menu-form {
.switch-label {
margin-left: 8px;
color: var(--color-text-2);
}
.form-actions {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
text-align: right;
}
}
.icon-search {
margin-bottom: 16px;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
max-height: 400px;
overflow-y: auto;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: rgb(var(--primary-6));
background-color: rgb(var(--primary-1));
}
&.is-selected {
border-color: rgb(var(--primary-6));
background-color: rgb(var(--primary-1));
position: relative;
&::after {
content: '✓';
position: absolute;
top: 4px;
right: 4px;
color: rgb(var(--primary-6));
font-size: 12px;
}
}
.icon-preview {
font-size: 24px;
color: var(--color-text-1);
}
.icon-name {
margin-top: 4px;
font-size: 10px;
color: var(--color-text-3);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div class="menu-tree">
<MenuTreeItem
v-for="node in nodes"
:key="node.id"
:node="node"
:level="1"
:expanded-keys="expandedKeys"
:selected-key="selectedKey"
:dragging-node="draggingNode"
@toggle="$emit('toggle', $event)"
@select="$emit('select', $event)"
@add-child="$emit('add-child', $event)"
@delete="$emit('delete', $event)"
@drag-start="$emit('drag-start', $event)"
@drag-end="$emit('drag-end')"
@drop="$emit('drop', $event)"
/>
<!-- 根级别拖放区域 -->
<div
v-if="draggingNode"
class="root-drop-zone"
:class="{ 'drag-over': isDragOver }"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.prevent="handleDropToRoot"
>
<icon-plus-circle /> 拖放到此处设为根级菜单
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import MenuTreeItem from './MenuTreeItem.vue'
import type { MenuNode } from '../types'
const props = defineProps<{
nodes: MenuNode[]
expandedKeys: Set<number>
selectedKey: number | null
draggingNode?: MenuNode | null
}>()
const emit = defineEmits<{
(e: 'toggle', nodeId: number): void
(e: 'select', node: MenuNode): void
(e: 'add-child', parentId: number): void
(e: 'delete', node: MenuNode): void
(e: 'drag-start', node: MenuNode): void
(e: 'drag-end'): void
(e: 'drop', data: { dragNode: MenuNode; targetNode: MenuNode | null; position: 'before' | 'after' | 'inside' | 'root' }): void
}>()
const isDragOver = ref(false)
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
isDragOver.value = true
}
const handleDragLeave = () => {
isDragOver.value = false
}
const handleDropToRoot = () => {
isDragOver.value = false
if (props.draggingNode) {
// 拖放到根级别
emit('drop', {
dragNode: props.draggingNode,
targetNode: null,
position: 'root'
})
}
}
</script>
<script lang="ts">
export default {
name: 'MenuTree',
}
</script>
<style scoped lang="less">
.menu-tree {
user-select: none;
}
.root-drop-zone {
margin-top: 8px;
padding: 16px;
border: 2px dashed var(--color-border-2);
border-radius: 4px;
text-align: center;
color: var(--color-text-3);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&:hover, &.drag-over {
border-color: rgb(var(--primary-6));
color: rgb(var(--primary-6));
background-color: rgb(var(--primary-1));
}
}
</style>

View File

@@ -0,0 +1,274 @@
<template>
<div class="menu-tree-item">
<div
class="menu-item-content"
:class="{
'is-selected': selectedKey === node.id,
'is-hovered': isHovered,
'drag-over': isDragOver
}"
:style="{ paddingLeft: `${level * 16}px` }"
draggable="true"
@click="handleSelect"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
@dragstart="handleDragStart"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@dragend="handleDragEnd"
>
<!-- 展开/折叠按钮 -->
<span class="expand-btn" @click.stop="handleToggle">
<icon-right v-if="!isExpanded && hasChildren" />
<icon-down v-else-if="isExpanded && hasChildren" />
<span v-else class="placeholder"></span>
</span>
<!-- 拖拽手柄 -->
<span class="drag-handle">
<icon-drag-dot-vertical />
</span>
<!-- 菜单图标 -->
<span v-if="node.menu_icon" class="menu-icon">
<component :is="getIcon(node.menu_icon)" />
</span>
<!-- 菜单标题 -->
<span class="menu-title">{{ node.title }}</span>
<!-- 分组标识 -->
<a-tag v-if="!node.menu_path" size="small" color="gray">分组</a-tag>
<!-- 操作按钮 -->
<span v-show="isHovered" class="action-btns">
<!-- 只有一级菜单根菜单才显示添加按钮 -->
<a-tooltip v-if="level === 1" content="添加子菜单">
<a-button type="text" size="mini" @click.stop="handleAddChild">
<template #icon><icon-plus /></template>
</a-button>
</a-tooltip>
<a-tooltip content="删除">
<a-button type="text" size="mini" status="danger" @click.stop="handleDelete">
<template #icon><icon-delete /></template>
</a-button>
</a-tooltip>
</span>
</div>
<!-- 子菜单 -->
<div v-if="isExpanded && hasChildren" class="menu-children">
<MenuTreeItem
v-for="child in node.children"
:key="child.id"
:node="child"
:level="level + 1"
:expanded-keys="expandedKeys"
:selected-key="selectedKey"
:dragging-node="draggingNode"
@toggle="$emit('toggle', $event)"
@select="$emit('select', $event)"
@add-child="$emit('add-child', $event)"
@delete="$emit('delete', $event)"
@drag-start="$emit('drag-start', $event)"
@drag-end="$emit('drag-end')"
@drop="$emit('drop', $event)"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import type { MenuNode } from '../types'
import { getIconComponent } from '../menuIcons'
const props = defineProps<{
node: MenuNode
level: number
expandedKeys: Set<number>
selectedKey: number | null
draggingNode?: MenuNode | null
}>()
const emit = defineEmits<{
(e: 'toggle', nodeId: number): void
(e: 'select', node: MenuNode): void
(e: 'add-child', parentId: number): void
(e: 'delete', node: MenuNode): void
(e: 'drag-start', node: MenuNode): void
(e: 'drag-end'): void
(e: 'drop', data: { dragNode: MenuNode; targetNode: MenuNode; position: 'before' | 'after' | 'inside' }): void
}>()
const isHovered = ref(false)
const isDragOver = ref(false)
const isExpanded = computed(() => props.expandedKeys.has(props.node.id))
const hasChildren = computed(() => props.node.children && props.node.children.length > 0)
const isDraggingThis = computed(() => props.draggingNode?.id === props.node.id)
const handleToggle = () => {
if (hasChildren.value) {
emit('toggle', props.node.id)
}
}
const handleSelect = () => {
emit('select', props.node)
}
const handleAddChild = () => {
emit('add-child', props.node.id)
}
const handleDelete = () => {
emit('delete', props.node)
}
// 拖拽相关
const handleDragStart = (e: DragEvent) => {
e.dataTransfer!.effectAllowed = 'move'
emit('drag-start', props.node)
// 添加拖拽样式
const target = e.target as HTMLElement
setTimeout(() => {
target.style.opacity = '0.5'
}, 0)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.dataTransfer!.dropEffect = 'move'
isDragOver.value = true
}
const handleDragLeave = () => {
isDragOver.value = false
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
isDragOver.value = false
if (props.draggingNode && props.draggingNode.id !== props.node.id) {
// 判断放置位置
const rect = (e.target as HTMLElement).getBoundingClientRect()
const y = e.clientY - rect.top
const height = rect.height
let position: 'before' | 'after' | 'inside' = 'inside'
if (y < height * 0.25) {
position = 'before'
} else if (y > height * 0.75) {
position = 'after'
} else {
position = 'inside'
}
emit('drop', {
dragNode: props.draggingNode,
targetNode: props.node,
position
})
}
}
const handleDragEnd = (e: DragEvent) => {
const target = e.target as HTMLElement
target.style.opacity = '1'
emit('drag-end')
}
// 获取图标组件
const getIcon = (iconName: string) => {
return getIconComponent(iconName)
}
</script>
<script lang="ts">
export default {
name: 'MenuTreeItem',
}
</script>
<style scoped lang="less">
.menu-tree-item {
.menu-item-content {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
&:hover, &.is-hovered {
background-color: var(--color-fill-2);
}
&.is-selected {
background-color: rgb(var(--primary-1));
color: rgb(var(--primary-6));
}
&.drag-over {
border-color: rgb(var(--primary-6));
background-color: rgb(var(--primary-1));
}
.expand-btn {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 4px;
color: var(--color-text-3);
.placeholder {
width: 16px;
height: 16px;
}
}
.drag-handle {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 4px;
color: var(--color-text-4);
cursor: grab;
&:hover {
color: var(--color-text-2);
}
}
.menu-icon {
margin-right: 8px;
color: var(--color-text-2);
}
.menu-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.action-btns {
display: flex;
gap: 4px;
}
}
.menu-children {
// 子菜单样式
}
}
</style>

View File

@@ -0,0 +1,463 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.ops', 'menu.ops.systemSettings', 'menu.ops.systemSettings.menuManagement']" />
<a-row :gutter="16" class="menu-container">
<!-- 左侧: 菜单树 -->
<a-col :xs="24" :md="10" :lg="8">
<a-card class="menu-tree-card" :bordered="false">
<!-- 头部操作区 -->
<template #title>
<div class="card-header">
<span>菜单管理</span>
<a-button type="primary" size="small" @click="handleAddRootMenu">
<template #icon><icon-plus /></template>
添加根菜单
</a-button>
</div>
</template>
<!-- 提示信息 -->
<a-alert class="menu-tip" type="info" :show-icon="true">
点击菜单项可编辑悬停显示操作按钮
</a-alert>
<!-- 菜单树 -->
<a-spin :loading="loading" class="menu-tree-spin">
<div class="menu-tree-wrapper">
<MenuTree
v-if="rootItems.length > 0"
:nodes="rootItems"
:expanded-keys="expandedKeys"
:selected-key="selectedNode?.id || null"
:dragging-node="draggingNode"
@toggle="handleToggleExpand"
@select="handleSelectNode"
@add-child="handleAddChildMenu"
@delete="handleDeleteClick"
@drag-start="handleDragStart"
@drag-end="handleDragEnd"
@drop="handleDrop"
/>
<a-empty v-else description="暂无菜单数据" />
</div>
</a-spin>
</a-card>
</a-col>
<!-- 右侧: 编辑表单 -->
<a-col :xs="24" :md="14" :lg="16">
<a-card class="menu-edit-card" :bordered="false">
<template #title>
<div class="edit-header">
<span>{{ getEditTitle() }}</span>
<span v-if="isAddingChild || (isEditing && selectedNode?.parent_id)" class="parent-info">
父菜单:
<a-tag color="arcoblue">
{{ isEditing && selectedNode?.parent_id ? getParentMenuName(selectedNode.parent_id) : getParentMenuName(parentId) }}
</a-tag>
</span>
</div>
</template>
<!-- 编辑表单 -->
<MenuForm
v-if="isEditing || isAdding || isAddingChild"
:mode="getEditMode()"
:parent-id="parentId"
:initial-values="selectedNode"
:parent-name="getParentMenuName(parentId)"
@save="handleSaveMenu"
@cancel="handleCancelEdit"
/>
<a-empty v-else description="请从左侧选择一个菜单项进行编辑" class="empty-state" />
</a-card>
</a-col>
</a-row>
<!-- 删除确认对话框 -->
<a-modal
v-model:visible="deleteConfirmVisible"
title="删除确认"
@ok="handleConfirmDelete"
@cancel="deleteConfirmVisible = false"
>
<p>确定要删除菜单 "{{ nodeToDelete?.title }}" </p>
<p v-if="nodeToDelete?.children?.length" class="warning-text">
<icon-exclamation-circle-fill /> 注意该菜单下有子菜单删除后子菜单也会被删除
</p>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { MenuNode, MenuRouteRequest } from './types'
import { fetchMenu, createMenu, modifyMenu, deleteMenu, updateMenuOrder } from '@/api/module/pmn'
import { buildTree } from '@/utils/tree'
import MenuTree from './components/MenuTree.vue'
import MenuForm from './components/MenuForm.vue'
// 状态管理
const loading = ref(false)
const menuItems = ref<MenuRouteRequest[]>([])
const expandedKeys = ref<Set<number>>(new Set())
const selectedNode = ref<MenuNode | null>(null)
// 编辑状态
const isEditing = ref(false)
const isAdding = ref(false)
const isAddingChild = ref(false)
const parentId = ref<number | null>(null)
// 删除确认
const deleteConfirmVisible = ref(false)
const nodeToDelete = ref<MenuNode | null>(null)
// 拖拽状态
const draggingNode = ref<MenuNode | null>(null)
// 计算属性 - 使用公共 buildTree 函数构建树
const treeData = computed(() => buildTree<MenuNode>(menuItems.value as MenuNode[], { orderKey: 'order' }))
const rootItems = computed(() => treeData.value.rootItems)
const itemMap = computed(() => treeData.value.itemMap)
// 加载菜单数据
const loadMenuItems = async () => {
try {
loading.value = true
const res = await fetchMenu({ page: 1, size: 10000 })
if (res?.code === 0) {
menuItems.value = res.details?.data || []
}
} catch (error) {
console.error('Failed to load menu items:', error)
} finally {
loading.value = false
}
}
// 切换节点展开/折叠
const handleToggleExpand = (nodeId: number) => {
if (expandedKeys.value.has(nodeId)) {
expandedKeys.value.delete(nodeId)
} else {
expandedKeys.value.add(nodeId)
}
}
// 选择节点
const handleSelectNode = (node: MenuNode) => {
selectedNode.value = node
isEditing.value = true
isAdding.value = false
isAddingChild.value = false
}
// 添加根菜单
const handleAddRootMenu = () => {
selectedNode.value = null
isEditing.value = false
isAdding.value = true
isAddingChild.value = false
parentId.value = null
}
// 添加子菜单
const handleAddChildMenu = (pId: number) => {
selectedNode.value = null
isEditing.value = false
isAdding.value = false
isAddingChild.value = true
parentId.value = pId
}
// 编辑菜单
const handleEditMenu = (node: MenuNode) => {
selectedNode.value = node
isEditing.value = true
isAdding.value = false
isAddingChild.value = false
parentId.value = node.parent_id || null
}
// 删除点击
const handleDeleteClick = (node: MenuNode) => {
nodeToDelete.value = node
deleteConfirmVisible.value = true
}
// 确认删除
const handleConfirmDelete = async () => {
if (!nodeToDelete.value?.id) return
try {
loading.value = true
await deleteMenu({ id: nodeToDelete.value.id })
Message.success('删除成功')
// 如果删除的是当前选中的节点,清空编辑状态
if (selectedNode.value?.id === nodeToDelete.value.id) {
selectedNode.value = null
isEditing.value = false
isAdding.value = false
isAddingChild.value = false
parentId.value = null
}
deleteConfirmVisible.value = false
nodeToDelete.value = null
await loadMenuItems()
} catch (error) {
console.error('Failed to delete menu:', error)
} finally {
loading.value = false
}
}
// 保存菜单
const handleSaveMenu = async (menuData: MenuRouteRequest) => {
try {
loading.value = true
if (isEditing.value && selectedNode.value?.id) {
await modifyMenu({ ...menuData, id: selectedNode.value.id })
} else {
await createMenu(menuData)
}
Message.success('保存成功')
// 重置状态
isEditing.value = false
isAdding.value = false
isAddingChild.value = false
await loadMenuItems()
} catch (error) {
console.error('Failed to save menu:', error)
} finally {
loading.value = false
}
}
// 取消编辑
const handleCancelEdit = () => {
isEditing.value = false
isAdding.value = false
isAddingChild.value = false
selectedNode.value = null
}
// 获取编辑模式
const getEditMode = (): 'add' | 'edit' | 'add_child' => {
if (isEditing.value) return 'edit'
if (isAddingChild.value) return 'add_child'
return 'add'
}
// 获取编辑区标题
const getEditTitle = () => {
if (isEditing.value && selectedNode.value) {
return `编辑: ${selectedNode.value.title}`
}
if (isAddingChild.value) {
const parentName = getParentMenuName(parentId.value)
return `为 "${parentName}" 添加子菜单`
}
if (isAdding.value) return '添加根菜单'
return '详情'
}
// 获取父菜单名称
const getParentMenuName = (pId?: number | null) => {
const map = itemMap.value
if (!pId || !map.has(pId)) return ''
const item = map.get(pId)
return item?.title || ''
}
// 拖拽开始
const handleDragStart = (node: MenuNode) => {
draggingNode.value = node
}
// 拖拽结束
const handleDragEnd = () => {
draggingNode.value = null
}
// 处理放置
const handleDrop = async (data: { dragNode: MenuNode; targetNode: MenuNode | null; position: 'before' | 'after' | 'inside' | 'root' }) => {
const { dragNode, targetNode, position } = data
if (!dragNode.id) return
try {
loading.value = true
if (position === 'root' && !targetNode) {
// 拖放到根级别
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: null
} as MenuRouteRequest)
} else if (targetNode) {
if (position === 'inside') {
// 拖放到某个节点内部,成为其子节点
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: targetNode.id
} as MenuRouteRequest)
} else {
// 拖放到某个节点前后,需要更新排序
const sortList: { pmn_id: number; sort_key: number }[] = []
let sortKey = 1
// 获取同级节点
const siblings = menuItems.value
.filter(item => item.parent_id === targetNode.parent_id)
.sort((a, b) => (a.order || 0) - (b.order || 0))
siblings.forEach((item, index) => {
if (item.id === targetNode.id) {
if (position === 'before') {
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
} else {
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
}
} else if (item.id !== dragNode.id) {
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
}
})
// 更新拖拽节点的 parent_id
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: targetNode.parent_id || null
} as MenuRouteRequest)
// 更新排序
if (sortList.length > 0) {
await updateMenuOrder(sortList)
}
}
}
Message.success('移动成功')
await loadMenuItems()
} catch (error) {
console.error('Failed to move menu:', error)
Message.error('移动失败')
} finally {
loading.value = false
draggingNode.value = null
}
}
// 递归收集子节点
const collectChildren = (
items: MenuRouteRequest[],
parentId: number,
sortList: { pmn_id: number; sort_key: number }[],
getSortKey: () => number
) => {
const children = items
.filter((item: MenuRouteRequest) => item.parent_id === parentId)
.sort((a: MenuRouteRequest, b: MenuRouteRequest) => (a.order || 0) - (b.order || 0))
children.forEach((item: MenuRouteRequest) => {
if (item.id) {
sortList.push({ pmn_id: item.id, sort_key: getSortKey() })
collectChildren(items, item.id, sortList, getSortKey)
}
})
}
// 初始加载
onMounted(() => {
loadMenuItems()
})
</script>
<script lang="ts">
export default {
name: 'MenuManagement',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
.menu-container {
height: calc(100vh - 300px);
> div {
height: 100%;
}
}
.menu-tree-card,
.menu-edit-card {
height: 100%;
display: flex;
flex-direction: column;
:deep(.arco-card-body) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.menu-tip {
margin-bottom: 16px;
}
.menu-tree-spin {
flex: 1;
overflow: hidden;
}
.menu-tree-wrapper {
height: 100%;
overflow-y: auto;
padding-right: 8px;
}
.edit-header {
display: flex;
align-items: center;
gap: 12px;
.parent-info {
font-size: 12px;
color: var(--color-text-3);
font-weight: normal;
}
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.warning-text {
color: rgb(var(--warning-6));
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,296 @@
// 菜单图标集合 - 使用 @tabler/icons-vue
import type { Component } from 'vue';
import {
IconDashboard, IconHome, IconSettings, IconUser, IconUsers, IconUserCircle,
IconBuilding, IconFileText, IconCoin, IconChartBar, IconChartPie, IconList,
IconShield, IconWorld, IconLanguage, IconHelp, IconInfoCircle, IconFolder,
IconDatabase, IconMenu, IconCircle, IconSearch, IconFolderOff,
IconAlertTriangle,
// Business & Finance
Icon2fa, IconAbacus, IconAccessPoint, IconActivity, IconAd, IconAdjustments,
IconAlarm, IconAlertOctagon, IconAnalyze, IconAward, IconBarcode, IconBuildingBank,
IconCalculator, IconCalendar, IconCash, IconChartInfographic, IconCreditCard,
// Communication & Social
IconMail, IconMessage, IconMessages, IconPhone, IconShare, IconSocial,
IconBrandFacebook, IconBrandTwitter, IconBrandLinkedin, IconBrandInstagram,
IconBrandWhatsapp, IconBrandTelegram, IconBrandSkype, IconBrandZoom,
// Development & Technology
IconCode, IconBraces, IconBug, IconDeviceDesktop,
IconDeviceLaptop, IconDeviceMobile, IconDeviceTablet, IconGitBranch, IconTerminal,
IconServer, IconWifi, IconBrandGithub, IconBrandVscode, IconBrandDocker,
IconCloud,
// Documents & Files
IconFile, IconFiles, IconFileZip, IconFile3d,
IconFileDownload, IconFileUpload, IconFolderPlus, IconBook,
IconBooks, IconBookmark, IconClipboard, IconNotes, IconPrinter,
// Interface Elements
IconCheckbox, IconForms, IconLayout,
IconMenu2, IconRadio, IconSelect, IconSwitch, IconTable,
IconToggleLeft, IconToggleRight, IconWindowMaximize,
// Media & Entertainment
IconCamera, IconPhoto, IconVideo, IconMusic, IconPlayerPlay, IconPlayerPause,
IconPlayerStop, IconMovie, IconMicrophone, IconVolume, IconHeadphones,
IconBrush, IconPalette, IconPencil, IconPaint, IconColorPicker,
// Navigation & Maps
IconMap, IconMapPin, IconCompass, IconDirection, IconGps, IconLocation,
IconNavigation, IconRoute, IconRoad, IconTrain, IconPlane, IconCar,
IconBike, IconWalk, IconRun, IconSwimming,
// Shopping & E-commerce
IconShoppingCart, IconShoppingBag, IconBasket, IconGift, IconDiscount,
IconTag, IconTags, IconReceipt, IconTruck, IconPackage, IconBox,
// Weather & Nature
IconSun, IconMoon, IconCloudRain, IconCloudSnow, IconWind,
IconTemperature, IconUmbrella, IconPlant, IconTree, IconLeaf, IconFlower,
// Additional UI Elements
IconEdit, IconCopy, IconArrowBack, IconArrowForward, IconRefresh, IconRotate,
IconZoomIn, IconZoomOut, IconFilter, IconSortAscending,
IconSortDescending, IconColumns, IconLayoutGrid,
// Security & Access
IconLock, IconKey, IconPassword, IconFingerprint, IconShieldLock,
IconCertificate, IconId, IconBadge,
// Time & Date
IconClock, IconCalendarEvent, IconCalendarTime, IconCalendarStats,
IconHourglass, IconHistory, IconTimeline, IconAlarmPlus,
// Health & Medical
IconHeartbeat, IconStethoscope, IconPill, IconVirus, IconVaccine,
IconEmergencyBed, IconAmbulance, IconMedicalCross, IconDna,
// Analytics & Data
IconChartArea, IconChartArrows, IconChartCandle,
IconChartLine, IconPresentationAnalytics,
IconReportAnalytics, IconTrendingUp, IconTrendingDown,
IconSend, IconUpload, IconPlug, IconNotebook, IconTools, IconPoint,
} from '@tabler/icons-vue';
// 预选图标列表
export const COMMON_ICONS: Record<string, Component> = {
// 常用图标
Dashboard: IconDashboard,
Home: IconHome,
Settings: IconSettings,
AccountCircle: IconUserCircle,
People: IconUsers,
Person: IconUser,
Groups: IconUsers,
Store: IconBuilding,
Description: IconFileText,
AttachMoney: IconCoin,
BarChart: IconChartBar,
Insights: IconChartPie,
Security: IconShield,
Public: IconWorld,
Language: IconLanguage,
Help: IconHelp,
Folder: IconFolder,
Storage: IconDatabase,
Menu: IconMenu,
Link: IconCircle,
Search: IconSearch,
List: IconList,
Building: IconBuilding,
World: IconWorld,
Coin: IconCoin,
Users: IconUsers,
// Business & Finance
TwoFactor: Icon2fa,
Abacus: IconAbacus,
AccessPoint: IconAccessPoint,
Activity: IconActivity,
Advertisement: IconAd,
Adjustments: IconAdjustments,
Alarm: IconAlarm,
AlertOctagon: IconAlertOctagon,
Analyze: IconAnalyze,
Award: IconAward,
Barcode: IconBarcode,
Bank: IconBuildingBank,
Calculator: IconCalculator,
Calendar: IconCalendar,
Cash: IconCash,
CreditCard: IconCreditCard,
// Communication & Social
Mail: IconMail,
Message: IconMessage,
Messages: IconMessages,
Phone: IconPhone,
Share: IconShare,
Social: IconSocial,
Facebook: IconBrandFacebook,
Twitter: IconBrandTwitter,
LinkedIn: IconBrandLinkedin,
Instagram: IconBrandInstagram,
WhatsApp: IconBrandWhatsapp,
Telegram: IconBrandTelegram,
Skype: IconBrandSkype,
Zoom: IconBrandZoom,
// Development & Technology
Code: IconCode,
Braces: IconBraces,
Bug: IconBug,
Cloud: IconCloud,
Desktop: IconDeviceDesktop,
Laptop: IconDeviceLaptop,
Mobile: IconDeviceMobile,
Tablet: IconDeviceTablet,
Git: IconGitBranch,
Terminal: IconTerminal,
Server: IconServer,
Wifi: IconWifi,
Github: IconBrandGithub,
VSCode: IconBrandVscode,
Docker: IconBrandDocker,
// Documents & Files
File: IconFile,
Files: IconFiles,
FileZip: IconFileZip,
FilePdf: IconFile3d,
FileDownload: IconFileDownload,
FileUpload: IconFileUpload,
FolderPlus: IconFolderPlus,
Book: IconBook,
Books: IconBooks,
Bookmark: IconBookmark,
Clipboard: IconClipboard,
Notes: IconNotes,
Printer: IconPrinter,
// Interface Elements
Checkbox: IconCheckbox,
Forms: IconForms,
Layout: IconLayout,
Menu2: IconMenu2,
Radio: IconRadio,
Select: IconSelect,
Switch: IconSwitch,
Table: IconTable,
ToggleLeft: IconToggleLeft,
ToggleRight: IconToggleRight,
WindowMaximize: IconWindowMaximize,
// Media & Entertainment
Camera: IconCamera,
Photo: IconPhoto,
Video: IconVideo,
Music: IconMusic,
Play: IconPlayerPlay,
Pause: IconPlayerPause,
Stop: IconPlayerStop,
Movie: IconMovie,
Microphone: IconMicrophone,
Volume: IconVolume,
Headphones: IconHeadphones,
Brush: IconBrush,
Palette: IconPalette,
Pencil: IconPencil,
Paint: IconPaint,
ColorPicker: IconColorPicker,
// Navigation & Maps
Map: IconMap,
MapPin: IconMapPin,
Compass: IconCompass,
Direction: IconDirection,
GPS: IconGps,
Location: IconLocation,
Navigation: IconNavigation,
Route: IconRoute,
Road: IconRoad,
Train: IconTrain,
Plane: IconPlane,
Car: IconCar,
Bike: IconBike,
Walk: IconWalk,
Run: IconRun,
Swimming: IconSwimming,
// Shopping & E-commerce
ShoppingCart: IconShoppingCart,
ShoppingBag: IconShoppingBag,
Basket: IconBasket,
Gift: IconGift,
Discount: IconDiscount,
Tag: IconTag,
Tags: IconTags,
Receipt: IconReceipt,
Truck: IconTruck,
Package: IconPackage,
Box: IconBox,
// Weather & Nature
Sun: IconSun,
Moon: IconMoon,
CloudRain: IconCloudRain,
CloudSnow: IconCloudSnow,
Wind: IconWind,
Temperature: IconTemperature,
Umbrella: IconUmbrella,
Plant: IconPlant,
Tree: IconTree,
Leaf: IconLeaf,
Flower: IconFlower,
// Additional UI Elements
Edit: IconEdit,
Copy: IconCopy,
Undo: IconArrowBack,
Redo: IconArrowForward,
Refresh: IconRefresh,
Rotate: IconRotate,
ZoomIn: IconZoomIn,
ZoomOut: IconZoomOut,
Filter: IconFilter,
Sort: IconSortAscending,
SortAscending: IconSortAscending,
SortDescending: IconSortDescending,
Columns: IconColumns,
Grid: IconLayoutGrid,
// Security & Access
Lock: IconLock,
Key: IconKey,
Password: IconPassword,
Fingerprint: IconFingerprint,
ShieldLock: IconShieldLock,
Certificate: IconCertificate,
ID: IconId,
Badge: IconBadge,
// Time & Date
Clock: IconClock,
CalendarEvent: IconCalendarEvent,
CalendarTime: IconCalendarTime,
CalendarStats: IconCalendarStats,
Hourglass: IconHourglass,
History: IconHistory,
Timeline: IconTimeline,
AlarmPlus: IconAlarmPlus,
// Health & Medical
Heartbeat: IconHeartbeat,
Stethoscope: IconStethoscope,
Pill: IconPill,
Virus: IconVirus,
Vaccine: IconVaccine,
EmergencyBed: IconEmergencyBed,
Ambulance: IconAmbulance,
MedicalCross: IconMedicalCross,
DNA: IconDna,
// Analytics & Data
ChartArea: IconChartArea,
ChartArrows: IconChartArrows,
ChartCandle: IconChartCandle,
ChartInfographic: IconChartInfographic,
ChartLine: IconChartLine,
PresentationAnalytics: IconPresentationAnalytics,
ReportAnalytics: IconReportAnalytics,
TrendingUp: IconTrendingUp,
TrendingDown: IconTrendingDown,
Send: IconSend,
InfoCircle: IconInfoCircle,
Upload: IconUpload,
Plug: IconPlug,
Notebook: IconNotebook,
Tools: IconTools,
Point: IconPoint,
};
// 导出图标名称列表
export const iconNames = Object.keys(COMMON_ICONS);
// 导出类型
export type IconName = keyof typeof COMMON_ICONS;
// 获取图标组件
export const getIconComponent = (iconName: string) => {
return COMMON_ICONS[iconName];
};

View File

@@ -0,0 +1,14 @@
// 从API导入类型
import type { MenuItem as MenuItemType } from '@/api/module/pmn'
// 菜单路由请求接口
export interface MenuRouteRequest extends MenuItemType {}
// 菜单节点类型
export interface MenuNode extends MenuRouteRequest {
id: number
children: MenuNode[]
}
// 图标名称类型
export type IconName = string

View File

@@ -0,0 +1,270 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.ops', 'menu.ops.systemSettings', 'menu.ops.systemSettings.systemLogs']" />
<!-- 使用 SearchTable 公共组件 -->
<SearchTable
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="系统日志"
search-button-text="查询"
reset-button-text="重置"
download-button-text="导出"
refresh-tooltip-text="刷新数据"
density-tooltip-text="表格密度"
column-setting-tooltip-text="列设置"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
@download="handleDownload"
>
<!-- 表格自定义列日志级别 -->
<template #level="{ record }">
<a-tag :color="getLevelColor(record.level)">
{{ record.level }}
</a-tag>
</template>
<!-- 表格自定义列序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 表格自定义列操作 -->
<template #operations="{ record }">
<a-button type="text" size="small" @click="handleView(record)">
查看
</a-button>
</template>
</SearchTable>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormItem } from '@/components/search-form/types'
// 定义表格数据类型
interface LogRecord {
id: number
level: string
module: string
content: string
operator: string
ip: string
createdAt: string
}
// 模拟数据生成
const generateMockData = (count: number): LogRecord[] => {
const levels = ['INFO', 'WARN', 'ERROR', 'DEBUG']
const modules = ['用户管理', '权限管理', '系统配置', '数据备份', '登录认证']
const operators = ['管理员', '张三', '李四', '系统', '定时任务']
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
level: levels[i % levels.length],
module: modules[i % modules.length],
content: `日志内容描述 ${i + 1}`,
operator: operators[i % operators.length],
ip: `192.168.${Math.floor(i / 255)}.${i % 255}`,
createdAt: new Date(Date.now() - i * 3600000).toLocaleString('zh-CN'),
}))
}
// 状态管理
const loading = ref(false)
const tableData = ref<LogRecord[]>([])
const formModel = ref({
level: '',
module: '',
operator: '',
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'level',
label: '日志级别',
type: 'select',
placeholder: '请选择日志级别',
options: [
{ label: 'INFO', value: 'INFO' },
{ label: 'WARN', value: 'WARN' },
{ label: 'ERROR', value: 'ERROR' },
{ label: 'DEBUG', value: 'DEBUG' },
],
},
{
field: 'module',
label: '模块',
type: 'select',
placeholder: '请选择模块',
options: [
{ label: '用户管理', value: '用户管理' },
{ label: '权限管理', value: '权限管理' },
{ label: '系统配置', value: '系统配置' },
{ label: '数据备份', value: '数据备份' },
{ label: '登录认证', value: '登录认证' },
],
},
{
field: 'operator',
label: '操作人',
type: 'input',
placeholder: '请输入操作人',
},
])
// 表格列配置
const columns = computed<TableColumnData[]>(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
},
{
title: '日志级别',
dataIndex: 'level',
slotName: 'level',
width: 100,
},
{
title: '模块',
dataIndex: 'module',
width: 120,
},
{
title: '日志内容',
dataIndex: 'content',
ellipsis: true,
tooltip: true,
},
{
title: '操作人',
dataIndex: 'operator',
width: 120,
},
{
title: 'IP地址',
dataIndex: 'ip',
width: 140,
},
{
title: '操作时间',
dataIndex: 'createdAt',
width: 180,
},
{
title: '操作',
dataIndex: 'operations',
slotName: 'operations',
width: 100,
fixed: 'right',
},
])
// 获取日志级别颜色
const getLevelColor = (level: string) => {
const colorMap: Record<string, string> = {
INFO: 'arcoblue',
WARN: 'orange',
ERROR: 'red',
DEBUG: 'gray',
}
return colorMap[level] || 'gray'
}
// 模拟异步获取数据
const fetchData = async () => {
loading.value = true
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
let data = generateMockData(100)
// 根据搜索条件过滤
if (formModel.value.level) {
data = data.filter(item => item.level === formModel.value.level)
}
if (formModel.value.module) {
data = data.filter(item => item.module === formModel.value.module)
}
if (formModel.value.operator) {
data = data.filter(item => item.operator.includes(formModel.value.operator))
}
// 更新分页
pagination.total = data.length
// 分页截取
const start = (pagination.current - 1) * pagination.pageSize
const end = start + pagination.pageSize
tableData.value = data.slice(start, end)
loading.value = false
}
// 事件处理
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleReset = () => {
formModel.value = {
level: '',
module: '',
operator: '',
}
pagination.current = 1
fetchData()
}
const handlePageChange = (current: number) => {
pagination.current = current
fetchData()
}
const handleRefresh = () => {
fetchData()
Message.success('数据已刷新')
}
const handleDownload = () => {
Message.info('导出功能开发中...')
}
const handleView = (record: LogRecord) => {
Message.info(`查看日志详情:${record.id}`)
}
// 初始化加载数据
fetchData()
</script>
<script lang="ts">
export default {
name: 'SystemLogs',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
</style>

View File

@@ -1,75 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userInfo.title.latestActivity')">
<template #extra>
<a-link>{{ $t('userInfo.viewAll') }}</a-link>
</template>
<a-list :bordered="false">
<a-list-item v-for="activity in activityList" :key="activity.id" action-layout="horizontal">
<a-skeleton v-if="loading" :loading="loading" :animation="true" class="skeleton-item">
<a-row :gutter="6">
<a-col :span="2">
<a-skeleton-shape shape="circle" />
</a-col>
<a-col :span="22">
<a-skeleton-line :widths="['40%', '100%']" :rows="2" />
</a-col>
</a-row>
</a-skeleton>
<a-list-item-meta v-else :title="activity.title" :description="activity.description">
<template #avatar>
<a-avatar>
<img :src="activity.avatar" />
</a-avatar>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
</a-card>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { queryLatestActivity, LatestActivity } from '@/api/user-center'
import useLoading from '@/hooks/loading'
const { loading, setLoading } = useLoading(true)
const activityList = ref<LatestActivity[]>(new Array(7).fill({}))
const fetchData = async () => {
try {
const { data } = await queryLatestActivity()
activityList.value = data
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
fetchData()
</script>
<style scoped lang="less">
.latest-activity {
&-header {
display: flex;
align-items: center;
justify-content: space-between;
}
}
.general-card :deep(.arco-list-item) {
padding-left: 0;
border-bottom: none;
.arco-list-item-meta-content {
flex: 1;
padding-bottom: 27px;
border-bottom: 1px solid var(--color-neutral-3);
}
.arco-list-item-meta-avatar {
padding-bottom: 27px;
}
.skeleton-item {
margin-top: 10px;
padding-bottom: 20px;
border-bottom: 1px solid var(--color-neutral-3);
}
}
</style>

View File

@@ -1,27 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userInfo.title.latestNotification')">
<a-skeleton v-if="loading" :animation="true">
<a-skeleton-line :rows="3" />
</a-skeleton>
<a-result v-else status="404">
<template #subtitle>
{{ $t('userInfo.nodata') }}
</template>
</a-result>
</a-card>
</template>
<script lang="ts" setup>
import useLoading from '@/hooks/loading'
const { loading, setLoading } = useLoading(true)
setTimeout(() => {
setLoading(false)
}, 500)
</script>
<style lang="less" scoped>
:deep(.arco-result) {
padding: 40px 32px 108px;
}
</style>

View File

@@ -1,82 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userInfo.title.myProject')">
<template #extra>
<a-link>{{ $t('userInfo.showMore') }}</a-link>
</template>
<a-row :gutter="16">
<a-col
v-for="(project, index) in projectList"
:key="index"
:xs="12"
:sm="12"
:md="12"
:lg="12"
:xl="8"
:xxl="8"
class="my-project-item"
>
<a-card>
<a-skeleton v-if="loading" :loading="loading" :animation="true">
<a-skeleton-line :rows="3" />
</a-skeleton>
<a-space v-else direction="vertical">
<a-typography-text bold>{{ project.name }}</a-typography-text>
<a-typography-text type="secondary">
{{ project.description }}
</a-typography-text>
<a-space>
<a-avatar-group :size="24">
{{ project.contributors }}
<a-avatar v-for="(contributor, idx) in project.contributors" :key="idx" :size="32">
<img alt="avatar" :src="contributor.avatar" />
</a-avatar>
</a-avatar-group>
<a-typography-text type="secondary">{{ project.peopleNumber }}</a-typography-text>
</a-space>
</a-space>
</a-card>
</a-col>
</a-row>
</a-card>
</template>
<script lang="ts" setup>
import { queryMyProjectList, MyProjectRecord } from '@/api/user-center'
import useRequest from '@/hooks/request'
const defaultValue = Array(6).fill({} as MyProjectRecord)
const { loading, response: projectList } = useRequest<MyProjectRecord[]>(queryMyProjectList, defaultValue)
</script>
<style scoped lang="less">
:deep(.arco-card-body) {
min-height: 128px;
padding-bottom: 0;
}
.my-project {
&-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
&-title {
margin-top: 0 !important;
margin-bottom: 18px !important;
}
&-list {
display: flex;
justify-content: space-between;
}
&-item {
// padding-right: 16px;
margin-bottom: 16px;
&:last-child {
padding-right: 0;
}
}
}
</style>

View File

@@ -1,57 +0,0 @@
<template>
<a-card
class="general-card"
:title="$t('userInfo.tab.title.team')"
:header-style="{ paddingBottom: '18px' }"
:body-style="{ paddingBottom: '12px' }"
>
<a-list :bordered="false">
<a-list-item v-for="team in teamList" :key="team.id" action-layout="horizontal">
<a-skeleton v-if="loading" :loading="loading" :animation="true">
<a-row :gutter="6">
<a-col :span="6">
<a-skeleton-shape shape="circle" />
</a-col>
<a-col :span="16">
<a-skeleton-line :widths="['100%', '40%']" :rows="2" />
</a-col>
</a-row>
</a-skeleton>
<a-list-item-meta v-else :title="team.name">
<template #avatar>
<a-avatar>
<img :src="team.avatar" />
</a-avatar>
</template>
<template #description>{{ team.peopleNumber }}</template>
</a-list-item-meta>
</a-list-item>
</a-list>
</a-card>
</template>
<script lang="ts" setup>
import { queryMyTeamList, MyTeamRecord } from '@/api/user-center'
import useRequest from '@/hooks/request'
const defaultValue: MyTeamRecord[] = new Array(4).fill({})
const { loading, response: teamList } = useRequest<MyTeamRecord[]>(queryMyTeamList, defaultValue)
</script>
<style scoped lang="less">
.general-card {
height: 356px;
.arco-list-item {
height: 72px;
padding-left: 0;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-neutral-3);
&:last-child {
border-bottom: none;
}
.arco-list-item-meta {
padding: 0;
}
}
}
</style>

View File

@@ -1,68 +0,0 @@
<template>
<div class="header">
<a-space :size="12" direction="vertical" align="center">
<a-avatar :size="64">
<template #trigger-icon>
<icon-camera />
</template>
<img :src="userInfo.avatar" />
</a-avatar>
<a-typography-title :heading="6" style="margin: 0">
{{ userInfo.name }}
</a-typography-title>
<div class="user-msg">
<a-space :size="18">
<div>
<icon-user />
<a-typography-text>{{ userInfo.jobName }}</a-typography-text>
</div>
<div>
<icon-home />
<a-typography-text>
{{ userInfo.organizationName }}
</a-typography-text>
</div>
<div>
<icon-location />
<a-typography-text>{{ userInfo.locationName }}</a-typography-text>
</div>
</a-space>
</div>
</a-space>
</div>
</template>
<script lang="ts" setup>
import { useUserStore } from '@/store'
const userInfo = useUserStore()
</script>
<style scoped lang="less">
.header {
display: flex;
align-items: center;
justify-content: center;
height: 204px;
color: var(--gray-10);
background: url(//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/41c6b125cc2e27021bf7fcc9a9b1897c.svg~tplv-49unhts6dw-image.image) no-repeat;
background-size: cover;
border-radius: 4px;
:deep(.arco-avatar-trigger-icon-button) {
color: rgb(var(--arcoblue-6));
:deep(.arco-icon) {
vertical-align: -1px;
}
}
.user-msg {
.arco-icon {
color: rgb(var(--gray-10));
}
.arco-typography {
margin-left: 6px;
}
}
}
</style>

View File

@@ -1,87 +0,0 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.user', 'menu.user.info']" />
<UserInfoHeader />
<div class="content">
<div class="content-left">
<a-grid :cols="24" :col-gap="16" :row-gap="16">
<a-grid-item :span="24">
<MyProject />
</a-grid-item>
<a-grid-item :span="24">
<LatestActivity />
</a-grid-item>
</a-grid>
</div>
<div class="content-right">
<a-grid :cols="24" :row-gap="16">
<a-grid-item :span="24">
<MyTeam />
</a-grid-item>
<a-grid-item class="panel" :span="24">
<LatestNotification />
</a-grid-item>
</a-grid>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import UserInfoHeader from './components/user-info-header.vue'
import LatestNotification from './components/latest-notification.vue'
import MyProject from './components/my-project.vue'
import LatestActivity from './components/latest-activity.vue'
import MyTeam from './components/my-team.vue'
</script>
<script lang="ts">
export default {
name: 'Info',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
.content {
display: flex;
margin-top: 12px;
&-left {
flex: 1;
margin-right: 16px;
overflow: hidden;
// background-color: var(--color-bg-2);
:deep(.arco-tabs-nav-tab) {
margin-left: 16px;
}
}
&-right {
width: 332px;
}
.tab-pane-wrapper {
padding: 0 16px 16px 16px;
}
}
</style>
<style lang="less" scoped>
.mobile {
.content {
display: block;
&-left {
margin-right: 0;
margin-bottom: 16px;
}
&-right {
width: 100%;
}
}
}
</style>

View File

@@ -1,15 +0,0 @@
export default {
'menu.user.info': 'User Info',
'userInfo.editUserInfo': 'Edit Info',
'userInfo.tab.title.overview': 'Overview',
'userInfo.tab.title.project': 'Project',
'userInfo.tab.title.team': 'My Team',
'userInfo.title.latestActivity': 'Latest Activity',
'userInfo.title.latestNotification': 'In-site Notification',
'userInfo.title.myProject': 'My Project',
'userInfo.showMore': 'Show More',
'userInfo.viewAll': 'View All',
'userInfo.nodata': 'No Data',
'userInfo.visits.unit': 'times',
'userInfo.visits.lastMonth': 'Last Month',
}

View File

@@ -1,15 +0,0 @@
export default {
'menu.user.info': '用户信息',
'userInfo.editUserInfo': '编辑信息',
'userInfo.tab.title.overview': '总览',
'userInfo.tab.title.project': '项目',
'userInfo.tab.title.team': '我的团队',
'userInfo.title.latestActivity': '最新动态',
'userInfo.title.latestNotification': '站内通知',
'userInfo.title.myProject': '我的项目',
'userInfo.showMore': '查看更多',
'userInfo.viewAll': '查看全部',
'userInfo.nodata': '暂无数据',
'userInfo.visits.unit': '人次',
'userInfo.visits.lastMonth': '较上月',
}

View File

@@ -1,152 +0,0 @@
import Mock from 'mockjs'
import setupMock, { successResponseWrap } from '@/utils/setup-mock'
setupMock({
setup() {
// 最新项目
Mock.mock(new RegExp('/api/user/my-project/list'), () => {
const contributors = [
{
name: '秦臻宇',
email: 'qingzhenyu@arco.design',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/a8c8cdb109cb051163646151a4a5083b.png~tplv-uwbnlip3yd-webp.webp',
},
{
name: '于涛',
email: 'yuebao@arco.design',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/a8c8cdb109cb051163646151a4a5083b.png~tplv-uwbnlip3yd-webp.webp',
},
{
name: '宁波',
email: 'ningbo@arco.design',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
},
{
name: '郑曦月',
email: 'zhengxiyue@arco.design',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/8361eeb82904210b4f55fab888fe8416.png~tplv-uwbnlip3yd-webp.webp',
},
{
name: '宁波',
email: 'ningbo@arco.design',
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
},
]
const units = [
{
name: '企业级产品设计系统',
description: 'System',
},
{
name: '智能应用',
description: 'The Volcano Engine',
},
{
name: 'OCR文本识别',
description: 'OCR text recognition',
},
{
name: '内容资源管理',
description: 'Content resource management ',
},
{
name: '今日头条内容管理',
description: 'Toutiao content management',
},
{
name: '智能机器人',
description: 'Intelligent Robot Project',
},
]
return successResponseWrap(
new Array(6).fill(null).map((_item, index) => ({
id: index,
name: units[index].name,
description: units[index].description,
peopleNumber: Mock.Random.natural(10, 1000),
contributors,
}))
)
})
// 最新动态
Mock.mock(new RegExp('/api/user/latest-activity'), () => {
return successResponseWrap(
new Array(7).fill(null).map((_item, index) => ({
id: index,
title: '发布了项目',
description: '企业级产品设计系统',
avatar: '//lf1-xgcdn-tos.pstatp.com/obj/vcloud/vadmin/start.8e0e4855ee346a46ccff8ff3e24db27b.png',
}))
)
})
// 访问量
Mock.mock(new RegExp('/api/user/visits'), () => {
return successResponseWrap([
{
name: '主页访问量',
visits: 5670,
growth: 206.32,
},
{
name: '项目访问量',
visits: 5670,
growth: 206.32,
},
])
})
// 项目和团队列表
Mock.mock(new RegExp('/api/user/project-and-team/list'), () => {
return successResponseWrap([
{
id: 1,
content: '他创建的项目',
},
{
id: 2,
content: '他参与的项目',
},
{
id: 3,
content: '他创建的团队',
},
{
id: 4,
content: '他加入的团队',
},
])
})
// 团队列表
Mock.mock(new RegExp('/api/user/my-team/list'), () => {
return successResponseWrap([
{
id: 1,
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/a8c8cdb109cb051163646151a4a5083b.png~tplv-uwbnlip3yd-webp.webp',
name: '火智能应用团队',
peopleNumber: Mock.Random.natural(10, 100),
},
{
id: 2,
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
name: '企业级产品设计团队',
peopleNumber: Mock.Random.natural(5000, 6000),
},
{
id: 3,
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
name: '前端/UE小分队',
peopleNumber: Mock.Random.natural(10, 5000),
},
{
id: 4,
avatar: '//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/8361eeb82904210b4f55fab888fe8416.png~tplv-uwbnlip3yd-webp.webp',
name: '内容识别插件小分队',
peopleNumber: Mock.Random.natural(10, 100),
},
])
})
},
})

View File

@@ -1,135 +0,0 @@
<template>
<a-form ref="formRef" :model="formData" class="form" :label-col-props="{ span: 8 }" :wrapper-col-props="{ span: 16 }">
<a-form-item
field="email"
:label="$t('userSetting.basicInfo.form.label.email')"
:rules="[
{
required: true,
message: $t('userSetting.form.error.email.required'),
},
]"
>
<a-input v-model="formData.email" :placeholder="$t('userSetting.basicInfo.placeholder.email')" />
</a-form-item>
<a-form-item
field="nickname"
:label="$t('userSetting.basicInfo.form.label.nickname')"
:rules="[
{
required: true,
message: $t('userSetting.form.error.nickname.required'),
},
]"
>
<a-input v-model="formData.nickname" :placeholder="$t('userSetting.basicInfo.placeholder.nickname')" />
</a-form-item>
<a-form-item
field="countryRegion"
:label="$t('userSetting.basicInfo.form.label.countryRegion')"
:rules="[
{
required: true,
message: $t('userSetting.form.error.countryRegion.required'),
},
]"
>
<a-select v-model="formData.countryRegion" :placeholder="$t('userSetting.basicInfo.placeholder.area')">
<a-option value="China">中国</a-option>
</a-select>
</a-form-item>
<a-form-item
field="area"
:label="$t('userSetting.basicInfo.form.label.area')"
:rules="[
{
required: true,
message: $t('userSetting.form.error.area.required'),
},
]"
>
<a-cascader
v-model="formData.area"
:placeholder="$t('userSetting.basicInfo.placeholder.area')"
:options="[
{
label: '北京',
value: 'beijing',
children: [
{
label: '北京',
value: 'beijing',
children: [
{
label: '朝阳',
value: 'chaoyang',
},
],
},
],
},
]"
allow-clear
/>
</a-form-item>
<a-form-item field="address" :label="$t('userSetting.basicInfo.form.label.address')">
<a-input v-model="formData.address" :placeholder="$t('userSetting.basicInfo.placeholder.address')" />
</a-form-item>
<a-form-item
field="profile"
:label="$t('userSetting.basicInfo.form.label.profile')"
:rules="[
{
maxLength: 200,
message: $t('userSetting.form.error.profile.maxLength'),
},
]"
row-class="keep-margin"
>
<a-textarea v-model="formData.profile" :placeholder="$t('userSetting.basicInfo.placeholder.profile')" />
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="validate">
{{ $t('userSetting.save') }}
</a-button>
<a-button type="secondary" @click="reset">
{{ $t('userSetting.reset') }}
</a-button>
</a-space>
</a-form-item>
</a-form>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { FormInstance } from '@arco-design/web-vue/es/form'
import { BasicInfoModel } from '@/api/user-center'
const formRef = ref<FormInstance>()
const formData = ref<BasicInfoModel>({
email: '',
nickname: '',
countryRegion: '',
area: '',
address: '',
profile: '',
})
const validate = async () => {
const res = await formRef.value?.validate()
if (!res) {
// do some thing
// you also can use html-type to submit
}
}
const reset = async () => {
await formRef.value?.resetFields()
}
</script>
<style scoped lang="less">
.form {
width: 540px;
margin: 0 auto;
}
</style>

View File

@@ -1,63 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userSetting.certification.title.record')" :header-style="{ border: 'none' }">
<a-table v-if="renderData.length" :data="renderData">
<template #columns>
<a-table-column :title="$t('userSetting.certification.columns.certificationType')">
<template #cell>
{{ $t('userSetting.certification.cell.certificationType') }}
</template>
</a-table-column>
<a-table-column :title="$t('userSetting.certification.columns.certificationContent')" data-index="certificationContent" />
<a-table-column :title="$t('userSetting.certification.columns.status')">
<template #cell="{ record }">
<p v-if="record.status === 0">
<span class="circle"></span>
<span>{{ $t('userSetting.certification.cell.auditing') }}</span>
</p>
<p v-if="record.status === 1">
<span class="circle pass"></span>
<span>{{ $t('userSetting.certification.cell.pass') }}</span>
</p>
</template>
</a-table-column>
<a-table-column :title="$t('userSetting.certification.columns.time')" data-index="time" />
<a-table-column :title="$t('userSetting.certification.columns.operation')">
<template #cell="{ record }">
<a-space>
<a-button type="text">
{{ $t('userSetting.certification.button.check') }}
</a-button>
<a-button v-if="record.status === 0" type="text">
{{ $t('userSetting.certification.button.withdraw') }}
</a-button>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
import { CertificationRecord } from '@/api/user-center'
defineProps({
renderData: {
type: Array as PropType<CertificationRecord>,
default() {
return []
},
},
})
</script>
<style scoped lang="less">
:deep(.arco-table-th) {
&:last-child {
.arco-table-th-item-title {
margin-left: 16px;
}
}
}
</style>

View File

@@ -1,33 +0,0 @@
<template>
<a-spin :loading="loading" style="width: 100%">
<EnterpriseCertification :enterprise-info="data.enterpriseInfo" />
<CertificationRecords :render-data="data.record" />
</a-spin>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { queryCertification, UnitCertification, EnterpriseCertificationModel } from '@/api/user-center'
import useLoading from '@/hooks/loading'
import EnterpriseCertification from './enterprise-certification.vue'
import CertificationRecords from './certification-records.vue'
const { loading, setLoading } = useLoading(true)
const data = ref<UnitCertification>({
enterpriseInfo: {} as EnterpriseCertificationModel,
record: [],
})
const fetchData = async () => {
try {
const { data: resData } = await queryCertification()
data.value = resData
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false)
}
}
fetchData()
</script>
<style scoped lang="less"></style>

View File

@@ -1,106 +0,0 @@
<template>
<a-card class="general-card" :title="$t('userSetting.certification.title.enterprise')" :header-style="{ padding: '0px 20px 16px 20px' }">
<template #extra>
<a-link>{{ $t('userSetting.certification.extra.enterprise') }}</a-link>
</template>
<a-descriptions
class="card-content"
:data="renderData"
:column="3"
align="right"
layout="inline-horizontal"
:label-style="{ fontWeight: 'normal' }"
:value-style="{
width: '200px',
paddingLeft: '8px',
textAlign: 'left',
}"
>
<template #label="{ label }">{{ $t(label) }} :</template>
<template #value="{ value, data }">
<a-tag v-if="data.label === 'userSetting.certification.label.status'" color="green" size="small">已认证</a-tag>
<span v-else>{{ value }}</span>
</template>
</a-descriptions>
</a-card>
</template>
<script lang="ts" setup>
import { PropType, computed } from 'vue'
import { EnterpriseCertificationModel } from '@/api/user-center'
import type { DescData } from '@arco-design/web-vue/es/descriptions/interface'
const props = defineProps({
enterpriseInfo: {
type: Object as PropType<EnterpriseCertificationModel>,
required: true,
},
})
const renderData = computed(() => {
const {
accountType,
status,
time,
legalPerson,
certificateType,
authenticationNumber,
enterpriseName,
enterpriseCertificateType,
organizationCode,
} = props.enterpriseInfo
return [
{
label: 'userSetting.certification.label.accountType',
value: accountType,
},
{
label: 'userSetting.certification.label.status',
value: status,
},
{
label: 'userSetting.certification.label.time',
value: time,
},
{
label: 'userSetting.certification.label.legalPerson',
value: legalPerson,
},
{
label: 'userSetting.certification.label.certificateType',
value: certificateType,
},
{
label: 'userSetting.certification.label.authenticationNumber',
value: authenticationNumber,
},
{
label: 'userSetting.certification.label.enterpriseName',
value: enterpriseName,
},
{
label: 'userSetting.certification.label.enterpriseCertificateType',
value: enterpriseCertificateType,
},
{
label: 'userSetting.certification.label.organizationCode',
value: organizationCode,
},
] as DescData[]
})
</script>
<style scoped lang="less">
.card-content {
width: 100%;
padding: 20px;
background-color: rgb(var(--gray-1));
}
.item-label {
min-width: 98px;
text-align: right;
color: var(--color-text-8);
&:after {
content: ':';
}
}
</style>

View File

@@ -1,120 +0,0 @@
<template>
<a-list :bordered="false">
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-typography-paragraph>
{{ $t('userSetting.SecuritySettings.form.label.password') }}
</a-typography-paragraph>
</template>
<template #description>
<div class="content">
<a-typography-paragraph>
{{ $t('userSetting.SecuritySettings.placeholder.password') }}
</a-typography-paragraph>
</div>
<div class="operation">
<a-link>
{{ $t('userSetting.SecuritySettings.button.update') }}
</a-link>
</div>
</template>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-typography-paragraph>
{{ $t('userSetting.SecuritySettings.form.label.securityQuestion') }}
</a-typography-paragraph>
</template>
<template #description>
<div class="content">
<a-typography-paragraph class="tip">
{{ $t('userSetting.SecuritySettings.placeholder.securityQuestion') }}
</a-typography-paragraph>
</div>
<div class="operation">
<a-link>
{{ $t('userSetting.SecuritySettings.button.settings') }}
</a-link>
</div>
</template>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-typography-paragraph>
{{ $t('userSetting.SecuritySettings.form.label.phone') }}
</a-typography-paragraph>
</template>
<template #description>
<div class="content">
<a-typography-paragraph>已绑定150******50</a-typography-paragraph>
</div>
<div class="operation">
<a-link>
{{ $t('userSetting.SecuritySettings.button.update') }}
</a-link>
</div>
</template>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-typography-paragraph>
{{ $t('userSetting.SecuritySettings.form.label.email') }}
</a-typography-paragraph>
</template>
<template #description>
<div class="content">
<a-typography-paragraph class="tip">
{{ $t('userSetting.SecuritySettings.placeholder.email') }}
</a-typography-paragraph>
</div>
<div class="operation">
<a-link>
{{ $t('userSetting.SecuritySettings.button.update') }}
</a-link>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
</template>
<script lang="ts" setup></script>
<style scoped lang="less">
:deep(.arco-list-item) {
border-bottom: none !important;
.arco-typography {
margin-bottom: 20px;
}
.arco-list-item-meta-avatar {
margin-bottom: 1px;
}
.arco-list-item-meta {
padding: 0;
}
}
:deep(.arco-list-item-meta-content) {
flex: 1;
border-bottom: 1px solid var(--color-neutral-3);
.arco-list-item-meta-description {
display: flex;
flex-flow: row;
justify-content: space-between;
.tip {
color: rgb(var(--gray-6));
}
.operation {
margin-right: 6px;
}
}
}
</style>

View File

@@ -1,140 +0,0 @@
<template>
<a-card :bordered="false">
<a-space :size="54">
<a-upload
:custom-request="customRequest"
list-type="picture-card"
:file-list="fileList"
:show-upload-button="true"
:show-file-list="false"
@change="uploadChange"
>
<template #upload-button>
<a-avatar :size="100" class="info-avatar">
<template #trigger-icon>
<icon-camera />
</template>
<img v-if="fileList.length" :src="fileList[0].url" />
</a-avatar>
</template>
</a-upload>
<a-descriptions
:data="renderData"
:column="2"
align="right"
layout="inline-horizontal"
:label-style="{
width: '140px',
fontWeight: 'normal',
color: 'rgb(var(--gray-8))',
}"
:value-style="{
width: '200px',
paddingLeft: '8px',
textAlign: 'left',
}"
>
<template #label="{ label }">{{ $t(label) }} :</template>
<template #value="{ value, data }">
<a-tag v-if="data.label === 'userSetting.label.certification'" color="green" size="small">已认证</a-tag>
<span v-else>{{ value }}</span>
</template>
</a-descriptions>
</a-space>
</a-card>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import type { FileItem, RequestOption } from '@arco-design/web-vue/es/upload/interfaces'
import { useUserStore } from '@/store'
import { userUploadApi } from '@/api/user-center'
import type { DescData } from '@arco-design/web-vue/es/descriptions/interface'
const userStore = useUserStore()
const file = {
uid: '-2',
name: 'avatar.png',
url: userStore.avatar,
}
const renderData = [
{
label: 'userSetting.label.name',
value: userStore.name,
},
{
label: 'userSetting.label.certification',
value: userStore.certification,
},
{
label: 'userSetting.label.accountId',
value: userStore.accountId,
},
{
label: 'userSetting.label.phone',
value: userStore.phone,
},
{
label: 'userSetting.label.registrationDate',
value: userStore.registrationDate,
},
] as DescData[]
const fileList = ref<FileItem[]>([file])
const uploadChange = (fileItemList: FileItem[], fileItem: FileItem) => {
fileList.value = [fileItem]
}
const customRequest = (options: RequestOption) => {
// docs: https://axios-http.com/docs/cancellation
const controller = new AbortController()
;(async function requestWrap() {
const { onProgress, onError, onSuccess, fileItem, name = 'file' } = options
onProgress(20)
const formData = new FormData()
formData.append(name as string, fileItem.file as Blob)
const onUploadProgress = (event: ProgressEvent) => {
let percent
if (event.total > 0) {
percent = (event.loaded / event.total) * 100
}
onProgress(parseInt(String(percent), 10), event)
}
try {
// https://github.com/axios/axios/issues/1630
// https://github.com/nuysoft/Mock/issues/127
const res = await userUploadApi(formData, {
controller,
onUploadProgress,
})
onSuccess(res)
} catch (error) {
onError(error)
}
})()
return {
abort() {
controller.abort()
},
}
}
</script>
<style scoped lang="less">
.arco-card {
padding: 14px 0 4px 4px;
border-radius: 4px;
}
:deep(.arco-avatar-trigger-icon-button) {
width: 32px;
height: 32px;
line-height: 32px;
background-color: #e8f3ff;
.arco-icon-camera {
margin-top: 8px;
color: rgb(var(--arcoblue-6));
font-size: 14px;
}
}
</style>

View File

@@ -1,57 +0,0 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.user', 'menu.user.setting']" />
<a-row style="margin-bottom: 16px">
<a-col :span="24">
<UserPanel />
</a-col>
</a-row>
<a-row class="wrapper">
<a-col :span="24">
<a-tabs default-active-key="1" type="rounded">
<a-tab-pane key="1" :title="$t('userSetting.tab.basicInformation')">
<BasicInformation />
</a-tab-pane>
<a-tab-pane key="2" :title="$t('userSetting.tab.securitySettings')">
<SecuritySettings />
</a-tab-pane>
<a-tab-pane key="3" :title="$t('userSetting.tab.certification')">
<Certification />
</a-tab-pane>
</a-tabs>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import UserPanel from './components/user-panel.vue'
import BasicInformation from './components/basic-information.vue'
import SecuritySettings from './components/security-settings.vue'
import Certification from './components/certification.vue'
</script>
<script lang="ts">
export default {
name: 'Setting',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
.wrapper {
padding: 20px 0 0 20px;
min-height: 580px;
background-color: var(--color-bg-2);
border-radius: 4px;
}
:deep(.section-title) {
margin-top: 0;
margin-bottom: 16px;
font-size: 14px;
}
</style>

View File

@@ -1,80 +0,0 @@
export default {
'menu.user.setting': 'User Setting',
'userSetting.menu.title.info': 'Personal Information',
'userSetting.menu.title.account': 'Account Setting',
'userSetting.menu.title.password': 'Password',
'userSetting.menu.title.message': 'Message Notification',
'userSetting.menu.title.result': 'Result',
'userSetting.menu.title.data': 'Export Data',
'userSetting.saveSuccess': 'Save Success',
'userSetting.title.basicInfo': 'Basic Information',
'userSetting.title.socialInfo': 'Social Information',
'userSetting.label.avatar': 'Avatar',
'userSetting.label.name': 'User Name',
'userSetting.label.location': 'Office Location',
'userSetting.label.introduction': 'Introduction',
'userSetting.label.personalWebsite': 'Website',
'userSetting.save': 'Save',
'userSetting.cancel': 'Cancel',
'userSetting.reset': 'Reset',
// new
'userSetting.label.certification': 'Certification',
'userSetting.label.phone': 'Phone',
'userSetting.label.accountId': 'Account Id',
'userSetting.label.registrationDate': 'Registration Date',
'userSetting.tab.basicInformation': 'Basic Information',
'userSetting.tab.securitySettings': 'Security Settings',
'userSetting.tab.certification': 'Certification',
'userSetting.basicInfo.form.label.email': 'Email',
'userSetting.basicInfo.placeholder.email': `Please enter your email address, such as xxx{'@'}bytedance.com`,
'userSetting.form.error.email.required': 'Please enter email address',
'userSetting.basicInfo.form.label.nickname': 'Nickname',
'userSetting.basicInfo.placeholder.nickname': 'Please enter nickname',
'userSetting.form.error.nickname.required': 'Please enter nickname',
'userSetting.basicInfo.form.label.countryRegion': 'Country/region',
'userSetting.basicInfo.placeholder.countryRegion': 'Please select country/region',
'userSetting.form.error.countryRegion.required': 'Please select country/region',
'userSetting.basicInfo.form.label.area': 'Area',
'userSetting.basicInfo.placeholder.area': 'Please select area',
'userSetting.form.error.area.required': 'Please Select a area',
'userSetting.basicInfo.form.label.address': 'Address',
'userSetting.basicInfo.placeholder.address': 'Please enter address',
'userSetting.basicInfo.form.label.profile': 'Personal profile',
'userSetting.basicInfo.placeholder.profile': 'Please enter your profile, no more than 200 words',
'userSetting.form.error.profile.maxLength': 'No more than 200 words',
'userSetting.SecuritySettings.form.label.password': 'Login Password',
'userSetting.SecuritySettings.placeholder.password':
'Has been set. The password must contain at least six letters, digits, and special characters except Spaces. The password must contain both uppercase and lowercase letters.',
'userSetting.SecuritySettings.form.label.securityQuestion': 'Security Question',
'userSetting.SecuritySettings.placeholder.securityQuestion':
'You have not set the password protection question. The password protection question can effectively protect the account security.',
'userSetting.SecuritySettings.form.label.phone': 'Phone',
// 'userSetting.SecuritySettings.placeholder.phone': '已绑定150******50',
'userSetting.SecuritySettings.form.label.email': 'Email',
'userSetting.SecuritySettings.placeholder.email':
'You have not set a mailbox yet. The mailbox binding can be used to retrieve passwords and receive notifications.',
'userSetting.SecuritySettings.button.settings': 'Settings',
'userSetting.SecuritySettings.button.update': 'Update',
'userSetting.certification.title.enterprise': 'Enterprise Real Name Authentication',
'userSetting.certification.extra.enterprise': 'Modifying an Authentication Body',
'userSetting.certification.label.accountType': 'Account Type',
'userSetting.certification.label.status': 'status',
'userSetting.certification.label.time': 'time',
'userSetting.certification.label.legalPerson': 'Legal Person Name',
'userSetting.certification.label.certificateType': 'Types of legal person documents',
'userSetting.certification.label.authenticationNumber': 'Legal person certification number',
'userSetting.certification.label.enterpriseName': 'Enterprise Name',
'userSetting.certification.label.enterpriseCertificateType': 'Types of corporate certificates',
'userSetting.certification.label.organizationCode': 'Organization Code',
'userSetting.certification.title.record': 'Certification Records',
'userSetting.certification.columns.certificationType': 'Certification Type',
'userSetting.certification.cell.certificationType': 'Enterprise certificate Certification',
'userSetting.certification.columns.certificationContent': 'Certification Content',
'userSetting.certification.columns.status': 'Status',
'userSetting.certification.cell.pass': 'Pass',
'userSetting.certification.cell.auditing': 'Auditing',
'userSetting.certification.columns.time': 'Time',
'userSetting.certification.columns.operation': 'Operation',
'userSetting.certification.button.check': 'Check',
'userSetting.certification.button.withdraw': 'Withdraw',
}

View File

@@ -1,78 +0,0 @@
export default {
'menu.user.setting': '用户设置',
'userSetting.menu.title.info': '个人信息',
'userSetting.menu.title.account': '账号设置',
'userSetting.menu.title.password': '密码',
'userSetting.menu.title.message': '消息通知',
'userSetting.menu.title.result': '结果页',
'userSetting.menu.title.data': '导出数据',
'userSetting.saveSuccess': '保存成功',
'userSetting.title.basicInfo': '基本信息',
'userSetting.title.socialInfo': '社交信息',
'userSetting.label.avatar': '头像',
'userSetting.label.name': '用户名',
'userSetting.label.location': '办公地点',
'userSetting.label.introduction': '个人简介',
'userSetting.label.personalWebsite': '个人网站',
'userSetting.save': '保存',
'userSetting.cancel': '取消',
'userSetting.reset': '重置',
// new
'userSetting.label.certification': '实名认证',
'userSetting.label.phone': '手机号码',
'userSetting.label.accountId': '账号ID',
'userSetting.label.registrationDate': '注册时间',
'userSetting.tab.basicInformation': '基础信息',
'userSetting.tab.securitySettings': '安全设置',
'userSetting.tab.certification': '实名认证',
'userSetting.basicInfo.form.label.email': '邮箱',
'userSetting.basicInfo.placeholder.email': `请输入邮箱地址如xxx{'@'}bytedance.com`,
'userSetting.form.error.email.required': '请输入邮箱',
'userSetting.basicInfo.form.label.nickname': '昵称',
'userSetting.basicInfo.placeholder.nickname': '请输入您的昵称',
'userSetting.form.error.nickname.required': '请输入昵称',
'userSetting.basicInfo.form.label.countryRegion': '国家/地区',
'userSetting.basicInfo.placeholder.countryRegion': '请选择',
'userSetting.form.error.countryRegion.required': '请选择国家/地区',
'userSetting.basicInfo.form.label.area': '所在区域',
'userSetting.basicInfo.placeholder.area': '请选择',
'userSetting.form.error.area.required': '请选择所在区域',
'userSetting.basicInfo.form.label.address': '具体地址',
'userSetting.basicInfo.placeholder.address': '请输入您的地址',
'userSetting.basicInfo.form.label.profile': '个人简介',
'userSetting.basicInfo.placeholder.profile': '请输入您的个人简介最多不超过200字。',
'userSetting.form.error.profile.maxLength': '最多不超过200字',
'userSetting.SecuritySettings.form.label.password': '登录密码',
'userSetting.SecuritySettings.placeholder.password':
'已设置。密码至少6位字符支持数字、字母和除空格外的特殊字符且必须同时包含数字和大小写字母。',
'userSetting.SecuritySettings.form.label.securityQuestion': '密保问题',
'userSetting.SecuritySettings.placeholder.securityQuestion': '您暂未设置密保问题,密保问题可以有效的保护账号的安全。',
'userSetting.SecuritySettings.form.label.phone': '安全手机',
// 'userSetting.SecuritySettings.placeholder.phone': '已绑定150******50',
'userSetting.SecuritySettings.form.label.email': '安全邮箱',
'userSetting.SecuritySettings.placeholder.email': '您暂未设置邮箱,绑定邮箱可以用来找回密码、接收通知等。',
'userSetting.SecuritySettings.button.settings': '设置',
'userSetting.SecuritySettings.button.update': '修改',
'userSetting.certification.title.enterprise': '企业实名认证',
'userSetting.certification.extra.enterprise': '修改认证主体',
'userSetting.certification.label.accountType': '账号类型',
'userSetting.certification.label.status': '认证状态',
'userSetting.certification.label.time': '认证时间',
'userSetting.certification.label.legalPerson': '法人姓名',
'userSetting.certification.label.certificateType': '法人证件类型',
'userSetting.certification.label.authenticationNumber': '法人认证号码',
'userSetting.certification.label.enterpriseName': '企业名称',
'userSetting.certification.label.enterpriseCertificateType': '企业证件类型',
'userSetting.certification.label.organizationCode': '组织机构代码',
'userSetting.certification.title.record': '认证记录',
'userSetting.certification.columns.certificationType': '认证类型',
'userSetting.certification.cell.certificationType': '企业证件认证',
'userSetting.certification.columns.certificationContent': '认证内容',
'userSetting.certification.columns.status': '当前状态',
'userSetting.certification.cell.pass': '已通过',
'userSetting.certification.cell.auditing': '审核中',
'userSetting.certification.columns.time': '创建时间',
'userSetting.certification.columns.operation': '操作',
'userSetting.certification.button.check': '查看',
'userSetting.certification.button.withdraw': '撤回',
}

View File

@@ -1,42 +0,0 @@
import Mock from 'mockjs'
import setupMock, { successResponseWrap } from '@/utils/setup-mock'
setupMock({
setup() {
Mock.mock(new RegExp('/api/user/save-info'), () => {
return successResponseWrap('ok')
})
Mock.mock(new RegExp('/api/user/certification'), () => {
return successResponseWrap({
enterpriseInfo: {
accountType: '企业账号',
status: 0,
time: '2018-10-22 14:53:12',
legalPerson: '李**',
certificateType: '中国身份证',
authenticationNumber: '130************123',
enterpriseName: '低调有实力的企业',
enterpriseCertificateType: '企业营业执照',
organizationCode: '7*******9',
},
record: [
{
certificationType: 1,
certificationContent: '企业实名认证,法人姓名:李**',
status: 0,
time: '2021-02-28 10:30:50',
},
{
certificationType: 1,
certificationContent: '企业实名认证,法人姓名:李**',
status: 1,
time: '2020-05-13 08:00:00',
},
],
})
})
Mock.mock(new RegExp('/api/user/upload'), () => {
return successResponseWrap('ok')
})
},
})