feat(web): 引入 Vite 前端应用并扩展仓库忽略规则 #1

Merged
caopeng merged 1 commits from cp_feat_dev into main 2026-05-17 15:25:02 +08:00
161 changed files with 69862 additions and 11 deletions

25
.gitignore vendored
View File

@@ -1,14 +1,17 @@
# OS
.DS_Store
Thumbs.db
desktop.ini
# dependencies
web/node_modules/
web/dist/
# Editor / IDE
# env files - ignore local overrides
web/src/.env.local
web/src/.env.*.local
# IDE files
.idea/
.vscode/*
!.vscode/extensions.json
.vscode/
# Archives & temp
*.tmp
*.temp
~$*
# OS files
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XMC Assets</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3500
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
web/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "RFID-Management-System-Frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@fortawesome/fontawesome-free": "^6.7.2",
"@primeuix/themes": "^2.0.3",
"@primevue/forms": "^4.5.3",
"@vue/compiler-sfc": "^3.5.25",
"axios": "^1.13.2",
"echarts": "^6.0.0",
"element-plus": "^2.12.0",
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.3",
"vue": "^3.5.25",
"vue-i18n": "^9.14.1",
"vue-router": "^4.6.4",
"vuefinder": "^4.0.33",
"webdav": "^5.8.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.3",
"sass": "^1.97.0",
"vite": "^7.3.0"
}
}

2261
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
web/role_export.xlsx Normal file

Binary file not shown.

1
web/src/.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_RFID_API_BASE_URL="http://localhost:5176/api/"

1
web/src/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_ALARMHUB_API_BASE_URL=http://127.0.0.1:8005/api

1
web/src/.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_ALARMHUB_API_BASE_URL=https://crm.whblueocean.cn/api/

128
web/src/App.vue Normal file
View File

@@ -0,0 +1,128 @@
<template>
<div class="app-wrapper">
<Toast position="top-right" />
<router-view />
</div>
</template>
<script setup>
import Toast from 'primevue/toast'
</script>
<style>
/* 1. 清零并锁死 body 级别滚动 */
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
#app {
height: 100%;
}
.app-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.layout {
display: flex;
flex: 1;
min-height: 0;
}
.content {
flex: 1;
padding: 1rem;
overflow: auto;
box-sizing: border-box;
}
</style>
<style>
/* 全局设置所有Button为橙色 - 必须在PrimeVue样式之后 */
/* 覆盖所有可能的按钮变体 */
button.p-button,
.p-button,
button.p-button:not(.p-button-text):not(.p-button-outlined):not(.p-button-link),
.p-button:not(.p-button-text):not(.p-button-outlined):not(.p-button-link) {
background-color: #3067E5 !important;
border-color: #3067E5 !important;
color: #ffffff !important;
}
button.p-button:hover,
.p-button:hover,
button.p-button:not(.p-button-text):not(.p-button-outlined):not(.p-button-link):hover,
.p-button:not(.p-button-text):not(.p-button-outlined):not(.p-button-link):hover {
background-color: #3067E5 !important;
border-color: #3067E5 !important;
color: #ffffff !important;
}
button.p-button:focus,
.p-button:focus,
button.p-button:not(.p-button-text):not(.p-button-outlined):not(.p-button-link):focus,
.p-button:not(.p-button-text):not(.p-button-outlined):not(.p-button-link):focus {
background-color: #3067E5 !important;
border-color: #3067E5 !important;
box-shadow: 0 0 0 0.2rem rgba(255, 152, 0, 0.5) !important;
color: #ffffff !important;
}
button.p-button:active,
.p-button:active,
button.p-button:not(.p-button-text):not(.p-button-outlined):not(.p-button-link):active,
.p-button:not(.p-button-text):not(.p-button-outlined):not(.p-button-link):active {
background-color: #f57c00 !important;
border-color: #f57c00 !important;
color: #ffffff !important;
}
/* 主要按钮p-button-primary */
button.p-button.p-button-primary,
.p-button.p-button-primary {
background-color: #3067E5 !important;
border-color: #3067E5 !important;
color: #ffffff !important;
}
button.p-button.p-button-primary:hover,
.p-button.p-button-primary:hover {
background-color: #3067E5 !important;
border-color: #3067E5 !important;
color: #ffffff !important;
}
/* 危险按钮也使用橙色系(深橙色) */
button.p-button.p-button-danger,
.p-button.p-button-danger {
background-color: #ff5722 !important;
border-color: #ff5722 !important;
color: #ffffff !important;
}
button.p-button.p-button-danger:hover,
.p-button.p-button-danger:hover {
background-color: #f4511e !important;
border-color: #f4511e !important;
color: #ffffff !important;
}
/* 文本按钮的悬停效果也使用橙色 */
button.p-button.p-button-text:hover,
.p-button.p-button-text:hover {
background-color: rgba(255, 152, 0, 0.1) !important;
color: #3067E5 !important;
}
button.p-button.p-button-text:focus,
.p-button.p-button-text:focus {
background-color: rgba(255, 152, 0, 0.1) !important;
color: #3067E5 !important;
}
</style>

View File

@@ -0,0 +1,54 @@
// <!-- 门禁报警记录模块 -->
import request from '../../utils/request'
// // 获取门禁报警记录
export function getTablist(data) {
return request(
{
url: "/access_control_alarm_record/list",
method: 'post',
data
})
}
// 删除门禁报警记录
export function delTablist(data) {
return request({
url: "/access_control_alarm_record/delete",
method: 'delete',
data
});
}
// 门禁报警记录导出数据
export function exportAccess(data) {
return request({
url: "/access_control_alarm_record/export",
method: 'post',
data,
responseType: 'blob'
});
}
/**
* 流式导出资产台账数据:返回原生 fetch Response调用方用 response.body.getReader() 流式读取
* 参数:{ search, filter, ids? }ids 不传或为空时按 search+filter 导出全部
*/
export function exportAssetStream(data) {
const baseURL = (typeof getApiBaseUrl === 'function' ? getApiBaseUrl() : null) || '';
const url = `${baseURL.replace(/\/+$/, '')}/access_control_alarm_record/export`;
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : '';
const uid = typeof localStorage !== 'undefined' ? localStorage.getItem('uid') : '';
const language = typeof localStorage !== 'undefined' ? localStorage.getItem('language') || 'zh-CN' : 'zh-CN';
const headers = {
'Content-Type': 'application/json',
'Accept-Language': language
};
if (token) {
headers.token = token;
headers.Authorization = `Bearer ${token}`;
}
if (uid) headers.uid = uid;
return fetch(url, {
method: 'post',
headers,
body: JSON.stringify(data)
});
}

View File

@@ -0,0 +1,62 @@
// <!--门禁权限管理模块 -->
import request from '../../utils/request'
// 获取门禁权限列表
export function getTablist(data) {
return request(
{
url: "/access_control/list",
method: 'post',
data
})
}
// 创建门禁权限
export function addTablist(data) {
return request(
{
url: "/access_control/create",
method: 'post',
data
})
}
// 更新门禁权限
export function updateTablist(data) {
return request(
{
url: "/access_control/update" ,
method: 'put',
data
})
}
// 删除门禁权限
export function delTablist(data) {
return request({
url: "/access_control/delete",
method: 'delete',
data
});
}
// 获取门禁关联的信息机列表根据列表id
export function getInformationTablist(data) {
return request(
{
url: "/access_control/information_machine/list",
method: 'post',
data
})
}
// 获取信息机对应的门禁权限
export function getMachineControl(data) {
return request(
{
url: "/information_machine/access_control/list",
method: 'post',
data
})
}

View File

@@ -0,0 +1,48 @@
// <!--资产盘点模块 -->
import request from '../../utils/request'
// 获取盘点数据列表
export function getTablist(data) {
return request(
{
url: "/asset_inventory/list",
method: 'post',
data
})
}
// 获取盘点历史数据列表根据列表id
export function getHistoryTablist(data) {
return request(
{
url: "/asset_inventory_history/list",
method: 'post',
data
})
}
// 手动盘点(无参数)
export function manualInventory() {
return request(
{
url: "/asset_inventory/inventory",
method: 'post',
})
}
// 主动盘点导出总表(可选传 ids 数组,不传则导出全部)
export function exportInventory(data) {
return request({
url: "/asset_inventory/export",
method: 'post',
data,
responseType: 'blob'
});
}
// 点击弹框里面的导出数据ids页面的数据[],asset_inventory_type number 1,2,3,4 ,其他的参数和初始化列表一致)
export function exportInventoryHistory(data) {
return request({
url: "/asset_inventory_history/export",
method: 'post',
data,
responseType: 'blob'
});
}

View File

@@ -0,0 +1,151 @@
// <!-- 资产台账模块 -->
import request from '../../utils/request'
// // 获取台账
export function getTablist(data) {
return request(
{
url: "/asset_ledger/list",
method: 'post',
data
})
}
// 创建台账
export function addTablist(data) {
return request(
{
url: "/asset_ledger/create",
method: 'post',
data
})
}
// 更新台账
export function updateTablist(data) {
return request(
{
url: "/asset_ledger/update" ,
method: 'put',
data
})
}
// 删除台账
export function delTablist(data) {
return request({
url: "/asset_ledger/delete",
method: 'delete',
data
});
}
// 批量更新资产台账
export function batchUpdateAsset(data) {
return request({
url: "/asset_ledger/batch_update",
method: 'post',
data
});
}
// 获取信息机对应的门禁权限
export function getMachineControl(data) {
return request({
url: "/information_machine/access_control/list",
method: 'post',
data,
});
}
// 保存信息机对应的门禁权限
export function saveMachineControl(data) {
return request({
url: "/asset_ledger/access_control/create",
method: 'post',
data,
});
}
// 下载资产台账导入模板(无参数,返回 blob
export function getAssetTemplate() {
return request({
url: "/asset_ledger/template",
method: 'post',
responseType: 'blob'
})
}
// 导出资产台账数据,参数:{ search, filter, ids? },返回 blob一次性
export function exportAsset(data) {
return request({
url: "/asset_ledger/export",
method: 'post',
data,
responseType: 'blob'
})
}
/**
* 流式导出资产台账数据:返回原生 fetch Response调用方用 response.body.getReader() 流式读取
* 参数:{ search, filter, ids? }ids 不传或为空时按 search+filter 导出全部
*/
export function exportAssetStream(data) {
const baseURL = (typeof getApiBaseUrl === 'function' ? getApiBaseUrl() : null) || '';
const url = `${baseURL.replace(/\/+$/, '')}/asset_ledger/export`;
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : '';
const uid = typeof localStorage !== 'undefined' ? localStorage.getItem('uid') : '';
const language = typeof localStorage !== 'undefined' ? localStorage.getItem('language') || 'zh-CN' : 'zh-CN';
const headers = {
'Content-Type': 'application/json',
'Accept-Language': language
};
if (token) {
headers.token = token;
headers.Authorization = `Bearer ${token}`;
}
if (uid) headers.uid = uid;
return fetch(url, {
method: 'post',
headers,
body: JSON.stringify(data)
});
}
/**
* 上传文件(解析模板):传入 FormData包含 file 字段),成功返回 { filename, import_type }
*/
export function uploadAsset(formData) {
return request({
url: "/asset_ledger/upload",
method: 'post',
data: formData,
});
}
/**
* 导入文件 参数filename和import_typeadd,update)
*/
export function importAsset(data) {
return request({
url: "/asset_ledger/import",
method: 'post',
data
});
}
/**
* 下载错误信息,参数 { filename },返回 blob 文件流
*/
export function downloadErrorAsset(data) {
return request({
url: "/import/error_file/download",
method: 'post',
data,
responseType: 'blob',
});
}

View File

@@ -0,0 +1,522 @@
// 附件管理 API
import request from '../../utils/request'
import { parseWebDAVXML } from './webdavDriver'
import attachmentRequest from '../../utils/attachmentRequest'
import { getApiBaseUrl } from '../../utils/config'
// 获取文件列表
export async function getFileList(path = '') {
try {
// 构建路径,确保 URL 格式为 webdav/ 或 webdav/path/
let normalizedPath = path || ''
if (normalizedPath.startsWith('/')) {
normalizedPath = normalizedPath.substring(1)
}
const url = normalizedPath ? `/webdav/${normalizedPath}${normalizedPath.endsWith('/') ? '' : '/'}` : '/webdav/'
// console.log('getFileList 请求:', { url, path, normalizedPath })
// 发送 PROPFIND 请求
const response = await request({
url: url,
method: 'PROPFIND',
headers: {
'Depth': '1'
}
})
// 检查响应类型
if (typeof response !== 'string') {
console.error('getFileList: 响应不是字符串类型', typeof response, response)
throw new Error('服务器返回的响应格式不正确,期望 XML 字符串')
}
// 记录响应信息用于调试
// console.log('getFileList 响应类型:', typeof response, '响应长度:', response.length)
if (response.length > 0) {
// console.log('getFileList 响应预览:', response.substring(0, Math.min(500, response.length)))
}
// 解析 XML 响应
const fileList = parseWebDAVXML(response)
// console.log('getFileList 解析成功,文件数量:', fileList.length)
// 转换为显示格式
return fileList.map(file => ({
name: file.name,
path: file.path,
size: file.size || 0,
isDirectory: file.isDirectory,
modified: file.lastModified
}))
} catch (error) {
console.error('获取文件列表失败:', {
error: error.message,
stack: error.stack,
path: path
})
throw error
}
}
// 格式化文件大小
export function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
// 格式化日期
export function formatDate(dateString) {
if (!dateString) return '-'
try {
const date = new Date(dateString)
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')
return `${year}/${month}/${day} ${hours}:${minutes}`
} catch (error) {
return '-'
}
}
// 获取文件类型
export function getFileType(fileName, isDirectory) {
if (isDirectory) {
return '文件夹'
}
const ext = fileName.split('.').pop()?.toLowerCase() || ''
const typeMap = {
'pdf': 'PDF 文档',
'doc': 'Word 文档',
'docx': 'Word 文档',
'xls': 'Excel 文档',
'xlsx': 'Excel 文档',
'ppt': 'PowerPoint 文档',
'pptx': 'PowerPoint 文档',
'txt': '文本文档',
'json': 'JSON 源文件',
'js': 'JavaScript 源文件',
'ts': 'TypeScript 源文件',
'vue': 'Vue 源文件',
'html': 'HTML 文档',
'css': 'CSS 样式表',
'jpg': 'JPEG 图像',
'jpeg': 'JPEG 图像',
'png': 'PNG 图像',
'gif': 'GIF 图像',
'zip': 'ZIP 压缩文件',
'rar': 'RAR 压缩文件',
'7z': '7Z 压缩文件',
'lnk': '快捷方式',
'exe': '可执行文件',
}
return typeMap[ext] || '文件'
}
// 创建文件夹
export async function createFolder(path, folderName) {
try {
const normalizedPath = path ? `${path}/${folderName}` : folderName
const url = `/webdav/${normalizedPath}/`
return await request({
url: url,
method: 'MKCOL'
})
} catch (error) {
console.error('创建文件夹失败:', error)
throw error
}
}
// 删除文件/文件夹
export async function deleteFile(path) {
try {
const normalizedPath = path.startsWith('/') ? path.substring(1) : path
const url = `/webdav/${normalizedPath}`
return await request({
url: url,
method: 'DELETE'
})
} catch (error) {
console.error('删除文件失败:', error)
throw error
}
}
// 重命名文件/文件夹
export async function renameFile(oldPath, newName) {
try {
const oldNormalizedPath = oldPath.startsWith('/') ? oldPath.substring(1) : oldPath
const pathParts = oldNormalizedPath.split('/')
pathParts[pathParts.length - 1] = newName
const newPath = pathParts.join('/')
// 对新路径进行 URL 编码(对每个路径段分别编码,保留斜杠)
const encodedNewPath = newPath
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/')
return await request({
url: `/webdav/${oldNormalizedPath}`,
method: 'MOVE',
headers: {
'Destination': `/webdav/${encodedNewPath}`
}
})
} catch (error) {
console.error('重命名文件失败:', error)
throw error
}
}
// 上传文件
export async function uploadFile(path, file, onProgress, signal = null) {
try {
const normalizedPath = path ? `${path}/${file.name}` : file.name
// 确保路径正确编码,避免特殊字符问题
const encodedPath = normalizedPath
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/')
const url = `/webdav/${encodedPath}`
// console.log('上传文件:', {
// fileName: file.name,
// fileSize: file.size,
// fileType: file.type,
// url: url,
// method: 'PUT'
// })
// 使用 attachmentRequest 发送 PUT 请求
const response = await attachmentRequest({
url: url,
method: 'PUT',
data: file,
headers: {
'Content-Type': file.type || 'application/octet-stream'
},
signal: signal, // 支持取消请求
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted, progressEvent.loaded, progressEvent.total)
}
}
})
return response
} catch (error) {
console.error('上传文件失败:', error)
throw error
}
}
// 下载文件
export async function downloadFile(path, fileName) {
try {
const normalizedPath = path.startsWith('/') ? path.substring(1) : path
const url = `/webdav/${normalizedPath}`
let baseURL = getApiBaseUrl() || ''
if (baseURL) {
baseURL = baseURL.replace(/\/+$/, '')
}
// 使用 attachmentRequest 下载文件
const response = await attachmentRequest({
url: url,
method: 'GET',
responseType: 'blob',
baseURL: baseURL
})
// 创建 blob URL
const blob = new Blob([response.data])
const blobUrl = window.URL.createObjectURL(blob)
// 创建下载链接并触发下载
const link = document.createElement('a')
link.href = blobUrl
link.download = fileName || normalizedPath.split('/').pop()
document.body.appendChild(link)
link.click()
// 清理
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
return response
} catch (error) {
console.error('下载文件失败:', error)
throw error
}
}
// 批量下载文件
export async function batchDownloadFiles(paths) {
try {
// 获取 baseURL
let baseURL = getApiBaseUrl() || ''
if (baseURL) {
baseURL = baseURL.replace(/\/+$/, '')
}
const url = `/webdav/download/files`
// const url = "http://192.168.100.53:8007/downloads/files"
// 规范化路径:确保路径格式正确(移除开头的 /,如果需要的话)
const normalizedPaths = paths.map(path => {
// 如果路径以 / 开头,移除它(根据 curl 示例,路径可能是 "/filea66" 或 "foldb"
return path.startsWith('/') ? path.substring(1) : path
})
// console.log('批量下载请求:', { url, baseURL, paths: normalizedPaths })
// 使用 attachmentRequest 批量下载
// 批量下载需要完整的 baseURL通过 config.baseURL 传递
const response = await attachmentRequest({
url: url,
method: 'POST',
responseType: 'blob',
baseURL: baseURL, // 显式设置 baseURL避免拦截器设置为空
data: {
paths: normalizedPaths // 使用 paths 而不是 files
},
headers: {
'Content-Type': 'application/json'
}
})
// 打印响应头用于调试
// console.log('响应头:', response.headers)
const contentType = response.headers['content-type'] || response.headers['Content-Type'] || ''
// console.log('Content-Type:', contentType)
// 检查响应是否是 JSON 错误Content-Type 为 application/json
if (contentType.includes('application/json')) {
// 如果是 JSON 响应,可能是错误信息,尝试解析
const text = await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsText(response.data)
})
console.error('服务器返回 JSON 响应(可能是错误):', text)
try {
const errorData = JSON.parse(text)
throw new Error(errorData.message || errorData.error || errorData.msg || '批量下载失败')
} catch (e) {
if (e instanceof SyntaxError) {
throw new Error('服务器返回了错误响应: ' + text)
}
throw e
}
}
// 从响应头中提取文件名
let fileName = `批量下载_${new Date().getTime()}.zip` // 默认文件名
const contentDisposition = response.headers['content-disposition'] || response.headers['Content-Disposition']
// console.log('Content-Disposition:', contentDisposition)
if (contentDisposition) {
// 解析 Content-Disposition 头,提取 filename
// 格式可能是: attachment; filename="files_1767671278.zip" 或 attachment; filename=files_1767671278.zip
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (filenameMatch && filenameMatch[1]) {
// 移除引号(如果有)
fileName = filenameMatch[1].replace(/['"]/g, '')
// 处理 URL 编码的文件名(如果有)
try {
fileName = decodeURIComponent(fileName)
} catch (e) {
// 如果解码失败,使用原始值
}
// console.log('从响应头提取的文件名:', fileName)
}
} else {
console.warn('响应头中没有 Content-Disposition使用默认文件名')
}
// 创建 blob
const blob = new Blob([response.data])
const blobUrl = window.URL.createObjectURL(blob)
// 创建下载链接并触发下载
const link = document.createElement('a')
link.href = blobUrl
link.download = fileName
document.body.appendChild(link)
link.click()
// 清理
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
return response
} catch (error) {
console.error('批量下载文件失败:', error)
throw error
}
}
// 复制文件/文件夹
export async function copyFileOrFolder(sourcePath, targetPath) {
try {
// 规范化源路径
const normalizedSourcePath = sourcePath.startsWith('/') ? sourcePath.substring(1) : sourcePath
// 规范化目标路径
const normalizedTargetPath = targetPath.startsWith('/') ? targetPath.substring(1) : targetPath
// 对目标路径进行 URL 编码(对每个路径段分别编码,保留斜杠)
const encodedTargetPath = normalizedTargetPath
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/')
return await request({
url: `/webdav/${normalizedSourcePath}`,
method: 'COPY',
headers: {
'Destination': `/webdav/${encodedTargetPath}`
}
})
} catch (error) {
console.error('复制文件/文件夹失败:', error)
throw error
}
}
// 移动 拖拽文件/文件夹
export async function moveFileOrFolder(sourcePath, targetPath) {
try {
// 规范化源路径
const normalizedSourcePath = sourcePath.startsWith('/') ? sourcePath.substring(1) : sourcePath
// 规范化目标路径
const normalizedTargetPath = targetPath.startsWith('/') ? targetPath.substring(1) : targetPath
// 对目标路径进行 URL 编码(对每个路径段分别编码,保留斜杠)
const encodedTargetPath = normalizedTargetPath
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/')
// 使用 MOVE 方法进行移动操作WebDAV 标准)
return await request({
url: `/webdav/${normalizedSourcePath}`,
method: 'MOVE',
headers: {
'Destination': `/webdav/${encodedTargetPath}`
}
})
} catch (error) {
console.error('移动文件/文件夹失败:', error)
throw error
}
}
// 复制文件夹(保留向后兼容)
export async function copyFolder(path, folderName) {
try {
const normalizedPath = path ? `${path}/${folderName}` : folderName
const url = `/webdav/${normalizedPath}/`
return await request({
url: url,
method: 'MOVE'
})
} catch (error) {
console.error('复制文件夹失败:', error)
throw error
}
}
// 获取回收站文件列表
export async function getRecycleBinList(path = '') {
try {
let normalizedPath = path || ''
if (normalizedPath.startsWith('/')) {
normalizedPath = normalizedPath.substring(1)
}
const url = normalizedPath ? `/trash/${normalizedPath}${normalizedPath.endsWith('/') ? '' : '/'}` : '/trash/'
// 发送 PROPFIND 请求
const response = await request({
url: url,
method: 'PROPFIND',
headers: {
'Depth': '1'
}
})
// 检查响应类型
if (typeof response !== 'string') {
console.error('getFileList: 响应不是字符串类型', typeof response, response)
throw new Error('服务器返回的响应格式不正确,期望 XML 字符串')
}
// 记录响应信息用于调试
if (response.length > 0) {
// console.log('getFileList 响应预览:', response.substring(0, Math.min(500, response.length)))
}
// 解析 XML 响应
const fileList = parseWebDAVXML(response)
// 转换为显示格式
return fileList.map(file => ({
name: file.name,
path: file.path,
size: file.size || 0,
isDirectory: file.isDirectory,
modified: file.lastModified
}))
} catch (error) {
console.error('获取回收站列表失败:', {
error: error.message,
stack: error.stack,
path: path
})
throw error
}
}
// 还原回收站文件(支持单个路径或路径数组)
export async function restoreRecycleBinFile(paths) {
try {
// 支持单个路径字符串或路径数组
const pathsArray = Array.isArray(paths) ? paths : [paths]
const response = await request({
url: '/trash/',
method: 'POST',
data: {
paths: pathsArray
}
})
return response
} catch (error) {
console.error('还原回收站文件失败:', error)
throw error
}
}
// 删除回收站文件/文件夹
export async function deleteRecycleBinFile(path) {
try {
const normalizedPath = path.startsWith('/') ? path.substring(1) : path
const url = `/trash/${normalizedPath}`
return await request({
url: url,
method: 'DELETE'
})
} catch (error) {
console.error('删除回收站文件失败:', error)
throw error
}
}

