This commit is contained in:
ygx
2026-03-21 19:32:53 +08:00
parent 69e421834b
commit 05e960fc44
37 changed files with 7427 additions and 688 deletions

93
src/api/kb/category.ts Normal file
View File

@@ -0,0 +1,93 @@
import { request } from "@/api/request";
/** 分类类型 */
export interface Category {
id: number;
created_at: string;
updated_at: string;
name: string;
description: string;
type: string;
icon: string;
color: string;
parent_id: number;
level: number;
path: string;
sort_order: number;
status: string;
creator_id: number;
creator_name: string;
doc_count: number;
faq_count: number;
metadata: string | null;
remarks: string;
}
/** API响应包装类型 */
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
/** 创建分类请求参数 */
export interface CreateCategoryParams {
name: string;
description?: string;
type?: string;
icon?: string;
color?: string;
parent_id?: number;
sort_order?: number;
remarks?: string;
}
/** 更新分类请求参数 */
export interface UpdateCategoryParams {
id: number;
name?: string;
description?: string;
icon?: string;
color?: string;
sort_order?: number;
status?: string;
remarks?: string;
}
/** 获取分类列表参数 */
export interface FetchCategoryListParams {
type?: string;
parent_id?: number;
}
/** 创建分类 */
export const createCategory = (data: CreateCategoryParams) => {
return request.post<ApiResponse<Category>>("/Kb/v1/category/create", data);
};
/** 更新分类 */
export const updateCategory = (data: UpdateCategoryParams) => {
return request.post<ApiResponse<Category>>("/Kb/v1/category/update", data);
};
/** 删除分类 */
export const deleteCategory = (id: number) => {
return request.delete<ApiResponse<string>>(`/Kb/v1/category/${id}`);
};
/** 获取分类详情 */
export const fetchCategoryDetail = (id: number) => {
return request.get<ApiResponse<Category>>(`/Kb/v1/category/${id}`);
};
/** 获取分类列表 */
export const fetchCategoryList = (params?: FetchCategoryListParams) => {
return request.get<ApiResponse<Category[]>>("/Kb/v1/category/list", { params });
};
/** 获取分类树 */
export const fetchCategoryTree = (type?: string) => {
return request.get<ApiResponse<Category[]>>("/Kb/v1/category/tree", {
params: type ? { type } : undefined,
});
};

212
src/api/kb/document.ts Normal file
View File

@@ -0,0 +1,212 @@
import { request } from "@/api/request";
/** 文档状态 */
export type DocumentStatus = 'draft' | 'published' | 'reviewed' | 'rejected';
/** 文档类型 */
export type DocumentType = 'common' | 'guide' | 'solution' | 'troubleshoot' | 'process' | 'technical';
/** 文档接口类型 */
export interface Document {
id: number;
created_at: string;
updated_at: string;
doc_no: string;
title: string;
description: string;
content: string;
type: DocumentType;
status: DocumentStatus;
category_id: number;
sub_category: string;
author_id: number;
author_name: string;
reviewer_id: number;
reviewer_name: string;
reviewed_at: string | null;
published_at: string | null;
publisher_id: number;
view_count: number;
like_count: number;
comment_count: number;
download_count: number;
version: string;
version_notes: string;
tags: string;
attachments: string | null;
related_docs: string | null;
metadata: string | null;
keywords: string;
remarks: string;
is_favorited?: boolean;
}
/** API响应包装类型 */
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
/** 分页响应类型 */
export interface PaginatedResponse<T> {
total: number;
page: number;
page_size: number;
data: T[];
}
/** 创建文档请求参数 */
export interface CreateDocumentParams {
title: string;
description?: string;
content: string;
type?: DocumentType;
category_id?: number;
sub_category?: string;
keywords?: string;
tags?: string;
remarks?: string;
}
/** 更新文档请求参数 */
export interface UpdateDocumentParams {
id: number;
title?: string;
description?: string;
content?: string;
type?: DocumentType;
category_id?: number;
sub_category?: string;
keywords?: string;
tags?: string;
remarks?: string;
}
/** 获取文档列表参数 */
export interface FetchDocumentListParams {
page?: number;
page_size?: number;
keyword?: string;
type?: DocumentType;
status?: DocumentStatus;
category_id?: number;
}
/** 文档类型选项 */
export const documentTypeOptions = [
{ label: '通用文档', value: 'common' },
{ label: '操作指南', value: 'guide' },
{ label: '解决方案', value: 'solution' },
{ label: '故障排查', value: 'troubleshoot' },
{ label: '流程规范', value: 'process' },
{ label: '技术文档', value: 'technical' },
];
/** 文档状态选项 */
export const documentStatusOptions = [
{ label: '草稿', value: 'draft' },
{ label: '已发布', value: 'published' },
{ label: '已审核', value: 'reviewed' },
{ label: '未通过审核', value: 'rejected' },
];
/** 获取文档状态文本 */
export const getDocumentStatusText = (status: DocumentStatus): string => {
const statusMap: Record<DocumentStatus, string> = {
draft: '草稿',
published: '已发布',
reviewed: '已审核',
rejected: '未通过审核',
};
return statusMap[status] || status;
};
/** 获取文档状态颜色 */
export const getDocumentStatusColor = (status: DocumentStatus): string => {
const colorMap: Record<DocumentStatus, string> = {
draft: 'gray',
published: 'blue',
reviewed: 'green',
rejected: 'red',
};
return colorMap[status] || 'gray';
};
/** 获取文档类型文本 */
export const getDocumentTypeText = (type: DocumentType): string => {
const typeMap: Record<DocumentType, string> = {
common: '通用文档',
guide: '操作指南',
solution: '解决方案',
troubleshoot: '故障排查',
process: '流程规范',
technical: '技术文档',
};
return typeMap[type] || type;
};
/** 创建文档 */
export const createDocument = (data: CreateDocumentParams) => {
return request.post<ApiResponse<Document>>("/Kb/v1/document/create", data);
};
/** 更新文档 */
export const updateDocument = (data: UpdateDocumentParams) => {
return request.post<ApiResponse<Document>>("/Kb/v1/document/update", data);
};
/** 删除文档(移入回收站) */
export const deleteDocument = (id: number) => {
return request.delete<ApiResponse<string>>(`/Kb/v1/document/${id}`);
};
/** 获取文档详情 */
export const fetchDocumentDetail = (id: number) => {
return request.get<ApiResponse<Document>>(`/Kb/v1/document/${id}`);
};
/** 获取文档列表 */
export const fetchDocumentList = (params?: FetchDocumentListParams) => {
return request.get<ApiResponse<PaginatedResponse<Document>>>("/Kb/v1/document/list", { params });
};
/** 发布文档 */
export const publishDocument = (id: number) => {
return request.post<ApiResponse<string>>("/Kb/v1/document/publish", { id });
};
/** 移入回收站 */
export const moveToTrash = (id: number) => {
return request.post<ApiResponse<string>>("/Kb/v1/trash/move", { id, type: 'document' });
};
/** 获取我的文档列表(由我创建的所有文档) */
export const fetchMyDocumentList = (params?: FetchDocumentListParams) => {
return request.get<ApiResponse<PaginatedResponse<Document>>>("/Kb/v1/review/publish/list", { params });
};
/** 获取已审核通过的文档列表 */
export const fetchApprovedDocumentList = (params?: FetchDocumentListParams) => {
return request.get<ApiResponse<PaginatedResponse<Document>>>("/Kb/v1/review/approved/list", { params });
};
/** 收藏文档 */
export const favoriteDocument = (id: number) => {
return request.post<ApiResponse<string>>("/Kb/v1/favorite/create", { resource_type: 'document', resource_id: id });
};
/** 取消收藏文档 */
export const unfavoriteDocument = (id: number) => {
return request.delete<ApiResponse<string>>("/Kb/v1/favorite/delete", { data: { resource_type: 'document', resource_id: id } });
};
/** 检查文档是否已收藏 */
export const checkFavorite = (id: number) => {
return request.get<ApiResponse<{ is_favorited: boolean }>>(`/Kb/v1/favorite/check`, { params: { resource_type: 'document', resource_id: id } });
};
/** 下载文档 */
export const downloadDocument = (id: number) => {
return request.get<Blob>(`/Kb/v1/document/${id}/download`, { responseType: 'blob' });
};

88
src/api/kb/trash.ts Normal file
View File

@@ -0,0 +1,88 @@
import { request } from "@/api/request";
/** API响应包装类型 */
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
/** 分页响应类型 */
export interface PaginatedResponse<T> {
total: number;
page: number;
page_size: number;
data: T[];
}
/** 回收站记录 */
export interface TrashRecord {
id: number;
created_at: string;
updated_at: string;
resource_type: 'document' | 'faq';
resource_id: number;
resource_name: string;
deleted_by: number;
deleted_name: string;
deleted_time: string;
delete_reason: string;
original_data: string;
remarks: string;
}
/** 获取回收站列表参数 */
export interface FetchTrashListParams {
page?: number;
page_size?: number;
resource_type?: 'document' | 'faq';
}
/** 恢复资源请求参数 */
export interface RestoreTrashParams {
id: number;
}
/** 彻底删除请求参数 */
export interface DeleteTrashParams {
id: number;
}
/** 资源类型选项 */
export const resourceTypeOptions = [
{ label: '文档', value: 'document' },
{ label: '常见问题', value: 'faq' },
];
/** 获取资源类型文本 */
export const getResourceTypeText = (type: string): string => {
const typeMap: Record<string, string> = {
document: '文档',
faq: '常见问题',
};
return typeMap[type] || type;
};
/** 获取资源类型颜色 */
export const getResourceTypeColor = (type: string): string => {
const colorMap: Record<string, string> = {
document: 'blue',
faq: 'green',
};
return colorMap[type] || 'gray';
};
/** 获取回收站列表 */
export const fetchTrashList = (params?: FetchTrashListParams) => {
return request.get<ApiResponse<PaginatedResponse<TrashRecord>>>("/Kb/v1/trash/list", { params });
};
/** 恢复资源 */
export const restoreTrash = (data: RestoreTrashParams) => {
return request.post<ApiResponse<string>>("/Kb/v1/trash/restore", data);
};
/** 彻底删除 */
export const deleteTrash = (data: DeleteTrashParams) => {
return request.post<ApiResponse<string>>("/Kb/v1/trash/delete", data);
};

139
src/api/ops/asset.ts Normal file
View File

@@ -0,0 +1,139 @@
import { request } from "@/api/request";
/** 资产状态枚举 */
export enum AssetStatus {
IN_USE = 'in_use', // 在用
IDLE = 'idle', // 闲置
MAINTAIN = 'maintain', // 维修中
SCRAP = 'scrap', // 待报废
DISPOSED = 'disposed', // 已报废
}
/** 资产状态选项 */
export const assetStatusOptions = [
{ label: '在用', value: AssetStatus.IN_USE },
{ label: '闲置', value: AssetStatus.IDLE },
{ label: '维修中', value: AssetStatus.MAINTAIN },
{ label: '待报废', value: AssetStatus.SCRAP },
{ label: '已报废', value: AssetStatus.DISPOSED },
];
/** 获取资产状态文本 */
export const getAssetStatusText = (status: string) => {
const item = assetStatusOptions.find(opt => opt.value === status);
return item?.label || status;
};
/** 获取资产状态颜色 */
export const getAssetStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
[AssetStatus.IN_USE]: 'green',
[AssetStatus.IDLE]: 'blue',
[AssetStatus.MAINTAIN]: 'orange',
[AssetStatus.SCRAP]: 'red',
[AssetStatus.DISPOSED]: 'gray',
};
return colorMap[status] || 'gray';
};
/** 资产列表查询参数 */
export interface AssetListParams {
page?: number;
page_size?: number;
keyword?: string;
status?: string;
category_id?: number;
supplier_id?: number;
datacenter_id?: number;
department?: string;
sort?: string;
order?: string;
}
/** 资产表单数据 */
export interface AssetForm {
id?: number;
asset_name: string;
asset_code: string;
category_id?: number;
model?: string;
manufacturer?: string;
serial_number?: string;
purchase_date?: string;
original_value?: number;
supplier_id?: number;
warranty_period?: string;
warranty_expiry?: string;
department?: string;
user?: string;
status?: string;
location?: string;
datacenter_id?: number;
floor_id?: number;
rack_id?: number;
unit_start?: number;
unit_end?: number;
qr_code?: string;
rfid_tag?: string;
asset_tag?: string;
specifications?: string;
description?: string;
remarks?: string;
}
/** 获取资产列表(分页) */
export const fetchAssetList = (data?: AssetListParams) => {
return request.post("/Assets/v1/asset/list", data || {});
};
/** 获取资产详情 */
export const fetchAssetDetail = (id: number) => {
return request.get(`/Assets/v1/asset/detail/${id}`);
};
/** 创建资产 */
export const createAsset = (data: AssetForm) => {
return request.post("/Assets/v1/asset/create", data);
};
/** 更新资产 */
export const updateAsset = (data: AssetForm) => {
return request.put("/Assets/v1/asset/update", data);
};
/** 删除资产 */
export const deleteAsset = (id: number) => {
return request.delete(`/Assets/v1/asset/delete/${id}`);
};
/** 导出资产 */
export const exportAssets = (keyword?: string) => {
const params: any = {};
if (keyword) params.keyword = keyword;
return request.get("/Assets/v1/asset/export", { params });
};
/** 获取资产分类列表(下拉) */
export const fetchCategoryOptions = () => {
return request.get("/Assets/v1/category/all");
};
/** 获取供应商列表(下拉) */
export const fetchSupplierOptions = () => {
return request.get("/Assets/v1/supplier/all");
};
/** 获取数据中心列表(下拉) */
export const fetchDatacenterOptions = () => {
return request.get("/Assets/v1/datacenter/list");
};
/** 根据数据中心获取楼层列表 */
export const fetchFloorOptions = (datacenterId: number) => {
return request.get(`/Assets/v1/datacenter/${datacenterId}`);
};
/** 获取机柜列表(下拉) */
export const fetchRackOptions = (params?: { datacenter_id?: number; floor_id?: number }) => {
return request.post("/Assets/v1/rack/list", params || {});
};

View File

@@ -0,0 +1,46 @@
import { request } from "@/api/request";
/** 获取资产分类列表(分页) */
export const fetchCategoryList = (data?: {
page?: number;
page_size?: number;
keyword?: string;
parent_id?: number;
}) => {
return request.post("/Assets/v1/category/list", data || {});
};
/** 获取资产分类详情 */
export const fetchCategoryDetail = (id: number) => {
return request.get(`/Assets/v1/category/detail/${id}`);
};
/** 创建资产分类 */
export const createCategory = (data: any) => {
return request.post("/Assets/v1/category/create", data);
};
/** 更新资产分类 */
export const updateCategory = (data: any) => {
return request.put("/Assets/v1/category/update", data);
};
/** 删除资产分类 */
export const deleteCategory = (id: number) => {
return request.delete(`/Assets/v1/category/delete/${id}`);
};
/** 获取所有资产分类(用于下拉选择) */
export const fetchAllCategories = () => {
return request.get("/Assets/v1/category/all");
};
/** 获取资产分类树形结构 */
export const fetchCategoryTree = () => {
return request.get("/Assets/v1/category/tree");
};
/** 获取指定分类的子分类列表 */
export const fetchCategoryChildren = (id: number) => {
return request.get(`/Assets/v1/category/children/${id}`);
};

40
src/api/ops/supplier.ts Normal file
View File

@@ -0,0 +1,40 @@
import { request } from "@/api/request";
/** 获取供应商列表(分页) */
export const fetchSupplierList = (data?: {
page?: number;
page_size?: number;
keyword?: string;
status?: string;
enabled?: boolean;
}) => {
return request.post("/Assets/v1/supplier/list", data || {});
};
/** 获取供应商详情 */
export const fetchSupplierDetail = (id: number) => {
return request.get(`/Assets/v1/supplier/detail/${id}`);
};
/** 创建供应商 */
export const createSupplier = (data: any) => {
return request.post("/Assets/v1/supplier/create", data);
};
/** 更新供应商 */
export const updateSupplier = (data: any) => {
return request.put("/Assets/v1/supplier/update", data);
};
/** 删除供应商 */
export const deleteSupplier = (id: number) => {
return request.delete(`/Assets/v1/supplier/delete/${id}`);
};
/** 获取所有供应商(用于下拉选择) */
export const fetchAllSuppliers = (keyword?: string, status?: string) => {
const params: any = {};
if (keyword) params.keyword = keyword;
if (status) params.status = status;
return request.get("/Assets/v1/supplier/all", { params });
};

View File