View File

@@ -0,0 +1,52 @@
// <!-- 附件管理模块 -->
import request from '../../utils/request'
import { getApiBaseUrl } from '../../utils/config'
// 获取文件列表WebDAV PROPFIND
export function getFileList(path = '') {
return request({
url: `/webdav/${path}`,
method: 'PROPFIND',
headers: {
'Depth': '1'
}
})
}
// 创建文件夹WebDAV MKCOL
export function createFolder(path, folderName) {
return request({
url: `/webdav/${path}${folderName}/`,
method: 'MKCOL'
})
}
// 删除文件或文件夹WebDAV DELETE
export function deleteFile(path) {
return request({
url: `/webdav/${path}`,
method: 'DELETE'
})
}
// 下载文件GET
export function downloadFile(path) {
const baseURL = getApiBaseUrl() || ''
const token = localStorage.getItem('token')
const uid = localStorage.getItem('uid')
return `${baseURL}/webdav/${path}?token=${token}&uid=${uid}`
}
// 上传文件PUT
export function uploadFile(path, file) {
return request({
url: `/webdav/${path}${file.name}`,
method: 'PUT',
data: file,
headers: {
'Content-Type': file.type || 'application/octet-stream'
}
})
}

View File

@@ -0,0 +1,42 @@
// <!-- 设备维护历史模块 -->
import request from '../../utils/request'
// // 获取设备维护历史
export function getTablist(data) {
return request(
{
url: "/equipment_maintenance_history/list",
method: 'post',
data
})
}
// 创建设备维护历史
export function addTablist(data) {
return request(
{
url: "/equipment_maintenance_history/create",
method: 'post',
data
})
}
// 更新设备维护历史
export function updateTablist(data) {
return request(
{
url: "/equipment_maintenance_history/update" ,
method: 'put',
data
})
}
// 删除设备维护历史
export function delTablist(data) {
return request({
url: "/equipment_maintenance_history/delete",
method: 'delete',
data
});
}

View File

@@ -0,0 +1,123 @@
// <!-- 设备维护计划模块 -->
import request from '../../utils/request'
// // 获取设备维护计划
export function getTablist(data) {
return request(
{
url: "/equipment_maintenance_schedule/list",
method: 'post',
data
})
}
// 创建设备维护计划
export function addTablist(data) {
return request(
{
url: "/equipment_maintenance_schedule/create",
method: 'post',
data
})
}
// 更新设备维护计划
export function updateTablist(data) {
return request(
{
url: "/equipment_maintenance_schedule/update" ,
method: 'put',
data
})
}
// 删除设备维护计划
export function delTablist(data) {
return request({
url: "/equipment_maintenance_schedule/delete",
method: 'delete',
data
});
}
// 获取设备维护邮件初始化状态
export function remarkTablist(data) {
return request(
{
url: "/equipment_maintenance_schedule/email_alert/info" ,
method: 'post',
data
})
}
// 更新设备维护邮件提醒状态true或者false
export function emailupDate(data) {
return request(
{
url: "/equipment_maintenance_schedule/email_alert/update" ,
method: 'put',
data
})
}
// 备注更新状态
export function remarkupDate(data) {
return request(
{
url: "/equipment_maintenance_schedule/batch/comment/update" ,
method: 'post',
data
})
}
// 批量更新(维护人员、维护内容、维护时间 + equipment_maintenance_schedule_ids
export function batchUpdate(data) {
return request({
url: "/equipment_maintenance_history/batch/create",
method: "post",
data
});
}
// 下载设备维护计划导入模板(返回 blob
export function getEquipmentMaintainTemplate() {
return request({
url: "/equipment_maintenance_schedule/template",
method: "post",
responseType: "blob",
});
}
// 导出设备维护计划:{ search, filter, ids? },返回 blob
export function exportEquipmentMaintain(data) {
return request({
url: "/equipment_maintenance_schedule/export",
method: "post",
data,
responseType: "blob",
});
}
/** 上传解析模板FormData 含 file成功返回 { filename, import_type } */
export function uploadEquipmentMaintain(formData) {
return request({
url: "/equipment_maintenance_schedule/upload",
method: "post",
data: formData,
});
}
/** 确认导入:{ filename, import_type } */
export function importEquipmentMaintain(data) {
return request({
url: "/equipment_maintenance_schedule/import",
method: "post",
data,
});
}
/** 下载错误文件:{ filename },返回 blob */
export function downloadErrorEquipmentMaintain(data) {
return request({
url: "/import/error_file/download",
method: "post",
data,
responseType: "blob",
});
}

View File

@@ -0,0 +1,134 @@
// <!-- 设备维修模块 -->
import request from '../../utils/request'
// // 获取设备维修
export function getTablist(data) {
return request(
{
url: "/equipment_repair/list",
method: 'post',
data
})
}
// 创建设备维修
export function addTablist(data) {
return request(
{
url: "/equipment_repair/create",
method: 'post',
data
})
}
// 更新设备维修
export function updateTablist(data) {
return request(
{
url: "/equipment_repair/update" ,
method: 'put',
data
})
}
// 删除设备维修
export function delTablist(data) {
return request({
url: "/equipment_repair/delete",
method: 'delete',
data
});
}
// 下载设备维修导入模板(无参数,返回 blob
export function getEquipmentRepairTemplate() {
return request({
url: "/equipment_repair/template",
method: 'post',
responseType: 'blob'
})
}
// 导出设备维修数据,参数:{ search, filter, ids? },返回 blob一次性
export function exportEquipmentRepair(data) {
return request({
url: "/equipment_repair/export",
method: 'post',
data,
responseType: 'blob'
})
}
/**
* 流式导出设备维修数据:返回原生 fetch Response调用方用 response.body.getReader() 流式读取
* 参数:{ search, filter, ids? }ids 不传或为空时按 search+filter 导出全部
*/
export function exportEquipmentRepairStream(data) {
const baseURL = (typeof getApiBaseUrl === 'function' ? getApiBaseUrl() : null) || '';
const url = `${baseURL.replace(/\/+$/, '')}/equipment_repair/export`;
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : '';
const uid = typeof localStorage !== 'undefined' ? localStorage.getItem('uid') : '';
const language = typeof localStorage !== 'undefined' ? localStorage.getItem('language') || 'zh-CN' : 'zh-CN';
const headers = {
'Content-Type': 'application/json',
'Accept-Language': language
};
if (token) {
headers.token = token;
headers.Authorization = `Bearer ${token}`;
}
if (uid) headers.uid = uid;
return fetch(url, {
method: 'post',
headers,
body: JSON.stringify(data)
});
}
/**
* 上传文件(解析模板):传入 FormData包含 file 字段),成功返回 { filename, import_type }
*/
export function uploadEquipmentRepair(formData) {
return request({
url: "/equipment_repair/upload",
method: 'post',
data: formData,
});
}
/**
* 导入文件 参数filename和import_typeadd,update)
*/
export function importEquipmentRepair(data) {
return request({
url: "/equipment_repair/import",
method: 'post',
data
});
}
/**
* 下载错误信息,参数 { filename },返回 blob 文件流
*/
export function downloadErrorEquipmentRepair(data) {
return request({
url: "/import/error_file/download",
method: 'post',
data,
responseType: 'blob',
});
}
// 设备维修批量更新 参数ids:[] 其他表单
export function allUpdate(data) {
return request({
url: "/equipment_repair/batch/update",
method: 'put',
data
});
}

View File

@@ -0,0 +1,54 @@
// <!-- 盘点报警记录模块 -->
import request from '../../utils/request'
// // 获取盘点报警记录
export function getTablist(data) {
return request(
{
url: "/inventory_alarm_record/list",
method: 'post',
data
})
}
// 删除盘点报警记录
export function delTablist(data) {
return request({
url: "/inventory_alarm_record/delete",
method: 'delete',
data
});
}
// 盘点报警记录导出数据
export function exportInventory(data) {
return request({
url: "/inventory_alarm_record/export",
method: 'post',
data,
responseType: 'blob'
});
}
/**
* 流式导出资产台账数据:返回原生 fetch Response调用方用 response.body.getReader() 流式读取
* 参数:{ search, filter, ids? }ids 不传或为空时按 search+filter 导出全部
*/
export function exportAssetStream(data) {
const baseURL = (typeof getApiBaseUrl === 'function' ? getApiBaseUrl() : null) || '';
const url = `${baseURL.replace(/\/+$/, '')}/inventory_alarm_record/export`;
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : '';
const uid = typeof localStorage !== 'undefined' ? localStorage.getItem('uid') : '';
const language = typeof localStorage !== 'undefined' ? localStorage.getItem('language') || 'zh-CN' : 'zh-CN';
const headers = {
'Content-Type': 'application/json',
'Accept-Language': language
};
if (token) {
headers.token = token;
headers.Authorization = `Bearer ${token}`;
}
if (uid) headers.uid = uid;
return fetch(url, {
method: 'post',
headers,
body: JSON.stringify(data)
});
}

View File

@@ -0,0 +1,343 @@
// WebDAV Driver for VueFinder
import request from '../../utils/request'
import { getApiBaseUrl } from '../../utils/config'
// 解析 WebDAV PROPFIND XML 响应
export function parseWebDAVXML(xmlString) {
try {
// 确保输入是字符串
if (typeof xmlString !== 'string') {
console.error('parseWebDAVXML: 输入不是字符串类型', typeof xmlString, xmlString)
throw new Error('XML 响应必须是字符串类型')
}
// 清理和提取有效的 XML 内容
let cleanedXml = xmlString.trim()
// 如果响应包含多个 XML 文档或末尾有额外内容,尝试提取第一个完整的 XML 文档
// 查找第一个 <?xml 或 <multistatus 或 <d:multistatus 标签
const xmlStartPattern = /(<\?xml[\s\S]*?<[^:]*:?multistatus)/i
const match = cleanedXml.match(xmlStartPattern)
if (match) {
// 找到 XML 开始位置
const startIndex = cleanedXml.indexOf(match[1])
// 查找对应的结束标签
const endTagPattern = /<\/[^:]*:?multistatus>/i
const endMatch = cleanedXml.substring(startIndex).match(endTagPattern)
if (endMatch) {
// 提取完整的 XML 文档
const endIndex = startIndex + endMatch.index + endMatch[0].length
cleanedXml = cleanedXml.substring(startIndex, endIndex)
// console.log('提取了有效的 XML 片段,原始长度:', xmlString.length, '清理后长度:', cleanedXml.length)
} else {
console.warn('未找到 XML 结束标签,使用原始响应')
}
}
// 如果仍然没有找到有效的 XML尝试查找第一个 <multistatus> 到 </multistatus>
if (!cleanedXml.includes('multistatus') && !cleanedXml.includes('<?xml')) {
console.warn('响应可能不是有效的 XML 格式:', cleanedXml.substring(0, 200))
}
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(cleanedXml, 'text/xml')
// 检查是否有解析错误
const parserError = xmlDoc.querySelector('parsererror')
if (parserError) {
// 记录原始响应的一部分用于调试
console.error('XML 解析错误详情:', {
errorText: parserError.textContent,
xmlPreview: cleanedXml.substring(0, 500),
xmlLength: cleanedXml.length,
originalLength: xmlString.length
})
throw new Error('XML 解析失败: ' + parserError.textContent)
}
// 辅助函数:通过本地名称查找元素(处理命名空间)
const getElementByLocalName = (parent, localName) => {
if (!parent || !parent.getElementsByTagName) return null
const allElements = parent.getElementsByTagName('*')
for (let i = 0; i < allElements.length; i++) {
const elem = allElements[i]
const elemLocalName = elem.localName || elem.tagName?.split(':').pop() || elem.tagName
if (elemLocalName === localName || elem.tagName === localName || elem.tagName?.endsWith(':' + localName)) {
return elem
}
}
return null
}
const getElementTextByLocalName = (parent, localName) => {
const element = getElementByLocalName(parent, localName)
return element?.textContent || ''
}
// 获取所有 response 节点
const allElements = xmlDoc.getElementsByTagName('*')
const responses = []
for (let i = 0; i < allElements.length; i++) {
const elem = allElements[i]
const elemLocalName = elem.localName || elem.tagName?.split(':').pop() || elem.tagName
if (elemLocalName === 'response' || elem.tagName === 'response' || elem.tagName?.endsWith(':response')) {
responses.push(elem)
}
}
const fileList = []
responses.forEach((response) => {
try {
const href = getElementTextByLocalName(response, 'href')
// 跳过根路径
if (href === '/' || !href) {
return
}
const propstat = getElementByLocalName(response, 'propstat')
if (!propstat) return
const prop = getElementByLocalName(propstat, 'prop')
if (!prop) return
// 检查是否为集合(文件夹)
const resourcetype = getElementByLocalName(prop, 'resourcetype')
const collectionNode = resourcetype ? getElementByLocalName(resourcetype, 'collection') : null
const isCollection = collectionNode !== null
// 获取文件大小
const getcontentlengthText = getElementTextByLocalName(prop, 'getcontentlength')
const size = getcontentlengthText ? parseInt(getcontentlengthText, 10) : 0
// 获取修改时间
const lastModified = getElementTextByLocalName(prop, 'getlastmodified')
// 获取内容类型
const getcontenttypeText = getElementTextByLocalName(prop, 'getcontenttype')
const contentType = getcontenttypeText || (isCollection ? 'directory' : 'application/octet-stream')
// 从路径中提取文件名和相对路径
// href 格式可能是: /folda/ 或 /filea可能是 URL 编码的
// 先对 href 进行 URL 解码
let decodedHref = href
try {
decodedHref = decodeURIComponent(href)
} catch (e) {
// 如果解码失败,使用原始 href
decodedHref = href
}
const cleanHref = decodedHref.replace(/\/$/, '') // 移除末尾斜杠
const pathParts = cleanHref.split('/').filter(p => p)
const name = pathParts[pathParts.length - 1] || cleanHref || 'unknown'
// 构建相对路径(相对于 webdav 根目录)
// 例如: /folda/ -> folda, /filea -> filea
// 注意:相对路径也需要解码后的值
const relativePath = pathParts.join('/')
fileList.push({
href: href,
name: name,
isDirectory: isCollection,
size: size,
lastModified: lastModified,
contentType: contentType,
path: relativePath, // 相对路径,例如: "folda" 或 "filea"
})
} catch (err) {
console.warn('解析单个 response 失败:', err)
}
})
return fileList
} catch (error) {
console.error('解析 WebDAV XML 失败:', error)
throw error
}
}
// 将 WebDAV 文件列表转换为 VueFinder 格式
function convertToVueFinderFormat(webdavFiles, basePath = '') {
const items = []
webdavFiles.forEach(file => {
// VueFinder 期望的格式path应该以/开头
let itemPath
if (basePath && basePath !== '' && basePath !== '/') {
const normalizedBasePath = basePath.startsWith('/') ? basePath : '/' + basePath
itemPath = normalizedBasePath + '/' + file.name
} else {
// 根目录下的文件path应该是 /folda 或 /filea
itemPath = '/' + file.name
}
const item = {
name: file.name,
path: itemPath, // 绝对路径,例如: /folda 或 /filea
type: file.isDirectory ? 'dir' : 'file',
size: file.size || 0,
modified: file.lastModified ? new Date(file.lastModified).getTime() : Date.now(),
isDirectory: file.isDirectory,
}
items.push(item)
})
// 目录排在前面
items.sort((a, b) => {
if (a.type === 'dir' && b.type !== 'dir') return -1
if (a.type !== 'dir' && b.type === 'dir') return 1
return a.name.localeCompare(b.name)
})
// 返回的path表示当前目录路径
// 对于根目录path应该是空字符串对于子目录path应该是相对路径不带开头的/
let normalizedPath = basePath || ''
if (normalizedPath === '/') {
normalizedPath = ''
}
return {
items: items,
path: normalizedPath, // 当前目录路径,根目录为空字符串
}
}
// 创建 WebDAV Driver
export function createWebDAVDriver() {
const baseURL = getApiBaseUrl() || ''
const basePath = `${baseURL}/webdav`
return {
async list(params = {}) {
try {
// 构建路径,确保 URL 格式为 webdav/ 或 webdav/path/
let path = params.path || ''
// 移除开头的 /因为我们要构建相对路径用于API请求
if (path.startsWith('/')) {
path = path.substring(1)
}
// 如果 path 为空,请求 webdav/,否则请求 webdav/path/
const url = path ? `/webdav/${path}${path.endsWith('/') ? '' : '/'}` : '/webdav/'
// console.log('WebDAV list 请求:', { url, path, params })
// 发送 PROPFIND 请求,使用 Depth: 1 请求头
const response = await request({
url: url,
method: 'PROPFIND',
headers: {
'Depth': '1'
}
})
// console.log('WebDAV 原始响应:', response)
// 解析 XML 响应
const fileList = parseWebDAVXML(response)
// console.log('解析后的文件列表:', fileList)
// 转换为 VueFinder 格式
// normalizedPath用于构建item的path应该保持原样不带开头的/
const normalizedPath = path || ''
const vueFinderData = convertToVueFinderFormat(fileList, normalizedPath)
// console.log('VueFinder 格式数据:', vueFinderData)
// console.log('VueFinder items 数量:', vueFinderData.items.length)
// console.log('VueFinder items 详情:', vueFinderData.items)
// 确保返回的数据格式正确
if (!vueFinderData.items || !Array.isArray(vueFinderData.items)) {
console.error('VueFinder 数据格式错误:', vueFinderData)
return { items: [], path: normalizedPath }
}
// 验证每个 item 的格式,确保所有必需字段都存在
vueFinderData.items.forEach((item, index) => {
if (!item.name || !item.path || !item.type) {
console.warn(`Item ${index} 格式不完整:`, item)
}
// 确保path是字符串格式
if (typeof item.path !== 'string') {
item.path = String(item.path)
}
// 确保size是数字
if (typeof item.size !== 'number') {
item.size = Number(item.size) || 0
}
})
console.log('返回给 VueFinder 的数据:', JSON.stringify(vueFinderData, null, 2))
console.log('最终返回数据:', vueFinderData)
return vueFinderData
} catch (error) {
console.error('WebDAV list 失败:', error)
throw error
}
},
async delete(params) {
return request({
url: `/webdav/${params.path}`,
method: 'DELETE'
})
},
async rename(params) {
// WebDAV MOVE 方法用于重命名
// 对新路径进行 URL 编码(对每个路径段分别编码,保留斜杠)
const encodedNewPath = params.newPath
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/')
return request({
url: `/webdav/${params.path}`,
method: 'MOVE',
headers: {
'Destination': `/webdav/${encodedNewPath}`
}
})
},
getDownloadUrl(params) {
const token = localStorage.getItem('token')
const uid = localStorage.getItem('uid')
const path = params.path?.startsWith('/') ? params.path.substring(1) : params.path
return `${basePath}/${path}?token=${token}&uid=${uid}`
},
getPreviewUrl(params) {
return this.getDownloadUrl(params)
},
async search(params) {
// 搜索功能可能需要遍历所有目录
// 这里先实现简单的列表
const result = await this.list({ path: params.path || '' })
return result.items.filter(item =>
item.name.toLowerCase().includes(params.query.toLowerCase())
)
},
async save(params) {
// 上传文件使用 PUT 方法
return request({
url: `/webdav/${params.path}`,
method: 'PUT',
data: params.content,
headers: {
'Content-Type': params.contentType || 'application/octet-stream'
}
})
}
}
}

View File

@@ -0,0 +1,171 @@
// <!-- 设备计量管理模块 -->
import request from '../../utils/request'
// 获取设备计量管理列表
export function getTablist(data) {
return request(
{
url: "/equipment_metering/list",
method: 'post',
data
})
}
// 设备计量报表列表(按年月)
export function getMeteringReportList(data) {
return request({
url: "/equipment_metering_report/list",
method: "post",
data,
});
}
// 创建设备计量管理
export function addTablist(data) {
return request(
{
url: "/equipment_metering/create",
method: 'post',
data
})
}
// 更新设备计量管理
export function updateTablist(data) {
return request(
{
url: "/equipment_metering/update" ,
method: 'put',
data
})
}
// 删除设备计量管理
export function delTablist(data) {
return request({
url: "/equipment_metering/delete",
method: 'delete',
data
});
}
// 下载设备计量导入模板(无参数,返回 blob
export function getAssetTemplate() {
return request({
url: "/equipment_metering/template",
method: 'post',
responseType: 'blob'
})
}
// 导出设备计量数据,参数:{ search, filter, ids? },返回 blob一次性
export function exportAsset(data) {
return request({
url: "/equipment_metering/export",
method: 'post',
data,
responseType: 'blob'
})
}
/**
* 流式导出设备计量数据:返回原生 fetch Response调用方用 response.body.getReader() 流式读取
* 参数:{ search, filter, ids? }ids 不传或为空时按 search+filter 导出全部
*/
export function exportAssetStream(data) {
const baseURL = (typeof getApiBaseUrl === 'function' ? getApiBaseUrl() : null) || '';
const url = `${baseURL.replace(/\/+$/, '')}/equipment_metering/export`;
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : '';
const uid = typeof localStorage !== 'undefined' ? localStorage.getItem('uid') : '';
const language = typeof localStorage !== 'undefined' ? localStorage.getItem('language') || 'zh-CN' : 'zh-CN';
const headers = {
'Content-Type': 'application/json',
'Accept-Language': language
};
if (token) {
headers.token = token;
headers.Authorization = `Bearer ${token}`;
}
if (uid) headers.uid = uid;
return fetch(url, {
method: 'post',
headers,
body: JSON.stringify(data)
});
}
/**
* 上传文件(解析模板):传入 FormData包含 file 字段),成功返回 { filename, import_type }
*/
export function uploadAsset(formData) {
return request({
url: "/equipment_metering/upload",
method: 'post',
data: formData,
});
}
/**
* 导入文件 参数filename和import_typeadd,update)
*/
export function importAsset(data) {
return request({
url: "/equipment_metering/import",
method: 'post',
data
});
}
/**
* 下载错误信息,参数 { filename },返回 blob 文件流
*/
export function downloadErrorAsset(data) {
return request({
url: "/import/error_file/download",
method: 'post',
data,
responseType: 'blob',
});
}
// 获取设备计量邮件初始化状态
export function remarkTablist(data) {
return request(
{
url: "/equipment_metering/email_alert/info" ,
method: 'post',
data
})
}
// 更新设备计量邮件提醒状态true或者false
export function emailupDate(data) {
return request(
{
url: "/equipment_metering/email_alert/update" ,
method: 'put',
data
})
}
// 设备计量当月计量:列表接口,传 filter.next_calibrate_time 或 filter.create_at 为 { start_time: "当月1号 00:00:00", end_time: "下月1号 00:00:00" }
export function monthUpDate(data) {
return request(
{
url: "/equipment_metering/list" ,
method: 'post',
data
})
}
// 设备计量批量更新 ids:[]和其他表单参数
export function allupDate(data) {
return request(
{
url: "/equipment_metering/batch/update" ,
method: 'put',
data
})
}

View File

@@ -0,0 +1,161 @@
// <!-- 物量计量管理模块 -->
import request from '../../utils/request'
// 获取计量管理列表
export function getTablist(data) {
return request(
{
url: "/material_metering/list",
method: 'post',
data
})
}
// 创建计量管理
export function addTablist(data) {
return request(
{
url: "/material_metering/create",
method: 'post',
data
})
}
// 更新计量管理
export function updateTablist(data) {
return request(
{
url: "/material_metering/update" ,
method: 'put',
data
})
}
// 删除计量管理
export function delTablist(data) {
return request({
url: "/material_metering/delete",
method: 'delete',
data
});
}
// 下载物料计量导入模板(无参数,返回 blob
export function getAssetTemplate() {
return request({
url: "/material_metering/template",
method: 'post',
responseType: 'blob'
})
}
// 导出物料计量数据,参数:{ search, filter, ids? },返回 blob一次性
export function exportAsset(data) {
return request({
url: "/material_metering/export",
method: 'post',
data,
responseType: 'blob'
})
}
/**
* 流式导出物料计量数据:返回原生 fetch Response调用方用 response.body.getReader() 流式读取
* 参数:{ search, filter, ids? }ids 不传或为空时按 search+filter 导出全部
*/
export function exportAssetStream(data) {
const baseURL = (typeof getApiBaseUrl === 'function' ? getApiBaseUrl() : null) || '';
const url = `${baseURL.replace(/\/+$/, '')}/material_metering/export`;
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : '';
const uid = typeof localStorage !== 'undefined' ? localStorage.getItem('uid') : '';
const language = typeof localStorage !== 'undefined' ? localStorage.getItem('language') || 'zh-CN' : 'zh-CN';
const headers = {
'Content-Type': 'application/json',
'Accept-Language': language
};
if (token) {
headers.token = token;
headers.Authorization = `Bearer ${token}`;
}
if (uid) headers.uid = uid;
return fetch(url, {
method: 'post',
headers,
body: JSON.stringify(data)
});
}
/**
* 上传文件(解析模板):传入 FormData包含 file 字段),成功返回 { filename, import_type }
*/
export function uploadAsset(formData) {
return request({
url: "/material_metering/upload",
method: 'post',
data: formData,
});
}
/**
* 导入文件 参数filename和import_typeadd,update)
*/
export function importAsset(data) {
return request({
url: "/material_metering/import",
method: 'post',
data
});
}
/**
* 下载错误信息,参数 { filename },返回 blob 文件流
*/
export function downloadErrorAsset(data) {
return request({
url: "/import/error_file/download",
method: 'post',
data,
responseType: 'blob',
});
}
// 获取物料计量邮件初始化状态
export function remarkTablist(data) {
return request(
{
url: "/material_metering/email_alert/info" ,
method: 'post',
data
})
}
// 更新物料计量邮件提醒状态true或者false
export function emailupDate(data) {
return request(
{
url: "/material_metering/email_alert/update" ,
method: 'put',
data
})
}
// 物料计量当月计量:列表接口,传 filter.next_calibrate_time 或 filter.create_at 为 { start_time: "当月1号 00:00:00", end_time: "下月1号 00:00:00" }
export function monthMaterialUpDate(data) {
return request(
{
url: "/material_metering/list" ,
method: 'post',
data
})
}
// 物料计量批量更新 ids:[]和其他表单参数
export function allupMaterialDate(data) {
return request(
{
url: "/material_metering/batch/update" ,
method: 'put',
data
})
}

View File

@@ -0,0 +1,23 @@
// <!-- 消息管理模块 -->
import request from '../../utils/request'
// 获取消息管理
export function getNoticeList(data) {
return request(
{
url: "/notice/list",
method: 'post',
data
})
}
//标记通知为已读
export function getReadALl(data) {
return request(
{
url: "/notice/read",
method: 'post',
data
})
}

View File

@@ -0,0 +1,210 @@
import request from '../../utils/request'
// 设备维修报表:按部门 + 本月(年月)
export function getEquipmentRepairReportList(data) {
return request({
url: "/equipment_repair_report/list",
method: "post",
data,
});
}
// 设备维修报表:按部门 + 年度按月汇总12 列)
export function getEquipmentRepairAnnualByDept(data) {
return request({
url: "/equipment_repair_report/annual_by_dept",
method: "post",
data,
});
}
// 设备维修报表:按厂商 + 年度
export function getEquipmentRepairByVendor(data) {
return request({
url: "/equipment_repair_report/by_vendor",
method: "post",
data,
});
}
// 获取部门统计数据接口
export function getAssetDepartments() {
return request({
url: "/dashboard/asset_departments",
method: "post",
});
}
// 获取厂商统计数据接口
export function getAssetManufacturers() {
return request({
url: "/dashboard/manufacturers",
method: "post",
});
}
// 设备计量报表(按年月)
export function getMeteringReportList(data) {
return request({
url: "/dashboard/equipment_metering_report",
method: "post",
data
});
}
// 设备验收报表(按年月)
export function getEquipmentAcceptanceReportList(data) {
return request({
url: "/dashboard/equipment_acceptance_report",
method: "post",
data,
});
}
// 设备维护报表(按年月)
export function getEquipmentMaintenanceReportList(data) {
return request({
url: "/dashboard/equipment_maintenance_report",
method: "post",
data,
});
}
// 概览看板月度统计部门为多选asset_department 为 string[]
export function getDashboardMonthlyStatistics(data) {
return request({
url: "/dashboard/view/monthly_statistics",
method: "post",
data,
});
}
// 概览看板年度统计部门为多选asset_department 为 string[]
export function getDashboardAnnualStatistics(data) {
return request({
url: "/dashboard/view/annual_statistics",
method: "post",
data,
});
}
// 概览看板下面的接口
// 按资产部门分类
export function getViewAssetLedgerByAssetDepartment(data) {
return request({
url: "/dashboard/view/asset_ledger/asset_department",
method: "post",
data,
});
}
// 按制造厂商分类
export function getViewAssetManufacturerByDepartment(data) {
return request({
url: "/dashboard/view/asset_ledger/manufacturer",
method: "post",
data,
});
}
// 按年份分类
export function getViewAssetYearByDepartment(data) {
return request({
url: "/dashboard/view/asset_ledger/year",
method: "post",
data,
});
}
/** 解析各部门报表列表(兼容多种 data 结构) */
export function parseEquipmentDeptReportList(res) {
const payload = res?.data;
if (Array.isArray(payload?.list)) return payload.list;
if (Array.isArray(payload?.rows)) return payload.rows;
if (Array.isArray(payload?.records)) return payload.records;
if (Array.isArray(payload?.items)) return payload.items;
if (Array.isArray(payload)) return payload;
return [];
}
/** 将 asset_departments 接口返回体规范为部门名称字符串数组(兼容多种 data 结构) */
export function normalizeAssetDepartmentsResponse(res) {
const pickLabel = (item) => {
if (typeof item === "string") return item.trim();
if (!item || typeof item !== "object") return "";
const s =
item.department ||
item.asset_department ||
item.dept_name ||
item.dept ||
item.name ||
item.label ||
(item.value != null && String(item.value)) ||
"";
return String(s).trim();
};
const payload = res?.data;
const arraysToTry = [
payload,
payload?.list,
payload?.rows,
payload?.records,
payload?.departments,
payload?.items,
payload?.data,
];
let raw = [];
for (const chunk of arraysToTry) {
if (Array.isArray(chunk)) {
raw = chunk;
break;
}
}
const labels = raw.map(pickLabel).filter(Boolean);
return [...new Set(labels)];
}
/** 将 manufacturers 接口返回体规范为厂商名称字符串数组(兼容多种 data 结构) */
export function normalizeAssetManufacturersResponse(res) {
const pickLabel = (item) => {
if (typeof item === "string") return item.trim();
if (!item || typeof item !== "object") return "";
const s =
item.vendor ||
item.manufacturer ||
item.supplier_name ||
item.factory_name ||
item.name ||
item.label ||
(item.value != null && String(item.value)) ||
"";
return String(s).trim();
};
const payload = res?.data;
const arraysToTry = [
payload,
payload?.list,
payload?.rows,
payload?.records,
payload?.manufacturers,
payload?.vendors,
payload?.items,
payload?.data,
];
let raw = [];
for (const chunk of arraysToTry) {
if (Array.isArray(chunk)) {
raw = chunk;
break;
}
}
const labels = raw.map(pickLabel).filter(Boolean);
return [...new Set(labels)];
}

View File

@@ -0,0 +1,42 @@
// <!-- 员工管理模块 -->
import request from '../../utils/request'
// 获取员工列表
export function getTablist(data) {
return request(
{
url: "/employee/list",
method: 'post',
data
})
}
// 创建员工
export function addTablist(data) {
return request(
{
url: "/employee/create",
method: 'post',
data
})
}
// 更新员工
export function updateTablist(data) {
return request(
{
url: "/employee/update" ,
method: 'put',
data
})
}
// 删除员工
export function delTablist(data) {
return request({
url: "/employee/delete",
method: 'delete',
data
});
}

View File

@@ -0,0 +1,41 @@
// <!-- 厂区管理模块 -->
import request from '../../utils/request'
// 获取厂区列表
export function getTablist(data) {
return request(
{
url: "/factory_area/list",
method: 'post',
data
})
}
// 创建厂区
export function addTablist(data) {
return request(
{
url: "/factory_area/create",
method: 'post',
data
})
}
// 更新厂区
export function updateTablist(data) {
return request(
{
url: "/factory_area/update" ,
method: 'put',
data
})
}
// 删除厂区
export function delTablist(data) {
return request({
url: "/factory_area/delete",
method: 'delete',
data
});
}

View File

@@ -0,0 +1,10 @@
import request from "../../utils/request";
/** 操作日志列表(游标分页) POST /log/list */
export function getLogList(data) {
return request({
url: "/log/list",
method: "post",
data,
});
}

View File

@@ -0,0 +1,174 @@
// <!-- 角色管理模块 -->
import request from '../../utils/request'
import { getApiBaseUrl } from '../../utils/config'
// 获取角色列表
export function getTablist(data) {
return request(
{
url: "/role/list",
method: 'post',
data
})
}
// 创建角色
export function addTablist(data) {
return request(
{
url: "/role/create",
method: 'post',
data
})
}
// 更新角色
export function updateTablist(data) {
return request(
{
url: "/role/update" ,
method: 'put',
data
})
}
// 删除角色
export function delTablist(data) {
return request({
url: "/role/delete",
method: 'delete',
data
});
}
// 获取用户所在部门的所有角色
export function getBuUidList(data) {
return request(
{
url: "/role/get-by-uid",
method: 'post',
data
})
}
// 根据当前的id获取配置页面权限
export function getPermissionPage(data) {
return request(
{
url: "/role/info",
method: 'post',
data
})
}
// 根据当前的id获取高级表头字段权限
export function getPermissionHigh(data) {
return request(
{
url: "/role/advanced/list",
method: 'post',
data
})
}
// 保存高级表头字段权限
export function savePermissionHigh(data) {
return request(
{
url: "/role/advanced/update",
method: 'post',
data
})
}
// 不同用户不同的页面权限
export function getPermisssionPage(data) {
return request(
{
url: "/permission/page",
method: 'post',
data
})
}
// 下载角色导入模板(无参数,返回 blob
export function getRoleTemplate() {
return request({
url: "/role/template",
method: 'post',
responseType: 'blob'
})
}
// 导出角色数据,参数:{ search, filter, ids? },返回 blob一次性
export function exportRole(data) {
return request({
url: "/role/export",
method: 'post',
data,
responseType: 'blob'
})
}
/**
* 流式导出角色数据:返回原生 fetch Response调用方用 response.body.getReader() 流式读取
* 参数:{ search, filter, ids? }ids 不传或为空时按 search+filter 导出全部
*/
export function exportRoleStream(data) {
const baseURL = (typeof getApiBaseUrl === 'function' ? getApiBaseUrl() : null) || '';
const url = `${baseURL.replace(/\/+$/, '')}/role/export`;
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : '';
const uid = typeof localStorage !== 'undefined' ? localStorage.getItem('uid') : '';
const language = typeof localStorage !== 'undefined' ? localStorage.getItem('language') || 'zh-CN' : 'zh-CN';
const headers = {
'Content-Type': 'application/json',
'Accept-Language': language
};
if (token) {
headers.token = token;
headers.Authorization = `Bearer ${token}`;
}
if (uid) headers.uid = uid;
return fetch(url, {
method: 'post',
headers,
body: JSON.stringify(data)
});
}
/**
* 上传文件(解析模板):传入 FormData包含 file 字段),成功返回 { filename, import_type }
*/
export function uploadole(formData) {
return request({
url: "/role/upload",
method: 'post',
data: formData,
});
}
/**
* 导入文件 参数filename和import_typeadd,update)
*/
export function importRole(data) {
return request({
url: "/role/import",
method: 'post',
data
});
}
/**
* 下载错误信息,参数 { filename },返回 blob 文件流
*/
export function downloadErrorRole(data) {
return request({
url: "/import/error_file/download",
method: 'post',
data,
responseType: 'blob',
});
}