@@ -83,6 +83,46 @@ const OPS: AppRouteRecordRaw = {
roles: ['*'],
},
},
{
path: 'assets/device',
name: 'AssetsDevice',
component: () => import('@/views/ops/pages/assets/device/index.vue'),
meta: {
locale: '设备管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'assets/supplier',
name: 'AssetsSupplier',
component: () => import('@/views/ops/pages/assets/supplier/index.vue'),
meta: {
locale: '供应商管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'assets/classify',
name: 'AssetsClassify',
component: () => import('@/views/ops/pages/assets/classify/index.vue'),
meta: {
locale: '资产分类',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'kb/items',
name: 'KbItems',
component: () => import('@/views/ops/pages/kb/items/index.vue'),
meta: {
locale: '知识库文档',
requiresAuth: true,
roles: ['*'],
},
},
],
}

View File

@@ -0,0 +1,165 @@
<template>
<a-modal
:visible="visible"
title="分类详情"
width="600px"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:footer="false"
>
<a-descriptions :data="detailData" layout="vertical" bordered :column="2" v-if="category">
<a-descriptions-item label="分类名称" :span="1">
{{ category.name }}
</a-descriptions-item>
<a-descriptions-item label="分类编码" :span="1">
{{ category.code }}
</a-descriptions-item>
<a-descriptions-item label="父级分类" :span="1">
{{ category.parent?.name || '无(一级分类)' }}
</a-descriptions-item>
<a-descriptions-item label="层级" :span="1">
{{ category.level }}
</a-descriptions-item>
<a-descriptions-item label="排序" :span="1">
{{ category.sort }}
</a-descriptions-item>
<a-descriptions-item label="是否启用" :span="1">
<a-tag :color="category.enabled ? 'green' : 'red'">
{{ category.enabled ? '是' : '否' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="颜色标识" :span="2">
<div class="color-display">
<div
class="color-block"
:style="{ backgroundColor: category.color || '#ccc' }"
></div>
<span>{{ category.color || '-' }}</span>
</div>
</a-descriptions-item>
<a-descriptions-item label="图标路径" :span="2">
{{ category.icon || '-' }}
</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">
{{ category.description || '-' }}
</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">
{{ category.remarks || '-' }}
</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="1">
{{ formatDate(category.created_at) }}
</a-descriptions-item>
<a-descriptions-item label="更新时间" :span="1">
{{ formatDate(category.updated_at) }}
</a-descriptions-item>
<a-descriptions-item label="创建人" :span="1">
{{ category.created_by || '-' }}
</a-descriptions-item>
<a-descriptions-item label="更新人" :span="1">
{{ category.updated_by || '-' }}
</a-descriptions-item>
</a-descriptions>
<a-empty v-else description="暂无数据" />
</a-modal>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
interface Category {
id?: number
name?: string
code?: string
description?: string
icon?: string
color?: string
parent_id?: number
parent?: any
level?: number
sort?: number
enabled?: boolean
remarks?: string
created_at?: string
updated_at?: string
created_by?: string
updated_by?: string
}
interface Props {
visible: boolean
category: Category | null
}
interface Emits {
(e: 'update:visible', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 详情数据
const detailData = computed(() => {
if (!props.category) return []
return [
{ label: '分类名称', value: props.category.name },
{ label: '分类编码', value: props.category.code },
{ label: '父级分类', value: props.category.parent?.name || '无(一级分类)' },
{ label: '层级', value: props.category.level },
{ label: '排序', value: props.category.sort },
{ label: '是否启用', value: props.category.enabled ? '是' : '否' },
{ label: '颜色标识', value: props.category.color || '-' },
{ label: '图标路径', value: props.category.icon || '-' },
{ label: '描述', value: props.category.description || '-' },
{ label: '备注', value: props.category.remarks || '-' },
{ label: '创建时间', value: formatDate(props.category.created_at) },
{ label: '更新时间', value: formatDate(props.category.updated_at) },
{ label: '创建人', value: props.category.created_by || '-' },
{ label: '更新人', value: props.category.updated_by || '-' },
]
})
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
</script>
<script lang="ts">
export default {
name: 'CategoryDetailDialog',
}
</script>
<style scoped lang="less">
.color-display {
display: flex;
align-items: center;
gap: 8px;
.color-block {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid #d9d9d9;
}
}
</style>

View File

@@ -0,0 +1,385 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑分类' : '新增分类'"
width="600px"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:confirm-loading="submitting"
>
<a-form :model="form" layout="vertical" ref="formRef">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="分类名称"
field="name"
:rules="[{ required: true, message: '请输入分类名称' }]"
>
<a-input
v-model="form.name"
placeholder="请输入分类名称"
:max-length="200"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="分类编码"
field="code"
:rules="[{ required: true, message: '请输入分类编码' }]"
>
<a-input
v-model="form.code"
placeholder="请输入分类编码"
:max-length="100"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item
v-if="showParentSelector !== false"
label="父级分类"
field="parent_id"
>
<a-select
v-model="form.parent_id"
placeholder="请选择父级分类(不选则为一级分类)"
allow-clear
allow-search
:disabled="fixedParentId !== undefined"
:filter-option="filterParentOption"
>
<a-option
v-for="item in parentCategories"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="排序" field="sort">
<a-input-number
v-model="form.sort"
placeholder="请输入排序"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="是否启用" field="enabled">
<a-switch v-model="form.enabled" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="颜色标识" field="color">
<div class="color-picker-wrapper">
<a-input
v-model="form.color"
placeholder="请选择颜色标识"
readonly
@click="showColorPicker = !showColorPicker"
>
<template #prefix>
<div
class="color-preview"
:style="{ backgroundColor: form.color || '#ccc' }"
></div>
</template>
</a-input>
<div v-if="showColorPicker" class="color-picker-dropdown">
<div class="color-picker-header">选择颜色</div>
<div class="color-picker-grid">
<div
v-for="color in presetColors"
:key="color"
class="color-item"
:style="{ backgroundColor: color }"
@click="selectColor(color)"
></div>
</div>
<div class="color-picker-custom">
<span>自定义颜色:</span>
<input
type="color"
v-model="form.color"
@change="showColorPicker = false"
/>
</div>
</div>
</div>
</a-form-item>
<a-form-item label="描述" field="description">
<a-textarea
v-model="form.description"
placeholder="请输入描述"
:auto-size="{ minRows: 3, maxRows: 6 }"
:max-length="500"
/>
</a-form-item>
<a-form-item label="备注" field="remarks">
<a-textarea
v-model="form.remarks"
placeholder="请输入备注"
:auto-size="{ minRows: 3, maxRows: 6 }"
:max-length="500"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { createCategory, updateCategory } from '@/api/ops/assetCategory'
interface Category {
id?: number
name?: string
code?: string
description?: string
icon?: string
color?: string
parent_id?: number
sort?: number
enabled?: boolean
remarks?: string
}
interface Props {
visible: boolean
category: Category | null
parentCategories: any[]
fixedParentId?: number
showParentSelector?: boolean
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref()
const submitting = ref(false)
const showColorPicker = ref(false)
// 预设颜色
const presetColors = [
'#FF0000', '#FF4500', '#FF8C00', '#FFD700', '#FFFF00',
'#9ACD32', '#32CD32', '#00FF00', '#00FA9A', '#00CED1',
'#1E90FF', '#0000FF', '#8A2BE2', '#9400D3', '#FF00FF',
'#FF1493', '#DC143C', '#B22222', '#8B0000', '#800000',
]
// 表单数据
const form = ref({
name: '',
code: '',
description: '',
icon: '',
color: '',
parent_id: undefined as number | undefined,
sort: 0,
enabled: true,
remarks: '',
})
// 是否为编辑模式
const isEdit = computed(() => !!props.category?.id)
// 父级分类下拉搜索过滤
const filterParentOption = (input: string, option: any) => {
return option.label.toLowerCase().includes(input.toLowerCase())
}
// 选择颜色
const selectColor = (color: string) => {
form.value.color = color
showColorPicker.value = false
}
// 监听对话框显示状态
watch(
() => props.visible,
(newVal) => {
if (newVal) {
if (props.category && isEdit.value) {
// 编辑模式:填充表单
form.value = {
name: props.category.name || '',
code: props.category.code || '',
description: props.category.description || '',
icon: props.category.icon || '',
color: props.category.color || '',
parent_id: props.category.parent_id,
sort: props.category.sort || 0,
enabled: props.category.enabled !== undefined ? props.category.enabled : true,
remarks: props.category.remarks || '',
}
} else {
// 新建模式:重置表单
form.value = {
name: '',
code: '',
description: '',
icon: '',
color: '',
parent_id: props.fixedParentId,
sort: 0,
enabled: true,
remarks: '',
}
}
showColorPicker.value = false
}
}
)
// 确认提交
const handleOk = async () => {
const valid = await formRef.value?.validate()
if (valid) return
submitting.value = true
try {
const data: any = {
name: form.value.name,
code: form.value.code,
description: form.value.description,
icon: form.value.icon,
color: form.value.color,
parent_id: form.value.parent_id,
sort: form.value.sort,
enabled: form.value.enabled,
remarks: form.value.remarks,
}
let res
if (isEdit.value && props.category?.id) {
// 编辑分类
data.id = props.category.id
res = await updateCategory(data)
} else {
// 新建分类
res = await createCategory(data)
}
if (res.code === 0) {
Message.success(isEdit.value ? '编辑成功' : '创建成功')
emit('success')
emit('update:visible', false)
} else {
Message.error(res.message || (isEdit.value ? '编辑失败' : '创建失败'))
}
} catch (error) {
Message.error(isEdit.value ? '编辑失败' : '创建失败')
console.error(error)
} finally {
submitting.value = false
}
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
</script>
<script lang="ts">
export default {
name: 'CategoryFormDialog',
}
</script>
<style scoped lang="less">
.color-picker-wrapper {
position: relative;
.color-preview {
width: 16px;
height: 16px;
border-radius: 2px;
border: 1px solid #d9d9d9;
}
.color-picker-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 12px;
margin-top: 4px;
width: 280px;
.color-picker-header {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: #262626;
}
.color-picker-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 4px;
margin-bottom: 12px;
.color-item {
width: 20px;
height: 20px;
border-radius: 2px;
cursor: pointer;
border: 1px solid #d9d9d9;
&:hover {
transform: scale(1.1);
border-color: #1890ff;
}
}
}
.color-picker-custom {
display: flex;
align-items: center;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
span {
font-size: 12px;
color: #595959;
}
input[type="color"] {
width: 60px;
height: 28px;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
}
}
}
}
</style>

View File

@@ -0,0 +1,387 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="资产分类管理"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="primary" @click="handleCreate">
新增分类
</a-button>
</template>
<!-- 序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 父级分类 -->
<template #parent="{ record }">
{{ record.parent?.name || '-' }}
</template>
<!-- 颜色标识 -->
<template #color="{ record }">
<div class="color-cell">
<div
class="color-block"
:style="{ backgroundColor: record.color || '#ccc' }"
></div>
<span>{{ record.color || '-' }}</span>
</div>
</template>
<!-- 是否启用 -->
<template #enabled="{ record }">
<a-tag :color="record.enabled ? 'green' : 'red'">
{{ record.enabled ? '是' : '否' }}
</a-tag>
</template>
<!-- 操作 -->
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleDetail(record)">
详情
</a-button>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
删除
</a-button>
<a-button type="text" size="small" status="success" @click="handleAddChild(record)">
新增
</a-button>
</template>
</search-table>
<!-- 分类表单对话框新增/编辑 -->
<category-form-dialog
v-model:visible="formVisible"
:category="editingCategory"
:parent-categories="parentCategories"
:fixed-parent-id="fixedParentId"
:show-parent-selector="showParentSelector"
@success="handleFormSuccess"
/>
<!-- 分类详情对话框 -->
<category-detail-dialog
v-model:visible="detailVisible"
:category="currentCategory"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import {
fetchCategoryList,
deleteCategory,
fetchAllCategories,
fetchCategoryDetail,
} from '@/api/ops/assetCategory'
import CategoryFormDialog from './components/CategoryFormDialog.vue'
import CategoryDetailDialog from './components/CategoryDetailDialog.vue'
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
const formModel = ref({
keyword: '',
parent_id: '',
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 父级分类列表(用于下拉选择)
const parentCategories = ref<any[]>([])
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入分类名称或编码',
},
{
field: 'parent_id',
label: '父级分类',
type: 'select',
placeholder: '请选择父级分类',
options: parentCategories.value.map((item) => ({
label: item.name,
value: item.id,
})),
allowClear: true,
allowSearch: true,
},
])
// 表格列配置
const columns = computed(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
align: 'center' as const,
},
{
title: '分类名称',
dataIndex: 'name',
ellipsis: true,
tooltip: true,
},
{
title: '分类编码',
dataIndex: 'code',
ellipsis: true,
tooltip: true,
},
{
title: '父级分类',
dataIndex: 'parent',
slotName: 'parent',
ellipsis: true,
tooltip: true,
},
{
title: '层级',
dataIndex: 'level',
width: 80,
align: 'center' as const,
},
{
title: '排序',
dataIndex: 'sort',
width: 80,
align: 'center' as const,
},
{
title: '颜色标识',
dataIndex: 'color',
slotName: 'color',
width: 120,
align: 'center' as const,
},
{
title: '是否启用',
dataIndex: 'enabled',
slotName: 'enabled',
width: 100,
align: 'center' as const,
},
{
title: '操作',
slotName: 'actions',
width: 300,
fixed: 'right' as const,
},
])
// 当前选中的分类
const currentCategory = ref<any>(null)
const editingCategory = ref<any>(null)
// 对话框可见性
const formVisible = ref(false)
const detailVisible = ref(false)
// 固定的父级分类ID用于新增子分类时
const fixedParentId = ref<number | undefined>(undefined)
// 是否显示父级分类选择器
const showParentSelector = ref(true)
// 获取父级分类列表(用于下拉选择)
const fetchParentCategories = async () => {
try {
const res = await fetchAllCategories()
parentCategories.value = res.details || []
} catch (error) {
console.error('获取父级分类列表失败:', error)
}
}
// 获取分类列表
const fetchCategories = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
page_size: pagination.pageSize,
keyword: formModel.value.keyword || undefined,
parent_id: formModel.value.parent_id || undefined,
}
const res = await fetchCategoryList(params)
tableData.value = res.details?.data || []
pagination.total = res.details?.total || 0
} catch (error) {
console.error('获取分类列表失败:', error)
Message.error('获取分类列表失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchCategories()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = value
}
// 重置
const handleReset = () => {
formModel.value = {
keyword: '',
parent_id: '',
}
pagination.current = 1
fetchCategories()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchCategories()
}
// 刷新
const handleRefresh = () => {
fetchCategories()
Message.success('数据已刷新')
}
// 新增分类
const handleCreate = () => {
editingCategory.value = null
fixedParentId.value = undefined
showParentSelector.value = false
formVisible.value = true
}
// 新增子分类
const handleAddChild = (record: any) => {
editingCategory.value = null
fixedParentId.value = record.id
showParentSelector.value = true
formVisible.value = true
}
// 编辑分类
const handleEdit = (record: any) => {
console.log('编辑分类:', record)
editingCategory.value = record
formVisible.value = true
}
// 详情
const handleDetail = async (record: any) => {
console.log('查看详情:', record)
try {
const res = await fetchCategoryDetail(record.id)
if (res.code === 0) {
currentCategory.value = res.details
detailVisible.value = true
} else {
Message.error(res.message || '获取详情失败')
}
} catch (error) {
console.error('获取分类详情失败:', error)
Message.error('获取详情失败')
}
}
// 删除分类
const handleDelete = async (record: any) => {
console.log('删除分类:', record)
try {
Modal.confirm({
title: '确认删除',
content: `确认删除分类 ${record.name} 吗?`,
onOk: async () => {
const res = await deleteCategory(record.id)
if (res.code === 0) {
Message.success('删除成功')
fetchCategories()
} else {
Message.error(res.message || '删除失败')
}
},
})
} catch (error) {
console.error('删除分类失败:', error)
}
}
// 表单成功回调
const handleFormSuccess = () => {
formVisible.value = false
fetchCategories()
fetchParentCategories()
}
// 初始化加载数据
onMounted(() => {
fetchParentCategories()
fetchCategories()
})
</script>
<script lang="ts">
export default {
name: 'AssetClassify',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
.color-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.color-block {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #d9d9d9;
}
}
</style>

View File

@@ -0,0 +1,233 @@
<template>
<a-modal
:visible="visible"
title="设备详情"
width="900px"
:footer="false"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
>
<a-spin :loading="loading" style="width: 100%">
<a-descriptions
:column="2"
bordered
size="medium"
:label-style="{ width: '140px' }"
>
<!-- 基本信息 -->
<a-descriptions-item label="资产名称">
{{ device?.asset_name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="资产编号">
{{ device?.asset_code || '-' }}
</a-descriptions-item>
<a-descriptions-item label="资产分类">
{{ device?.category?.name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="规格型号">
{{ device?.model || '-' }}
</a-descriptions-item>
<a-descriptions-item label="生产厂商">
{{ device?.manufacturer || '-' }}
</a-descriptions-item>
<a-descriptions-item label="序列号">
{{ device?.serial_number || '-' }}
</a-descriptions-item>
<a-descriptions-item label="采购日期">
{{ formatDate(device?.purchase_date) }}
</a-descriptions-item>
<a-descriptions-item label="原值(元)">
{{ formatMoney(device?.original_value) }}
</a-descriptions-item>
<a-descriptions-item label="供应商">
{{ device?.supplier?.name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="质保期">
{{ device?.warranty_period || '-' }}
</a-descriptions-item>
<a-descriptions-item label="质保到期日期">
{{ formatDate(device?.warranty_expiry) }}
</a-descriptions-item>
<a-descriptions-item label="资产状态">
<a-tag :color="getAssetStatusColor(device?.status || '')">
{{ getAssetStatusText(device?.status || '') }}
</a-tag>
</a-descriptions-item>
<!-- 使用信息 -->
<a-descriptions-item label="使用部门">
{{ device?.department || '-' }}
</a-descriptions-item>
<a-descriptions-item label="使用人">
{{ device?.user || '-' }}
</a-descriptions-item>
<!-- 位置信息 -->
<a-descriptions-item label="所属数据中心">
{{ device?.datacenter?.name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="所属楼层">
{{ device?.floor?.name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="所属机柜">
{{ device?.rack?.name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="U位">
<template v-if="device?.unit_start || device?.unit_end">
{{ device?.unit_start || '-' }} - {{ device?.unit_end || '-' }}
</template>
<template v-else>-</template>
</a-descriptions-item>
<!-- 标签信息 -->
<a-descriptions-item label="二维码标签">
{{ device?.qr_code || '-' }}
</a-descriptions-item>
<a-descriptions-item label="RFID标签">
{{ device?.rfid_tag || '-' }}
</a-descriptions-item>
<a-descriptions-item label="资产标签号">
{{ device?.asset_tag || '-' }}
</a-descriptions-item>
<a-descriptions-item label="技术规格">
{{ device?.specifications || '-' }}
</a-descriptions-item>
<!-- 描述信息 -->
<a-descriptions-item label="资产描述" :span="2">
{{ device?.description || '-' }}
</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">
{{ device?.remarks || '-' }}
</a-descriptions-item>
<!-- 系统信息 -->
<a-descriptions-item label="创建时间">
{{ formatDateTime(device?.created_at) }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">
{{ formatDateTime(device?.updated_at) }}
</a-descriptions-item>
<a-descriptions-item label="创建人">
{{ device?.created_by || '-' }}
</a-descriptions-item>
<a-descriptions-item label="更新人">
{{ device?.updated_by || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import {
getAssetStatusText,
getAssetStatusColor,
} from '@/api/ops/asset'
interface Device {
id?: number
asset_name?: string
asset_code?: string
category?: { id: number; name: string; code: string }
model?: string
manufacturer?: string
serial_number?: string
purchase_date?: string
original_value?: number
supplier?: { id: number; name: string; code: string }
warranty_period?: string
warranty_expiry?: string
department?: string
user?: string
status?: string
location?: string
datacenter?: { id: number; name: string; code: string }
floor?: { id: number; name: string; code: string }
rack?: { id: number; name: string; code: string }
unit_start?: number
unit_end?: number
qr_code?: string
rfid_tag?: string
asset_tag?: string
specifications?: string
description?: string
remarks?: string
created_at?: string
updated_at?: string
created_by?: string
updated_by?: string
}
interface Props {
visible: boolean
device: Device | null
}
interface Emits {
(e: 'update:visible', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const loading = ref(false)
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
// 格式化金额
const formatMoney = (value: number | string | null | undefined) => {
if (value === null || value === undefined || value === '') return '-'
const num = Number(value)
if (isNaN(num)) return '-'
return num.toLocaleString('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
})
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 格式化日期时间
const formatDateTime = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
</script>
<script lang="ts">
export default {
name: 'DeviceDetailDialog',
}
</script>
<style scoped lang="less">
:deep(.arco-descriptions-item-label) {
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,687 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑设备' : '新增设备'"
width="900px"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:confirm-loading="submitting"
>
<a-form :model="form" layout="vertical" ref="formRef">
<a-tabs v-model:active-key="activeTab">
<!-- 基本信息 -->
<a-tab-pane key="basic" title="基本信息">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="资产名称"
field="asset_name"
:rules="[{ required: true, message: '请输入资产名称' }]"
>
<a-input
v-model="form.asset_name"
placeholder="请输入资产名称"
:max-length="200"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="资产编号"
field="asset_code"
:rules="[{ required: true, message: '请输入资产编号' }]"
>
<a-input
v-model="form.asset_code"
placeholder="请输入资产编号"
:max-length="100"
:disabled="isEdit"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="资产分类" field="category_id">
<a-select
v-model="form.category_id"
placeholder="请选择资产分类"
allow-clear
allow-search
:loading="categoryLoading"
>
<a-option
v-for="item in categoryOptions"
:key="item.id"
:value="item.id"
:label="item.name"
/>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="规格型号" field="model">
<a-input
v-model="form.model"
placeholder="请输入规格型号"
:max-length="200"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="生产厂商" field="manufacturer">
<a-input
v-model="form.manufacturer"
placeholder="请输入生产厂商"
:max-length="200"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="序列号" field="serial_number">
<a-input
v-model="form.serial_number"
placeholder="请输入序列号"
:max-length="100"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="采购日期" field="purchase_date">
<a-date-picker
v-model="form.purchase_date"
placeholder="请选择采购日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="原值(元)" field="original_value">
<a-input-number
v-model="form.original_value"
placeholder="请输入原值"
:min="0"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="供应商" field="supplier_id">
<a-select
v-model="form.supplier_id"
placeholder="请选择供应商"
allow-clear
allow-search
:loading="supplierLoading"
>
<a-option
v-for="item in supplierOptions"
:key="item.id"
:value="item.id"
:label="item.name"
/>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="质保期" field="warranty_period">
<a-input
v-model="form.warranty_period"
placeholder="请输入质保期36个月"
:max-length="50"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="质保到期日期" field="warranty_expiry">
<a-date-picker
v-model="form.warranty_expiry"
placeholder="请选择质保到期日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="资产状态" field="status">
<a-select
v-model="form.status"
placeholder="请选择资产状态"
>
<a-option
v-for="item in assetStatusOptions"
:key="item.value"
:value="item.value"
:label="item.label"
/>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-tab-pane>
<!-- 使用信息 -->
<a-tab-pane key="usage" title="使用信息">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="使用部门" field="department">
<a-input
v-model="form.department"
placeholder="请输入使用部门"
:max-length="100"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="使用人" field="user">
<a-input
v-model="form.user"
placeholder="请输入使用人"
:max-length="50"
/>
</a-form-item>
</a-col>
</a-row>
</a-tab-pane>
<!-- 位置信息 -->
<a-tab-pane key="location" title="位置信息">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属数据中心" field="datacenter_id">
<a-select
v-model="form.datacenter_id"
placeholder="请选择数据中心"
allow-clear
allow-search
:loading="datacenterLoading"
@change="handleDatacenterChange"
>
<a-option
v-for="item in datacenterOptions"
:key="item.id"
:value="item.id"
:label="item.name"
/>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="所属楼层" field="floor_id">
<a-select
v-model="form.floor_id"
placeholder="请选择楼层"
allow-clear
allow-search
:loading="floorLoading"
:disabled="!form.datacenter_id"
@change="handleFloorChange"
>
<a-option
v-for="item in floorOptions"
:key="item.id"
:value="item.id"
:label="item.name"
/>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属机柜" field="rack_id">
<a-select
v-model="form.rack_id"
placeholder="请选择机柜"
allow-clear
allow-search
:loading="rackLoading"
:disabled="!form.floor_id"
>
<a-option
v-for="item in rackOptions"
:key="item.id"
:value="item.id"
:label="item.name"
/>
</a-select>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="起始U位编号" field="unit_start">
<a-input-number
v-model="form.unit_start"
placeholder="起始U位"
:min="1"
:max="48"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="结束U位编号" field="unit_end">
<a-input-number
v-model="form.unit_end"
placeholder="结束U位"
:min="1"
:max="48"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-tab-pane>
<!-- 其他信息 -->
<a-tab-pane key="other" title="其他信息">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="二维码标签" field="qr_code">
<a-input
v-model="form.qr_code"
placeholder="请输入二维码标签"
:max-length="100"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="RFID标签" field="rfid_tag">
<a-input
v-model="form.rfid_tag"
placeholder="请输入RFID标签"
:max-length="100"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="资产标签号" field="asset_tag">
<a-input
v-model="form.asset_tag"
placeholder="请输入资产标签号"
:max-length="100"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="技术规格" field="specifications">
<a-input
v-model="form.specifications"
placeholder="请输入技术规格"
:max-length="500"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="资产描述" field="description">
<a-textarea
v-model="form.description"
placeholder="请输入资产描述"
:auto-size="{ minRows: 3, maxRows: 6 }"
:max-length="1000"
/>
</a-form-item>
<a-form-item label="备注" field="remarks">
<a-textarea
v-model="form.remarks"
placeholder="请输入备注"
:auto-size="{ minRows: 3, maxRows: 6 }"
:max-length="500"
/>
</a-form-item>
</a-tab-pane>
</a-tabs>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
createAsset,
updateAsset,
fetchCategoryOptions,
fetchSupplierOptions,
fetchDatacenterOptions,
fetchFloorOptions,
fetchRackOptions,
assetStatusOptions,
AssetForm,
} from '@/api/ops/asset'
interface Device {
id?: number
asset_name?: string
asset_code?: string
category_id?: number
model?: string
manufacturer?: string
serial_number?: string
purchase_date?: string
original_value?: number
supplier_id?: number
warranty_period?: string
warranty_expiry?: string
department?: string
user?: string
status?: string
location?: string
datacenter_id?: number
floor_id?: number
rack_id?: number
unit_start?: number
unit_end?: number
qr_code?: string
rfid_tag?: string
asset_tag?: string
specifications?: string
description?: string
remarks?: string
}
interface Props {
visible: boolean
device: Device | null
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref()
const submitting = ref(false)
const activeTab = ref('basic')
// 下拉选项
const categoryOptions = ref<any[]>([])
const supplierOptions = ref<any[]>([])
const datacenterOptions = ref<any[]>([])
const floorOptions = ref<any[]>([])
const rackOptions = ref<any[]>([])
// 加载状态
const categoryLoading = ref(false)
const supplierLoading = ref(false)
const datacenterLoading = ref(false)
const floorLoading = ref(false)
const rackLoading = ref(false)
// 是否为编辑模式
const isEdit = computed(() => !!props.device?.id)
// 表单数据
const form = ref<AssetForm>({
asset_name: '',
asset_code: '',
category_id: undefined,
model: '',
manufacturer: '',
serial_number: '',
purchase_date: '',
original_value: undefined,
supplier_id: undefined,
warranty_period: '',
warranty_expiry: '',
department: '',
user: '',
status: 'in_use',
location: '',
datacenter_id: undefined,
floor_id: undefined,
rack_id: undefined,
unit_start: undefined,
unit_end: undefined,
qr_code: '',
rfid_tag: '',
asset_tag: '',
specifications: '',
description: '',
remarks: '',
})
// 加载下拉选项
const loadOptions = async () => {
// 加载资产分类
categoryLoading.value = true
try {
const res = await fetchCategoryOptions()
if (res.code === 0) {
categoryOptions.value = res.details || []
}
} catch (error) {
console.error('加载资产分类失败:', error)
} finally {
categoryLoading.value = false
}
// 加载供应商
supplierLoading.value = true
try {
const res = await fetchSupplierOptions()
if (res.code === 0) {
supplierOptions.value = res.details || []
}
} catch (error) {
console.error('加载供应商失败:', error)
} finally {
supplierLoading.value = false
}
// 加载数据中心
datacenterLoading.value = true
try {
const res = await fetchDatacenterOptions()
if (res.code === 0) {
datacenterOptions.value = res.details?.data || res.details || []
}
} catch (error) {
console.error('加载数据中心失败:', error)
} finally {
datacenterLoading.value = false
}
}
// 数据中心变化时加载楼层
const handleDatacenterChange = async (value: number | undefined) => {
form.value.floor_id = undefined
form.value.rack_id = undefined
floorOptions.value = []
rackOptions.value = []
if (value) {
floorLoading.value = true
try {
const res = await fetchFloorOptions(value)
if (res.code === 0) {
floorOptions.value = res.details || []
}
} catch (error) {
console.error('加载楼层失败:', error)
} finally {
floorLoading.value = false
}
}
}
// 楼层变化时加载机柜
const handleFloorChange = async (value: number | undefined) => {
form.value.rack_id = undefined
rackOptions.value = []
if (value) {
rackLoading.value = true
try {
const res = await fetchRackOptions({
datacenter_id: form.value.datacenter_id,
floor_id: value,
})
if (res.code === 0) {
rackOptions.value = res.details?.data || res.details || []
}
} catch (error) {
console.error('加载机柜失败:', error)
} finally {
rackLoading.value = false
}
}
}
// 监听 device 变化,初始化表单
watch(
() => props.device,
async (newVal) => {
if (newVal) {
form.value = { ...newVal } as AssetForm
// 如果有数据中心,加载楼层
if (newVal.datacenter_id) {
try {
const res = await fetchFloorOptions(newVal.datacenter_id)
if (res.code === 0) {
floorOptions.value = res.details || []
}
} catch (error) {
console.error('加载楼层失败:', error)
}
}
// 如果有楼层,加载机柜
if (newVal.floor_id) {
try {
const res = await fetchRackOptions({
datacenter_id: newVal.datacenter_id,
floor_id: newVal.floor_id,
})
if (res.code === 0) {
rackOptions.value = res.details?.data || res.details || []
}
} catch (error) {
console.error('加载机柜失败:', error)
}
}
} else {
// 重置表单
form.value = {
asset_name: '',
asset_code: '',
category_id: undefined,
model: '',
manufacturer: '',
serial_number: '',
purchase_date: '',
original_value: undefined,
supplier_id: undefined,
warranty_period: '',
warranty_expiry: '',
department: '',
user: '',
status: 'in_use',
location: '',
datacenter_id: undefined,
floor_id: undefined,
rack_id: undefined,
unit_start: undefined,
unit_end: undefined,
qr_code: '',
rfid_tag: '',
asset_tag: '',
specifications: '',
description: '',
remarks: '',
}
floorOptions.value = []
rackOptions.value = []
}
activeTab.value = 'basic'
},
{ immediate: true }
)
// 提交表单
const handleOk = async () => {
try {
const valid = await formRef.value?.validate()
if (valid) {
return
}
submitting.value = true
const data = { ...form.value }
let res
if (isEdit.value) {
res = await updateAsset({ ...data, id: props.device?.id })
} else {
res = await createAsset(data)
}
if (res.code === 0) {
Message.success(isEdit.value ? '更新成功' : '创建成功')
emit('success')
} else {
Message.error(res.message || '操作失败')
}
} catch (error) {
console.error('提交表单失败:', error)
Message.error('操作失败')
} finally {
submitting.value = false
}
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
// 初始化
onMounted(() => {
loadOptions()
})
</script>
<script lang="ts">
export default {
name: 'DeviceFormDialog',
}
</script>
<style scoped lang="less">
:deep(.arco-tabs-content) {
padding-top: 16px;
}
</style>

View File

@@ -0,0 +1,400 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="设备资产管理"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="primary" @click="handleCreate">
新增设备
</a-button>
<a-button @click="handleExport">
导出
</a-button>
</template>
<!-- 序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 资产状态 -->
<template #status="{ record }">
<a-tag :color="getAssetStatusColor(record.status)">
{{ getAssetStatusText(record.status) }}
</a-tag>
</template>
<!-- 原值 -->
<template #original_value="{ record }">
{{ formatMoney(record.original_value) }}
</template>
<!-- 采购日期 -->
<template #purchase_date="{ record }">
{{ formatDate(record.purchase_date) }}
</template>
<!-- 质保到期日期 -->
<template #warranty_expiry="{ record }">
{{ formatDate(record.warranty_expiry) }}
</template>
<!-- 操作 -->
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleDetail(record)">
详情
</a-button>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
删除
</a-button>
</template>
</search-table>
<!-- 设备表单对话框新增/编辑 -->
<device-form-dialog
v-model:visible="formVisible"
:device="editingDevice"
@success="handleFormSuccess"
/>
<!-- 设备详情对话框 -->
<device-detail-dialog
v-model:visible="detailVisible"
:device="currentDevice"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import {
fetchAssetList,
deleteAsset,
fetchAssetDetail,
getAssetStatusText,
getAssetStatusColor,
assetStatusOptions,
} from '@/api/ops/asset'
import DeviceFormDialog from './components/DeviceFormDialog.vue'
import DeviceDetailDialog from './components/DeviceDetailDialog.vue'
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
const formModel = ref({
keyword: '',
status: '',
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入资产名称/编号/部门',
},
{
field: 'status',
label: '资产状态',
type: 'select',
placeholder: '请选择状态',
options: assetStatusOptions,
allowClear: true,
},
])
// 表格列配置
const columns = computed(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 70,
align: 'center' as const,
},
{
title: '资产编号',
dataIndex: 'asset_code',
ellipsis: true,
tooltip: true,
width: 130,
},
{
title: '资产名称',
dataIndex: 'asset_name',
ellipsis: true,
tooltip: true,
width: 180,
},
{
title: '资产分类',
dataIndex: 'category.name',
ellipsis: true,
tooltip: true,
width: 120,
},
{
title: '规格型号',
dataIndex: 'model',
ellipsis: true,
tooltip: true,
width: 150,
},
{
title: '使用部门',
dataIndex: 'department',
ellipsis: true,
tooltip: true,
width: 120,
},
{
title: '使用人',
dataIndex: 'user',
ellipsis: true,
tooltip: true,
width: 100,
},
{
title: '原值(元)',
dataIndex: 'original_value',
slotName: 'original_value',
width: 120,
align: 'right' as const,
},
{
title: '资产状态',
dataIndex: 'status',
slotName: 'status',
width: 100,
align: 'center' as const,
},
{
title: '采购日期',
dataIndex: 'purchase_date',
slotName: 'purchase_date',
width: 110,
align: 'center' as const,
},
{
title: '操作',
slotName: 'actions',
width: 280,
fixed: 'right' as const,
},
])
// 当前选中的设备
const currentDevice = ref<any>(null)
const editingDevice = ref<any>(null)
// 对话框可见性
const formVisible = ref(false)
const detailVisible = ref(false)
// 获取设备列表
const fetchDevices = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
page_size: pagination.pageSize,
keyword: formModel.value.keyword || undefined,
status: formModel.value.status || undefined,
}
const res = await fetchAssetList(params)
tableData.value = res.details?.data || []
pagination.total = res.details?.total || 0
} catch (error) {
console.error('获取设备列表失败:', error)
Message.error('获取设备列表失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchDevices()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = value
}
// 重置
const handleReset = () => {
formModel.value = {
keyword: '',
status: '',
}
pagination.current = 1
fetchDevices()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchDevices()
}
// 刷新
const handleRefresh = () => {
fetchDevices()
Message.success('数据已刷新')
}
// 新增设备
const handleCreate = () => {
editingDevice.value = null
formVisible.value = true
}
// 编辑设备
const handleEdit = (record: any) => {
editingDevice.value = record
formVisible.value = true
}
// 详情
const handleDetail = async (record: any) => {
try {
const res = await fetchAssetDetail(record.id)
if (res.code === 0) {
currentDevice.value = res.details
detailVisible.value = true
} else {
Message.error(res.message || '获取详情失败')
}
} catch (error) {
console.error('获取设备详情失败:', error)
Message.error('获取详情失败')
}
}
// 删除设备
const handleDelete = async (record: any) => {
try {
Modal.confirm({
title: '确认删除',
content: `确认删除设备 ${record.asset_name} 吗?`,
onOk: async () => {
const res = await deleteAsset(record.id)
if (res.code === 0) {
Message.success('删除成功')
fetchDevices()
} else {
Message.error(res.message || '删除失败')
}
},
})
} catch (error) {
console.error('删除设备失败:', error)
}
}
// 导出
const handleExport = async () => {
try {
Message.info('正在导出...')
const res = await fetchAssetList({
page: 1,
page_size: 10000,
keyword: formModel.value.keyword || undefined,
status: formModel.value.status || undefined,
})
if (res.code === 0 && res.details?.data) {
const data = res.details.data
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `assets_${new Date().toISOString().slice(0, 10)}.json`
link.click()
URL.revokeObjectURL(url)
Message.success('导出成功')
}
} catch (error) {
console.error('导出失败:', error)
Message.error('导出失败')
}
}
// 表单成功回调
const handleFormSuccess = () => {
formVisible.value = false
fetchDevices()
}
// 格式化金额
const formatMoney = (value: number | string | null | undefined) => {
if (value === null || value === undefined || value === '') return '-'
const num = Number(value)
if (isNaN(num)) return '-'
return num.toLocaleString('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
})
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 初始化加载数据
onMounted(() => {
fetchDevices()
})
</script>
<script lang="ts">
export default {
name: 'DeviceManage',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<a-modal
:visible="visible"
title="供应商详情"
width="800px"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:footer="false"
>
<a-tabs v-model:active-key="activeTab" v-if="supplier">
<!-- 基本信息 -->
<a-tab-pane key="basic" title="基本信息">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="供应商名称" :span="2">
{{ supplier.name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="供应商编码">
{{ supplier.code || '-' }}
</a-descriptions-item>
<a-descriptions-item label="简称">
{{ supplier.short_name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="供应商类型">
{{ supplier.supplier_type || '-' }}
</a-descriptions-item>
<a-descriptions-item label="供应商级别">
{{ supplier.supplier_level || '-' }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(supplier.status)">
{{ getStatusText(supplier.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="供应商描述" :span="2">
{{ supplier.description || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<!-- 联系信息 -->
<a-tab-pane key="contact" title="联系信息">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="联系人">
{{ supplier.contact_person || '-' }}
</a-descriptions-item>
<a-descriptions-item label="联系电话">
{{ supplier.contact_phone || '-' }}
</a-descriptions-item>
<a-descriptions-item label="备用联系电话">
{{ supplier.contact_mobile || '-' }}
</a-descriptions-item>
<a-descriptions-item label="联系邮箱">
{{ supplier.contact_email || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<!-- 地址信息 -->
<a-tab-pane key="address" title="地址信息">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="省份">
{{ supplier.province || '-' }}
</a-descriptions-item>
<a-descriptions-item label="城市">
{{ supplier.city || '-' }}
</a-descriptions-item>
<a-descriptions-item label="邮编">
{{ supplier.postal_code || '-' }}
</a-descriptions-item>
<a-descriptions-item label="详细地址">
{{ supplier.address || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<!-- 企业信息 -->
<a-tab-pane key="company" title="企业信息">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="法定代表人">
{{ supplier.legal_representative || '-' }}
</a-descriptions-item>
<a-descriptions-item label="注册资本">
{{ supplier.registered_capital || '-' }}
</a-descriptions-item>
<a-descriptions-item label="营业执照号">
{{ supplier.business_license || '-' }}
</a-descriptions-item>
<a-descriptions-item label="税号">
{{ supplier.tax_id || '-' }}
</a-descriptions-item>
<a-descriptions-item label="企业类型">
{{ supplier.company_type || '-' }}
</a-descriptions-item>
<a-descriptions-item label="官网地址">
{{ supplier.website || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<!-- 统计信息 -->
<a-tab-pane key="statistics" title="统计信息">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="订单数量">
{{ supplier.order_count ?? 0 }}
</a-descriptions-item>
<a-descriptions-item label="累计交易金额">
{{ formatMoney(supplier.total_amount) }}
</a-descriptions-item>
<a-descriptions-item label="合作开始日期">
{{ formatDate(supplier.cooperation_start_date) }}
</a-descriptions-item>
<a-descriptions-item label="合作结束日期">
{{ formatDate(supplier.cooperation_end_date) }}
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<!-- 系统信息 -->
<a-tab-pane key="system" title="系统信息">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="创建时间">
{{ formatDateTime(supplier.created_at) }}
</a-descriptions-item>
<a-descriptions-item label="更新时间">
{{ formatDateTime(supplier.updated_at) }}
</a-descriptions-item>
<a-descriptions-item label="创建人">
{{ supplier.created_by || '-' }}
</a-descriptions-item>
<a-descriptions-item label="更新人">
{{ supplier.updated_by || '-' }}
</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">
{{ supplier.remarks || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
</a-tabs>
<a-empty v-else description="暂无数据" />
</a-modal>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
interface Supplier {
id?: number
name?: string
code?: string
short_name?: string
description?: string
contact_person?: string
contact_phone?: string
contact_email?: string
contact_mobile?: string
province?: string
city?: string
address?: string
postal_code?: string
website?: string
legal_representative?: string
registered_capital?: string
business_license?: string
tax_id?: string
company_type?: string
supplier_type?: string
supplier_level?: string
status?: string
order_count?: number
total_amount?: number
cooperation_start_date?: string
cooperation_end_date?: string
created_at?: string
updated_at?: string
created_by?: string
updated_by?: string
remarks?: string
}
interface Props {
visible: boolean
supplier: Supplier | null
}
interface Emits {
(e: 'update:visible', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const activeTab = ref('basic')
// 获取状态颜色
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
active: 'green',
paused: 'orange',
blacklist: 'red',
inactive: 'gray',
}
return colorMap[status || ''] || 'gray'
}
// 获取状态文本
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
active: '合作中',
paused: '暂停合作',
blacklist: '黑名单',
inactive: '停止合作',
}
return textMap[status || ''] || status || '-'
}
// 格式化金额
const formatMoney = (value?: number | string | null) => {
if (value === null || value === undefined || value === '') return '-'
const num = Number(value)
if (isNaN(num)) return '-'
return num.toLocaleString('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
})
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
// 格式化日期时间
const formatDateTime = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
</script>
<script lang="ts">
export default {
name: 'SupplierDetailDialog',
}
</script>
<style scoped lang="less">
:deep(.arco-descriptions) {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,464 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑供应商' : '新增供应商'"
width="800px"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:confirm-loading="submitting"
>
<a-form :model="form" layout="vertical" ref="formRef">
<a-tabs v-model:active-key="activeTab">
<!-- 基本信息 -->
<a-tab-pane key="basic" title="基本信息">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="供应商名称"
field="name"
:rules="[{ required: true, message: '请输入供应商名称' }]"
>
<a-input
v-model="form.name"
placeholder="请输入供应商名称"
:max-length="200"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="供应商编码"
field="code"
:rules="[{ required: true, message: '请输入供应商编码' }]"
>
<a-input
v-model="form.code"
placeholder="请输入供应商编码"
:max-length="100"
:disabled="isEdit"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="简称" field="short_name">
<a-input
v-model="form.short_name"
placeholder="请输入简称"
:max-length="100"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="供应商类型"
field="supplier_type"
:rules="[{ required: true, message: '请选择供应商类型' }]"
>
<a-select
v-model="form.supplier_type"
placeholder="请选择供应商类型"
allow-clear
>
<a-option value="生产商">生产商</a-option>
<a-option value="代理商">代理商</a-option>
<a-option value="经销商">经销商</a-option>
<a-option value="服务商">服务商</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="供应商级别" field="supplier_level">
<a-select
v-model="form.supplier_level"
placeholder="请选择供应商级别"
allow-clear
>
<a-option value="A">A</a-option>
<a-option value="B">B</a-option>
<a-option value="C">C</a-option>
<a-option value="D">D</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="状态"
field="status"
:rules="[{ required: true, message: '请选择状态' }]"
>
<a-select
v-model="form.status"
placeholder="请选择状态"
>
<a-option value="active">合作中</a-option>
<a-option value="paused">暂停合作</a-option>
<a-option value="blacklist">黑名单</a-option>
<a-option value="inactive">停止合作</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-tab-pane>
<!-- 联系信息 -->
<a-tab-pane key="contact" title="联系信息">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="联系人"
field="contact_person"
:rules="[{ required: true, message: '请输入联系人' }]"
>
<a-input
v-model="form.contact_person"
placeholder="请输入联系人"
:max-length="50"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="联系电话"
field="contact_phone"
:rules="[{ required: true, message: '请输入联系电话' }]"
>
<a-input
v-model="form.contact_phone"
placeholder="请输入联系电话"
:max-length="20"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="备用联系电话"
field="contact_mobile"
:rules="[{ required: true, message: '请输入备用联系电话' }]"
>
<a-input
v-model="form.contact_mobile"
placeholder="请输入备用联系电话"
:max-length="20"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="联系邮箱"
field="contact_email"
:rules="[
{ required: true, message: '请输入联系邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]"
>
<a-input
v-model="form.contact_email"
placeholder="请输入联系邮箱"
:max-length="100"
/>
</a-form-item>
</a-col>
</a-row>
</a-tab-pane>
<!-- 地址信息 -->
<a-tab-pane key="address" title="地址信息">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="省份" field="province">
<a-input
v-model="form.province"
placeholder="请输入省份"
:max-length="50"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="城市" field="city">
<a-input
v-model="form.city"
placeholder="请输入城市"
:max-length="50"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="邮编" field="postal_code">
<a-input
v-model="form.postal_code"
placeholder="请输入邮编"
:max-length="10"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="详细地址" field="address">
<a-input
v-model="form.address"
placeholder="请输入详细地址"
:max-length="200"
/>
</a-form-item>
</a-col>
</a-row>
</a-tab-pane>
<!-- 企业信息 -->
<a-tab-pane key="company" title="企业信息">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="法定代表人" field="legal_representative">
<a-input
v-model="form.legal_representative"
placeholder="请输入法定代表人"
:max-length="50"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="注册资本" field="registered_capital">
<a-input
v-model="form.registered_capital"
placeholder="请输入注册资本"
:max-length="50"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="营业执照号" field="business_license">
<a-input
v-model="form.business_license"
placeholder="请输入营业执照号"
:max-length="100"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="税号" field="tax_id">
<a-input
v-model="form.tax_id"
placeholder="请输入税号"
:max-length="50"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="企业类型" field="company_type">
<a-input
v-model="form.company_type"
placeholder="请输入企业类型"
:max-length="50"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="官网地址" field="website">
<a-input
v-model="form.website"
placeholder="请输入官网地址"
:max-length="200"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="供应商描述" field="description">
<a-textarea
v-model="form.description"
placeholder="请输入供应商描述"
:auto-size="{ minRows: 3, maxRows: 6 }"
:max-length="500"
/>
</a-form-item>
</a-tab-pane>
</a-tabs>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { createSupplier, updateSupplier } from '@/api/ops/supplier'
interface Supplier {
id?: number
name?: string
code?: string
short_name?: string
description?: string
contact_person?: string
contact_phone?: string
contact_email?: string
contact_mobile?: string
province?: string
city?: string
address?: string
postal_code?: string
website?: string
legal_representative?: string
registered_capital?: string
business_license?: string
tax_id?: string
company_type?: string
supplier_type?: string
supplier_level?: string
status?: string
}
interface Props {
visible: boolean
supplier: Supplier | null
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref()
const submitting = ref(false)
const activeTab = ref('basic')
// 是否为编辑模式
const isEdit = computed(() => !!props.supplier?.id)
// 表单数据
const form = ref<Supplier>({
name: '',
code: '',
short_name: '',
description: '',
contact_person: '',
contact_phone: '',
contact_email: '',
contact_mobile: '',
province: '',
city: '',
address: '',
postal_code: '',
website: '',
legal_representative: '',
registered_capital: '',
business_license: '',
tax_id: '',
company_type: '',
supplier_type: '',
supplier_level: '',
status: 'active',
})
// 监听 supplier 变化,初始化表单
watch(
() => props.supplier,
(newVal) => {
if (newVal) {
form.value = { ...newVal }
} else {
// 重置表单
form.value = {
name: '',
code: '',
short_name: '',
description: '',
contact_person: '',
contact_phone: '',
contact_email: '',
contact_mobile: '',
province: '',
city: '',
address: '',
postal_code: '',
website: '',
legal_representative: '',
registered_capital: '',
business_license: '',
tax_id: '',
company_type: '',
supplier_type: '',
supplier_level: '',
status: 'active',
}
}
activeTab.value = 'basic'
},
{ immediate: true }
)
// 提交表单
const handleOk = async () => {
try {
const valid = await formRef.value?.validate()
if (valid) {
return
}
submitting.value = true
const data = { ...form.value }
let res
if (isEdit.value) {
res = await updateSupplier({ ...data, id: props.supplier?.id })
} else {
res = await createSupplier(data)
}
if (res.code === 0) {
Message.success(isEdit.value ? '更新成功' : '创建成功')
emit('success')
} else {
Message.error(res.message || '操作失败')
}
} catch (error) {
console.error('提交表单失败:', error)
Message.error('操作失败')
} finally {
submitting.value = false
}
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
</script>
<script lang="ts">
export default {
name: 'SupplierFormDialog',
}
</script>
<style scoped lang="less">
:deep(.arco-tabs-content) {
padding-top: 16px;
}
</style>

View File

@@ -0,0 +1,384 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="供应商管理"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="primary" @click="handleCreate">
新增供应商
</a-button>
</template>
<!-- 序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 状态 -->
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<!-- 累计交易金额 -->
<template #total_amount="{ record }">
{{ formatMoney(record.total_amount) }}
</template>
<!-- 合作开始日期 -->
<template #cooperation_start_date="{ record }">
{{ formatDate(record.cooperation_start_date) }}
</template>
<!-- 操作 -->
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleDetail(record)">
详情
</a-button>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
删除
</a-button>
</template>
</search-table>
<!-- 供应商表单对话框新增/编辑 -->
<supplier-form-dialog
v-model:visible="formVisible"
:supplier="editingSupplier"
@success="handleFormSuccess"
/>
<!-- 供应商详情对话框 -->
<supplier-detail-dialog
v-model:visible="detailVisible"
:supplier="currentSupplier"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import {
fetchSupplierList,
deleteSupplier,
fetchSupplierDetail,
} from '@/api/ops/supplier'
import SupplierFormDialog from './components/SupplierFormDialog.vue'
import SupplierDetailDialog from './components/SupplierDetailDialog.vue'
// 状态选项
const statusOptions = [
{ label: '合作中', value: 'active' },
{ label: '暂停合作', value: 'paused' },
{ label: '黑名单', value: 'blacklist' },
{ label: '停止合作', value: 'inactive' },
]
// 状态管理
const loading = ref(false)
const tableData = ref<any[]>([])
const formModel = ref({
keyword: '',
status: '',
})
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入供应商名称/编码/联系人/电话',
},
{
field: 'status',
label: '状态',
type: 'select',
placeholder: '请选择状态',
options: statusOptions,
allowClear: true,
},
])
// 表格列配置
const columns = computed(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
align: 'center' as const,
},
{
title: '供应商编码',
dataIndex: 'code',
ellipsis: true,
tooltip: true,
width: 120,
},
{
title: '供应商名称',
dataIndex: 'name',
ellipsis: true,
tooltip: true,
width: 200,
},
{
title: '联系人',
dataIndex: 'contact_person',
ellipsis: true,
tooltip: true,
width: 100,
},
{
title: '联系电话',
dataIndex: 'contact_phone',
ellipsis: true,
tooltip: true,
width: 130,
},
{
title: '订单数量',
dataIndex: 'order_count',
width: 100,
align: 'center' as const,
},
{
title: '累计交易金额',
dataIndex: 'total_amount',
slotName: 'total_amount',
width: 130,
align: 'right' as const,
},
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
width: 100,
align: 'center' as const,
},
{
title: '合作开始日期',
dataIndex: 'cooperation_start_date',
slotName: 'cooperation_start_date',
width: 130,
align: 'center' as const,
},
{
title: '操作',
slotName: 'actions',
width: 200,
fixed: 'right' as const,
},
])
// 当前选中的供应商
const currentSupplier = ref<any>(null)
const editingSupplier = ref<any>(null)
// 对话框可见性
const formVisible = ref(false)
const detailVisible = ref(false)
// 获取供应商列表
const fetchSuppliers = async () => {
loading.value = true
try {
const params: any = {
page: pagination.current,
page_size: pagination.pageSize,
keyword: formModel.value.keyword || undefined,
status: formModel.value.status || undefined,
}
const res = await fetchSupplierList(params)
tableData.value = res.details?.data || []
pagination.total = res.details?.total || 0
} catch (error) {
console.error('获取供应商列表失败:', error)
Message.error('获取供应商列表失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchSuppliers()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = value
}
// 重置
const handleReset = () => {
formModel.value = {
keyword: '',
status: '',
}
pagination.current = 1
fetchSuppliers()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchSuppliers()
}
// 刷新
const handleRefresh = () => {
fetchSuppliers()
Message.success('数据已刷新')
}
// 新增供应商
const handleCreate = () => {
editingSupplier.value = null
formVisible.value = true
}
// 编辑供应商
const handleEdit = (record: any) => {
editingSupplier.value = record
formVisible.value = true
}
// 详情
const handleDetail = async (record: any) => {
try {
const res = await fetchSupplierDetail(record.id)
if (res.code === 0) {
currentSupplier.value = res.details
detailVisible.value = true
} else {
Message.error(res.message || '获取详情失败')
}
} catch (error) {
console.error('获取供应商详情失败:', error)
Message.error('获取详情失败')
}
}
// 删除供应商
const handleDelete = async (record: any) => {
try {
Modal.confirm({
title: '确认删除',
content: `确认删除供应商 ${record.name} 吗?`,
onOk: async () => {
const res = await deleteSupplier(record.id)
if (res.code === 0) {
Message.success('删除成功')
fetchSuppliers()
} else {
Message.error(res.message || '删除失败')
}
},
})
} catch (error) {
console.error('删除供应商失败:', error)
}
}
// 表单成功回调
const handleFormSuccess = () => {
formVisible.value = false
fetchSuppliers()
}
// 获取状态颜色
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
active: 'green',
paused: 'orange',
blacklist: 'red',
inactive: 'gray',
}
return colorMap[status] || 'gray'
}
// 获取状态文本
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
active: '合作中',
paused: '暂停合作',
blacklist: '黑名单',
inactive: '停止合作',
}
return textMap[status] || status
}
// 格式化金额
const formatMoney = (value: number | string | null | undefined) => {
if (value === null || value === undefined || value === '') return '-'
const num = Number(value)
if (isNaN(num)) return '-'
return num.toLocaleString('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
})
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
// 初始化加载数据
onMounted(() => {
fetchSuppliers()
})
</script>
<script lang="ts">
export default {
name: 'SupplierManage',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<a-modal
:visible="visible"
title="快捷配置"
:mask-closable="false"
:ok-loading="loading"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form :model="form" layout="vertical">
<a-form-item label="远程访问端口">
<a-input-number
v-model="form.remote_port"
placeholder="请输入远程访问端口,为空则不可远程访问"
:min="1"
:max="65535"
style="width: 100%"
allow-clear
/>
<template #extra>
<span style="color: #86909c">为空则不可远程访问</span>
</template>
</a-form-item>
<a-form-item label="Agent URL配置">
<a-input
v-model="form.agent_url"
placeholder="请输入Agent URL"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
interface Props {
visible: boolean
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['update:visible', 'success'])
const loading = ref(false)
const form = ref({
remote_port: undefined as number | undefined,
agent_url: '',
})
watch(
() => props.visible,
(val) => {
if (val && props.record) {
form.value.remote_port = props.record.remote_port
form.value.agent_url = props.record.agent_url || ''
}
}
)
const handleSubmit = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise((resolve) => setTimeout(resolve, 500))
// 更新记录
props.record.remote_port = form.value.remote_port
props.record.agent_url = form.value.agent_url
props.record.remote_access = !!form.value.remote_port
props.record.agent_config = !!form.value.agent_url
Message.success('配置成功')
emit('success')
emit('update:visible', false)
} catch (error) {
Message.error('配置失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('update:visible', false)
}
</script>

View File

@@ -74,7 +74,7 @@ export const columns = [
{
dataIndex: 'actions',
title: '操作',
width: 100,
width: 180,
fixed: 'right' as const,
slotName: 'actions',
},

View File

@@ -99,46 +99,59 @@
</a-tag>
</template>
<!-- 操作栏 - 下拉菜单 -->
<!-- 操作栏 -->
<template #actions="{ record }">
<a-dropdown trigger="hover">
<a-button type="primary" size="small">
管理
<icon-down />
<a-space>
<a-button
v-if="!record.agent_config"
type="outline"
size="small"
@click="handleQuickConfig(record)"
>
<template #icon>
<icon-settings />
</template>
快捷配置
</a-button>
<template #content>
<a-doption @click="handleRestart(record)">
<template #icon>
<icon-refresh />
</template>
重启
</a-doption>
<a-doption @click="handleDetail(record)">
<template #icon>
<icon-eye />
</template>
详情
</a-doption>
<a-doption @click="handleEdit(record)">
<template #icon>
<icon-edit />
</template>
编辑
</a-doption>
<a-doption @click="handleRemoteControl(record)">
<template #icon>
<icon-desktop />
</template>
远程控制
</a-doption>
<a-doption @click="handleDelete(record)" style="color: rgb(var(--danger-6))">
<template #icon>
<icon-delete />
</template>
删除
</a-doption>
</template>
</a-dropdown>
<a-dropdown trigger="hover">
<a-button type="primary" size="small">
管理
<icon-down />
</a-button>
<template #content>
<a-doption @click="handleRestart(record)">
<template #icon>
<icon-refresh />
</template>
重启
</a-doption>
<a-doption @click="handleDetail(record)">
<template #icon>
<icon-eye />
</template>
详情
</a-doption>
<a-doption @click="handleEdit(record)">
<template #icon>
<icon-edit />
</template>
编辑
</a-doption>
<a-doption @click="handleRemoteControl(record)">
<template #icon>
<icon-desktop />
</template>
远程控制
</a-doption>
<a-doption @click="handleDelete(record)" style="color: rgb(var(--danger-6))">
<template #icon>
<icon-delete />
</template>
删除
</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
</search-table>
@@ -149,6 +162,13 @@
@success="handleFormSuccess"
/>
<!-- 快捷配置对话框 -->
<QuickConfigDialog
v-model:visible="quickConfigVisible"
:record="currentRecord"
@success="handleFormSuccess"
/>
</div>
</template>
@@ -163,7 +183,8 @@ import {
IconDesktop,
IconDelete,
IconRefresh,
IconEye
IconEye,
IconSettings
} from '@arco-design/web-vue/es/icon'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
@@ -174,6 +195,7 @@ import {
deletePC,
} from '@/api/ops/pc'
import ServerFormDialog from './components/ServerFormDialog.vue'
import QuickConfigDialog from './components/QuickConfigDialog.vue'
import ServerDetail from './components/ServerDetail.vue'
const router = useRouter()
@@ -313,6 +335,7 @@ const formModel = ref({
})
const formDialogVisible = ref(false)
const quickConfigVisible = ref(false)
const currentRecord = ref<any>(null)
const pagination = reactive({
@@ -440,6 +463,12 @@ const handleAdd = () => {
formDialogVisible.value = true
}
// 快捷配置
const handleQuickConfig = (record: any) => {
currentRecord.value = record
quickConfigVisible.value = true
}
// 编辑PC
const handleEdit = (record: any) => {
currentRecord.value = record

View File

@@ -85,7 +85,7 @@ export const columns = [
{
dataIndex: 'actions',
title: '操作',
width: 100,
width: 180,
fixed: 'right' as const,
slotName: 'actions',
},

View File

@@ -108,44 +108,57 @@
<!-- 操作栏 - 下拉菜单 -->
<template #actions="{ record }">
<a-dropdown trigger="hover">
<a-button type="primary" size="small">
管理
<icon-down />
<a-space>
<a-button
v-if="!record.agent_config"
type="outline"
size="small"
@click="handleQuickConfig(record)"
>
<template #icon>
<icon-settings />
</template>
快捷配置
</a-button>
<template #content>
<a-doption @click="handleRestart(record)">
<template #icon>
<icon-refresh />
</template>
重启
</a-doption>
<a-doption @click="handleDetail(record)">
<template #icon>
<icon-eye />
</template>
详情
</a-doption>
<a-doption @click="handleEdit(record)">
<template #icon>
<icon-edit />
</template>
编辑
</a-doption>
<a-doption @click="handleRemoteControl(record)">
<template #icon>
<icon-desktop />
</template>
远程控制
</a-doption>
<a-doption @click="handleDelete(record)" style="color: rgb(var(--danger-6))">
<template #icon>
<icon-delete />
</template>
删除
</a-doption>
</template>
</a-dropdown>
<a-dropdown trigger="hover">
<a-button type="primary" size="small">
管理
<icon-down />
</a-button>
<template #content>
<a-doption @click="handleRestart(record)">
<template #icon>
<icon-refresh />
</template>
重启
</a-doption>
<a-doption @click="handleDetail(record)">
<template #icon>
<icon-eye />
</template>
详情
</a-doption>
<a-doption @click="handleEdit(record)">
<template #icon>
<icon-edit />
</template>
编辑
</a-doption>
<a-doption @click="handleRemoteControl(record)">
<template #icon>
<icon-desktop />
</template>
远程控制
</a-doption>
<a-doption @click="handleDelete(record)" style="color: rgb(var(--danger-6))">
<template #icon>
<icon-delete />
</template>
删除
</a-doption>
</template>
</a-dropdown>
</a-space>
</template>
</search-table>
@@ -155,6 +168,13 @@
:record="currentRecord"
@success="handleFormSuccess"
/>
<!-- 快捷配置对话框 -->
<QuickConfigDialog
v-model:visible="quickConfigVisible"
:record="currentRecord"
@success="handleFormSuccess"
/>
</div>
</template>
@@ -169,12 +189,14 @@ import {
IconDesktop,
IconDelete,
IconRefresh,
IconEye
IconEye,
IconSettings
} from '@arco-design/web-vue/es/icon'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import ServerFormDialog from '../pc/components/ServerFormDialog.vue'
import QuickConfigDialog from '../pc/components/QuickConfigDialog.vue'
import { columns as columnsConfig } from './config/columns'
import {
fetchServerList,
@@ -391,6 +413,7 @@ const mockServerData = [
const loading = ref(false)
const tableData = ref<any[]>([])
const formDialogVisible = ref(false)
const quickConfigVisible = ref(false)
const currentRecord = ref<any>(null)
const formModel = ref({
keyword: '',
@@ -524,6 +547,12 @@ const handleAdd = () => {
formDialogVisible.value = true
}
// 快捷配置
const handleQuickConfig = (record: any) => {
currentRecord.value = record
quickConfigVisible.value = true
}
// 编辑服务器
const handleEdit = (record: any) => {
currentRecord.value = record

View File

@@ -0,0 +1,2 @@
export { default as DocumentDetailDrawer } from './DocumentDetailDrawer.vue'
export { default as DocumentFormModal } from './DocumentFormModal.vue'

View File

@@ -0,0 +1,92 @@
/**
* 表格列配置 - 知识库文档
*/
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import { getDocumentStatusText, getDocumentStatusColor, getDocumentTypeText } from './options'
export const getColumns = (): TableColumnData[] => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 60,
align: 'center',
},
{
title: '文档编号',
dataIndex: 'doc_no',
width: 150,
ellipsis: true,
tooltip: true,
},
{
title: '文档标题',
dataIndex: 'title',
width: 200,
ellipsis: true,
tooltip: true,
},
{
title: '文档类型',
dataIndex: 'type',
slotName: 'type',
width: 120,
align: 'center',
},
{
title: '分类',
dataIndex: 'category_name',
width: 120,
ellipsis: true,
tooltip: true,
},
{
title: '作者',
dataIndex: 'author_name',
width: 100,
ellipsis: true,
tooltip: true,
},
{
title: '审核人',
dataIndex: 'reviewer_name',
width: 100,
ellipsis: true,
tooltip: true,
},
{
title: '发布时间',
dataIndex: 'published_at',
slotName: 'published_at',
width: 160,
align: 'center',
},
{
title: '版本号',
dataIndex: 'version',
width: 80,
align: 'center',
},
{
title: '浏览次数',
dataIndex: 'view_count',
width: 90,
align: 'center',
},
{
title: '文档状态',
dataIndex: 'status',
slotName: 'status',
width: 110,
align: 'center',
fixed: 'right',
},
{
title: '操作',
dataIndex: 'operation',
slotName: 'operation',
width: 200,
align: 'center',
fixed: 'right',
},
]

View File

@@ -0,0 +1,31 @@
/**
* 筛选项配置 - 知识库文档
*/
import type { FormItem } from '@/components/search-form/types'
import { documentTypeOptions, documentStatusOptions } from './options'
export const getFilters = (): FormItem[] => [
{
label: '关键词',
field: 'keyword',
type: 'input',
placeholder: '搜索文档标题、描述或关键字',
span: 6,
},
{
label: '文档类型',
field: 'type',
type: 'select',
placeholder: '请选择文档类型',
options: documentTypeOptions,
span: 4,
},
{
label: '文档状态',
field: 'status',
type: 'select',
placeholder: '请选择文档状态',
options: documentStatusOptions,
span: 4,
},
]

View File

@@ -0,0 +1,62 @@
/**
* 选项配置 - 知识库文档
*/
// 文档类型选项
export const documentTypeOptions = [
{ label: '通用文档', value: 'common' },
{ label: '操作指南', value: 'guide' },
{ label: '解决方案', value: 'solution' },
{ label: '故障排查', value: 'troubleshoot' },
{ label: '流程规范', value: 'process' },
{ label: '技术文档', value: 'technical' },
];
// 文档状态选项
export const documentStatusOptions = [
{ label: '草稿', value: 'draft' },
{ label: '已发布', value: 'published' },
{ label: '已审核', value: 'reviewed' },
{ label: '未通过审核', value: 'rejected' },
];
// 列表类型选项
export const listTypeOptions = [
{ label: '我的', value: 'mine' },
{ label: '全部', value: 'all' },
];
// 获取文档状态文本
export const getDocumentStatusText = (status: string): string => {
const statusMap: Record<string, string> = {
draft: '草稿',
published: '已发布',
reviewed: '已审核',
rejected: '未通过审核',
};
return statusMap[status] || status;
};
// 获取文档状态颜色
export const getDocumentStatusColor = (status: string): string => {
const colorMap: Record<string, string> = {
draft: 'gray',
published: 'blue',
reviewed: 'green',
rejected: 'red',
};
return colorMap[status] || 'gray';
};
// 获取文档类型文本
export const getDocumentTypeText = (type: string): string => {
const typeMap: Record<string, string> = {
common: '通用文档',
guide: '操作指南',
solution: '解决方案',
troubleshoot: '故障排查',
process: '流程规范',
technical: '技术文档',
};
return typeMap[type] || type;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,350 @@
<template>
<div class="container">
<Breadcrumb :items="['知识管理', '回收站']" />
<a-card class="general-card">
<!-- 搜索表单 -->
<SearchForm
:model-value="searchForm"
:form-items="filters"
@update:model-value="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
/>
<a-divider style="margin-top: 0" />
<!-- 数据表格 -->
<DataTable
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="{
current: page,
pageSize,
total,
}"
:show-download="false"
@page-change="handlePageChange"
@refresh="fetchData"
>
<!-- 序号列 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (page - 1) * pageSize }}
</template>
<!-- 资源类型列 -->
<template #resource_type="{ record }">
<a-tag :color="getResourceTypeColor(record.resource_type)">
{{ getResourceTypeText(record.resource_type) }}
</a-tag>
</template>
<!-- 删除时间列 -->
<template #deleted_time="{ record }">
{{ formatTime(record.deleted_time) }}
</template>
<!-- 删除人列 -->
<template #deleted_name="{ record }">
{{ record.deleted_name || '-' }}
</template>
<!-- 操作列 -->
<template #operation="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleRestore(record)">
恢复
</a-button>
<a-button
type="text"
size="small"
status="danger"
@click="handleDelete(record)"
>
彻底删除
</a-button>
</a-space>
</template>
</DataTable>
</a-card>
<!-- 恢复确认对话框 -->
<a-modal
v-model:visible="restoreConfirmVisible"
title="恢复确认"
@ok="handleConfirmRestore"
@cancel="restoreConfirmVisible = false"
>
<p>
确定要恢复{{ recordToRestore?.resource_name }}
</p>
<p style="color: rgb(var(--primary-6))">恢复后资源将回到正常状态</p>
</a-modal>
<!-- 彻底删除确认对话框 -->
<a-modal
v-model:visible="deleteConfirmVisible"
title="彻底删除确认"
@ok="handleConfirmDelete"
@cancel="deleteConfirmVisible = false"
>
<p>
确定要彻底删除{{ recordToDelete?.resource_name }}
</p>
<p style="color: rgb(var(--danger-6))">
警告此操作不可恢复删除后将无法找回
</p>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import dayjs from 'dayjs'
import SearchForm from '@/components/search-form/index.vue'
import DataTable from '@/components/data-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { TrashRecord, FetchTrashListParams } from '@/api/kb/trash'
import {
fetchTrashList,
restoreTrash,
deleteTrash,
getResourceTypeText,
getResourceTypeColor,
resourceTypeOptions,
} from '@/api/kb/trash'
// 表格列配置
const columns = computed((): TableColumnData[] => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
align: 'center',
},
{
title: '标题',
dataIndex: 'resource_name',
ellipsis: true,
tooltip: true,
},
{
title: '分类',
dataIndex: 'resource_type',
slotName: 'resource_type',
width: 120,
align: 'center',
},
{
title: '删除人',
dataIndex: 'deleted_name',
slotName: 'deleted_name',
width: 120,
align: 'center',
},
{
title: '删除时间',
dataIndex: 'deleted_time',
slotName: 'deleted_time',
width: 180,
align: 'center',
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '操作',
dataIndex: 'operation',
slotName: 'operation',
width: 180,
align: 'center',
fixed: 'right',
},
])
// 搜索表单配置
const filters = computed((): FormItem[] => [
{
label: '关键词',
field: 'keyword',
type: 'input',
placeholder: '搜索标题',
span: 6,
},
{
label: '资源类型',
field: 'resource_type',
type: 'select',
placeholder: '请选择资源类型',
options: resourceTypeOptions,
span: 4,
},
])
// 搜索表单数据
const searchForm = reactive({
keyword: '',
resource_type: '',
})
// 处理表单模型更新
const handleFormModelUpdate = (newFormModel: Record<string, any>) => {
Object.assign(searchForm, newFormModel)
}
// 表格数据
const tableData = ref<TrashRecord[]>([])
const loading = ref(false)
// 分页
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
// 恢复确认
const restoreConfirmVisible = ref(false)
const recordToRestore = ref<TrashRecord | null>(null)
// 删除确认
const deleteConfirmVisible = ref(false)
const recordToDelete = ref<TrashRecord | null>(null)
// 获取数据
const fetchData = async () => {
try {
loading.value = true
const params: FetchTrashListParams = {
page: page.value,
page_size: pageSize.value,
resource_type: (searchForm.resource_type || undefined) as
| 'document'
| 'faq'
| undefined,
}
const res = await fetchTrashList(params)
if (res?.code === 200 && res.data) {
// 如果有关键词,在前端过滤
let data = res.data.data || []
if (searchForm.keyword) {
const keyword = searchForm.keyword.toLowerCase()
data = data.filter(
(item) =>
item.resource_name?.toLowerCase().includes(keyword) ||
item.deleted_name?.toLowerCase().includes(keyword)
)
}
tableData.value = data
total.value = res.data.total || 0
}
} catch (error) {
console.error('获取回收站列表失败:', error)
Message.error('获取回收站列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
page.value = 1
fetchData()
}
// 重置
const handleReset = () => {
searchForm.keyword = ''
searchForm.resource_type = ''
page.value = 1
fetchData()
}
// 页码变化
const handlePageChange = (current: number) => {
page.value = current
fetchData()
}
// 格式化时间
const formatTime = (time: string | null): string => {
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
}
// 恢复资源
const handleRestore = (record: TrashRecord) => {
recordToRestore.value = record
restoreConfirmVisible.value = true
}
// 确认恢复
const handleConfirmRestore = async () => {
if (!recordToRestore.value?.id) return
try {
loading.value = true
const res = await restoreTrash({ id: recordToRestore.value.id })
if (res?.code === 200) {
Message.success('恢复成功')
restoreConfirmVisible.value = false
recordToRestore.value = null
await fetchData()
} else {
Message.error(res?.message || '恢复失败')
}
} catch (error) {
console.error('恢复失败:', error)
Message.error('恢复失败')
} finally {
loading.value = false
}
}
// 彻底删除
const handleDelete = (record: TrashRecord) => {
recordToDelete.value = record
deleteConfirmVisible.value = true
}
// 确认彻底删除
const handleConfirmDelete = async () => {
if (!recordToDelete.value?.id) return
try {
loading.value = true
const res = await deleteTrash({ id: recordToDelete.value.id })
if (res?.code === 200) {
Message.success('彻底删除成功')
deleteConfirmVisible.value = false
recordToDelete.value = null
await fetchData()
} else {
Message.error(res?.message || '删除失败')
}
} catch (error) {
console.error('删除失败:', error)
Message.error('删除失败')
} finally {
loading.value = false
}
}
// 初始化
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
.general-card {
margin-top: 16px;
}
</style>

View File

View File

@@ -0,0 +1,324 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑分类' : '新增分类'"
width="600px"
@ok="handleOk"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
:confirm-loading="submitting"
>
<a-form :model="form" layout="vertical" ref="formRef">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="分类名称"
field="name"
:rules="[{ required: true, message: '请输入分类名称' }]"
>
<a-input
v-model="form.name"
placeholder="请输入分类名称"
:max-length="200"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="分类类型" field="type">
<a-select
v-model="form.type"
placeholder="请选择分类类型"
allow-clear
>
<a-option value="document">文档分类</a-option>
<a-option value="faq">FAQ分类</a-option>
<a-option value="general">通用分类</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="分类描述" field="description">
<a-textarea
v-model="form.description"
placeholder="请输入分类描述"
:auto-size="{ minRows: 2, maxRows: 4 }"
:max-length="500"
/>
</a-form-item>
<a-form-item label="分类颜色" field="color">
<div class="color-picker-wrapper">
<a-input
v-model="form.color"
placeholder="请选择颜色"
readonly
@click="showColorPicker = !showColorPicker"
>
<template #prefix>
<div
class="color-preview"
:style="{ backgroundColor: form.color || '#ccc' }"
></div>
</template>
</a-input>
<div v-if="showColorPicker" class="color-picker-dropdown">
<div class="color-picker-header">选择颜色</div>
<div class="color-picker-grid">
<div
v-for="color in presetColors"
:key="color"
class="color-item"
:style="{ backgroundColor: color }"
@click="selectColor(color)"
></div>
</div>
<div class="color-picker-custom">
<span>自定义颜色:</span>
<input
type="color"
v-model="form.color"
@change="showColorPicker = false"
/>
</div>
</div>
</div>
</a-form-item>
<a-form-item label="排序号" field="sort_order">
<a-input-number
v-model="form.sort_order"
placeholder="请输入排序号"
:min="0"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="备注信息" field="remarks">
<a-textarea
v-model="form.remarks"
placeholder="请输入备注信息"
:auto-size="{ minRows: 2, maxRows: 4 }"
:max-length="500"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
createCategory,
updateCategory,
type Category,
} from '@/api/kb/category'
interface Props {
visible: boolean
category: Category | null
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref()
const submitting = ref(false)
const showColorPicker = ref(false)
// 预设颜色
const presetColors = [
'#FF0000', '#FF4500', '#FF8C00', '#FFD700', '#FFFF00',
'#9ACD32', '#32CD32', '#00FF00', '#00FA9A', '#00CED1',
'#1E90FF', '#0000FF', '#8A2BE2', '#9400D3', '#FF00FF',
'#FF1493', '#DC143C', '#B22222', '#8B0000', '#800000',
]
// 表单数据
const form = ref({
name: '',
description: '',
type: 'document',
color: '',
sort_order: 0,
remarks: '',
})
// 是否为编辑模式
const isEdit = computed(() => !!props.category?.id)
// 选择颜色
const selectColor = (color: string) => {
form.value.color = color
showColorPicker.value = false
}
// 监听对话框显示状态
watch(
() => props.visible,
(newVal) => {
if (newVal) {
if (props.category && isEdit.value) {
// 编辑模式:填充表单
form.value = {
name: props.category.name || '',
description: props.category.description || '',
type: props.category.type || 'document',
color: props.category.color || '',
sort_order: props.category.sort_order || 0,
remarks: props.category.remarks || '',
}
} else {
// 新建模式:重置表单
form.value = {
name: '',
description: '',
type: 'document',
color: '',
sort_order: 0,
remarks: '',
}
}
showColorPicker.value = false
}
}
)
// 确认提交
const handleOk = async () => {
const valid = await formRef.value?.validate()
if (valid) return
submitting.value = true
try {
const data: any = {
name: form.value.name,
description: form.value.description,
type: form.value.type,
color: form.value.color,
sort_order: form.value.sort_order,
remarks: form.value.remarks,
}
let res: any
if (isEdit.value && props.category?.id) {
// 编辑分类
data.id = props.category.id
res = await updateCategory(data)
} else {
// 新建分类
res = await createCategory(data)
}
if (res.code === 200) {
Message.success(isEdit.value ? '编辑成功' : '创建成功')
emit('success')
emit('update:visible', false)
} else {
Message.error(res.message || (isEdit.value ? '编辑失败' : '创建失败'))
}
} catch (error) {
Message.error(isEdit.value ? '编辑失败' : '创建失败')
console.error(error)
} finally {
submitting.value = false
}
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
// 处理对话框可见性变化
const handleVisibleChange = (visible: boolean) => {
emit('update:visible', visible)
}
</script>
<script lang="ts">
export default {
name: 'KbCategoryFormDialog',
}
</script>
<style scoped lang="less">
.color-picker-wrapper {
position: relative;
.color-preview {
width: 16px;
height: 16px;
border-radius: 2px;
border: 1px solid #d9d9d9;
}
.color-picker-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 12px;
margin-top: 4px;
width: 280px;
.color-picker-header {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: #262626;
}
.color-picker-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 4px;
margin-bottom: 12px;
.color-item {
width: 20px;
height: 20px;
border-radius: 2px;
cursor: pointer;
border: 1px solid #d9d9d9;
&:hover {
transform: scale(1.1);
border-color: #1890ff;
}
}
}
.color-picker-custom {
display: flex;
align-items: center;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
span {
font-size: 12px;
color: #595959;
}
input[type="color"] {
width: 60px;
height: 28px;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
}
}
}
}
</style>

View File

@@ -0,0 +1,283 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
title="分类管理"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="primary" @click="handleCreate">
新增分类
</a-button>
</template>
<!-- 序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 }}
</template>
<!-- 分类类型 -->
<template #type="{ record }">
<a-tag :color="getTypeColor(record.type)">
{{ getTypeLabel(record.type) }}
</a-tag>
</template>
<!-- 操作 -->
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
删除
</a-button>
</template>
</search-table>
<!-- 分类表单对话框新增/编辑 -->
<category-form-dialog
v-model:visible="formVisible"
:category="editingCategory"
@success="handleFormSuccess"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import {
fetchCategoryList,
deleteCategory,
type Category,
} from '@/api/kb/category'
import CategoryFormDialog from './components/CategoryFormDialog.vue'
// 状态管理
const loading = ref(false)
const tableData = ref<Category[]>([])
const formModel = ref({
keyword: '',
type: '',
})
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入分类名称',
},
{
field: 'type',
label: '分类类型',
type: 'select',
placeholder: '请选择分类类型',
options: [
{ label: '文档分类', value: 'document' },
{ label: 'FAQ分类', value: 'faq' },
{ label: '通用分类', value: 'general' },
],
allowClear: true,
},
])
// 表格列配置
const columns = computed(() => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
align: 'center' as const,
},
{
title: '分类名称',
dataIndex: 'name',
ellipsis: true,
tooltip: true,
},
{
title: '分类描述',
dataIndex: 'description',
ellipsis: true,
tooltip: true,
},
{
title: '分类类型',
dataIndex: 'type',
slotName: 'type',
width: 120,
align: 'center' as const,
},
{
title: '备注信息',
dataIndex: 'remarks',
ellipsis: true,
tooltip: true,
},
{
title: '操作',
slotName: 'actions',
width: 250,
fixed: 'right' as const,
},
])
// 当前选中的分类
const editingCategory = ref<Category | null>(null)
// 对话框可见性
const formVisible = ref(false)
// 获取分类类型标签
const getTypeLabel = (type: string) => {
const typeMap: Record<string, string> = {
document: '文档分类',
faq: 'FAQ分类',
general: '通用分类',
}
return typeMap[type] || type
}
// 获取分类类型颜色
const getTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
document: 'blue',
faq: 'green',
general: 'orange',
}
return colorMap[type] || 'gray'
}
// 获取分类列表
const fetchCategories = async () => {
loading.value = true
try {
const params: any = {}
if (formModel.value.type) {
params.type = formModel.value.type
}
const res: any = await fetchCategoryList(params)
if (res.code === 0) {
let data = res.details || []
// 如果有关键词搜索,进行过滤
if (formModel.value.keyword) {
data = data.filter((item: Category) =>
item.name.toLowerCase().includes(formModel.value.keyword.toLowerCase())
)
}
tableData.value = data
} else {
Message.error(res.message || '获取分类列表失败')
tableData.value = []
}
} catch (error) {
console.error('获取分类列表失败:', error)
Message.error('获取分类列表失败')
tableData.value = []
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
fetchCategories()
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = value
}
// 重置
const handleReset = () => {
formModel.value = {
keyword: '',
type: '',
}
fetchCategories()
}
// 刷新
const handleRefresh = () => {
fetchCategories()
Message.success('数据已刷新')
}
// 新增分类
const handleCreate = () => {
editingCategory.value = null
formVisible.value = true
}
// 编辑分类
const handleEdit = (record: Category) => {
editingCategory.value = { ...record }
formVisible.value = true
}
// 删除分类
const handleDelete = async (record: Category) => {
Modal.confirm({
title: '确认删除',
content: `确认删除分类「${record.name}」吗?`,
onOk: async () => {
try {
const res: any = await deleteCategory(record.id)
if (res.code === 200) {
Message.success('删除成功')
fetchCategories()
} else {
Message.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除分类失败:', error)
Message.error('删除失败')
}
},
})
}
// 表单成功回调
const handleFormSuccess = () => {
formVisible.value = false
fetchCategories()
}
// 初始化加载数据
onMounted(() => {
fetchCategories()
})
</script>
<script lang="ts">
export default {
name: 'KbCategoryManage',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
</style>

View File

@@ -1,113 +1,62 @@
<template>
<div class="metrics-summary-panel">
<!-- 查询表单 -->
<a-form :model="formModel" layout="inline" class="search-form">
<a-form-item label="数据源" field="data_source">
<a-select
v-model="formModel.data_source"
placeholder="请选择数据源"
style="width: 180px"
@change="handleDataSourceChange"
>
<a-option value="dc-host">主机</a-option>
<a-option value="dc-network">网络设备</a-option>
<a-option value="dc-database">数据库</a-option>
<a-option value="dc-middleware">中间件</a-option>
</a-select>
</a-form-item>
<a-form-item label="指标名称" field="metric_names">
<a-input
v-model="formModel.metric_names"
placeholder="多个指标名称,逗号分隔"
style="width: 250px"
/>
</a-form-item>
<a-form-item label="标识" field="identities">
<a-input
v-model="formModel.identities"
:placeholder="identityPlaceholder"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="聚合方式" field="aggregation">
<a-select
v-model="formModel.aggregation"
placeholder="请选择聚合方式"
style="width: 120px"
>
<a-option value="avg">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
<a-option value="sum">求和</a-option>
<a-option value="count">计数</a-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围" field="timeRange">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" :loading="loading" @click="handleSearch">
查询
</a-button>
<a-button @click="handleReset">
重置
</a-button>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 结果表格 -->
<a-table
<div>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
:bordered="false"
stripe
class="result-table"
@page-change="handlePageChange"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<template #columns>
<a-table-column title="标识" data-index="group_key" width="200" />
<a-table-column title="指标名称" data-index="metric_name" width="180" />
<a-table-column title="指标单位" data-index="metric_unit" width="100" />
<a-table-column title="聚合值" data-index="value" width="120">
<template #cell="{ record }">
<span>{{ formatValue(record.value) }}</span>
</template>
</a-table-column>
<a-table-column title="样本数" data-index="sample_count" width="100" />
<a-table-column title="聚合方式" data-index="aggregation" width="100" />
<a-table-column title="数据源" data-index="data_source" width="120" />
<!-- 时间范围选择器插槽 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="时间范围" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
</a-col>
</template>
</a-table>
<!-- 空状态 -->
<a-empty v-if="!loading && tableData.length === 0" description="暂无数据" />
<!-- 导出按钮插槽 -->
<template #actions>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</template>
<!-- 聚合值 -->
<template #value="{ record }">
<span>{{ formatValue(record.value) }}</span>
</template>
</search-table>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { fetchMetricsSummary, exportMetricsSummary } from '@/api/ops/report'
// 表单模型
const formModel = reactive({
const formModel = ref<{
data_source: string
metric_names: string
identities: string
aggregation: string
timeRange: string[]
}>({
data_source: 'dc-host',
metric_names: '',
identities: '',
@@ -122,7 +71,7 @@ const exporting = ref(false)
// 表格数据
const tableData = ref<any[]>([])
// 分页
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
@@ -131,16 +80,97 @@ const pagination = reactive({
// 标识字段占位符
const identityPlaceholder = computed(() => {
if (formModel.data_source === 'dc-host') {
if (formModel.value.data_source === 'dc-host') {
return '服务器标识,必填'
}
return '服务标识,必填'
})
// 处理数据源变化
const handleDataSourceChange = () => {
formModel.identities = ''
}
// 表单项配置
const formItems = computed<FormItem[]>(() => [
{
field: 'data_source',
label: '数据源',
type: 'select',
placeholder: '请选择数据源',
options: [
{ label: '主机', value: 'dc-host' },
{ label: '网络设备', value: 'dc-network' },
{ label: '数据库', value: 'dc-database' },
{ label: '中间件', value: 'dc-middleware' },
],
colProps: { span: 8 },
},
{
field: 'metric_names',
label: '指标名称',
type: 'input',
placeholder: '多个指标名称,逗号分隔',
colProps: { span: 8 },
},
{
field: 'identities',
label: '标识',
type: 'input',
placeholder: identityPlaceholder.value,
colProps: { span: 8 },
},
{
field: 'aggregation',
label: '聚合方式',
type: 'select',
placeholder: '请选择聚合方式',
options: [
{ label: '平均值', value: 'avg' },
{ label: '最大值', value: 'max' },
{ label: '最小值', value: 'min' },
{ label: '求和', value: 'sum' },
{ label: '计数', value: 'count' },
],
colProps: { span: 8 },
},
])
// 表格列配置
const columns = computed(() => [
{
title: '标识',
dataIndex: 'group_key',
width: 200,
fixed: 'left' as const,
},
{
title: '指标名称',
dataIndex: 'metric_name',
width: 180,
},
{
title: '指标单位',
dataIndex: 'metric_unit',
width: 100,
},
{
title: '聚合值',
dataIndex: 'value',
width: 120,
slotName: 'value',
},
{
title: '样本数',
dataIndex: 'sample_count',
width: 100,
},
{
title: '聚合方式',
dataIndex: 'aggregation',
width: 100,
},
{
title: '数据源',
dataIndex: 'data_source',
width: 120,
},
])
// 格式化数值
const formatValue = (value: number) => {
@@ -151,55 +181,58 @@ const formatValue = (value: number) => {
// 构建查询参数
const buildParams = () => {
const params: any = {
data_source: formModel.data_source,
metric_names: formModel.metric_names,
aggregation: formModel.aggregation,
start_time: formModel.timeRange[0],
end_time: formModel.timeRange[1],
data_source: formModel.value.data_source,
metric_names: formModel.value.metric_names,
aggregation: formModel.value.aggregation,
start_time: formModel.value.timeRange[0],
end_time: formModel.value.timeRange[1],
}
// 根据数据源添加标识
if (formModel.data_source === 'dc-host') {
params.server_identities = formModel.identities
if (formModel.value.data_source === 'dc-host') {
params.server_identities = formModel.value.identities
} else {
params.service_identities = formModel.identities
params.service_identities = formModel.value.identities
}
return params
}
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.data_source) {
if (!formModel.value.data_source) {
Message.warning('请选择数据源')
return
}
if (!formModel.metric_names) {
if (!formModel.value.metric_names) {
Message.warning('请输入指标名称')
return
}
if (!formModel.identities) {
if (!formModel.value.identities) {
Message.warning('请输入标识')
return
}
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
if (!formModel.value.timeRange || formModel.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
pagination.current = 1
await fetchTableData()
}
// 获取表格数据
const fetchTableData = async () => {
loading.value = true
try {
const params = buildParams()
const res = await fetchMetricsSummary(params)
if (res.code === 0) {
const data = res.data?.data || []
tableData.value = data
tableData.value = res.data?.data || []
pagination.total = res.data?.count || 0
pagination.current = 1
if (tableData.value.length === 0) {
Message.info('未查询到数据')
}
@@ -218,67 +251,77 @@ const handleSearch = async () => {
}
}
// 重置
const handleReset = () => {
formModel.data_source = 'dc-host'
formModel.metric_names = ''
formModel.identities = ''
formModel.aggregation = 'avg'
formModel.timeRange = []
tableData.value = []
pagination.total = 0
pagination.current = 1
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
// 如果需要分页查询,可以在这里实现
// 重置
const handleReset = () => {
formModel.value = {
data_source: 'dc-host',
metric_names: '',
identities: '',
aggregation: 'avg',
timeRange: [],
}
pagination.current = 1
tableData.value = []
pagination.total = 0
}
// 刷新
const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 导出
const handleExport = async () => {
// 验证必填项
if (!formModel.data_source) {
if (!formModel.value.data_source) {
Message.warning('请选择数据源')
return
}
if (!formModel.metric_names) {
if (!formModel.value.metric_names) {
Message.warning('请输入指标名称')
return
}
if (!formModel.identities) {
if (!formModel.value.identities) {
Message.warning('请输入标识')
return
}
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
if (!formModel.value.timeRange || formModel.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
exporting.value = true
try {
const params = buildParams()
params.utf8_bom = 'true'
const blob = await exportMetricsSummary(params)
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
// 生成文件名
const now = new Date()
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
link.download = `metrics_summary_${timestamp}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
Message.success('导出成功')
} catch (error: any) {
console.error('导出失败:', error)
@@ -287,6 +330,37 @@ const handleExport = async () => {
exporting.value = false
}
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 组件挂载时初始化时间范围并自动查询
onMounted(() => {
initDefaultTimeRange()
// 自动查询(使用默认参数)
formModel.value.metric_names = 'cpu_usage_percent'
formModel.value.identities = '*'
handleSearch()
})
</script>
<script lang="ts">
@@ -296,20 +370,4 @@ export default {
</script>
<style scoped lang="less">
.metrics-summary-panel {
.search-form {
margin-bottom: 20px;
padding: 16px;
background-color: var(--color-fill-2);
border-radius: 4px;
:deep(.arco-form-item) {
margin-bottom: 12px;
}
}
.result-table {
background-color: #fff;
}
}
</style>

View File

@@ -1,129 +1,57 @@
<template>
<div class="metrics-topn-panel">
<!-- 查询表单 -->
<a-form :model="formModel" layout="inline" class="search-form">
<a-form-item label="数据源" field="data_source">
<a-select
v-model="formModel.data_source"
placeholder="请选择数据源"
style="width: 180px"
@change="handleDataSourceChange"
>
<a-option value="dc-host">主机</a-option>
<a-option value="dc-network">网络设备</a-option>
<a-option value="dc-database">数据库</a-option>
<a-option value="dc-middleware">中间件</a-option>
</a-select>
</a-form-item>
<a-form-item label="指标名称" field="metric_name">
<a-input
v-model="formModel.metric_name"
placeholder="请输入指标名称"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="聚合方式" field="aggregation">
<a-select
v-model="formModel.aggregation"
placeholder="请选择聚合方式"
style="width: 120px"
>
<a-option value="avg">平均值</a-option>
<a-option value="max">最大值</a-option>
<a-option value="min">最小值</a-option>
<a-option value="sum">求和</a-option>
<a-option value="count">计数</a-option>
</a-select>
</a-form-item>
<a-form-item label="排序" field="order">
<a-select
v-model="formModel.order"
style="width: 100px"
>
<a-option value="desc">降序</a-option>
<a-option value="asc">升序</a-option>
</a-select>
</a-form-item>
<a-form-item label="数量" field="limit">
<a-input-number
v-model="formModel.limit"
:min="1"
:max="1000"
style="width: 120px"
/>
</a-form-item>
<a-form-item label="标识" field="identities">
<a-input
v-model="formModel.identities"
:placeholder="identityPlaceholder"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="时间范围" field="timeRange">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" :loading="loading" @click="handleSearch">
查询
</a-button>
<a-button @click="handleReset">
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 结果表格 -->
<a-table
<div>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="false"
:bordered="false"
stripe
class="result-table"
:pagination="pagination"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<template #columns>
<a-table-column title="排名" data-index="rank" width="80" />
<a-table-column title="标识" data-index="group_key" width="200" />
<a-table-column title="指标名称" data-index="metric_name" width="180" />
<a-table-column title="指标单位" data-index="metric_unit" width="100" />
<a-table-column title="聚合值" data-index="value" width="120">
<template #cell="{ record }">
<span>{{ formatValue(record.value) }}</span>
</template>
</a-table-column>
<a-table-column title="样本数" data-index="sample_count" width="100" />
<a-table-column title="聚合方式" data-index="aggregation" width="100" />
<a-table-column title="数据源" data-index="data_source" width="120" />
<!-- 时间范围选择器插槽 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="时间范围" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
</a-col>
</template>
</a-table>
<!-- 空状态 -->
<a-empty v-if="!loading && tableData.length === 0" description="暂无数据" />
<!-- 聚合值 -->
<template #value="{ record }">
<span>{{ formatValue(record.value) }}</span>
</template>
</search-table>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { fetchMetricsTopN } from '@/api/ops/report'
// 表单模型
const formModel = reactive({
const formModel = ref<{
data_source: string
metric_name: string
aggregation: string
order: string
limit: number
identities: string
timeRange: string[]
}>({
data_source: 'dc-host',
metric_name: '',
aggregation: 'avg',
@@ -139,18 +67,134 @@ const loading = ref(false)
// 表格数据
const tableData = ref<any[]>([])
// 分页配置TOPN 查询不使用分页,设置为 false
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: false,
showJumper: false,
showPageSize: false,
})
// 标识字段占位符
const identityPlaceholder = computed(() => {
if (formModel.data_source === 'dc-host') {
if (formModel.value.data_source === 'dc-host') {
return '多个服务器标识,逗号分隔'
}
return '多个服务标识,逗号分隔'
})
// 处理数据源变化
const handleDataSourceChange = () => {
formModel.identities = ''
}
// 表单项配置
const formItems = computed(() => [
{
field: 'data_source',
label: '数据源',
type: 'select' as const,
placeholder: '请选择数据源',
options: [
{ label: '主机', value: 'dc-host' },
{ label: '网络设备', value: 'dc-network' },
{ label: '数据库', value: 'dc-database' },
{ label: '中间件', value: 'dc-middleware' },
],
colProps: { span: 8 },
},
{
field: 'metric_name',
label: '指标名称',
type: 'input' as const,
placeholder: '请输入指标名称',
colProps: { span: 8 },
},
{
field: 'aggregation',
label: '聚合方式',
type: 'select' as const,
placeholder: '请选择聚合方式',
options: [
{ label: '平均值', value: 'avg' },
{ label: '最大值', value: 'max' },
{ label: '最小值', value: 'min' },
{ label: '求和', value: 'sum' },
{ label: '计数', value: 'count' },
],
colProps: { span: 8 },
},
{
field: 'order',
label: '排序',
type: 'select' as const,
options: [
{ label: '降序', value: 'desc' },
{ label: '升序', value: 'asc' },
],
colProps: { span: 8 },
},
{
field: 'limit',
label: '数量',
type: 'input' as const,
props: {
min: 1,
max: 1000,
},
colProps: { span: 8 },
},
{
field: 'identities',
label: '标识',
type: 'input' as const,
placeholder: identityPlaceholder.value,
colProps: { span: 8 },
},
])
// 表格列配置
const columns = computed(() => [
{
title: '排名',
dataIndex: 'rank',
width: 80,
fixed: 'left' as const,
},
{
title: '标识',
dataIndex: 'group_key',
width: 200,
},
{
title: '指标名称',
dataIndex: 'metric_name',
width: 180,
},
{
title: '指标单位',
dataIndex: 'metric_unit',
width: 100,
},
{
title: '聚合值',
dataIndex: 'value',
width: 120,
slotName: 'value',
},
{
title: '样本数',
dataIndex: 'sample_count',
width: 100,
},
{
title: '聚合方式',
dataIndex: 'aggregation',
width: 100,
},
{
title: '数据源',
dataIndex: 'data_source',
width: 120,
},
])
// 格式化数值
const formatValue = (value: number) => {
@@ -161,72 +205,130 @@ const formatValue = (value: number) => {
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.data_source) {
if (!formModel.value.data_source) {
Message.warning('请选择数据源')
return
}
if (!formModel.metric_name) {
if (!formModel.value.metric_name) {
Message.warning('请输入指标名称')
return
}
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
if (!formModel.value.timeRange || formModel.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
pagination.current = 1
await fetchTableData()
}
// 获取表格数据
const fetchTableData = async () => {
loading.value = true
try {
const params: any = {
data_source: formModel.data_source,
metric_name: formModel.metric_name,
aggregation: formModel.aggregation,
order: formModel.order,
limit: formModel.limit,
start_time: formModel.timeRange[0],
end_time: formModel.timeRange[1],
data_source: formModel.value.data_source,
metric_name: formModel.value.metric_name,
aggregation: formModel.value.aggregation,
order: formModel.value.order,
limit: formModel.value.limit,
start_time: formModel.value.timeRange[0],
end_time: formModel.value.timeRange[1],
}
// 根据数据源添加标识过滤
if (formModel.identities) {
if (formModel.data_source === 'dc-host') {
params.server_identities = formModel.identities
if (formModel.value.identities) {
if (formModel.value.data_source === 'dc-host') {
params.server_identities = formModel.value.identities
} else {
params.service_identities = formModel.identities
params.service_identities = formModel.value.identities
}
}
const res = await fetchMetricsTopN(params)
if (res.code === 0) {
tableData.value = res.data?.items || []
pagination.total = tableData.value.length
if (tableData.value.length === 0) {
Message.info('未查询到数据')
}
} else {
Message.error(res.message || '查询失败')
tableData.value = []
pagination.total = 0
}
} catch (error: any) {
console.error('查询失败:', error)
Message.error(error.message || '查询失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.data_source = 'dc-host'
formModel.metric_name = ''
formModel.aggregation = 'avg'
formModel.order = 'desc'
formModel.limit = 10
formModel.identities = ''
formModel.timeRange = []
formModel.value = {
data_source: 'dc-host',
metric_name: '',
aggregation: 'avg',
order: 'desc',
limit: 10,
identities: '',
timeRange: [],
}
pagination.current = 1
tableData.value = []
pagination.total = 0
}
// 刷新
const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 组件挂载时自动加载
onMounted(() => {
initDefaultTimeRange()
// 自动查询(使用默认参数)
formModel.value.metric_name = 'cpu_usage_percent'
formModel.value.data_source = 'dc-host'
handleSearch()
})
</script>
<script lang="ts">
@@ -236,20 +338,4 @@ export default {
</script>
<style scoped lang="less">
.metrics-topn-panel {
.search-form {
margin-bottom: 20px;
padding: 16px;
background-color: var(--color-fill-2);
border-radius: 4px;
:deep(.arco-form-item) {
margin-bottom: 12px;
}
}
.result-table {
background-color: #fff;
}
}
</style>

View File

@@ -6,7 +6,6 @@
:columns="columns"
:loading="loading"
:pagination="pagination"
title="网络设备状态"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@@ -68,7 +67,7 @@
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
@@ -338,6 +337,37 @@ const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 组件挂载时初始化并自动查询
onMounted(() => {
initDefaultTimeRange()
// 自动查询(使用默认参数)
formModel.value.service_identities = '*'
formModel.value.metric_names = 'cpu_usage_percent'
handleSearch()
})
</script>
<script lang="ts">

View File

@@ -6,7 +6,6 @@
:columns="columns"
:loading="loading"
:pagination="pagination"
title="服务器状态"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@@ -58,7 +57,7 @@
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
@@ -288,6 +287,37 @@ const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 组件挂载时初始化并自动查询
onMounted(() => {
initDefaultTimeRange()
// 自动查询(使用默认参数)
formModel.value.server_identities = '*'
formModel.value.metric_names = 'cpu_usage_percent'
handleSearch()
})
</script>
<script lang="ts">

View File

@@ -1,109 +1,71 @@
<template>
<div class="traffic-summary-panel">
<!-- 查询表单 -->
<a-form :model="formModel" layout="inline" class="search-form">
<a-form-item label="拓扑 ID" field="topology_id">
<a-input-number
v-model="formModel.topology_id"
:min="0"
placeholder="拓扑 ID"
style="width: 150px"
/>
</a-form-item>
<a-form-item label="链路 ID" field="link_id">
<a-input-number
v-model="formModel.link_id"
:min="0"
placeholder="链路 ID"
style="width: 150px"
/>
</a-form-item>
<a-form-item label="节点 ID" field="node_ids">
<a-input
v-model="formModel.node_ids"
placeholder="多个节点 ID逗号分隔"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="时间范围" field="timeRange">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" :loading="loading" @click="handleSearch">
查询
</a-button>
<a-button @click="handleReset">
重置
</a-button>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 汇总信息卡片 -->
<div v-if="summaryData.totals" class="summary-cards">
<a-card title="流量汇总" :bordered="false" class="summary-card">
<a-descriptions :column="3" bordered>
<a-descriptions-item v-for="(value, key) in displayTotals" :key="key" :label="formatLabel(key)">
{{ formatValue(key, value) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</div>
<!-- 节点流量表格 -->
<a-divider v-if="summaryData.by_node && summaryData.by_node.length > 0">节点流量明细</a-divider>
<a-table
v-if="summaryData.by_node && summaryData.by_node.length > 0"
<div>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="summaryData.by_node"
:columns="tableColumns"
:loading="loading"
:pagination="false"
:bordered="false"
stripe
class="result-table"
:pagination="pagination"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
>
<template #columns>
<a-table-column title="节点 ID" data-index="node_id" width="200" fixed="left" />
<a-table-column
v-for="column in dynamicColumns"
:key="String(column.key)"
:title="column.title"
:data-index="String(column.key)"
:width="150"
>
<template #cell="{ record }">
<span>{{ formatValue(column.key, record[column.key]) }}</span>
</template>
</a-table-column>
<!-- 时间范围选择器插槽 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="时间范围" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
</a-col>
</template>
</a-table>
<!-- 空状态 -->
<a-empty v-if="!loading && !summaryData.totals" description="暂无数据" />
<!-- 汇总信息卡片插槽 -->
<template #summary>
<a-card v-if="summaryData.totals" title="流量汇总" :bordered="false" class="summary-card">
<a-descriptions :column="3" bordered>
<a-descriptions-item v-for="(value, key) in displayTotals" :key="key" :label="formatLabel(key)">
{{ formatValue(key, value) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</template>
<!-- 导出按钮插槽 -->
<template #actions>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</template>
<!-- 动态列的单元格渲染 -->
<template v-for="column in dynamicColumns" :key="String(column.key)" #[column.slotName]="{ record }">
<span>{{ formatValue(column.key, record[column.key]) }}</span>
</template>
</search-table>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import SearchTable from '@/components/search-table/index.vue'
import { fetchTrafficSummary, exportTrafficReport } from '@/api/ops/report'
// 表单模型
const formModel = reactive({
const formModel = ref<{
topology_id: number
link_id: number
node_ids: string
timeRange: string[]
}>({
topology_id: 0,
link_id: 0,
node_ids: '',
@@ -120,6 +82,49 @@ const summaryData = reactive<any>({
by_node: [],
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: false,
showJumper: false,
showPageSize: false,
})
// 表单项配置
const formItems = computed(() => [
{
field: 'topology_id',
label: '拓扑 ID',
type: 'input' as const,
placeholder: '拓扑 ID',
colProps: { span: 8 },
props: {
type: 'number',
min: 0,
},
},
{
field: 'link_id',
label: '链路 ID',
type: 'input' as const,
placeholder: '链路 ID',
colProps: { span: 8 },
props: {
type: 'number',
min: 0,
},
},
{
field: 'node_ids',
label: '节点 ID',
type: 'input' as const,
placeholder: '多个节点 ID逗号分隔',
colProps: { span: 8 },
},
])
// 显示的汇总字段(过滤掉元数据)
const displayTotals = computed(() => {
if (!summaryData.totals) return {}
@@ -143,7 +148,7 @@ const dynamicColumns = computed(() => {
if (key !== 'node_id') {
columns.push({
key,
title: formatLabel(key),
slotName: String(key),
})
}
}
@@ -151,6 +156,30 @@ const dynamicColumns = computed(() => {
return columns
})
// 表格列配置
const tableColumns = computed(() => {
const columns: any[] = [
{
title: '节点 ID',
dataIndex: 'node_id',
width: 200,
fixed: 'left' as const,
},
]
// 添加动态列
dynamicColumns.value.forEach((column) => {
columns.push({
title: formatLabel(column.key),
dataIndex: String(column.key),
width: 150,
slotName: column.slotName,
})
})
return columns
})
// 格式化标签
const formatLabel = (key: string) => {
const labelMap: Record<string, string> = {
@@ -209,19 +238,19 @@ const formatValue = (key: string, value: any) => {
// 构建查询参数
const buildParams = () => {
const params: any = {
start_time: formModel.timeRange[0],
end_time: formModel.timeRange[1],
start_time: formModel.value.timeRange[0],
end_time: formModel.value.timeRange[1],
kind: 'summary',
}
if (formModel.topology_id > 0) {
params.topology_id = formModel.topology_id
if (formModel.value.topology_id > 0) {
params.topology_id = formModel.value.topology_id
}
if (formModel.link_id > 0) {
params.link_id = formModel.link_id
if (formModel.value.link_id > 0) {
params.link_id = formModel.value.link_id
}
if (formModel.node_ids) {
params.node_ids = formModel.node_ids
if (formModel.value.node_ids) {
params.node_ids = formModel.value.node_ids
}
return params
@@ -230,11 +259,17 @@ const buildParams = () => {
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
if (!formModel.value.timeRange || formModel.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
pagination.current = 1
await fetchTableData()
}
// 获取表格数据
const fetchTableData = async () => {
loading.value = true
try {
@@ -244,6 +279,7 @@ const handleSearch = async () => {
if (res.code === 0) {
summaryData.totals = res.data?.totals || null
summaryData.by_node = res.data?.by_node || []
pagination.total = summaryData.by_node.length
if (!summaryData.totals && summaryData.by_node.length === 0) {
Message.info('未查询到数据')
@@ -252,31 +288,51 @@ const handleSearch = async () => {
Message.error(res.message || '查询失败')
summaryData.totals = null
summaryData.by_node = []
pagination.total = 0
}
} catch (error: any) {
console.error('查询失败:', error)
Message.error(error.message || '查询失败')
summaryData.totals = null
summaryData.by_node = []
pagination.total = 0
} finally {
loading.value = false
}
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.topology_id = 0
formModel.link_id = 0
formModel.node_ids = ''
formModel.timeRange = []
formModel.value = {
topology_id: 0,
link_id: 0,
node_ids: '',
timeRange: [],
}
pagination.current = 1
summaryData.totals = null
summaryData.by_node = []
pagination.total = 0
}
// 刷新
const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 导出
const handleExport = async () => {
// 验证必填项
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
if (!formModel.value.timeRange || formModel.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
@@ -312,6 +368,35 @@ const handleExport = async () => {
exporting.value = false
}
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 组件挂载时初始化时间范围并自动查询
onMounted(() => {
initDefaultTimeRange()
// 自动查询
handleSearch()
})
</script>
<script lang="ts">
@@ -321,30 +406,11 @@ export default {
</script>
<style scoped lang="less">
.traffic-summary-panel {
.search-form {
margin-bottom: 20px;
.summary-card {
margin-bottom: 20px;
:deep(.arco-card-body) {
padding: 16px;
background-color: var(--color-fill-2);
border-radius: 4px;
:deep(.arco-form-item) {
margin-bottom: 12px;
}
}
.summary-cards {
margin-bottom: 20px;
.summary-card {
:deep(.arco-card-body) {
padding: 16px;
}
}
}
.result-table {
background-color: #fff;
}
}
</style>

View File

@@ -1,109 +1,65 @@
<template>
<div class="traffic-trend-panel">
<!-- 查询表单 -->
<a-form :model="formModel" layout="inline" class="search-form">
<a-form-item label="拓扑 ID" field="topology_id">
<a-input-number
v-model="formModel.topology_id"
:min="0"
placeholder="拓扑 ID"
style="width: 150px"
/>
</a-form-item>
<a-form-item label="链路 ID" field="link_id">
<a-input-number
v-model="formModel.link_id"
:min="0"
placeholder="链路 ID"
style="width: 150px"
/>
</a-form-item>
<a-form-item label="节点 ID" field="node_ids">
<a-input
v-model="formModel.node_ids"
placeholder="多个节点 ID逗号分隔"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="时间粒度" field="granularity">
<a-select
v-model="formModel.granularity"
placeholder="请选择时间粒度"
style="width: 120px"
>
<a-option value="minute">分钟</a-option>
<a-option value="hour">小时</a-option>
<a-option value="day"></a-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围" field="timeRange">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 380px"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" :loading="loading" @click="handleSearch">
查询
</a-button>
<a-button @click="handleReset">
重置
</a-button>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 趋势图表 -->
<div v-if="trendData.length > 0" class="chart-section">
<a-card title="流量趋势图" :bordered="false">
<div ref="chartRef" class="chart-container" />
</a-card>
</div>
<!-- 趋势数据表格 -->
<a-divider v-if="trendData.length > 0">趋势数据明细</a-divider>
<a-table
v-if="trendData.length > 0"
<div>
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="tableColumns"
:loading="loading"
:pagination="pagination"
:bordered="false"
stripe
class="result-table"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@refresh="handleRefresh"
@page-change="handlePageChange"
>
<template #columns>
<a-table-column title="时间" data-index="time" width="180" fixed="left" />
<a-table-column title="节点 ID" data-index="node_id" width="180" />
<a-table-column
v-for="column in dynamicColumns"
:key="String(column.key)"
:title="column.title"
:data-index="String(column.key)"
:width="150"
>
<template #cell="{ record }">
<span>{{ formatValue(column.key, record[column.key]) }}</span>
</template>
</a-table-column>
<!-- 时间粒度选择器插槽 -->
<template #form-items>
<a-col :span="8">
<a-form-item label="时间粒度" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<a-select
v-model="formModel.granularity"
placeholder="请选择时间粒度"
style="width: 100%"
>
<a-option value="minute">分钟</a-option>
<a-option value="hour">小时</a-option>
<a-option value="day"></a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="时间范围" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<a-range-picker
v-model="formModel.timeRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
</a-col>
</template>
</a-table>
<!-- 空状态 -->
<a-empty v-if="!loading && trendData.length === 0" description="暂无数据" />
<!-- 趋势图表插槽 -->
<template #chart>
<a-card v-if="trendData.length > 0" title="流量趋势图" :bordered="false">
<div ref="chartRef" class="chart-container" />
</a-card>
</template>
<!-- 导出按钮插槽 -->
<template #actions>
<a-button :loading="exporting" @click="handleExport">
导出 CSV
</a-button>
</template>
<!-- 动态列的单元格渲染 -->
<template v-for="column in dynamicColumns" :key="String(column.key)" #[column.slotName]="{ record }">
<span>{{ formatValue(column.key, record[column.key]) }}</span>
</template>
</search-table>
</div>
</template>
@@ -111,10 +67,17 @@
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Message } from '@arco-design/web-vue'
import * as echarts from 'echarts'
import SearchTable from '@/components/search-table/index.vue'
import { fetchTrafficTrend, exportTrafficReport } from '@/api/ops/report'
// 表单模型
const formModel = reactive({
const formModel = ref<{
topology_id: number
link_id: number
node_ids: string
granularity: string
timeRange: string[]
}>({
topology_id: 0,
link_id: 0,
node_ids: '',
@@ -143,6 +106,39 @@ const pagination = reactive({
const chartRef = ref<HTMLElement>()
let chartInstance: echarts.ECharts | null = null
// 表单项配置
const formItems = computed(() => [
{
field: 'topology_id',
label: '拓扑 ID',
type: 'input' as const,
placeholder: '拓扑 ID',
colProps: { span: 8 },
props: {
type: 'number',
min: 0,
},
},
{
field: 'link_id',
label: '链路 ID',
type: 'input' as const,
placeholder: '链路 ID',
colProps: { span: 8 },
props: {
type: 'number',
min: 0,
},
},
{
field: 'node_ids',
label: '节点 ID',
type: 'input' as const,
placeholder: '多个节点 ID逗号分隔',
colProps: { span: 8 },
},
])
// 动态列(从第一条记录中提取所有字段,排除 time、timestamp、node_id
const dynamicColumns = computed(() => {
if (trendData.value.length === 0) return []
@@ -154,7 +150,7 @@ const dynamicColumns = computed(() => {
if (key !== 'time' && key !== 'timestamp' && key !== 'node_id') {
columns.push({
key,
title: formatLabel(key),
slotName: String(key),
})
}
}
@@ -162,6 +158,35 @@ const dynamicColumns = computed(() => {
return columns
})
// 表格列配置
const tableColumns = computed(() => {
const columns: any[] = [
{
title: '时间',
dataIndex: 'time',
width: 180,
fixed: 'left' as const,
},
{
title: '节点 ID',
dataIndex: 'node_id',
width: 180,
},
]
// 添加动态列
dynamicColumns.value.forEach((column) => {
columns.push({
title: formatLabel(column.key),
dataIndex: String(column.key),
width: 150,
slotName: column.slotName,
})
})
return columns
})
// 格式化标签
const formatLabel = (key: string) => {
const labelMap: Record<string, string> = {
@@ -342,20 +367,20 @@ const handleResize = () => {
// 构建查询参数
const buildParams = () => {
const params: any = {
start_time: formModel.timeRange[0],
end_time: formModel.timeRange[1],
start_time: formModel.value.timeRange[0],
end_time: formModel.value.timeRange[1],
kind: 'trend',
granularity: formModel.granularity,
granularity: formModel.value.granularity,
}
if (formModel.topology_id > 0) {
params.topology_id = formModel.topology_id
if (formModel.value.topology_id > 0) {
params.topology_id = formModel.value.topology_id
}
if (formModel.link_id > 0) {
params.link_id = formModel.link_id
if (formModel.value.link_id > 0) {
params.link_id = formModel.value.link_id
}
if (formModel.node_ids) {
params.node_ids = formModel.node_ids
if (formModel.value.node_ids) {
params.node_ids = formModel.value.node_ids
}
return params
@@ -372,11 +397,17 @@ const updateTableData = () => {
// 查询
const handleSearch = async () => {
// 验证必填项
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
if (!formModel.value.timeRange || formModel.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
pagination.current = 1
await fetchTableData()
}
// 获取表格数据
const fetchTableData = async () => {
loading.value = true
try {
@@ -386,7 +417,6 @@ const handleSearch = async () => {
if (res.code === 0) {
trendData.value = res.data?.data || []
pagination.total = trendData.value.length
pagination.current = 1
if (trendData.value.length > 0) {
updateTableData()
@@ -418,23 +448,39 @@ const handleSearch = async () => {
}
}
// 处理表单模型更新
const handleFormModelUpdate = (value: any) => {
formModel.value = {
...formModel.value,
...value,
}
}
// 重置
const handleReset = () => {
formModel.topology_id = 0
formModel.link_id = 0
formModel.node_ids = ''
formModel.granularity = 'hour'
formModel.timeRange = []
formModel.value = {
topology_id: 0,
link_id: 0,
node_ids: '',
granularity: 'hour',
timeRange: [],
}
pagination.current = 1
trendData.value = []
tableData.value = []
pagination.total = 0
pagination.current = 1
if (chartInstance) {
chartInstance.clear()
}
}
// 刷新
const handleRefresh = () => {
fetchTableData()
Message.success('数据已刷新')
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
@@ -444,7 +490,7 @@ const handlePageChange = (current: number) => {
// 导出
const handleExport = async () => {
// 验证必填项
if (!formModel.timeRange || formModel.timeRange.length !== 2) {
if (!formModel.value.timeRange || formModel.value.timeRange.length !== 2) {
Message.warning('请选择时间范围')
return
}
@@ -481,9 +527,35 @@ const handleExport = async () => {
}
}
// 初始化默认时间范围最近24小时
const initDefaultTimeRange = () => {
const end = new Date()
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
// 格式化为 YYYY-MM-DD HH:mm:ss
const formatDateTime = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
formModel.value.timeRange = [
formatDateTime(start),
formatDateTime(end),
]
}
// 生命周期钩子
onMounted(() => {
// 可选:自动加载默认数据
// 初始化默认时间范围
initDefaultTimeRange()
// 自动查询
handleSearch()
})
onBeforeUnmount(() => {
@@ -502,29 +574,8 @@ export default {
</script>
<style scoped lang="less">
.traffic-trend-panel {
.search-form {
margin-bottom: 20px;
padding: 16px;
background-color: var(--color-fill-2);
border-radius: 4px;
:deep(.arco-form-item) {
margin-bottom: 12px;
}
}
.chart-section {
margin-bottom: 20px;
.chart-container {
width: 100%;
height: 400px;
}
}
.result-table {
background-color: #fff;
}
.chart-container {
width: 100%;
height: 400px;
}
</style>

View File

@@ -8,32 +8,32 @@
<a-tabs v-model:active-key="activeTab" @change="handleTabChange">
<!-- 监测指标 TOPN -->
<a-tab-pane key="metrics-topn" title="监测指标 TOPN">
<metrics-topn-panel />
<MetricsTopNPanel />
</a-tab-pane>
<!-- 监测指标汇总 -->
<a-tab-pane key="metrics-summary" title="监测指标汇总">
<metrics-summary-panel />
<MetricsSummaryPanel />
</a-tab-pane>
<!-- 流量报表汇总 -->
<a-tab-pane key="traffic-summary" title="流量报表汇总">
<traffic-summary-panel />
<TrafficSummaryPanel />
</a-tab-pane>
<!-- 流量报表趋势 -->
<a-tab-pane key="traffic-trend" title="流量报表趋势">
<traffic-trend-panel />
<TrafficTrendPanel />
</a-tab-pane>
<!-- 服务器状态 -->
<a-tab-pane key="server-status" title="服务器状态">
<server-status-panel />
<ServerStatusPanel />
</a-tab-pane>
<!-- 网络设备状态 -->
<a-tab-pane key="network-status" title="网络设备状态">
<network-status-panel />
<NetworkStatusPanel />
</a-tab-pane>
</a-tabs>
</a-card>