View File

@@ -0,0 +1,41 @@
// <!-- 信息机管理模块 -->
import request from '../../utils/request'
// 获取信息机列表
export function getTablist(data) {
return request(
{
url: "/information_machine/list",
method: 'post',
data
})
}
// 创建信息机
export function addTablist(data) {
return request(
{
url: "/information_machine/create",
method: 'post',
data
})
}
// 更新信息机
export function updateTablist(data) {
return request(
{
url: "/information_machine/update" ,
method: 'put',
data
})
}
// 删除信息机
export function delTablist(data) {
return request({
url: "/information_machine/delete",
method: 'delete',
data
});
}

View File

@@ -0,0 +1,95 @@
// <!-- 用户管理模块 -->
import request from '../../utils/request'
import { getApiBaseUrl } from '../../utils/config'
// 获取用户列表
export function getTablist(data) {
return request(
{
url: "/user/list",
method: 'post',
data
})
}
// 创建用户
export function addTablist(data) {
return request(
{
url: "/user/create",
method: 'post',
data
})
}
// 更新用户
export function updateTablist(data) {
return request(
{
url: "/user/update" ,
method: 'put',
data
})
}
// 删除用户
export function delTablist(data) {
return request({
url: "/user/delete",
method: 'delete',
data
});
}
// 登录接口
export function userLogin(data) {
return request(
{
url: "/login",
method: 'post',
data
})
}
// 下载用户导入模板(无参数,返回 blob
export function getUserTemplate() {
return request({
url: "/user/template",
method: 'post',
responseType: 'blob'
})
}
// 导出用户数据
export function exportUser(data) {
return request({
url: "/user/export",
method: 'post',
data,
responseType: 'blob'
})
}
export function exportRoleStream(data) {
const baseURL = (typeof getApiBaseUrl === 'function' ? getApiBaseUrl() : null) || '';
const url = `${baseURL.replace(/\/+$/, '')}/role/export`;
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : '';
const uid = typeof localStorage !== 'undefined' ? localStorage.getItem('uid') : '';
const language = typeof localStorage !== 'undefined' ? localStorage.getItem('language') || 'zh-CN' : 'zh-CN';
const headers = {
'Content-Type': 'application/json',
'Accept-Language': language
};
if (token) {
headers.token = token;
headers.Authorization = `Bearer ${token}`;
}
if (uid) headers.uid = uid;
return fetch(url, {
method: 'post',
headers,
body: JSON.stringify(data)
});
}

View File

@@ -0,0 +1,947 @@
<!-- 批量导入公共弹框两步解析模板确认上传验证成功可开始上传失败可下载结果 -->
<template>
<Dialog v-model:visible="dialogVisible" modal class="batch-import-dialog"
:style="{ width: '560px', 'max-width': '90vw', 'border-radius': '6px' }" :showHeader="false"
@update:visible="onVisibleChange">
<div class="dialog_header">
<div class="dialog-header-custom">{{ $t('batchImport.dialogTitle') }}</div>
<div class="dialog-close" @click="closeDialog">
<img src="../images/login/delClose.png" alt="" />
</div>
</div>
<div class="batch-import-content">
<div class="import-steps">
<div class="step-item" :class="step1Class">
<div class="step-icon">
<span v-if="step1Status === 'pending'" class="step-number">1</span>
<i v-else-if="step1Status === 'loading'" class="pi pi-spin pi-spinner step-spinner"></i>
<i v-else-if="step1Status === 'success'" class="pi pi-check step-check"></i>
<i v-else-if="step1Status === 'error'" class="pi pi-times step-error"></i>
</div>
<div class="step-content">
<div class="step-title">{{ $t('batchImport.step1Title') }}</div>
<div class="step-desc">{{ $t('batchImport.step1Desc') }}</div>
</div>
</div>
<div class="steps-connector-line" :class="{ 'steps-connector-line--done': step1Status === 'success' }"
aria-hidden="true"></div>
<div class="step-item"
:class="[step2Class, { 'step-pending': step2Status === 'pending' || step2Status === 'loading' }]">
<div class="step-icon">
<span v-if="step2Status === 'pending' || step2Status === 'loading'" class="step-number">2</span>
<i v-else-if="step2Status === 'success'" class="pi pi-check step-check"></i>
<img v-else src="../images/export/error.png" style="width: 24px;height: 24px;"
class="step-icon-img step-icon-error" alt="" />
</div>
<div class="step-content">
<div class="step-title">{{ $t('batchImport.step2Title') }}</div>
<div class="step-desc">{{ step2Status === 'error' ? $t('batchImport.step2DescError') : step2Status === 'success' ? $t('batchImport.step2DescSuccess')
: $t('batchImport.step2DescPending') }}</div>
</div>
</div>
</div>
<!-- 操作类型单选框默认新增 -->
<div class="import-type-row">
<label class="import-type-label">
<RadioButton v-model="selectedImportType" inputId="importTypeAdd" value="add" />
{{ $t('common.add') }}
</label>
<label class="import-type-label">
<RadioButton v-model="selectedImportType" inputId="importTypeUpdate" value="update" />
{{ $t('common.update') }}
</label>
</div>
<!-- 中间内容区选择文件 / 解析中 / 校验完成 / 解析完成+下载 -->
<div v-if="!selectedFile" class="upload-area" @drop.prevent="handleFileDrop" @dragover.prevent
@dragenter.prevent @click="handleUploadAreaClick">
<input ref="fileInputRef" type="file" accept=".xlsx,.xls" class="batch-import-file-input"
@change="handleFileSelect" />
<div class="upload-hint">
<img src="../images/export/upload.png" class="upload-icon-img" alt="" />
<div class="title">
<p class="active">{{ $t('batchImport.clickUpload') }}</p>
<p class="icon">/</p>
<p>{{ $t('batchImport.dragHere') }}</p>
</div>
</div>
</div>
<div v-else class="upload-result-area">
<template v-if="parsing">
<div class="result-parsing">
<i class="pi pi-spin pi-spinner"></i>
<span>{{ $t('batchImport.parsing') }}</span>
</div>
</template>
<template v-else-if="validationSuccess">
<div class="result-success">{{ $t('batchImport.validationDone') }}</div>
</template>
<template v-else-if="step2Status === 'error' && uploadErrorResult">
<div class="result-error-panel">
<img src="../images/export/error.png" class="result-error-icon" alt="" />
<div class="result-error-title">{{ $t('batchImport.uploadFail') }}</div>
<div class="result-error-stats">
<div class="result-error-row">{{ $t('batchImport.totalRows') }}{{ uploadErrorResult.total_rows ?? 0 }}</div>
<div class="result-error-row">{{ $t('batchImport.successCount') }}{{ uploadErrorResult.success_count ?? 0 }}</div>
<div class="result-error-row">{{ $t('batchImport.failCount') }}{{ uploadErrorResult.fail_count ?? 0 }}</div>
</div>
<div v-if="uploadErrorResult.first_error" class="result-error-first">
{{ $t('batchImport.firstError') }}{{ uploadErrorResult.first_error }}
</div>
<button v-if="dataErrorFilename" type="button" class="btn-download link"
@click="downloadUploadError">{{ $t('batchImport.downloadErrorLink') }}</button>
</div>
</template>
<template v-else>
<div class="result-done">{{ $t('batchImport.parseDone') }}</div>
<button v-if="step1Status === 'error'" type="button" class="btn-download"
@click="downloadTemplateResult">{{ $t('batchImport.downloadTemplateResult') }}</button>
<button v-else-if="step2Status === 'error'" type="button" class="btn-download"
@click="downloadDataResult">{{ $t('batchImport.downloadDataResult') }}</button>
</template>
</div>
<div class="hint-small">{{ $t('batchImport.hintBatch') }}</div>
</div>
<div class="batch-import-footer">
<div class="btn">
<div class="btnUpload" @click="handleReupload">{{ $t('batchImport.reupload') }}</div>
<div class="btnAncle" @click="closeDialog">{{ $t('common.cancel') }}</div>
<div class="btnOk" :class="{ 'btnOk--loading': uploading, 'btnOk--disabled': !canStartUpload }"
:style="{ pointerEvents: uploading || !canStartUpload ? 'none' : 'auto' }"
@click="handleStartUpload">{{ $t('batchImport.startUpload') }}
</div>
</div>
</div>
</Dialog>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import Dialog from "primevue/dialog";
import RadioButton from "primevue/radiobutton";
const { t: $t } = useI18n();
const props = defineProps({
visible: { type: Boolean, default: false },
/** 上传接口:接收 (File, context?)context 为第一步返回的 { filename, import_type } 等,返回 Promise */
uploadApi: { type: Function, required: true },
/** 可选:解析/验证模板,可返回 { success, filename?, import_type?, downloadBlob?, fileName?, errorFilename? } */
validateTemplateApi: { type: Function, default: null },
/** 可选:解析/验证数据,返回 { success, downloadBlob?, fileName?, errorFilename? } */
validateDataApi: { type: Function, default: null },
/** 可选:按 filename 下载错误文件,返回 Promise<Blob>,用于第一步或第二步返回 errorFilename 时 */
downloadErrorApi: { type: Function, default: null },
});
const emit = defineEmits(["update:visible", "success"]);
const dialogVisible = computed({
get: () => props.visible,
set: (v) => emit("update:visible", v),
});
const fileInputRef = ref(null);
const selectedFile = ref(null);
const parsing = ref(false);
const uploading = ref(false);
const step1Status = ref("pending"); // pending | loading | success | error
const step2Status = ref("pending");
const templateResultBlob = ref(null);
const templateResultFileName = ref("");
const templateErrorFilename = ref("");
const dataResultBlob = ref(null);
const dataResultFileName = ref("");
const dataErrorFilename = ref("");
/** 上传失败时接口返回的 { success_count, fail_count, total_rows, first_error },用于中间区展示 */
const uploadErrorResult = ref(null);
/** 第一步接口返回的 { filename, import_type },供「开始上传」时传给 uploadApi */
const uploadContext = ref(null);
/** 导入类型单选框:开始上传时作为 import_type 传给 uploadApi默认新增 */
const selectedImportType = ref("add");
const step1Class = computed(() => ({
active: step1Status.value === "loading" || (step1Status.value === "pending" && !selectedFile.value),
completed: step1Status.value === "success",
error: step1Status.value === "error",
}));
const step2Class = computed(() => ({
active: validationSuccess.value,
error: step2Status.value === "error",
}));
const validationSuccess = computed(
() => step1Status.value === "success" && step2Status.value === "success" && !parsing.value
);
const canStartUpload = computed(() => validationSuccess.value && selectedFile.value);
function downloadBlob(blob, fileName) {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName || "下载结果.xlsx";
a.click();
URL.revokeObjectURL(url);
}
function handleUploadAreaClick() {
fileInputRef.value?.click();
}
function handleFileSelect(event) {
const file = event.target.files?.[0];
if (file && (file.name.endsWith(".xlsx") || file.name.endsWith(".xls"))) {
selectedFile.value = file;
runValidation();
}
event.target.value = "";
}
function handleFileDrop(event) {
const file = event.dataTransfer.files?.[0];
if (file && (file.name.endsWith(".xlsx") || file.name.endsWith(".xls"))) {
selectedFile.value = file;
runValidation();
}
}
async function runValidation() {
step1Status.value = "loading";
step2Status.value = "pending";
templateResultBlob.value = null;
templateErrorFilename.value = "";
dataResultBlob.value = null;
dataErrorFilename.value = "";
uploadContext.value = null;
parsing.value = true;
const hasTemplateApi = typeof props.validateTemplateApi === "function";
const hasDataApi = typeof props.validateDataApi === "function";
if (!hasTemplateApi && !hasDataApi) {
step1Status.value = "success";
step2Status.value = "loading";
await new Promise((r) => setTimeout(r, 600));
step2Status.value = "success";
parsing.value = false;
return;
}
if (hasTemplateApi) {
try {
const res = await props.validateTemplateApi(selectedFile.value);
if (res && res.success) {
step1Status.value = "success";
if (res.filename != null || res.import_type != null) {
uploadContext.value = { filename: res.filename, import_type: res.import_type };
}
if (hasDataApi) {
step2Status.value = "loading";
try {
const dataRes = await props.validateDataApi(selectedFile.value);
if (dataRes && dataRes.success) {
step2Status.value = "success";
} else {
step2Status.value = "error";
dataResultBlob.value = dataRes?.downloadBlob ?? null;
dataResultFileName.value = dataRes?.fileName || "数据验证结果.xlsx";
dataErrorFilename.value = dataRes?.errorFilename ?? "";
}
} catch {
step2Status.value = "error";
}
} else {
step2Status.value = "success";
}
} else {
step1Status.value = "error";
templateResultBlob.value = res?.downloadBlob ?? null;
templateResultFileName.value = res?.fileName || "模板匹配结果.xlsx";
templateErrorFilename.value = res?.errorFilename ?? "";
}
} catch {
step1Status.value = "error";
}
}
if (!hasTemplateApi && hasDataApi) {
step1Status.value = "success";
step2Status.value = "loading";
try {
const dataRes = await props.validateDataApi(selectedFile.value);
if (dataRes && dataRes.success) {
step2Status.value = "success";
} else {
step2Status.value = "error";
dataResultBlob.value = dataRes?.downloadBlob ?? null;
dataResultFileName.value = dataRes?.fileName || "数据验证结果.xlsx";
dataErrorFilename.value = dataRes?.errorFilename ?? "";
}
} catch {
step2Status.value = "error";
}
}
parsing.value = false;
}
async function downloadTemplateResult() {
if (templateResultBlob.value) {
downloadBlob(templateResultBlob.value, templateResultFileName.value);
return;
}
if (templateErrorFilename.value && typeof props.downloadErrorApi === "function") {
try {
const blob = await props.downloadErrorApi(templateErrorFilename.value);
downloadBlob(blob, templateResultFileName.value || "模板匹配结果.xlsx");
} catch (_) { }
}
}
async function downloadDataResult() {
if (dataResultBlob.value) {
downloadBlob(dataResultBlob.value, dataResultFileName.value);
return;
}
if (dataErrorFilename.value && typeof props.downloadErrorApi === "function") {
try {
const blob = await props.downloadErrorApi(dataErrorFilename.value);
downloadBlob(blob, dataResultFileName.value || "数据验证结果.xlsx");
} catch (_) { }
}
}
/** 上传失败时下载错误文件(调用 downloadErrorApi由父组件传入如 downloadErrorRole 的 file_name/filename 参数) */
async function downloadUploadError() {
if (!dataErrorFilename.value || typeof props.downloadErrorApi !== "function") return;
try {
const blob = await props.downloadErrorApi(dataErrorFilename.value);
downloadBlob(blob, "导入错误结果.xlsx");
} catch (_) { }
}
function handleReupload() {
selectedFile.value = null;
step1Status.value = "pending";
step2Status.value = "pending";
templateResultBlob.value = null;
templateErrorFilename.value = "";
dataResultBlob.value = null;
dataErrorFilename.value = "";
uploadErrorResult.value = null;
uploadContext.value = null;
selectedImportType.value = "add";
fileInputRef.value && (fileInputRef.value.value = "");
}
function closeDialog() {
dialogVisible.value = false;
}
function onVisibleChange(v) {
if (!v) {
handleReupload();
}
}
async function handleStartUpload() {
if (!selectedFile.value || !canStartUpload.value) return;
uploading.value = true;
dataErrorFilename.value = "";
try {
const context = { ...uploadContext.value, import_type: selectedImportType.value };
await props.uploadApi(selectedFile.value, context);
emit("success");
closeDialog();
} catch (e) {
const errData = e?.response?.data ?? e?.data ?? {};
/** 兼容接口直接返回统计 或 放在 data 里:{ code, message, data: { success_count, fail_count, error_file_path, ... } } */
const payload = errData?.data ?? errData;
const errFilename = e?.errorFilename ?? payload?.error_file_path ?? payload?.error_filename ?? payload?.errorFilename ?? payload?.filename ?? errData?.error_file_path ?? errData?.error_filename ?? errData?.errorFilename ?? errData?.filename;
step2Status.value = "error";
if (errFilename) dataErrorFilename.value = errFilename;
uploadErrorResult.value = {
success_count: payload?.success_count,
fail_count: payload?.fail_count,
total_rows: payload?.total_rows,
first_error: payload?.first_error ?? payload?.firstError ?? "",
};
throw e;
} finally {
uploading.value = false;
}
}
watch(
() => props.visible,
(v) => {
if (!v) handleReupload();
}
);
</script>
<style scoped>
/* 已通过 :showHeader="false" 移除自带表头,仅保留下方原生 div.dialog_header */
.dialog_header {
display: flex;
align-items: center;
justify-content: space-between;
height: 55px;
margin: 0 -1.5rem 0 -1.5rem;
padding: 0 1.5rem;
border-bottom: 1px solid #E7E7E7;
flex-shrink: 0;
min-width: 0;
overflow-x: hidden;
}
.dialog_header .dialog-header-custom {
font-weight: 500;
font-size: 16px;
color: rgba(0, 0, 0, 0.9);
line-height: 24px;
}
.dialog_header .dialog-close {
cursor: pointer;
}
.dialog_header .dialog-close img {
width: 16px;
height: 16px;
}
:deep(.batch-import-dialog.p-dialog),
:deep(.batch-import-dialog .p-dialog-content) {
overflow: hidden !important;
overflow-x: hidden !important;
}
/* 弹窗 portaled 到 body需用 :global 才能生效(参考用户管理弹框) */
:global(.batch-import-dialog.p-dialog) {
overflow-x: hidden !important;
}
:global(.batch-import-dialog .p-dialog-content) {
overflow-x: hidden !important;
}
:deep(.batch-import-dialog .p-dialog-content) {
max-height: min(75vh, 480px);
display: flex !important;
flex-direction: column;
padding: 0 1.5rem !important;
overflow: hidden !important;
overflow-x: hidden !important;
min-width: 0;
}
:deep(.batch-import-dialog .p-dialog-content > *) {
flex-shrink: 0;
}
:deep(.batch-import-dialog .p-dialog-content > .batch-import-content) {
flex: 1;
min-height: 0;
}
.batch-import-content {
padding: 1rem 0 .5rem;
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
.import-steps {
padding: 0px 55px;
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1.25rem;
position: relative;
flex-shrink: 0;
min-width: 0;
}
/* 新写的横线:与图标中心平齐,第一步完成后可变为蓝色 */
.steps-connector-line {
flex: 1;
height: 2px;
margin: 11px 15px 30px;
background-color: #dee2e6;
align-self: center;
transition: background-color 0.3s;
}
.steps-connector-line--done {
background-color: #3067E5;
}
.step-item {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
position: relative;
z-index: 1;
flex: 0 0 auto;
}
.step-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
border-radius: 50%;
background-color: #dee2e6;
color: #6c757d;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
transition: all 0.3s;
}
.step-number {
font-size: 1rem;
display: block;
margin-bottom: 2.5px;
}
.step-spinner {
font-size: 1.25rem;
color: #fff;
}
.step-check {
font-size: 1.25rem;
color: #fff;
}
.step-error {
font-size: 1.25rem;
color: #fff;
}
.step-item.active .step-icon {
background-color: #3067E5;
color: white;
}
.step-item.completed .step-icon {
background-color: #3067E5;
color: white;
}
.step-item.error .step-icon {
background-color: #dc3545;
color: white;
}
.step-item.step-pending .step-icon {
background: #fff;
border: 2px dashed #dee2e6;
color: #6c757d;
}
.step-content {
text-align: left;
padding-top: 0;
line-height: 1.4;
}
/* 操作类型单选框行 */
.import-type-row {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.25rem;
flex-shrink: 0;
}
/* 弹框内单选框统一使用蓝色主题变量 */
.import-type-row :deep(.p-radiobutton) {
--p-highlight-bg: #3067E5;
--p-highlight-border-color: #3067E5;
--p-primary-color: #3067E5;
}
.import-type-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 14px;
color: rgba(0, 0, 0, 0.9);
}
.import-type-label :deep(.p-radiobutton) {
margin-right: 0;
}
.import-type-label :deep(.p-radiobutton .p-radiobutton-box) {
width: 18px;
height: 18px;
border: 1px solid #dee2e6;
}
/* 选中态强制蓝色,覆盖主题绿色 */
.import-type-label :deep(.p-radiobutton.p-highlight .p-radiobutton-box),
.import-type-label :deep(.p-radiobutton[data-p-checked="true"] .p-radiobutton-box) {
border-color: #3067E5 !important;
background: #3067E5 !important;
color: #3067E5 !important;
}
.import-type-label :deep(.p-radiobutton.p-highlight .p-radiobutton-box .p-radiobutton-icon),
.import-type-label :deep(.p-radiobutton[data-p-checked="true"] .p-radiobutton-box .p-radiobutton-icon) {
background: #fff !important;
}
.import-type-label :deep(.p-radiobutton.p-highlight .p-radiobutton-box:hover),
.import-type-label :deep(.p-radiobutton[data-p-checked="true"] .p-radiobutton-box:hover) {
border-color: #3067E5 !important;
background: #3067E5 !important;
}
.import-type-label :deep(.p-radiobutton:focus-visible .p-radiobutton-box) {
box-shadow: 0 0 0 2px rgba(48, 103, 229, 0.2);
}
.step-icon-img {
width: 24px;
height: 24px;
object-fit: contain;
}
.step-icon-error {
filter: none;
}
.step-item.error .step-icon .step-icon-error {
opacity: 1;
}
.step-title {
font-weight: 500;
font-size: 16px;
margin-bottom: 0.25rem;
font-style: normal;
text-transform: none;
font-family: Source Han Sans SC, Source Han Sans SC;
color: rgba(0,0,0,0.4);
}
.step-item.active .step-title {
color: #0052D9 ;
}
.step-desc {
font-family: Source Han Sans SC, Source Han Sans SC;
font-weight: 400;
font-size: 14px;
color: rgba(0,0,0,0.6);
line-height: 22px;
text-align: left;
font-style: normal;
text-transform: none;
}
.upload-area {
border: 1px dashed #DCDCDC;
border-radius: 8px;
padding: 1rem 1.5rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background-color: #fff;
flex: 1;
min-width: 0;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
.upload-area:hover {
border-color: #3067E5;
background-color: #EFF4FF;
}
.batch-import-file-input {
display: none;
}
.upload-hint {
height: 70px;
display: flex;
flex-direction: column;
align-items: center;
.title {
display: flex;
align-items: center;
.active {
color: #3067E5;
}
.icon {
margin: 0px 10px;
}
p {
font-family: PingFang SC, PingFang SC;
font-weight: 400;
font-size: 14px;
color: rgba(0, 0, 0, 0.6);
line-height: 22px;
text-align: center;
font-style: normal;
text-transform: none;
}
}
}
.upload-hint i {
font-size: 3rem;
color: #3067E5;
margin-bottom: 0.5rem;
}
.upload-icon-img {
width: 21px;
height: 24px;
object-fit: contain;
margin-bottom: 0.5rem;
}
.upload-hint p {}
.hint-small {
margin: 12px;
display: block;
font-family: Source Han Sans SC, Source Han Sans SC;
font-weight: 400;
font-size: 14px;
color: rgba(0, 0, 0, 0.4);
text-align: center;
font-style: normal;
text-transform: none;
}
.upload-result-area {
border-radius: 4px;
border: 1px dotted #DCDCDC;
padding: 10px;
text-align: center;
background-color: #fff;
flex: 1;
min-width: 0;
min-height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.result-parsing {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: #495057;
}
.result-parsing i {
font-size: 1.5rem;
color: #3067E5;
}
.result-success,
.result-done {
font-size: 1rem;
color: #495057;
margin-bottom: 1rem;
}
.result-error-panel {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 5px;
/* border: 2px dashed #ffcdd2; */
border-radius: 8px;
/* background:#EFF4FF; */
width: 512px;
}
.result-error-icon {
width: 24px;
height: 24px;
object-fit: contain;
margin-bottom: 0.5rem;
}
.result-error-title {
font-size: 14px;
font-weight: 600;
color: #ED5050;
margin-bottom: 5px;
}
.result-error-stats {
text-align: left;
width: 100%;
display: flex;
align-items: center;
border-radius: 6px;
align-items: stretch;
justify-content: center;
}
.result-error-row {
margin: 0 3px;
font-size: 0.875rem;
color: #495057;
}
.result-error-row:last-of-type {
margin-bottom: 0;
}
.result-error-first {
word-break: break-all;
font-family: Source Han Sans SC, Source Han Sans SC;
font-weight: 400;
font-size: 14px;
color: rgba(0, 0, 0, 0.6);
line-height: 22px;
text-align: center;
font-style: normal;
text-transform: none;
}
.btn-download {
/* margin-top: 0.5rem; */
padding: 0.35rem 0.75rem;
font-size: 14px;
color: #3067E5;
background: transparent;
border: 1px solid #dee2e6;
border-radius: 3px;
cursor: pointer;
}
.btn-download:hover {
border-color: #3067E5;
background: #EFF4FF;
}
.btn-download.link {
background: transparent;
border: none;
color: #3067E5;
text-decoration: underline;
padding: 0.25rem 0;
}
.btn-download.link:hover {
background: transparent;
border: none;
}
/* 与左侧弹框一致:表头、底部原生按钮,负 margin 使表头/表尾线铺满 */
.dialog_header {
height: 55px;
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 -1.5rem 0 -1.5rem;
padding: 0 1.5rem;
border-bottom: 1px solid #E7E7E7;
}
.dialog_header .dialog-header-custom {
font-weight: 500;
font-size: 16px;
color: rgba(0, 0, 0, 0.9);
line-height: 24px;
}
.dialog_header .dialog-close {
cursor: pointer;
}
.dialog_header .dialog-close img {
width: 16px;
height: 16px;
}
.batch-import-footer {
margin: 0 -1.5rem 0 -1.5rem;
margin-top: 16px;
padding: 16px 1.5rem 0;
border-top: 1px solid #E7E7E7;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
min-width: 0;
overflow-x: hidden;
}
.batch-import-footer .btn {
display: flex;
gap: 8px;
}
.batch-import-footer .btnUpload {
cursor: pointer;
width: 88px;
height: 32px;
background: #EFF4FF;
border-radius: 3px 3px 3px 3px;
font-weight: 400;
font-size: 14px;
color: #3067E5;
text-align: center;
line-height: 32px;
}
.batch-import-footer .btnAncle {
cursor: pointer;
width: 60px;
height: 32px;
background: #E7E7E7;
border-radius: 3px 3px 3px 3px;
font-weight: 400;
font-size: 14px;
line-height: 32px;
}
.batch-import-footer .btnOk {
cursor: pointer;
width: 80px;
height: 32px;
background: #3067E5;
border-radius: 3px;
text-align: center;
line-height: 32px;
font-weight: 400;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
}
.batch-import-footer .btnOk--disabled {
background: #ccc;
color: #999;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,198 @@
<template>
<div class="echarts-container">
<div ref="chartRef" class="chart-wrapper"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
// 饼图数据
data: {
type: Array,
default: () => []
},
// 图表标题
title: {
type: String,
default: '概览看板'
}
});
const chartRef = ref(null);
let chartInstance = null;
// 初始化图表
const initChart = () => {
if (!chartRef.value) return;
// 如果已经存在实例,先销毁
if (chartInstance) {
chartInstance.dispose();
}
// 创建新的图表实例
chartInstance = echarts.init(chartRef.value);
// 定义颜色方案(匹配图片中的颜色)
const colorPalette = [
'#4169E1', // 皇家蓝 (Royal Blue)
'#32CD32', // 酸橙绿 (Lime Green)
'#708090', // 石板灰 (Slate Gray)
'#FFA500', // 橙色 (Orange)
'#00CED1' // 深青色 (Dark Turquoise/Cyan)
];
// 设置图表配置
const option = {
backgroundColor: '#ffffff',
title: {
text: props.title,
left: 'center',
top: '5%',
textStyle: {
fontSize: 18,
fontWeight: 'bold',
color: '#333'
}
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} 小时 ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left',
top: '15%',
itemGap: 15,
textStyle: {
fontSize: 14,
color: '#333'
},
formatter: function(name) {
// 在图例中显示名称和对应的数值
const item = props.data.find(d => d.name === name);
if (item) {
return `${name}: ${item.value} 小时`;
}
return name;
}
},
series: [
{
name: '项目工时',
type: 'pie',
radius: ['40%', '70%'], // 环形图
center: ['60%', '55%'], // 调整中心位置,为图例留出空间
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 0, // 无圆角
borderColor: 'transparent', // 无边框
borderWidth: 0
},
label: {
show: false // 不显示标签
},
labelLine: {
show: false // 不显示标签线
},
emphasis: {
itemStyle: {
shadowBlur: 0,
shadowOffsetX: 0,
shadowColor: 'transparent'
}
},
color: colorPalette, // 使用自定义颜色
data: props.data
}
]
};
chartInstance.setOption(option);
// 响应式调整
window.addEventListener('resize', handleResize);
};
// 处理窗口大小变化
const handleResize = () => {
if (chartInstance) {
chartInstance.resize();
}
};
// 监听数据变化
watch(() => props.data, () => {
if (chartInstance) {
const colorPalette = [
'#4169E1', // 皇家蓝 (Royal Blue)
'#32CD32', // 酸橙绿 (Lime Green)
'#708090', // 石板灰 (Slate Gray)
'#FFA500', // 橙色 (Orange)
'#00CED1' // 深青色 (Dark Turquoise/Cyan)
];
const option = {
title: {
text: props.title
},
legend: {
formatter: function(name) {
const item = props.data.find(d => d.name === name);
if (item) {
return `${name}: ${item.value}`;
}
return name;
}
},
series: [{
data: props.data,
color: colorPalette
}]
};
chartInstance.setOption(option);
}
}, { deep: true });
// 监听标题变化
watch(() => props.title, () => {
if (chartInstance) {
chartInstance.setOption({
title: {
text: props.title
}
});
}
});
onMounted(() => {
initChart();
});
onBeforeUnmount(() => {
if (chartInstance) {
window.removeEventListener('resize', handleResize);
chartInstance.dispose();
chartInstance = null;
}
});
</script>
<style scoped>
.echarts-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.chart-wrapper {
width: 100%;
height: 100%;
min-height: 400px;
}
</style>

View File

@@ -0,0 +1,97 @@
<!-- 公共邮件提醒开关通过 props 传入各自页面的获取/更新接口支持多语言 -->
<template>
<div class="email-reminder email-reminder-switch" @click="handleToggle">
<div class="email-reminder-label">{{ $t('common.emailReminder') }}</div>
<img src="../images/assets/open.png" alt="" class="email-reminder-icon" v-if="enabled">
<img src="../images/assets/close.png" alt="" class="email-reminder-icon" v-else>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
/** 获取邮件提醒状态,返回 Promise<{ data: { email_alert: boolean } }> */
fetchApi: { type: Function, required: true },
/** 更新邮件提醒状态,参数 { email_alert: boolean },返回 Promise<{ code, msg?, message? }> */
updateApi: { type: Function, required: true },
});
const emit = defineEmits(['success', 'fail']);
const enabled = ref(false);
const fetchState = async () => {
try {
const res = await props.fetchApi();
if (res && res.data !== undefined) {
enabled.value = !!res.data.email_alert;
}
} catch (error) {
console.warn('获取邮件提醒状态失败,使用默认值:', error);
enabled.value = false;
}
};
const handleToggle = async () => {
const next = !enabled.value;
const payload = { email_alert: next };
try {
const result = await props.updateApi(payload);
const code = result?.code;
const msg = result?.msg ?? result?.message;
if (code === 0) {
enabled.value = next;
emit('success', { title: t('common.success'), detail: t('common.emailReminderUpdateSuccess') });
} else {
emit('fail', { title: t('common.warning'), detail: msg || t('common.operationFail') });
}
} catch (err) {
const errorMsg =
err?.response?.data?.msg ??
err?.response?.data?.message ??
err?.data?.msg ??
err?.data?.message ??
err?.message ??
t('common.emailReminderUpdateFail');
emit('fail', { title: t('common.error'), detail: errorMsg });
}
};
onMounted(() => {
fetchState();
});
</script>
<style scoped>
.email-reminder-switch {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
height: 34px;
padding: 0 12px;
white-space: nowrap;
}
.email-reminder-label {
font-family: Source Han Sans SC, Source Han Sans SC;
font-weight: 500;
font-size: 14px;
color: #333333;
line-height: 20px;
text-align: right;
font-style: normal;
text-transform: none;
margin-right: 8px;
white-space: nowrap;
}
.email-reminder-icon {
width: 42px;
height: 22px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,247 @@
<!-- 导入/导出公共按钮下拉下载导入模板批量导入导出 -->
<template>
<div class="import-export-wrap" :class="{ 'locale-en': isEn }" ref="importExportWrapRef">
<div class="table-down-box" @click="toggleDropdown">
<div class="downIcon">
<img src="../images/download/file.png" alt="">
</div>
<div class="downName">{{ $t('importExport.importExport') }}</div>
<div class="downIconRight">
<img src="../images/download/down.png" alt="">
</div>
</div>
<Transition name="dropdown">
<div v-show="dropdownVisible" class="import-export-dropdown">
<div v-if="!exportOnly" class="import-export-item" @click="onDownloadTemplate">
<div class="item-icon-wrap">
<img src="../images/download/download.png" class="item-icon item-icon-default" alt="">
<img src="../images/download/downloadSelect.png" class="item-icon item-icon-hover" alt="">
</div>
<span>{{ $t('importExport.downloadTemplate') }}</span>
</div>
<div v-if="!exportOnly" class="import-export-item" @click="onBatchImportClick">
<div class="item-icon-wrap">
<img src="../images/download/allIn.png" class="item-icon item-icon-default" alt="">
<img src="../images/download/allInSelect.png" class="item-icon item-icon-hover" alt="">
</div>
<span>{{ $t('importExport.batchImport') }}</span>
</div>
<div class="import-export-item" @click="onExport">
<div class="item-icon-wrap">
<img src="../images/download/export.png" class="item-icon item-icon-default" alt="">
<img src="../images/download/exportSelect.png" class="item-icon item-icon-hover" alt="">
</div>
<span>{{ $t('importExport.export') }}</span>
</div>
</div>
</Transition>
<input v-if="!exportOnly" ref="batchImportInputRef" type="file" accept=".xlsx,.xls" class="batch-import-input"
@change="onFileChange" />
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { useI18n } from "vue-i18n";
const { locale } = useI18n();
const isEn = computed(() => (locale.value || "").startsWith("en"));
const props = defineProps({
/** 为 true 时点击「批量导入」只触发事件不打开文件选择,由父级用弹框处理 */
useBatchImportDialog: { type: Boolean, default: false },
/** 为 true 时下拉只显示「导出」选项,隐藏「下载导入模板」「批量导入」 */
exportOnly: { type: Boolean, default: false },
});
const emit = defineEmits(["download-template", "batch-import", "export"]);
const importExportWrapRef = ref(null);
const dropdownVisible = ref(false);
const batchImportInputRef = ref(null);
const toggleDropdown = () => {
dropdownVisible.value = !dropdownVisible.value;
};
const closeDropdown = (e) => {
if (importExportWrapRef.value && !importExportWrapRef.value.contains(e.target)) {
dropdownVisible.value = false;
}
};
const onDownloadTemplate = () => {
dropdownVisible.value = false;
emit("download-template");
};
const onBatchImportClick = () => {
dropdownVisible.value = false;
if (props.useBatchImportDialog) {
emit("batch-import");
return;
}
batchImportInputRef.value?.click();
};
const onFileChange = (e) => {
emit("batch-import", e);
e.target.value = "";
};
const onExport = () => {
dropdownVisible.value = false;
emit("export");
};
onMounted(() => {
document.addEventListener("click", closeDropdown);
});
onBeforeUnmount(() => {
document.removeEventListener("click", closeDropdown);
});
</script>
<style scoped>
.import-export-wrap {
position: relative;
margin-left: 10px;
}
.table-down-box {
cursor: pointer;
width: 135px;
height: 34px;
background: #3067E5;
border-radius: 4px 4px 4px 4px;
display: flex;
align-items: center;
justify-content: center;
}
/* 英文时左右 padding 固定为 15px与中文/泰文一致有左右留白;避免 padding 压缩内容区导致图标变形 */
.import-export-wrap.locale-en .table-down-box {
padding: 0 10px;
box-sizing: content-box;
}
/* 英文时禁止左右图标被 flex 压缩,保持 16x16 不变形 */
.import-export-wrap.locale-en .table-down-box .downIcon,
.import-export-wrap.locale-en .table-down-box .downIconRight {
flex-shrink: 0;
}
.table-down-box .downIcon {
margin-right: 5px;
width: 16px;
height: 16px;
}
.table-down-box .downIcon img {
width: 100%;
height: 100%;
}
.table-down-box .downName {
font-family: Source Han Sans SC, Source Han Sans SC;
font-weight: 400;
font-size: 14px;
color: #FFFFFF;
line-height: 22px;
text-align: left;
font-style: normal;
text-transform: none;
}
.table-down-box .downIconRight {
margin-left: 5px;
width: 16px;
height: 16px;
}
.table-down-box .downIconRight img {
width: 100%;
height: 100%;
}
.import-export-dropdown {
/* width: 165px; */
width: 175px;
position: absolute;
top: calc(100% + 4px);
left: 0;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
padding: 8px;
z-index: 1000;
}
.import-export-item {
width: 160px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
font-size: 14px;
color: #333;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.import-export-item:hover {
background: #EFF4FF;
border-radius: 4px 4px 4px 4px;
margin-right: 8px;
color: #3067E5;
}
.import-export-item .item-icon-wrap {
position: relative;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.import-export-item .item-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
transition: opacity 0.15s ease;
}
.import-export-item .item-icon-hover {
position: absolute;
left: 0;
top: 0;
opacity: 0;
transition: opacity 0.15s ease;
}
.import-export-item:hover .item-icon-default {
opacity: 0;
}
.import-export-item:hover .item-icon-hover {
opacity: 1;
}
.batch-import-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
overflow: hidden;
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View File

@@ -0,0 +1,340 @@
<!-- 月度各部门实际 / 应完成 / 及时率 柱状图 + 折线 + 明细表 -->
<template>
<div class="monthly-dept-chart-wrap">
<div ref="chartRef" class="monthly-dept-chart-canvas"></div>
<div class="monthly-dept-chart-detail-table-wrap">
<table class="monthly-dept-chart-detail-table">
<thead>
<tr>
<th>指标</th>
<th v-for="dept in chartSeries.departments" :key="`chart-head-${dept}`">
{{ dept }}
</th>
<th class="summary-head-cell">{{ summaryColumnLabel }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in chartDetailRows" :key="row.label">
<td class="metric-label-cell">{{ row.label }}</td>
<td v-for="(value, idx) in row.deptValues" :key="`${row.label}-${idx}`">
{{ value }}
</td>
<td class="summary-body-cell">{{ row.summaryValue }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import * as echarts from "echarts";
const props = defineProps({
chartTitle: {
type: String,
required: true,
},
departments: {
type: Array,
default: () => [],
},
reportList: {
type: Array,
default: () => [],
},
/** 折线图例与明细表第三行名称,如:验收及时率 / 维护及时率 / 按时计量率 */
rateSeriesName: {
type: String,
default: "维护及时率",
},
/** 汇总列标题(图表 X 轴末项与明细表最右列) */
summaryColumnLabel: {
type: String,
default: "汇总",
},
});
const ACTUAL_NAME = "实际完成数量";
const SHOULD_NAME = "应完成数量";
const chartRef = ref(null);
let chartInstance = null;
const getDepartmentName = (item) =>
item?.department ||
item?.asset_department ||
item?.dept_name ||
item?.dept ||
item?.name ||
"";
const toNumber = (value) => {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
};
const findDepartmentRow = (dept) => props.reportList.find((item) => getDepartmentName(item) === dept);
const buildChartMetrics = (dept) => {
const item = findDepartmentRow(dept);
const actual = toNumber(item?.actual_completed ?? item?.actual_count);
const should = toNumber(item?.should_completed ?? item?.should_count);
const rawRate = Number(item?.on_time_rate ?? item?.rate);
const rate = Number.isFinite(rawRate) ? (rawRate <= 1 ? rawRate * 100 : rawRate) : 0;
return { actual, should, rate };
};
const chartSeries = computed(() => {
const departments = props.departments;
const metricRows = departments.map((dept) => buildChartMetrics(dept));
const actualCompleted = metricRows.map((m) => m.actual);
const shouldCompleted = metricRows.map((m) => m.should);
const onTimeRate = metricRows.map((m) => Number(m.rate.toFixed(2)));
const sumActual = actualCompleted.reduce((s, v) => s + v, 0);
const sumShould = shouldCompleted.reduce((s, v) => s + v, 0);
const summaryRateRaw = sumShould > 0 ? (sumActual / sumShould) * 100 : 0;
const summaryRate = Number(summaryRateRaw.toFixed(2));
const xCategories = [...departments, props.summaryColumnLabel];
return {
departments,
xCategories,
actualCompleted,
shouldCompleted,
onTimeRate,
sumActual,
sumShould,
summaryRate,
};
});
const chartDetailRows = computed(() => {
const { actualCompleted, shouldCompleted, onTimeRate, sumActual, sumShould, summaryRate } =
chartSeries.value;
return [
{
label: ACTUAL_NAME,
deptValues: actualCompleted.map((v) => `${v}`),
summaryValue: `${sumActual}`,
},
{
label: SHOULD_NAME,
deptValues: shouldCompleted.map((v) => `${v}`),
summaryValue: `${sumShould}`,
},
{
label: props.rateSeriesName,
deptValues: onTimeRate.map((v) => `${v.toFixed(2)}%`),
summaryValue: `${summaryRate.toFixed(2)}%`,
},
];
});
const updateChart = () => {
if (!chartRef.value) return;
if (!chartInstance) {
chartInstance = echarts.init(chartRef.value);
}
const { xCategories, actualCompleted, shouldCompleted, onTimeRate, sumActual, sumShould, summaryRate } =
chartSeries.value;
const rateName = props.rateSeriesName;
const actualData = [...actualCompleted, sumActual];
const shouldData = [...shouldCompleted, sumShould];
const rateData = [...onTimeRate, summaryRate];
chartInstance.setOption({
color: ["#409EFF", "#F59E0B", "#6B7280"],
title: {
text: props.chartTitle,
left: "center",
top: 6,
textStyle: {
fontSize: 15,
fontWeight: 600,
color: "#1f2937",
},
},
tooltip: {
trigger: "axis",
formatter(params) {
const points = Array.isArray(params) ? params : [params];
if (!points.length) return "";
const axisLabel = points[0]?.axisValueLabel ?? points[0]?.name ?? "";
const rows = points.map((point) => {
const isRate = point.seriesName === rateName;
const rawValue = Number(point.value);
const value = Number.isFinite(rawValue)
? isRate
? `${rawValue.toFixed(2)}%`
: `${rawValue}`
: point.value;
return `${point.marker}${point.seriesName} ${value}`;
});
return [axisLabel, ...rows].join("<br/>");
},
},
legend: {
top: 36,
data: [ACTUAL_NAME, SHOULD_NAME, rateName],
},
grid: {
left: 24,
right: 36,
top: 72,
bottom: 28,
containLabel: true,
},
xAxis: {
type: "category",
data: xCategories,
axisLabel: { interval: 0, rotate: xCategories.length > 8 ? 35 : 0 },
},
yAxis: [
{
type: "value",
name: "数量",
minInterval: 1,
},
{
type: "value",
name: "及时率",
min: 0,
max: 100,
axisLabel: {
formatter: "{value}%",
},
},
],
series: [
{
name: ACTUAL_NAME,
type: "bar",
data: actualData,
barMaxWidth: 26,
},
{
name: SHOULD_NAME,
type: "bar",
data: shouldData,
barMaxWidth: 26,
},
{
name: rateName,
type: "line",
yAxisIndex: 1,
data: rateData,
smooth: false,
symbolSize: 6,
label: {
show: true,
formatter: "{c}%",
},
},
],
});
};
const handleResize = () => {
chartInstance?.resize();
};
watch(
[
() => props.chartTitle,
() => props.rateSeriesName,
() => props.summaryColumnLabel,
() => props.departments,
() => props.reportList,
],
async () => {
await nextTick();
updateChart();
},
{ deep: true },
);
onMounted(async () => {
await nextTick();
updateChart();
window.addEventListener("resize", handleResize);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleResize);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
<style scoped>
.monthly-dept-chart-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
padding: 8px 12px 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.monthly-dept-chart-canvas {
width: 100%;
flex: 1;
min-height: clamp(280px, 42vh, 380px);
}
.monthly-dept-chart-detail-table-wrap {
width: 100%;
margin-top: 8px;
flex-shrink: 0;
overflow-x: auto;
border: 1px solid #e5e7eb;
border-radius: 6px;
}
.monthly-dept-chart-detail-table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
background: #fff;
}
.monthly-dept-chart-detail-table th,
.monthly-dept-chart-detail-table td {
padding: 4px 8px;
border: 1px solid #e5e7eb;
text-align: center;
white-space: nowrap;
font-size: 11px;
color: #374151;
}
.monthly-dept-chart-detail-table th {
background: #f9fafb;
font-weight: 600;
}
.metric-label-cell {
background: #f9fafb;
font-weight: 600;
}
.summary-head-cell,
.summary-body-cell {
background: #f3f4f6;
font-weight: 600;
position: sticky;
right: 0;
}
.summary-head-cell {
z-index: 2;
}
.summary-body-cell {
z-index: 1;
}
</style>

View File

@@ -0,0 +1,534 @@
<template>
<aside class="sidebar" :class="{ collapsed: isCollapsed }">
<div class="sider_top">
<!-- <div class="img_box">
<img :src="isCollapsed ? foldLogo : siderTopLogo" alt="">
</div> -->
<!-- GENUINE OPTICS (THAILAND) -->
</div>
<ul>
<li v-for="(item, index) in allMenuItems" :key="item.groupIndex" class="menu-group">
<!-- 父级菜单项 -->
<div class="menu-parent" :class="{ expanded: item.expanded, 'has-active-child': hasActiveChild(item), 'parent-active': isParentActive(item, item.groupIndex) }"
@click="toggleMenu(item.groupIndex)">
<div class="menu-parent-inner">
<img v-if="item.iconImg" :src="item.iconImg" class="menu-icon-img" :alt="item.label" />
<i v-else class="pi" :class="item.icon"></i>
<span v-if="!isCollapsed">{{ item.label }}</span>
</div>
<i v-if="!isCollapsed" class="pi pi-chevron-down caret" :class="{ expanded: item.expanded }"></i>
</div>
<!-- 子级菜单项 -->
<ul v-if="!isCollapsed && item.expanded && item.children" class="sub-menu">
<li v-for="(child, childIndex) in item.children" :key="childIndex" @click="goPage(child.name)"
:class="{ active: currentName === child.name }" class="sub-menu-item">
<div class="sub-menu-inner">
<img v-if="child.iconImg" :src="child.iconImg" class="sub-menu-icon-img" alt="" />
<i v-else class="dot"></i>
<span>{{ child.label }}</span>
</div>
<div v-if="currentName === child.name" class="active-indicator"></div>
</li>
</ul>
</li>
</ul>
<div class="sidebar-footer">
<div class="watermark">
<img :src="isCollapsed ? foldCompanyLogo : companyLogo" alt="">
</div>
</div>
</aside>
</template>
<script setup>
import { getTimesheetRouteName } from "../configs/common";
import { ref, watch, computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { useUiStore } from "../store/ui";
import systemIcon from "../images/sider/system.png";
import assetsIcon from "../images/sider/assets.png";
import meteringIcon from "../images/sider/metering.png";
import operationLogIcon from "../images/sider/log.svg";
import overviewDashboardIcon from "../images/sider/overview.svg";
import siderTopLogo from "../images/sider/newLogo.svg";
import foldLogo from "../images/sider/newLogo.svg";
import companyLogo from "../images/logo.png";
import foldCompanyLogo from "../images/sider/foldCompanyLoago.svg";
import { storeToRefs } from "pinia";
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const uiStore = useUiStore();
const { isSidebarCollapsed: isCollapsed } = storeToRefs(uiStore);
// 菜单展开状态 - 与 fullMenuConfig 下标一致0 概览看板、1 资产管理、2 计量管理、3 系统管理、4 操作日志
const menuExpandedStates = ref({
0: true, // 概览看板默认展开(与默认首页一致)
1: false,
2: false,
3: false,
4: false,
});
// 当前“选中”的父级索引(仅此父级显示蓝色,其他保持灰色)
const lastClickedParentIndex = ref(0);
// 所有菜单项配置(路由 name 与 Vue Router 一致)
const fullMenuConfig = [
{
// 概览看板
labelKey: "menu.overviewDashboard",
icon: "pi-chart-line",
iconImg: overviewDashboardIcon,
isAsset: false,
children: [
{ labelKey: "menu.overviewDashboard", name: "OverviewDashboard", icon: "pi-chart-line" },
// { labelKey: "role.pagePermission.equipmentRepairReport", name: "EquipmentRepairReport", icon: "pi-file" },
// { labelKey: "role.pagePermission.equipmentAcceptanceReport", name: "EquipmentAcceptanceReport", icon: "pi-file" },
{ labelKey: "role.pagePermission.equipmentMaintenanceReport", name: "EquipmentMaintenanceReport", icon: "pi-file" },
{ labelKey: "role.pagePermission.equipmentMeteringReport", name: "EquipmentMeteringReport", icon: "pi-file" },
],
},
{
// 资产管理
labelKey: "menu.assetManagement",
icon: "pi-database",
iconImg: assetsIcon,
isAsset: true,
children: [
{ labelKey: "menu.assetList", name: "AssetList", icon: "pi-box" },
{ labelKey: "menu.assetInventory", name: "AssetInventory", icon: "pi-list" },
{ labelKey: "menu.deviceMaintain", name: "DeviceMaintain", icon: "pi-wrench" },
{ labelKey: "menu.deviceRepair", name: "DeviceRepair", icon: "pi-box" },
{ labelKey: "menu.assetAlarmRecord", name: "AssetAlarmRecord", icon: "pi-bell" },
{ labelKey: "menu.accessControlManage", name: "AccessControlManage", icon: "pi-lock" },
{ labelKey: "menu.attachmentManage", name: "AttachmentManage", icon: "pi-paperclip" },
],
},
{
// 计量管理
labelKey: "menu.measurementManagement",
icon: "pi-database",
iconImg: meteringIcon,
isAsset: true,
children: [
{ labelKey: "menu.deviceMetering", name: "DeviceMetering", icon: "pi-paperclip" },
{ labelKey: "menu.measureMaterialManage", name: "MeasureMaterialManage", icon: "pi-box" },
],
},
{
// 系统管理
labelKey: "menu.systemManagement",
icon: "pi-cog",
iconImg: systemIcon,
isAsset: false,
children: [
{ labelKey: "menu.userManagement", name: "UserManage", icon: "pi-users" },
{ labelKey: "menu.roleManagement", name: "RoleManage", icon: "pi-shield" },
{ labelKey: "menu.employeeManagement", name: "EmployeeManage", icon: "pi-user" },
{ labelKey: "menu.factoryManagement", name: "FactoryManage", icon: "pi-building" },
{ labelKey: "menu.sensorManagement", name: "SensorManage", icon: "pi-microchip" },
],
},
{
// 操作日志(子项:操作日志页)
labelKey: "menu.operationLogParent",
icon: "pi-history",
iconImg: operationLogIcon,
isAsset: false,
children: [
{ labelKey: "menu.operationLog", name: "OperationLog", icon: "pi-history" },
],
},
];
// 左侧菜单始终展示全部配置项(不再按接口 permission_codes 过滤)
const allMenuItems = computed(() => {
const expanded = menuExpandedStates.value;
return fullMenuConfig.map((group, groupIndex) => {
const visibleChildren = group.children || [];
return {
groupIndex,
label: t(group.labelKey),
icon: group.icon,
iconImg: group.iconImg,
expanded: expanded[groupIndex],
isAsset: group.isAsset,
children: visibleChildren.map((child) => ({
label: t(child.labelKey),
name: getTimesheetRouteName(child.name),
icon: child.icon,
iconImg: child.iconImg,
})),
};
});
});
// 获取当前可见菜单的所有路由名称
const getAllMenuRouteNames = () => {
const routeNames = [];
allMenuItems.value.forEach((item) => {
if (item.children) {
item.children.forEach((child) => {
if (child.name) routeNames.push(child.name);
});
}
});
return routeNames;
};
// 默认路由名称(概览看板);当前路由不在菜单内时的回退路由
const defaultRouteName = getTimesheetRouteName("OverviewDashboard");
// 计算当前选中的路由名称
const currentName = computed(() => {
const routeName = route.name;
const menuRouteNames = getAllMenuRouteNames();
// 如果当前路由不在菜单项中,或者路由为空/未定义,则默认选中概览看板
if (!routeName || !menuRouteNames.includes(routeName)) {
return defaultRouteName;
}
return routeName;
});
// 判断该父菜单下是否有当前激活的子项(用于父级标题变色)
const hasActiveChild = (item) => {
if (!item.children) return false;
return item.children.some((child) => child.name === currentName.value);
};
// 当前父级是否为“选中”状态(仅一个父级为 true用于蓝色高亮
const isParentActive = (item, groupIndex) => {
return groupIndex === lastClickedParentIndex.value && (hasActiveChild(item) || item.expanded);
};
const toggleMenu = (index) => {
if (isCollapsed.value) {
const targetGroup = allMenuItems.value.find((item) => item.groupIndex === index);
if (targetGroup?.children?.length) {
goPage(targetGroup.children[0].name);
}
lastClickedParentIndex.value = index;
return;
}
menuExpandedStates.value[index] = !menuExpandedStates.value[index];
lastClickedParentIndex.value = index;
};
const goPage = (name) => {
if (name) {
router.push({ name });
}
};
const handleLogout = () => {
// 清除本地存储的 token 和 uid
localStorage.removeItem("token");
localStorage.removeItem("uid");
// 跳转到登录页
router.push({ name: getTimesheetRouteName("Login") });
};
watch(
() => route.name,
(newName) => {
const menuRouteNames = getAllMenuRouteNames();
if (newName && !menuRouteNames.includes(newName)) {
router.push({ name: defaultRouteName });
}
// 手风琴:只展开当前路由所在分组,其余分组折叠
const newExpanded = { 0: false, 1: false, 2: false, 3: false, 4: false };
allMenuItems.value.forEach((item) => {
if (item.children && item.children.some((child) => child.name === newName)) {
newExpanded[item.groupIndex] = true;
lastClickedParentIndex.value = item.groupIndex;
}
});
menuExpandedStates.value = newExpanded;
},
{ immediate: true }
);
</script>
<style scoped>
.sidebar {
background: #FFFFFF;
box-shadow: 2px 0px 15px 0px rgba(0, 69, 143, 0.05);
border-radius: 0px 0px 0px 0px;
padding: 1rem 0;
width: 200px;
min-width: 200px;
max-width: 200px;
padding: 0 12px;
position: relative;
padding-bottom: 4rem;
flex-shrink: 0;
.sider_top {
width: 100%;
display: flex;
justify-content: center;
margin-top: 20px;
margin-bottom: 25px;
.img_box {
width: 140px;
height: 26px;
img {
width: 100%;
height: 100%;
}
}
}
}
.sidebar.collapsed {
width: 72px;
min-width: 72px;
max-width: 72px;
padding: 0 8px;
.sider_top .img_box {
width: 44px;
height: 26px;
}
}
.sidebar>ul {
margin: 0;
padding: 0;
list-style: none;
}
.menu-group {
list-style: none;
margin: 0;
padding: 0;
}
/* 父级菜单样式 */
.menu-parent {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.7rem 0.6rem;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.sidebar.collapsed .menu-parent {
justify-content: center;
padding: 0.7rem 0;
}
.menu-parent-inner {
display: flex;
align-items: center;
gap: 0.5rem;
/* flex: 1; */
min-width: 0;
overflow: hidden;
}
.sidebar.collapsed .menu-parent-inner {
justify-content: center;
}
.menu-parent .pi {
font-size: 1rem;
transition: color 0.2s;
}
.menu-parent .menu-icon-img {
width: 1rem;
height: 1rem;
object-fit: contain;
flex-shrink: 0;
}
/* 父级菜单默认正常色(灰) */
.menu-parent .pi-cog,
.menu-parent .pi-database,
.menu-parent .pi-ruler,
.menu-parent .pi-chart-line,
.menu-parent .pi-history {
color: #6b7280;
}
.menu-parent span {
color: #6b7280;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
transition: color 0.2s;
}
/* 仅当前“选中”的父级parent-active显示主题色其他父级保持灰色 */
.menu-parent.parent-active .pi,
.menu-parent.parent-active span {
color: #3067E5;
}
.caret {
margin-top: 3px;
font-size: 0.75rem;
transition: transform 0.2s, color 0.2s;
}
.menu-parent .caret {
color: #6b7280;
scale: .75;
}
.menu-parent.parent-active .caret {
color: #3067E5;
}
.caret.expanded {
transform: rotate(180deg);
}
/* 子菜单容器 */
.sub-menu {
margin: 0;
padding: 0;
list-style: none;
background: #fff;
}
/* 子菜单项样式 */
.sub-menu-item {
display: flex;
align-items: center;
list-style: none;
position: relative;
cursor: pointer;
transition: background 0.2s;
height: 36px;
}
.sub-menu-item:hover {
background: #EFF4FF;
border-radius: 4px 4px 4px 4px;
}
/* .sub-menu-item.active {
background: #fff5f0;
} */
.sub-menu-inner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem 0.75rem 3rem;
min-width: 0;
overflow: hidden;
}
.sub-menu-item .pi {
font-size: 0.875rem;
transition: color 0.2s;
}
.sub-menu-item:not(.active) .pi {
color: #6b7280;
}
.sub-menu-item:not(.active) span {
color: #6b7280;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* flex: 1; */
min-width: 0;
}
.sub-menu-item.active .pi {
color: #3067E5;
}
.sub-menu-item.active span {
color: #3067E5;
font-size: 0.875rem;
/* white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; */
}
/* 激活指示器(右侧橙色竖条) */
.active-indicators {
display: block;
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 3px;
background: #3067E5;
}
.sidebar-footer {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.logout-btn {
width: 100%;
justify-content: center;
color: #6b7280;
font-size: 0.875rem;
}
.logout-btn:hover {
color: #3067E5;
background-color: #fff5f0;
}
.watermark {
text-align: center;
width: 110px;
height: 20px;
img{
width: 100%;
height: 100%;
}
}
.sidebar.collapsed .watermark {
width: 36px;
height: 20px;
}
.dot {
width: 5px;
height: 5px;
background: #3067E5;
border-radius: 5px;
}
.sub-menu-icon-img {
width: 14px;
height: 14px;
object-fit: contain;
flex-shrink: 0;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
<template>
<div class="dashboard-line-wrap" :style="{ minHeight: height }">
<div ref="chartRef" class="dashboard-line-canvas" :style="{ height }" />
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import * as echarts from "echarts";
const props = defineProps({
/** X 轴类目,如年份 */
categories: {
type: Array,
default: () => [],
},
/** 与 categories 对齐的数值 */
data: {
type: Array,
default: () => [],
},
/** 系列名称(图例) */
seriesName: {
type: String,
default: "",
},
/** 系列类型line / bar */
seriesType: {
type: String,
default: "line",
validator: (v) => ["line", "bar"].includes(v),
},
color: {
type: String,
default: "#3a5a67",
},
/** 折线下方区域填充透明度0 表示不填充 */
areaOpacity: {
type: Number,
default: 0.06,
},
height: {
type: String,
default: "300px",
},
/** Y 轴是否用紧凑数字(万、千万等) */
compactYAxis: {
type: Boolean,
default: true,
},
});
const chartRef = ref(null);
let chartInstance = null;
const isBar = () => props.seriesType === "bar";
const resolveBarWidth = () => {
const categoryCount = props.categories.length;
if (categoryCount <= 1) return 40;
if (categoryCount <= 3) return 32;
if (categoryCount <= 6) return 24;
return "56%";
};
const buildOption = () => ({
backgroundColor: "transparent",
tooltip: {
trigger: isBar() ? "item" : "axis",
axisPointer: isBar() ? undefined : { type: "line" },
},
legend: {
bottom: 0,
left: "center",
data: props.seriesName ? [props.seriesName] : [],
show: Boolean(props.seriesName),
},
grid: {
left: "3%",
right: "4%",
bottom: "48px",
top: "12px",
containLabel: true,
},
xAxis: {
type: "category",
boundaryGap: isBar(),
data: props.categories,
axisLabel: {
color: "#6b7280",
fontSize: 11,
rotate: props.categories.length > 16 ? 35 : 0,
},
axisLine: { lineStyle: { color: "#e5e7eb" } },
},
yAxis: {
type: "value",
axisLabel: {
color: "#6b7280",
fontSize: 11,
formatter: props.compactYAxis
? (v) => {
if (v >= 1e7) return `${(v / 1e7).toFixed(1)}e+7`;
if (v >= 1e4) return `${(v / 1e4).toFixed(1)}`;
return String(v);
}
: undefined,
},
splitLine: { lineStyle: { color: "#f3f4f6" } },
},
series: [
{
name: props.seriesName,
type: props.seriesType,
smooth: isBar() ? false : true,
symbol: isBar() ? "none" : "circle",
symbolSize: isBar() ? 0 : 6,
showSymbol: isBar() ? false : props.categories.length <= 24,
lineStyle: isBar() ? undefined : { width: 2, color: props.color },
itemStyle: { color: props.color, borderRadius: isBar() ? [4, 4, 0, 0] : 0 },
barWidth: isBar() ? resolveBarWidth() : undefined,
barMaxWidth: isBar() ? 40 : undefined,
barMinWidth: isBar() ? 12 : undefined,
areaStyle:
!isBar() && props.areaOpacity > 0
? {
color: props.color,
opacity: props.areaOpacity,
}
: undefined,
data: props.data,
},
],
});
const initChart = () => {
if (!chartRef.value) return;
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(buildOption());
window.addEventListener("resize", handleResize);
};
const handleResize = () => {
chartInstance?.resize();
};
watch(
() => [props.categories, props.data, props.seriesName, props.seriesType, props.color, props.areaOpacity, props.compactYAxis],
() => {
if (chartInstance) {
chartInstance.setOption(buildOption(), true);
}
},
{ deep: true }
);
onMounted(() => {
initChart();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleResize);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
<style scoped>
.dashboard-line-wrap {
width: 100%;
}
.dashboard-line-canvas {
width: 100%;
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="dashboard-pie-wrap" :style="{ minHeight: height }">
<div ref="chartRef" class="dashboard-pie-canvas" :style="{ height }" />
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import * as echarts from "echarts";
const props = defineProps({
/** { name: string, value: number }[] */
data: {
type: Array,
default: () => [],
},
/** 图例数值展示:数量 / 金额 */
valueMode: {
type: String,
default: "count",
validator: (v) => ["count", "currency"].includes(v),
},
height: {
type: String,
default: "320px",
},
});
const chartRef = ref(null);
let chartInstance = null;
const legendVisibleCount = 5;
const legendItemWidth = 10;
/** 需 ≥ 字号行高;过小会导致条目与分页区被裁剪 */
const legendItemHeight = 14;
const legendItemGap = 10;
const legendPageButtonSize = 10;
/** scroll 图例分页在顶部时需预留按钮行 + 与首条的间距 */
const legendPageControlsHeight = 42;
/** 等价于 margin-top避免分页箭头与第一条贴顶被裁切 */
const legendPaddingTop = 14;
const legendPaddingBottom = 8;
const legendItemsHeight = legendVisibleCount * legendItemHeight + (legendVisibleCount - 1) * legendItemGap;
const legendHeight =
legendPaddingTop + legendItemsHeight + legendPageControlsHeight + legendPaddingBottom;
const palette = [
"#5470c6",
"#91cc75",
"#fac858",
"#ee6666",
"#73c0de",
"#3ba272",
"#fc8452",
"#9a60b4",
"#ea7ccc",
"#4169e1",
"#32cd32",
"#708090",
"#ffa500",
"#00ced1",
"#b8860b",
"#4682b4",
"#cd853f",
"#6b8e23",
];
const formatLegendValue = (val) => {
if (props.valueMode === "currency") {
const n = Number(val);
if (!Number.isFinite(n)) return String(val);
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
const n = Number(val);
return Number.isFinite(n) ? String(Math.round(n * 100) / 100) : String(val);
};
const buildOption = () => ({
backgroundColor: "transparent",
color: palette,
tooltip: {
trigger: "item",
formatter: (p) => {
const v = formatLegendValue(p.value);
return `${p.name}<br/>${v} (${p.percent}%)`;
},
},
legend: {
show: true,
type: "scroll",
orient: "vertical",
right: "0",
top: "middle",
height: legendHeight,
padding: [legendPaddingTop, 4, legendPaddingBottom, 4],
pageButtonPosition: "start",
pageButtonItemGap: 12,
pageIcons: {
vertical: ["path://M8,4 L2,8 L8,12 Z", "path://M2,4 L8,8 L2,12 Z"],
},
pageIconColor: "#5470C6",
pageIconInactiveColor: "#5470C6",
pageIconSize: legendPageButtonSize,
pageFormatter: () => "",
icon: "circle",
itemWidth: legendItemWidth,
itemHeight: legendItemHeight,
itemGap: legendItemGap,
textStyle: { fontSize: 12, color: "#374151" },
formatter: (name) => {
const item = props.data.find((d) => d.name === name);
if (!item) return name;
return `${name} (${formatLegendValue(item.value)})`;
},
},
series: [
{
type: "pie",
radius: "56%",
center: ["34%", "50%"],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 2, borderColor: "#fff", borderWidth: 1 },
label: {
show: true,
position: "outside",
color: "#374151",
fontSize: 12,
formatter: (p) => `${p.name}: ${formatLegendValue(p.value)} (${p.percent}%)`,
},
labelLine: {
show: true,
length: 12,
length2: 10,
smooth: false,
},
labelLayout: {
hideOverlap: true,
moveOverlap: "shiftY",
},
emphasis: {
itemStyle: { shadowBlur: 8, shadowOffsetX: 0, shadowColor: "rgba(0,0,0,0.12)" },
},
data: props.data,
},
],
});
const initChart = () => {
if (!chartRef.value) return;
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
chartInstance = echarts.init(chartRef.value);
chartInstance.setOption(buildOption());
window.addEventListener("resize", handleResize);
};
const handleResize = () => {
chartInstance?.resize();
};
watch(
() => [props.data, props.valueMode],
() => {
if (chartInstance) {
chartInstance.setOption(buildOption(), true);
}
},
{ deep: true }
);
onMounted(() => {
initChart();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleResize);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
<style scoped>
.dashboard-pie-wrap {
width: 100%;
}
.dashboard-pie-canvas {
width: 100%;
}
</style>

View File

@@ -0,0 +1,4 @@
export const prefix = "timesheet";
export function getTimesheetRouteName(name) {
return `${prefix}.${name}`;
}

View File

@@ -0,0 +1,82 @@
/**
* 数据权限分组与字段(与角色管理弹框展示一致)
* 每组内 field.key 格式为 原字段名_父组key(camelCase),全局唯一
*/
const RAW_FIELD_PERMISSION_GROUPS = [
{ key: "AssetAlarmRecord", label: "资产报警记录", fields: [
{ key: "alarm_time_assetAlarmRecord", label: "报警时间" }, { key: "alarm_type_assetAlarmRecord", label: "报警类型" }, { key: "asset_no_assetAlarmRecord", label: "资产编号" }, { key: "asset_name_assetAlarmRecord", label: "资产名称" }, { key: "current_factory_assetAlarmRecord", label: "当前厂区" }, { key: "epc_assetAlarmRecord", label: "EPC" }, { key: "spec_model_assetAlarmRecord", label: "规格型号" }, { key: "factory_area_assetAlarmRecord", label: "所属厂区" }, { key: "asset_dept_assetAlarmRecord", label: "资产部门" }, { key: "responsible_assetAlarmRecord", label: "责任人" }, { key: "signal_machine_assetAlarmRecord", label: "信号机" }, { key: "info_machine_name_assetAlarmRecord", label: "信息机名称" }
]},
{ key: "AssetList", label: "资产列表", fields: [
{ key: "agent_assetList", label: "代理商" }, { key: "asset_no_assetList", label: "资产编号" }, { key: "asset_name_assetList", label: "资产名称" }, { key: "sub_asset_no_assetList", label: "附属资产编号" }, { key: "sub_asset_assetList", label: "附属资产" }, { key: "attachment_assetList", label: "附件" }, { key: "entry_time_assetList", label: "进厂时间" }, { key: "asset_class_assetList", label: "资产分类" }, { key: "depreciation_period_assetList", label: "折旧期数" }, { key: "epc_assetList", label: "EPC" }, { key: "factory_area_assetList", label: "所属厂区" }, { key: "anti_dismantle_assetList", label: "资产防拆提醒" }, { key: "measure_type_assetList", label: "计量类型" }, { key: "original_value_assetList", label: "资产原值" }, { key: "under_voltage_alarm_assetList", label: "欠压告警" }, { key: "maintain_type_assetList", label: "维护类型" }, { key: "manufacturer_assetList", label: "制造厂家" }, { key: "doc_no_assetList", label: "资料编号" }, { key: "remark_assetList", label: "备注说明" }, { key: "net_value_assetList", label: "折旧净值" }, { key: "original_zy_no_assetList", label: "原zy编号" }, { key: "factory_no_assetList", label: "出厂编号" }, { key: "asset_dept_assetList", label: "资产部门" }, { key: "purchase_code_assetList", label: "采购编码" }, { key: "asset_responsible_assetList", label: "资产责任人" }, { key: "spec_model_assetList", label: "规格型号" }, { key: "asset_status_assetList", label: "资产状态" }, { key: "warranty_assetList", label: "保修期" }
]},
{ key: "AssetAcceptance", label: "资产验收", fields: [
{ key: "accept_status_assetAcceptance", label: "验收状态" }, { key: "accept_responsible_assetAcceptance", label: "验收责任人" }, { key: "asset_class_assetAcceptance", label: "资产分类" }, { key: "asset_no_assetAcceptance", label: "资产编号" }, { key: "asset_name_assetAcceptance", label: "资产名称" }, { key: "attachment_assetAcceptance", label: "附件" }, { key: "accept_finish_time_assetAcceptance", label: "验收完成时间" }, { key: "factory_area_assetAcceptance", label: "所属厂区" }, { key: "factory_no_assetAcceptance", label: "出厂编号" }, { key: "asset_dept_assetAcceptance", label: "资产部门" }, { key: "plan_accept_time_assetAcceptance", label: "计划验收时间" }, { key: "remark_assetAcceptance", label: "备注说明" }, { key: "asset_responsible_assetAcceptance", label: "资产责任人" }, { key: "accept_start_time_assetAcceptance", label: "验收发起时间" }, { key: "purchase_no_assetAcceptance", label: "采购编号" }
]},
{ key: "DeviceEntry", label: "设备进厂", fields: [
{ key: "accept_cycle_deviceEntry", label: "验收周期" }, { key: "agent_deviceEntry", label: "代理商" }, { key: "asset_class_deviceEntry", label: "资产分类" }, { key: "asset_no_deviceEntry", label: "资产编号" }, { key: "asset_name_deviceEntry", label: "资产名称" }, { key: "attachment_deviceEntry", label: "附件" }, { key: "contract_no_deviceEntry", label: "合同号" }, { key: "epc_deviceEntry", label: "EPC" }, { key: "factory_area_deviceEntry", label: "所属厂区" }, { key: "entry_time_deviceEntry", label: "进厂时间" }, { key: "original_value_deviceEntry", label: "资产原值" }, { key: "manufacturer_deviceEntry", label: "制造厂家" }, { key: "factory_no_deviceEntry", label: "出厂编号" }, { key: "asset_dept_deviceEntry", label: "资产部门" }, { key: "purchase_no_deviceEntry", label: "采购编号" }, { key: "remark_deviceEntry", label: "备注说明" }, { key: "asset_responsible_deviceEntry", label: "资产负责人" }, { key: "spec_model_deviceEntry", label: "规格型号" }, { key: "temp_return_date_deviceEntry", label: "临时设备计划退还日期" }, { key: "warranty_deviceEntry", label: "保修期" }
]},
{ key: "DeviceMaintain", label: "设备维护", fields: [
{ key: "maintain_responsible_deviceMaintain", label: "维护责任人" }, { key: "asset_no_deviceMaintain", label: "资产编号" }, { key: "asset_name_deviceMaintain", label: "资产名称" }, { key: "attachment_deviceMaintain", label: "附件" }, { key: "create_time_deviceMaintain", label: "创建时间" }, { key: "maintain_content_deviceMaintain", label: "维护内容" }, { key: "this_maintain_time_deviceMaintain", label: "本次维护时间" }, { key: "next_maintain_time_deviceMaintain", label: "下次维护时间" }, { key: "asset_dept_deviceMaintain", label: "资产部门" }, { key: "maintain_cycle_deviceMaintain", label: "维护周期" }, { key: "spec_model_deviceMaintain", label: "规格型号" }, { key: "asset_status_deviceMaintain", label: "资产状态" }
]},
{ key: "DeviceMetering", label: "设备计量", fields: [
{ key: "meter_responsible_deviceMetering", label: "计量负责人" }, { key: "asset_class_deviceMetering", label: "资产分类" }, { key: "asset_no_deviceMetering", label: "资产编号" }, { key: "asset_name_deviceMetering", label: "资产名称" }, { key: "attachment_deviceMetering", label: "附件" }, { key: "calibration_type_deviceMetering", label: "校正别" }, { key: "calibration_result_deviceMetering", label: "校准结果" }, { key: "this_calibration_date_deviceMetering", label: "本次校正日期" }, { key: "measure_type_deviceMetering", label: "计量类型" }, { key: "next_calibration_date_deviceMetering", label: "下次校正日期" }, { key: "factory_no_deviceMetering", label: "出厂编号" }, { key: "asset_dept_deviceMetering", label: "资产部门" }, { key: "cycle_deviceMetering", label: "周期" }, { key: "remark_deviceMetering", label: "备注说明" }, { key: "asset_responsible_deviceMetering", label: "资产责任人" }, { key: "spec_model_deviceMetering", label: "规格型号" }, { key: "asset_status_deviceMetering", label: "资产状态" }, { key: "update_time_deviceMetering", label: "更新时间" }
]},
{ key: "DeviceRepair", label: "设备维修", fields: [
{ key: "repair_responsible_deviceRepair", label: "维修责任人" }, { key: "asset_class_deviceRepair", label: "资产分类" }, { key: "expected_return_date_deviceRepair", label: "预计外修回厂日期" }, { key: "fault_phenomenon_deviceRepair", label: "故障现象" }, { key: "asset_no_deviceRepair", label: "资产编号" }, { key: "asset_name_deviceRepair", label: "资产名称" }, { key: "fault_analysis_deviceRepair", label: "故障原因分析及维修具体情况" }, { key: "fault_type_deviceRepair", label: "故障类型" }, { key: "attachment_deviceRepair", label: "附件" }, { key: "manufacturer_deviceRepair", label: "制造厂家" }, { key: "repair_finish_time_deviceRepair", label: "维修完成时间" }, { key: "factory_no_deviceRepair", label: "出厂编号" }, { key: "external_repair_unit_deviceRepair", label: "外修单位" }, { key: "asset_dept_deviceRepair", label: "资产部门" }, { key: "remark_deviceRepair", label: "备注说明" }, { key: "internal_external_deviceRepair", label: "内修/外修" }, { key: "spec_model_deviceRepair", label: "规格型号" }, { key: "repair_start_time_deviceRepair", label: "维修发起时间" }
]},
{ key: "DeviceTransfer", label: "设备转移", fields: [
{ key: "applicant_deviceTransfer", label: "申请人" }, { key: "original_dept_deviceTransfer", label: "资产原部门" }, { key: "asset_no_deviceTransfer", label: "资产编号" }, { key: "asset_name_deviceTransfer", label: "资产名称" }, { key: "receive_factory_deviceTransfer", label: "接收厂区" }, { key: "receive_dept_deviceTransfer", label: "接收部门" }, { key: "attachment_deviceTransfer", label: "附件" }, { key: "receive_responsible_deviceTransfer", label: "接收责任人" }, { key: "create_time_deviceTransfer", label: "创建时间" }, { key: "remark_deviceTransfer", label: "备注说明" }, { key: "factory_no_deviceTransfer", label: "出厂编号" }, { key: "spec_model_deviceTransfer", label: "规格型号" }
]},
{ key: "DeviceLoan", label: "设备转借", fields: [
{ key: "applicant_dept_deviceLoan", label: "申请人部门" }, { key: "applicant_deviceLoan", label: "申请人" }, { key: "loan_due_time_deviceLoan", label: "转借到期时间" }, { key: "loan_factory_deviceLoan", label: "资产借用厂区" }, { key: "asset_no_deviceLoan", label: "资产编号" }, { key: "asset_name_deviceLoan", label: "资产名称" }, { key: "loan_responsible_deviceLoan", label: "资产借用责任人" }, { key: "factory_no_deviceLoan", label: "出厂编号" }, { key: "attachment_deviceLoan", label: "附件" }, { key: "create_time_deviceLoan", label: "创建时间" }, { key: "remark_deviceLoan", label: "备注说明" }, { key: "spec_model_deviceLoan", label: "规格型号" }, { key: "loan_start_time_deviceLoan", label: "转借发起时间" }
]},
{ key: "EmployeeManage", label: "员工管理", fields: [
{ key: "create_time_employeeManage", label: "创建时间" }, { key: "email_employeeManage", label: "邮箱" }, { key: "employee_name_employeeManage", label: "员工名称" }, { key: "org_structure_employeeManage", label: "组织结构" }, { key: "contact_phone_employeeManage", label: "联系电话" }
]},
{ key: "DeviceExFactory", label: "设备出厂", fields: [
{ key: "applicant_dept_deviceExFactory", label: "申请人部门" }, { key: "applicant_deviceExFactory", label: "申请人" }, { key: "asset_no_deviceExFactory", label: "资产编号" }, { key: "asset_name_deviceExFactory", label: "资产名称" }, { key: "attachment_deviceExFactory", label: "附件" }, { key: "create_time_deviceExFactory", label: "创建时间" }, { key: "expected_return_time_deviceExFactory", label: "预计返厂时间" }, { key: "ex_factory_time_deviceExFactory", label: "出厂时间" }, { key: "factory_no_deviceExFactory", label: "出厂编号" }, { key: "remark_deviceExFactory", label: "备注说明" }, { key: "spec_model_deviceExFactory", label: "规格型号" }
]},
{ key: "FactoryManage", label: "厂区管理", fields: [
{ key: "creator_factoryManage", label: "创建人" }, { key: "create_time_factoryManage", label: "创建时间" }, { key: "factory_name_factoryManage", label: "厂区名称" }, { key: "remark_factoryManage", label: "备注" }
]},
{ key: "InventoryDetail", label: "盘点详情", fields: [
{ key: "asset_no_inventoryDetail", label: "资产编号" }, { key: "asset_name_inventoryDetail", label: "资产名称" }, { key: "epc_inventoryDetail", label: "EPC" }, { key: "factory_no_inventoryDetail", label: "出厂编号" }, { key: "asset_dept_inventoryDetail", label: "资产部门" }, { key: "factory_inventoryDetail", label: "厂区" }, { key: "responsible_inventoryDetail", label: "责任人" }, { key: "info_machine_id_inventoryDetail", label: "信息机ID" }, { key: "info_machine_name_inventoryDetail", label: "信息机名称" }, { key: "spec_model_inventoryDetail", label: "规格型号" }
]},
{ key: "AssetInventory", label: "资产盘点", fields: [
{ key: "inventory_no_assetInventory", label: "盘点单号" }, { key: "to_inventory_assetInventory", label: "应盘资产" }, { key: "create_time_assetInventory", label: "创建时间" }, { key: "loss_assets_assetInventory", label: "盘亏资产" }, { key: "gain_assets_assetInventory", label: "盘盈资产" }, { key: "no_epc_assets_assetInventory", label: "无EPC资产" }, { key: "inventory_time_assetInventory", label: "盘点时间" }
]},
{ key: "IdleHandle", label: "闲置处理", fields: [
{ key: "apply_dept_idleHandle", label: "申请部门" }, { key: "years_to_plant_idleHandle", label: "到厂年限" }, { key: "applicant_idleHandle", label: "申请人" }, { key: "factory_no_idleHandle", label: "出厂编号" }, { key: "asset_no_idleHandle", label: "资产编号" }, { key: "asset_name_idleHandle", label: "资产名称" }, { key: "description_idleHandle", label: "描述说明" }, { key: "spec_model_idleHandle", label: "规格型号" }, { key: "attachment_idleHandle", label: "附件" }, { key: "asset_status_idleHandle", label: "资产状态" }, { key: "create_time_idleHandle", label: "创建时间" }, { key: "idle_opinion_idleHandle", label: "闲置处理意见" }
]},
{ key: "MaterialMetering", label: "物料计量", fields: [
{ key: "meter_responsible_materialMetering", label: "计量负责人" }, { key: "material_no_materialMetering", label: "物料编号" }, { key: "remark_materialMetering", label: "备注说明" }, { key: "material_class_materialMetering", label: "物料分类" }, { key: "material_name_materialMetering", label: "物料名称" }, { key: "asset_responsible_materialMetering", label: "资产责任人" }, { key: "attachment_materialMetering", label: "附件" }, { key: "measure_type_materialMetering", label: "计量类型" }, { key: "spec_model_materialMetering", label: "规格型号" }, { key: "calibration_type_materialMetering", label: "校正别" }, { key: "next_calibration_date_materialMetering", label: "下次校正日期" }, { key: "material_status_materialMetering", label: "物料状态" }, { key: "calibration_result_materialMetering", label: "校准结果" }, { key: "asset_dept_materialMetering", label: "资产部门" }, { key: "update_time_materialMetering", label: "更新时间" }, { key: "this_calibration_date_materialMetering", label: "本次校正日期" }, { key: "cycle_materialMetering", label: "周期" }
]},
{ key: "AccessControlManage", label: "门禁权限管理", fields: [
{ key: "asset_no_accessControlManage", label: "资产编号" }, { key: "access_control_accessControlManage", label: "门禁" }, { key: "asset_name_accessControlManage", label: "资产名称" }, { key: "asset_status_accessControlManage", label: "资产状态" }, { key: "epc_accessControlManage", label: "EPC" }, { key: "access_expire_time_accessControlManage", label: "门禁到期时间" }, { key: "asset_dept_accessControlManage", label: "资产部门" }, { key: "asset_responsible_accessControlManage", label: "资产负责人" }
]},
{ key: "RoleManage", label: "角色管理", fields: [
{ key: "creator_roleManage", label: "创建人" }, { key: "create_time_roleManage", label: "创建时间" }, { key: "description_roleManage", label: "描述" }, { key: "role_name_roleManage", label: "角色名称" }, { key: "permission_type_roleManage", label: "权限类型" }
]},
{ key: "SensorManage", label: "信息机管理", fields: [
{ key: "create_time_sensorManage", label: "创建时间" }, { key: "factory_name_sensorManage", label: "厂区名称" }, { key: "sensor_no_sensorManage", label: "信息机编号" }, { key: "sensor_name_sensorManage", label: "信息机名称" }, { key: "sensor_type_sensorManage", label: "信息机类型" }, { key: "online_status_sensorManage", label: "在线状态" }
]},
{ key: "UserManage", label: "用户管理", fields: [
{ key: "create_time_userManage", label: "创建时间" }, { key: "email_userManage", label: "邮箱" }, { key: "employee_name_userManage", label: "员工名称" }, { key: "data_permission_userManage", label: "数据权限" }, { key: "description_userManage", label: "描述" }, { key: "role_name_userManage", label: "角色名称" }, { key: "contact_phone_userManage", label: "联系电话" }, { key: "username_userManage", label: "用户名" }
]},
];
/** 每组内按 field.key 去重,保证 key 不重复(保留首次出现) */
function dedupeFieldsByKey(groups) {
return groups.map((group) => {
const seen = new Set();
const fields = group.fields.filter((f) => {
if (seen.has(f.key)) return false;
seen.add(f.key);
return true;
});
return { ...group, fields };
});
}
export const FIELD_PERMISSION_GROUPS = dedupeFieldsByKey(RAW_FIELD_PERMISSION_GROUPS);

29
web/src/i18n.js Normal file
View File

@@ -0,0 +1,29 @@
import { createI18n } from 'vue-i18n';
import zh from './locales/zh.js';
import en from './locales/en.js';
import th from './locales/th.js';
const messages = {
'zh': zh,
'zh-CN': zh,
'en': en,
'en-US': en,
'th': th,
'th-TH': th
};
// 暂时固定为中文(不读取历史 language避免英文/泰文残留)
const FIXED_LOCALE = 'zh-CN';
if (typeof localStorage !== 'undefined') {
localStorage.setItem('language', FIXED_LOCALE);
}
const i18n = createI18n({
legacy: false,
locale: FIXED_LOCALE,
fallbackLocale: 'zh-CN',
messages
});
export default i18n;

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,4 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.4146 1H5.68762C5.4089 1 5.1416 1.11116 4.94452 1.30902C4.74744 1.50688 4.63672 1.77524 4.63672 2.05506V15.1694C4.63672 15.3093 4.69208 15.4435 4.79062 15.5425C4.88916 15.6414 5.02281 15.697 5.16217 15.697H14.4143C14.5537 15.697 14.6873 15.6414 14.7858 15.5425C14.8844 15.4435 14.9397 15.3093 14.9397 15.1694V5.96933H10.6773C10.6076 5.96933 10.5408 5.94154 10.4915 5.89207C10.4423 5.84261 10.4146 5.77552 10.4146 5.70556V1ZM6.85622 9.1345C6.85622 8.99459 6.91158 8.86041 7.01012 8.76148C7.10866 8.66255 7.24231 8.60697 7.38167 8.60697H11.9289C12.0683 8.60697 12.2019 8.66255 12.3005 8.76148C12.399 8.86041 12.4544 8.99459 12.4544 9.1345C12.4544 9.27441 12.399 9.40859 12.3005 9.50752C12.2019 9.60645 12.0683 9.66203 11.9289 9.66203H7.38167C7.24231 9.66203 7.10866 9.60645 7.01012 9.50752C6.91158 9.40859 6.85622 9.27441 6.85622 9.1345ZM7.45208 11.4873H11.8585C11.9979 11.4873 12.1315 11.5429 12.2301 11.6418C12.3286 11.7407 12.384 11.8749 12.384 12.0148C12.384 12.1547 12.3286 12.2889 12.2301 12.3878C12.1315 12.4868 11.9979 12.5423 11.8585 12.5423H7.45208C7.31272 12.5423 7.17907 12.4868 7.08053 12.3878C6.98199 12.2889 6.92663 12.1547 6.92663 12.0148C6.92663 11.8749 6.98199 11.7407 7.08053 11.6418C7.17907 11.5429 7.31272 11.4873 7.45208 11.4873Z" fill="white"/>
<path d="M11.268 1L14.9394 5.01127H11.268V1ZM1 1.30321C1 1.23341 1.02772 1.16646 1.07707 1.1171C1.12642 1.06774 1.19336 1.04001 1.26315 1.04001H3.41463C3.48442 1.04001 3.55135 1.06774 3.6007 1.1171C3.65005 1.16646 3.67778 1.23341 3.67778 1.30321V3.09513H1V1.30321ZM1 4.15637H3.67778V14.0424C3.67765 14.0929 3.66303 14.1422 3.63567 14.1845L2.6073 15.8785C2.58362 15.9156 2.55103 15.9461 2.51252 15.9673C2.47401 15.9886 2.4308 15.9998 2.38683 16C2.34286 16.0002 2.29954 15.9894 2.26083 15.9685C2.22212 15.9477 2.18924 15.9175 2.16521 15.8807L1.04316 14.1856C1.01504 14.1428 1.00004 14.0926 1 14.0414V4.15637Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.9568 7.01354C14.8919 6.65154 14.7553 6.30616 14.555 5.99772C14.3548 5.68928 14.0948 5.42401 13.7905 5.21751C13.328 4.89699 12.7787 4.72525 12.2161 4.72525C11.6534 4.72525 11.1041 4.89699 10.6416 5.21751L9.47535 4.05125C9.664 3.81415 9.79227 3.53481 9.84915 3.2372C9.90603 2.9396 9.88982 2.63264 9.80191 2.34268C9.69487 1.9856 9.48428 1.66832 9.1968 1.431C8.90933 1.19368 8.5579 1.047 8.18701 1.00954C7.81612 0.972074 7.44245 1.04551 7.11332 1.22055C6.78419 1.39558 6.51441 1.66435 6.33812 1.99281C6.1971 2.2628 6.12345 2.56289 6.12345 2.8675C6.12345 3.17211 6.1971 3.4722 6.33812 3.74219L5.0844 5.13004C4.4869 4.80032 3.78804 4.70485 3.124 4.86224C2.45996 5.01962 1.87829 5.41859 1.49233 5.9814C1.07583 6.58316 0.914411 7.32529 1.04332 8.04568C1.11177 8.40012 1.25016 8.73737 1.4504 9.03773C1.65065 9.3381 1.90873 9.59555 2.20958 9.79506C2.66902 10.1177 3.21679 10.2908 3.77819 10.2907C4.2797 10.2904 4.77162 10.1533 5.20102 9.89419L6.43143 11.1596C6.25651 11.4501 6.1638 11.7826 6.16319 12.1217C6.16111 12.3673 6.20799 12.6109 6.30111 12.8382C6.39422 13.0655 6.53169 13.2719 6.7055 13.4454C7.04415 13.7912 7.5029 13.9933 7.98659 14.0099C8.47027 14.0264 8.94178 13.8562 9.30329 13.5344C9.66481 13.2126 9.8886 12.7641 9.92823 12.2817C9.96787 11.7994 9.82031 11.3203 9.51617 10.9438L10.6824 9.70759C11.1419 10.0302 11.6896 10.2033 12.251 10.2033C12.6949 10.2048 13.1327 10.0997 13.5275 9.89676C13.9222 9.69379 14.2624 9.39893 14.5194 9.03699C14.9271 8.44689 15.0843 7.71932 14.9568 7.01354ZM4.6179 8.20895C4.77625 8.0127 4.86084 7.76717 4.85698 7.51503C4.85813 7.29821 4.79496 7.08592 4.67546 6.905C4.55596 6.72408 4.38549 6.58266 4.18562 6.49863C3.98574 6.41459 3.76544 6.39171 3.55256 6.43288C3.33968 6.47405 3.14379 6.57742 2.98966 6.72991C2.83553 6.88241 2.73008 7.07719 2.68665 7.28962C2.64322 7.50205 2.66376 7.72259 2.74566 7.92334C2.82757 8.1241 2.96717 8.29606 3.1468 8.41748C3.32644 8.5389 3.53805 8.60432 3.75487 8.60548H3.91231L4.6004 9.3169C4.43929 9.39358 4.26868 9.44849 4.09308 9.48017C3.70794 9.5483 3.31125 9.50099 2.95293 9.3442C2.59462 9.18741 2.29068 8.92814 2.07936 8.59901C1.86805 8.26989 1.75881 7.88562 1.76537 7.49455C1.77194 7.10348 1.89403 6.7231 2.11628 6.40126C2.26369 6.1854 2.45341 6.00174 2.67394 5.86141C2.89446 5.72108 3.14119 5.627 3.39916 5.58488C3.51451 5.56345 3.63173 5.55368 3.74904 5.55572C4.15189 5.55541 4.54498 5.67965 4.87447 5.91143C5.08991 6.05794 5.27336 6.24667 5.41369 6.46619C5.55402 6.68571 5.64831 6.93144 5.69085 7.18848C5.74137 7.44535 5.74005 7.70972 5.68698 7.96607C5.6339 8.22242 5.53015 8.46559 5.38179 8.68129C5.33875 8.74425 5.29201 8.80461 5.24184 8.86206L4.6179 8.20895ZM10.6125 6.34877L11.2539 7.00188C11.124 7.2388 11.0866 7.51552 11.1491 7.7784C11.2116 8.04128 11.3695 8.2716 11.5921 8.42471C11.8296 8.58913 12.1221 8.65375 12.4067 8.60468C12.6914 8.5556 12.9454 8.39675 13.1141 8.1623V8.11565C13.2625 7.88843 13.3188 7.61319 13.2715 7.34592C13.2468 7.20306 13.1939 7.06656 13.1158 6.9444C13.0377 6.82225 12.936 6.7169 12.8167 6.63451C12.6754 6.53685 12.5134 6.47337 12.3433 6.44908C12.1733 6.42479 12 6.44036 11.837 6.49456L11.2014 5.7948C11.53 5.5994 11.9074 5.50165 12.2895 5.51301C12.6716 5.52437 13.0426 5.64437 13.359 5.85895C13.7912 6.15826 14.0868 6.61697 14.1808 7.13421C14.2749 7.65144 14.1596 8.18485 13.8605 8.61714C13.7168 8.84269 13.5289 9.03684 13.3082 9.18789C13.0876 9.33895 12.8386 9.4438 12.5763 9.49613C12.3141 9.54846 12.0439 9.54719 11.7822 9.4924C11.5204 9.43761 11.2724 9.33042 11.0532 9.1773C10.8339 9.02417 10.6479 8.82827 10.5063 8.60138C10.3648 8.37449 10.2705 8.1213 10.2294 7.85706C10.1882 7.59281 10.2009 7.32296 10.2667 7.06376C10.3326 6.80455 10.4502 6.56135 10.6125 6.34877ZM7.11368 4.54691C7.38001 4.70264 7.68398 4.78228 7.99246 4.77714C8.30093 4.772 8.60208 4.68228 8.86307 4.51775L10.0935 5.76565L9.97101 5.92892C9.6338 6.41571 9.46147 6.99775 9.47931 7.58966C9.49716 8.18156 9.70423 8.75217 10.0701 9.21776L8.90389 10.4948C8.60184 10.3374 8.26265 10.265 7.92264 10.2856C7.58264 10.3061 7.25462 10.4188 6.97373 10.6114L5.80748 9.44518C6.28282 8.92057 6.53824 8.23326 6.52088 7.52553C6.50351 6.81781 6.21468 6.14386 5.71418 5.64319L6.83961 4.42445L6.7813 4.34281C6.89299 4.42699 7.01437 4.49747 7.14284 4.55274L7.11368 4.54691Z" fill="#3067E5"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.9514 16C11.2673 16 9.90284 14.5535 9.90284 12.768C9.90284 10.9823 11.2672 9.53585 12.9514 9.53585C14.6355 9.53585 16 10.9823 16 12.7682C16 14.5535 14.6357 16 12.9514 16ZM14.9991 11.7352C14.9772 11.7113 14.9506 11.6921 14.9209 11.6791C14.8912 11.666 14.8591 11.6592 14.8267 11.6592C14.7942 11.6592 14.7621 11.666 14.7325 11.6791C14.7028 11.6921 14.6761 11.7113 14.6543 11.7352L12.5134 14.0053L11.3357 12.7569C11.3138 12.733 11.2872 12.7139 11.2575 12.7008C11.2278 12.6878 11.1958 12.681 11.1634 12.681C11.1309 12.681 11.0989 12.6878 11.0692 12.7008C11.0395 12.7139 11.0129 12.733 10.991 12.7569C10.8956 12.8582 10.8956 13.0211 10.991 13.1224L12.37 14.5844C12.3919 14.6083 12.4185 14.6274 12.4482 14.6405C12.4778 14.6536 12.5099 14.6603 12.5423 14.6603C12.5748 14.6603 12.6068 14.6536 12.6365 14.6405C12.6662 14.6274 12.6928 14.6083 12.7147 14.5844C12.7479 14.5491 12.7666 14.5029 12.777 14.4565L14.9991 12.1009C15.0945 12.0018 15.0945 11.8366 14.9991 11.7354V11.7352ZM11.4686 9.22341C11.4344 9.21634 11.3996 9.21263 11.3647 9.21233H4.04875C3.7121 9.21233 3.43801 9.50091 3.43801 9.85972V10.1173C3.43801 10.474 3.71006 10.7647 4.04858 10.7647H9.82392C9.62836 11.1077 9.48486 11.478 9.39813 11.8632H4.04858C3.7121 11.8632 3.43801 12.1516 3.43801 12.5105V12.7682C3.43801 13.1248 3.71006 13.4154 4.04858 13.4154H9.34835C9.51864 14.492 10.1064 15.4211 10.9309 16.0002H2.21875C1.54631 16 1 15.421 1 14.7076V3.06949C1 2.35631 1.54614 1.77727 2.21909 1.77727H3.43801V2.68205C3.43801 3.3954 3.98415 3.97443 4.6571 3.97443C5.32989 3.97443 5.87602 3.3954 5.87602 2.68205V1.77727H6.48659V2.68205C6.48659 3.3954 7.03273 3.97443 7.70568 3.97443C8.37847 3.97443 8.9246 3.3954 8.9246 2.68205V1.77727H9.53739V2.68205C9.53739 3.3954 10.0835 3.97443 10.7563 3.97443C11.4293 3.97443 11.9754 3.3954 11.9754 2.68205V1.77727H13.1943C13.8673 1.77727 14.4134 2.35631 14.4134 3.06949V9.21216C13.9648 9.00523 13.4706 8.88642 12.9492 8.88642C12.437 8.88923 11.9316 9.0042 11.4686 9.22324V9.22341ZM11.9754 7.20676C11.9754 6.85 11.7034 6.55937 11.3648 6.55937H4.04858C3.7121 6.55937 3.43801 6.84778 3.43801 7.20676V7.46415C3.43801 7.82091 3.71006 8.11153 4.04858 8.11153H11.3648C11.7013 8.11153 11.9754 7.82312 11.9754 7.46432V7.20676ZM10.7563 3.3296C10.4198 3.3296 10.1457 3.04119 10.1457 2.68222V1.64756C10.1457 1.29045 10.4178 1 10.7563 1C11.0948 1 11.3669 1.28841 11.3669 1.64722V2.68205C11.3648 3.03881 11.0928 3.32943 10.7563 3.32943V3.3296ZM7.70773 3.3296C7.37125 3.3296 7.09716 3.04119 7.09716 2.68222V1.64756C7.09716 1.29045 7.3692 1 7.70773 1C8.04625 1 8.3183 1.28841 8.3183 1.64722V2.68205C8.31625 3.03881 8.0442 3.32943 7.70773 3.32943V3.3296ZM4.65915 3.3296C4.32267 3.3296 4.04858 3.04119 4.04858 2.68222V1.64756C4.04858 1.29045 4.32062 1 4.65915 1C4.99562 1 5.26972 1.28841 5.26972 1.64722V2.68205C5.26767 3.03881 4.99562 3.32943 4.65915 3.32943V3.3296Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

BIN
web/src/images/login/ch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

BIN
web/src/images/login/en.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
web/src/images/login/tn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

BIN
web/src/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

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