This commit is contained in:
Mr.j 2025-08-11 11:14:16 +08:00
commit e3ec37fa79
10 changed files with 2241 additions and 452 deletions

View File

@ -3,10 +3,15 @@
VITE_API_PREFIX = '/dev-api'
# 接口地址
VITE_API_BASE_URL = 'http://localhost:8888/'
# VITE_API_BASE_URL = 'http://pms.dtyx.net:9158/'
# VITE_API_BASE_URL = 'http://localhost:8888/'
VITE_API_BASE_URL = 'http://10.18.34.163:8888/'
# VITE_API_BASE_URL = 'http://10.18.34.213:8888/'
# 接口地址 (WebSocket)
VITE_API_WS_URL = 'ws://localhost:8000'
# VITE_API_WS_URL = 'ws://localhost:8000'
VITE_API_WS_URL = 'ws://10.18.34.163:8000'
# VITE_API_WS_URL = 'ws://10.18.34.213:8000'
# 地址前缀
VITE_BASE = '/'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -37,7 +37,7 @@
<div class="folder-content">
<!-- 加载状态 -->
<a-skeleton :loading="loading && folderList.length === 0" :rows="5" v-if="loading" animation="pulse">
<a-skeleton :loading="loading && folderList.length === 0" :rows="5" v-if="loading" :animation="true">
<template #skeleton>
<div class="skeleton-item flex items-center px-4 py-3" v-for="i in 5" :key="i">
<div class="w-6 h-6 rounded bg-gray-200 mr-3"></div>
@ -53,51 +53,53 @@
</a-typography-text>
</div>
<a-empty v-if="!loading && folderList.length === 0" :description="searchKeyword ? '未找到匹配的文件夹' : '暂无文件夹'" />
<a-list v-if="!loading && folderList.length > 0" class="folder-list">
<!-- 文件夹列表 -->
<a-list-item
v-for="folder in folderList"
:key="folder.id"
:class="['folder-list-item', { 'active': currentFolderId === folder.id }]"
@click="handleFolderClick(folder.id)"
:tooltip="sidebarCollapsed ? folder.name : ''"
>
<!-- 第一行文件夹图标和名称 -->
<div class="folder-main-info">
<div class="folder-icon-wrapper">
<IconFolder class="folder-icon" :style="{ color: folderColor }" />
</div>
<span v-if="!sidebarCollapsed" class="folder-name">{{ folder.name }}</span>
</div>
<!-- 树形文件夹结构 -->
<div v-if="!loading && folderList.length > 0" class="folder-tree-container">
<!-- 第二行文件夹操作按钮 -->
<div v-if="!sidebarCollapsed" class="folder-actions-row">
<a-tree
:data="folderTreeData"
:selected-keys="currentFolderId ? [currentFolderId] : []"
:field-names="{ key: 'key', title: 'title', children: 'children' }"
:show-line="!sidebarCollapsed"
:block-node="true"
:default-expand-all="true"
@select="handleFolderSelect"
@dblclick="handleFolderDoubleClick"
class="folder-tree"
:class="{ 'collapsed': sidebarCollapsed }"
/>
<!-- 文件夹操作按钮 -->
<div v-if="currentFolderId && currentFolderId !== '0'" class="folder-actions-bar" style="padding: 8px; border-top: 1px solid #e5e6eb; margin-top: 8px;">
<a-space>
<a-button
type="text"
shape="circle"
size="small"
@click.stop="handleRenameFolder(folder, folder.id, folder.name)"
@click="handleRenameCurrentFolder"
tooltip="重命名"
class="action-btn"
>
<icon-edit />
<template #icon><icon-edit /></template>
重命名
</a-button>
<a-button
type="text"
shape="circle"
size="small"
@click.stop="handleDeleteFolder(folder)"
@click="handleDeleteCurrentFolder"
tooltip="删除"
status="danger"
class="action-btn"
>
<icon-delete />
<template #icon><icon-delete /></template>
删除
</a-button>
</a-space>
</div>
</div>
</a-list-item>
</a-list>
</div>
<!-- 侧边栏底部分页控件 -->
@ -108,7 +110,8 @@
</a-typography-text>
</div>
<div class="pagination-controls">
<!-- 隐藏分页控件因为现在获取所有文件夹 -->
<!-- <div class="pagination-controls">
<a-pagination
:current="currentPage"
:page-size="pageSize"
@ -120,7 +123,7 @@
size="small"
show-total
/>
</div>
</div> -->
</div>
</a-layout-sider>
@ -128,8 +131,14 @@
<a-layout-header class="file-header">
<div class="breadcrumbs">
<a-breadcrumb>
<a-breadcrumb-item>知识库</a-breadcrumb-item>
<a-breadcrumb-item>{{ currentFolderName }}</a-breadcrumb-item>
<a-breadcrumb-item
v-for="(item, index) in breadcrumbPath"
:key="index"
:class="{ 'clickable': index < breadcrumbPath.length - 1 }"
@click="handleBreadcrumbClick(index)"
>
{{ item }}
</a-breadcrumb-item>
</a-breadcrumb>
<a-button
type="text"
@ -369,20 +378,18 @@
<a-input v-model="folderForm.name" placeholder="输入文件夹名称" max-length="50" />
</a-form-item>
<a-form-item label="父级目录" field="parentId" :rules="folderRules.parentId">
<a-select
<a-tree-select
v-model="folderForm.parentId"
placeholder="请选择父级目录"
:data="folderTreeSelectData"
:field-names="{ key: 'id', title: 'name', children: 'children' }"
allow-clear
:tree-props="{ showLine: true }"
>
<a-option value="0">根目录</a-option>
<a-option
v-for="folder in folderList"
:key="folder.id"
:value="folder.id"
v-if="!folderForm.id || folder.id !== folderForm.id"
>
{{ folder.name }}
</a-option>
</a-select>
<template #title="{ node }">
<span>{{ node?.title || node?.name }}</span>
</template>
</a-tree-select>
</a-form-item>
</a-form>
</a-modal>
@ -592,7 +599,8 @@ import {
const folderList = ref([]);
const fileList = ref([]);
const currentFolderId = ref('');
const currentFolderName = ref('');
// currentFolderName使
// const currentFolderName = ref('');
const loading = ref(false);
const folderDialogVisible = ref(false);
const uploadDialogVisible = ref(false);
@ -664,6 +672,111 @@ const canUpload = computed(() => {
return hasFiles.value && !uploading.value && uploadForm.folderId;
});
//
const folderTreeData = computed(() => {
console.log('=== folderTreeData计算属性执行 ===');
console.log('folderList.value:', folderList.value);
console.log('folderList.value.length:', folderList.value?.length);
if (!folderList.value || folderList.value.length === 0) {
console.log('folderList为空返回空数组');
return [];
}
//
const folderMap = new Map();
const rootFolders = [];
console.log('=== 开始创建文件夹映射 ===');
//
folderList.value.forEach((folder, index) => {
console.log(`处理第${index + 1}个文件夹:`, folder);
//
if (folder && folder.id && folder.name) {
const node = {
key: folder.id, // Treekey
title: folder.name, // Treetitle
children: [], // Treechildren
//
id: folder.id,
name: folder.name,
parentId: folder.parentId
};
folderMap.set(folder.id, node);
console.log(`✅ 成功添加文件夹到映射: ${folder.name} (ID: ${folder.id})`);
} else {
console.warn('❌ 跳过不完整的文件夹数据:', folder);
}
});
console.log('=== 开始构建树形结构 ===');
console.log('文件夹映射表大小:', folderMap.size);
//
folderList.value.forEach((folder, index) => {
console.log(`构建第${index + 1}个文件夹的树形结构:`, folder);
//
if (!folder || !folder.id || !folder.name) {
console.warn('❌ 跳过不完整的文件夹数据:', folder);
return;
}
const node = folderMap.get(folder.id);
if (!node) {
console.warn('❌ 找不到文件夹节点:', folder.id);
return;
}
console.log(`处理文件夹: ${folder.name} (ID: ${folder.id}, ParentID: ${folder.parentId})`);
if (folder.parentId === '0' || folder.parentId === 0) {
//
rootFolders.push(node);
console.log(`✅ 添加为根文件夹: ${folder.name}`);
} else {
//
const parent = folderMap.get(folder.parentId);
if (parent) {
parent.children.push(node);
console.log(`✅ 添加为子文件夹: ${folder.name} -> ${parent.name}`);
} else {
//
console.warn('⚠️ 找不到父文件夹,将文件夹作为根文件夹:', folder.name, folder.parentId);
rootFolders.push(node);
}
}
});
console.log('=== 树形结构构建完成 ===');
console.log('根文件夹数量:', rootFolders.length);
console.log('构建的树形结构:', rootFolders);
//
rootFolders.forEach((root, index) => {
console.log(`根文件夹${index + 1}:`, {
id: root.id,
name: root.name,
childrenCount: root.children?.length || 0
});
});
return rootFolders;
});
//
const folderTreeSelectData = computed(() => {
const rootOption = {
key: '0', // Treekey
title: '根目录', // Treetitle
children: [], // Treechildren
//
id: '0',
name: '根目录'
};
return [rootOption, ...folderTreeData.value];
});
//
const searchKeyword = ref(''); //
const fileSearchKeyword = ref(''); //
@ -682,7 +795,7 @@ const initData = async () => {
const apiParams = {
page: currentPage.value,
pageSize: pageSize.value,
pageSize: 1000, //
folderName: searchKeyword.value.trim() || undefined
};
@ -701,11 +814,43 @@ const initData = async () => {
//
if (folderRes.code === 200 && folderRes.data) {
const processedFolders = folderRes.data.rows.map(folder => ({
console.log('=== 开始处理数据 ===');
console.log('folderRes.data:', folderRes.data);
console.log('folderRes.data.rows:', folderRes.data.rows);
console.log('folderRes.data.rows类型:', typeof folderRes.data.rows);
console.log('folderRes.data.rows长度:', folderRes.data.rows?.length);
//
if (!folderRes.data.rows || !Array.isArray(folderRes.data.rows)) {
console.error('API返回的数据结构不正确rows字段不存在或不是数组');
console.log('可用的字段:', Object.keys(folderRes.data));
folderList.value = [];
totalFolders.value = 0;
return;
}
const processedFolders = folderRes.data.rows.map((folder, index) => {
console.log(`处理第${index + 1}个原始文件夹数据:`, folder);
console.log(`原始数据字段:`, Object.keys(folder));
console.log(`folderId:`, folder.folderId);
console.log(`folderName:`, folder.folderName);
console.log(`parentId:`, folder.parentId);
//
if (!folder.folderId || !folder.folderName) {
console.warn('❌ 跳过不完整的文件夹数据:', folder);
return null;
}
const processedFolder = {
id: String(folder.folderId),
name: folder.folderName,
name: String(folder.folderName),
parentId: String(folder.parentId || 0)
}));
};
console.log(`✅ 处理后的文件夹数据:`, processedFolder);
return processedFolder;
}).filter(Boolean); // null
folderList.value = processedFolders;
totalFolders.value = Number(folderRes.data.total) || 0;
@ -720,6 +865,8 @@ const initData = async () => {
totalFolders.value = 0;
console.log('API响应异常清空列表');
console.log('响应码不是200或数据为空');
console.log('folderRes.code:', folderRes.code);
console.log('folderRes.data:', folderRes.data);
}
} catch (error) {
console.error('初始化文件夹数据失败:', error);
@ -784,6 +931,7 @@ const handleSearchClear = () => {
}
currentPage.value = 1;
console.log('重置页码为:', currentPage.value);
//
initData();
};
@ -854,13 +1002,6 @@ const loadFiles = async (folderId) => {
}
currentFolderId.value = folderId;
if (folderId === '0') {
currentFolderName.value = '根目录';
} else {
const folder = folderList.value.find(f => f.id === folderId);
currentFolderName.value = folder ? folder.name : '未知文件夹';
}
} catch (error) {
console.error('加载文件失败:', error);
Message.error('服务开小差,请稍后再试');
@ -873,14 +1014,141 @@ const loadFiles = async (folderId) => {
//
const handleFolderClick = (folderId) => {
const id = String(folderId);
if (currentFolderId.value !== id) {
// const handleFolderClick = (folderId) => {
// const id = String(folderId);
// if (currentFolderId.value !== id) {
// fileCurrentPage.value = 1;
// //
// fileSearchKeyword.value = '';
// }
// currentFolderId.value = id;
// };
//
const handleFolderSelect = (selectedKeys, info) => {
if (selectedKeys.length > 0) {
const folderId = selectedKeys[0];
if (currentFolderId.value !== folderId) {
fileCurrentPage.value = 1;
//
fileSearchKeyword.value = '';
}
currentFolderId.value = id;
currentFolderId.value = folderId;
loadFiles(folderId);
}
};
//
const handleFolderDoubleClick = (info) => {
console.log('文件夹双击:', info);
const { node } = info;
if (!node) return;
//
Modal.confirm({
title: '文件夹操作',
content: `请选择对文件夹"${node.title}"的操作`,
okText: '重命名',
cancelText: '删除',
onOk: () => handleRenameFolder(node),
onCancel: () => handleDeleteFolder(node)
});
};
//
const handleRenameCurrentFolder = () => {
if (!currentFolderId.value || currentFolderId.value === '0') {
Message.warning('请先选择一个文件夹');
return;
}
// folderList
const currentFolder = folderList.value.find(folder => folder.id === currentFolderId.value);
if (!currentFolder) {
Message.error('找不到当前文件夹信息');
return;
}
// node
const node = {
key: currentFolder.id,
title: currentFolder.name,
id: currentFolder.id,
name: currentFolder.name
};
handleRenameFolder(node);
};
//
const handleDeleteCurrentFolder = () => {
if (!currentFolderId.value || currentFolderId.value === '0') {
Message.warning('请先选择一个文件夹');
return;
}
// folderList
const currentFolder = folderList.value.find(folder => folder.id === currentFolderId.value);
if (!currentFolder) {
Message.error('找不到当前文件夹信息');
return;
}
// node
const node = {
key: currentFolder.id,
title: currentFolder.name,
id: currentFolder.id,
name: currentFolder.name
};
handleDeleteFolder(node);
};
//
const breadcrumbPath = computed(() => {
if (!currentFolderId.value || currentFolderId.value === '0') {
return ['知识库', '根目录'];
}
const path = ['知识库'];
let currentId = currentFolderId.value;
//
while (currentId && currentId !== '0') {
const folder = folderList.value.find(f => f.id === currentId);
if (folder) {
path.unshift(folder.name);
currentId = folder.parentId;
} else {
break;
}
}
return path;
});
//
const handleBreadcrumbClick = (index) => {
if (index === 0) {
// ""
currentFolderId.value = '0';
loadFiles('0');
} else {
// ID
const targetPath = breadcrumbPath.value.slice(0, index + 1);
const targetFolderName = targetPath[targetPath.length - 1];
//
const targetFolder = folderList.value.find(folder => folder.name === targetFolderName);
if (targetFolder) {
currentFolderId.value = targetFolder.id;
loadFiles(targetFolder.id);
}
}
};
//
@ -901,20 +1169,40 @@ const renameFileForm = reactive({
});
//
const handleRenameFolder = (folder, folderId, currentName) => {
console.log('handleRenameFolder 被调用:', { folder, folderId, currentName });
const handleRenameFolder = (folder) => {
console.log('重命名文件夹:', folder);
if (!folder) {
Message.error('文件夹信息不能为空');
return;
}
const folderId = folder.key || folder.id;
const currentName = folder.title || folder.name;
//
if (!folderId) {
console.error('folderId 为空');
Message.error('文件夹ID不能为空');
return;
}
if (!currentName) {
console.error('currentName 为空');
Message.error('文件夹名称不能为空');
return;
}
if (!currentName) {
console.error('❌ currentName 为空');
console.error('尝试从folder对象获取名称...');
const fallbackName = folder?.title || folder?.name;
console.error('fallbackName:', fallbackName);
if (!fallbackName) {
Message.error('当前文件夹名称不能为空');
return;
} else {
console.log('✅ 使用fallbackName:', fallbackName);
currentName = fallbackName;
}
}
//
@ -961,13 +1249,14 @@ const confirmRename = async () => {
if (result && result.code === 200) {
if (isRoot) {
Message.success('根目录重命名成功');
currentFolderName.value = newName.trim();
// currentFolderName使
// currentFolderName.value = newName.trim();
} else {
Message.success('文件夹重命名成功');
//
if (currentFolderId.value === folderId) {
currentFolderName.value = newName.trim();
}
// currentFolderName使
// if (currentFolderId.value === folderId) {
// currentFolderName.value = newName.trim();
// }
}
initData(); //
@ -1015,6 +1304,10 @@ const handleFilePageSizeChange = (current, size) => {
const refreshData = async () => {
refreshing.value = true;
try {
//
searchKeyword.value = '';
currentPage.value = 1;
await initData();
if (currentFolderId.value) {
await loadFiles(currentFolderId.value);
@ -1035,14 +1328,22 @@ const submitFolderForm = async () => {
await updateFolderApi(folderForm.id, folderForm.name);
Message.success('文件夹重命名成功');
} else {
await createFolderApi({
const result = await createFolderApi({
name: folderForm.name,
parentId: folderForm.parentId
});
Message.success('文件夹创建成功');
//
await initData();
// ID
if (result.data && result.data.folderId) {
currentFolderId.value = String(result.data.folderId);
loadFiles(currentFolderId.value);
}
}
folderDialogVisible.value = false;
initData();
} catch (error) {
console.error('文件夹操作失败:', error);
Message.error(folderForm.id ? '重命名失败' : '创建失败');
@ -1669,7 +1970,7 @@ const showAudioPreview = (url, fileName) => {
marginBottom: '20px',
color: '#165DFF'
}
}, '🎵'),
}, '<EFBFBD><EFBFBD>'),
//
h('audio', {
@ -1883,16 +2184,17 @@ const confirmRenameFile = async () => {
const handleDeleteFolder = (folder) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除文件夹「${folder.name}」吗?删除后无法恢复,文件夹内的所有文件也将被删除。`,
content: `确定要删除文件夹「${folder.title || folder.name}」吗?删除后无法恢复,文件夹内的所有文件也将被删除。`,
onOk: async () => {
try {
const result = await deleteFolderApi(folder.id);
const result = await deleteFolderApi(folder.key || folder.id);
if (result.code === 200) {
Message.success('文件夹删除成功');
//
if (currentFolderId.value === folder.id) {
if (currentFolderId.value === (folder.key || folder.id)) {
currentFolderId.value = '0';
currentFolderName.value = '根目录';
// currentFolderName使
// currentFolderName.value = '';
fileList.value = [];
totalFiles.value = 0;
}
@ -2067,6 +2369,8 @@ onMounted(() => {
background-color: var(--color-bg-2);
}
/* 侧边栏样式 */
.folder-sidebar {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
@ -3208,4 +3512,110 @@ onMounted(() => {
}
}
}
/* 树形文件夹结构 */
.folder-tree-container {
padding: 8px;
background: var(--color-bg-2);
border-radius: 6px;
margin: 8px;
overflow: hidden;
}
.tree-node-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 0;
}
.folder-name {
flex: 1;
margin-left: 4px;
font-size: 14px;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
}
.tree-node-content:hover .folder-actions {
opacity: 1;
}
.folder-actions .action-btn {
width: 20px;
height: 20px;
color: var(--color-text-3);
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
color: var(--color-primary);
background: var(--color-fill-3);
transform: scale(1.1);
}
}
.folder-tree {
:deep(.arco-tree-node-content) {
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
&:hover {
background-color: var(--color-fill-2);
}
}
:deep(.arco-tree-node-selected .arco-tree-node-content) {
background-color: var(--color-primary-light-1);
color: var(--color-primary);
}
:deep(.arco-tree-node-indent) {
padding-left: 8px;
}
&.collapsed {
:deep(.arco-tree-node-content) {
padding: 8px 4px;
justify-content: center;
}
:deep(.arco-tree-node-title) {
display: none;
}
:deep(.arco-tree-node-switcher) {
display: none;
}
:deep(.arco-tree-node-indent) {
display: none;
}
}
}
/* 面包屑导航样式 */
.breadcrumbs {
.clickable {
cursor: pointer;
color: var(--color-primary);
transition: color 0.2s ease;
&:hover {
color: var(--color-primary-light-1);
text-decoration: underline;
}
}
}
</style>

View File

@ -0,0 +1,125 @@
<template>
<div>
<a-tab-pane key="props" tap="形变" title="形变原数据">
<div class="tab-content">
<raw-data>
</raw-data>
</div>
</a-tab-pane>
</div>
</template>
<script setup>
import rawData from './raw-data.vue';
</script>
<style lang="scss" scoped>
.data-storage-container {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
}
.page-header {
margin-bottom: 16px;
.page-title {
font-size: 20px;
font-weight: 500;
color: #262626;
margin: 0;
}
}
.tabs-section {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.tab-content {
margin-top: 16px;
}
.filter-section {
margin-bottom: 16px;
padding: 12px;
background: #fafafa;
border-radius: 4px;
.filter-item {
display: inline-flex;
align-items: center;
.filter-label {
font-size: 14px;
color: #595959;
margin-right: 8px;
}
}
}
.uploaded-files-section {
.section-title {
font-size: 16px;
font-weight: 500;
color: #262626;
margin: 0 0 16px 0;
}
}
.preview-container {
text-align: center;
.image-preview,
.video-preview {
max-height: 500px;
overflow: hidden;
}
.file-info {
text-align: left;
p {
margin: 8px 0;
font-size: 14px;
color: #595959;
}
}
}
:deep(.arco-tabs-nav) {
margin-bottom: 0;
}
:deep(.arco-tabs-tab) {
font-size: 14px;
padding: 8px 16px;
}
:deep(.arco-table-th) {
background-color: #fafafa;
color: #8c8c8c;
font-weight: 500;
}
:deep(.arco-table-td) {
padding: 12px 16px;
}
:deep(.arco-tag) {
border-radius: 4px;
font-size: 12px;
}
:deep(.arco-btn-size-small) {
padding: 2px 8px;
font-size: 12px;
}
:deep(.arco-upload-drag:hover) {
border-color: #1890ff;
}
</style>

View File

@ -0,0 +1,590 @@
<template>
<GiPageLayout>
<div class="raw-data-container">
<!-- <div class="page-header">
<div class="page-title">原始数据管理</div>
<div class="page-subtitle">管理和分析原始视频数据</div>
</div> -->
<div class="action-bar">
<div class="action-buttons">
<a-button type="primary" @click="showUploadModal = true">
<template #icon>
<IconUpload />
</template>
上传视频
</a-button>
<a-button type="primary" @click="handleBatchAnalysis">
<template #icon>
<IconPlayCircle />
</template>
批量分析
</a-button>
<a-button type="primary" @click="handleExportData">
<template #icon>
<IconDownload />
</template>
导出数据
</a-button>
</div>
<div class="filter-section">
<a-form :model="filterForm" layout="inline">
<a-form-item label="项目">
<a-select v-model="filterForm.projectId" placeholder="请选择项目">
<a-option value="project-1">风电场A区</a-option>
<a-option value="project-2">风电场B区</a-option>
<a-option value="project-3">风电场C区</a-option>
</a-select>
</a-form-item>
<a-form-item label="机组号">
<a-input v-model="filterForm.unitNumber" placeholder="请输入机组号" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model="filterForm.status" placeholder="请选择状态">
<a-option value="completed">已完成</a-option>
<a-option value="pending">待分析</a-option>
<a-option value="analyzing">分析中</a-option>
<a-option value="failed">失败</a-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
</a-form-item>
</a-form>
</div>
</div>
<div class="project-sections">
<div v-for="project in filteredProjects" :key="project.id" class="project-section">
<div class="project-header">
<div class="project-title">{{ project.name }}</div>
<div class="project-stats">
<div class="stat-item">
<IconVideoCamera />
{{ project.totalVideos }} 个视频
</div>
<div class="stat-item">
<IconCheckCircle />
{{ project.completedCount }} 个已完成
</div>
<div class="stat-item">
<IconClockCircle />
{{ project.pendingCount }} 个待分析
</div>
</div>
</div>
<div class="units-grid">
<div v-for="unit in project.units" :key="unit.id" class="unit-card">
<div class="unit-header">
<div class="unit-title">{{ unit.number }}</div>
<div class="unit-actions">
<a-button type="primary" size="small" @click="handleViewUnitVideos(unit)">查看全部</a-button>
<a-button type="primary" size="small" @click="handleAnalyzeUnit(unit)">{{
getAnalysisButtonText(unit.status)
}}</a-button>
</div>
</div>
<div class="videos-list">
<div v-for="video in unit.videos" :key="video.id" class="video-item">
<div class="video-thumbnail">
<img :src="video.thumbnail" alt="Video Thumbnail" />
<div class="video-overlay" @click="handlePlayVideo(video)">
<IconPlayArrowFill style="font-size: 24px; color: #fff;" />
</div>
</div>
<div class="video-info">
<div class="video-name">{{ video.name }}</div>
<div class="video-meta">
<span>{{ video.duration }}</span>
<span>{{ video.angle }}°</span>
</div>
<div class="video-status">
<a-tag :color="getStatusColor(video.status)">{{ getStatusText(video.status) }}</a-tag>
</div>
</div>
</div>
</div>
<div class="analysis-progress">
<div class="progress-info">
<span>分析进度</span>
<span>{{ unit.progress }}%</span>
</div>
<a-progress :percent="unit.progress" :show-text="false" status="active" />
</div>
</div>
</div>
</div>
</div>
<!-- 视频播放模态框 -->
<a-modal v-model:visible="videoModalVisible" title="原始视频播放" width="900px" @ok="videoModalVisible = false"
@cancel="videoModalVisible = false">
<video v-if="selectedVideo" :src="selectedVideo.url" controls
style="width: 100%; height: 480px; border-radius: 8px; background: #000;"></video>
<div v-if="selectedVideo" class="video-meta-info">
<p>项目{{ selectedVideo.projectName }}</p>
<p>机组号{{ selectedVideo.unitNumber }}</p>
<p>采集人{{ selectedVideo.collector }}</p>
<p>风速{{ selectedVideo.windSpeed }} m/s</p>
<p>转速{{ selectedVideo.rpm }} rpm</p>
<p>采集时间{{ selectedVideo.time }}</p>
<p>角度{{ selectedVideo.angle }}°</p>
</div>
</a-modal>
<!-- 上传视频模态框 -->
<a-modal v-model:visible="showUploadModal" title="上传原始视频" width="600px" @ok="handleUpload"
@cancel="showUploadModal = false">
<a-form :model="uploadForm" layout="vertical">
<a-form-item label="项目" required>
<a-select v-model="uploadForm.projectId" placeholder="请选择项目">
<a-option value="project-1">风电场A区</a-option>
<a-option value="project-2">风电场B区</a-option>
<a-option value="project-3">风电场C区</a-option>
</a-select>
</a-form-item>
<a-form-item label="机组号" required>
<a-input v-model="uploadForm.unitNumber" placeholder="请输入机组号" />
</a-form-item>
<a-form-item label="采集人" required>
<a-input v-model="uploadForm.collector" placeholder="请输入采集人姓名" />
</a-form-item>
<a-form-item label="风速 (m/s)">
<a-input-number v-model="uploadForm.windSpeed" :min="0" />
</a-form-item>
<a-form-item label="转速 (rpm)">
<a-input-number v-model="uploadForm.rpm" :min="0" />
</a-form-item>
<a-form-item label="采集时间" required>
<a-date-picker v-model="uploadForm.time" show-time format="YYYY-MM-DD HH:mm" style="width: 100%;" />
</a-form-item>
<a-form-item label="视频文件可多选建议3个角度" required>
<a-upload v-model:file-list="uploadForm.fileList" :multiple="true" :limit="3" accept="video/*"
:auto-upload="false" list-type="picture-card">
<template #upload-button>
<a-button>选择视频</a-button>
</template>
</a-upload>
</a-form-item>
</a-form>
</a-modal>
</div>
</GiPageLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
IconUpload,
IconPlayCircle,
IconDownload,
IconVideoCamera,
IconCheckCircle,
IconClockCircle,
IconPlayArrowFill
} from '@arco-design/web-vue/es/icon'
const showUploadModal = ref(false)
const videoModalVisible = ref(false)
const selectedVideo = ref<any>(null)
const filterForm = reactive({
projectId: '',
unitNumber: '',
status: ''
})
const uploadForm = reactive({
projectId: '',
unitNumber: '',
collector: '',
windSpeed: null,
rpm: null,
time: '',
fileList: []
})
//
const projects = ref([
{
id: 'project-1',
name: '风电场A区',
totalVideos: 6,
completedCount: 4,
pendingCount: 2,
units: [
{
id: 'A-001',
number: 'A-001',
status: 'completed',
progress: 100,
videos: [
{
id: 'v1',
name: 'A-001-正面',
url: '/videos/A-001-front.mp4',
thumbnail: '/images/A-001-front.jpg',
angle: 0,
duration: '00:30',
status: 'completed',
projectName: '风电场A区',
unitNumber: 'A-001',
collector: '张三',
windSpeed: 8.2,
rpm: 15,
time: '2023-11-05 08:00'
},
{
id: 'v2',
name: 'A-001-侧面',
url: '/videos/A-001-side.mp4',
thumbnail: '/images/A-001-side.jpg',
angle: 90,
duration: '00:30',
status: 'completed',
projectName: '风电场A区',
unitNumber: 'A-001',
collector: '张三',
windSpeed: 8.2,
rpm: 15,
time: '2023-11-05 08:00'
},
{
id: 'v3',
name: 'A-001-背面',
url: '/videos/A-001-back.mp4',
thumbnail: '/images/A-001-back.jpg',
angle: 180,
duration: '00:30',
status: 'pending',
projectName: '风电场A区',
unitNumber: 'A-001',
collector: '张三',
windSpeed: 8.2,
rpm: 15,
time: '2023-11-05 08:00'
}
]
},
{
id: 'A-002',
number: 'A-002',
status: 'analyzing',
progress: 60,
videos: [
{
id: 'v4',
name: 'A-002-正面',
url: '/videos/A-002-front.mp4',
thumbnail: '/images/A-002-front.jpg',
angle: 0,
duration: '00:28',
status: 'analyzing',
projectName: '风电场A区',
unitNumber: 'A-002',
collector: '李四',
windSpeed: 7.9,
rpm: 14,
time: '2023-11-05 12:00'
},
{
id: 'v5',
name: 'A-002-侧面',
url: '/videos/A-002-side.mp4',
thumbnail: '/images/A-002-side.jpg',
angle: 90,
duration: '00:28',
status: 'pending',
projectName: '风电场A区',
unitNumber: 'A-002',
collector: '李四',
windSpeed: 7.9,
rpm: 14,
time: '2023-11-05 12:00'
},
{
id: 'v6',
name: 'A-002-背面',
url: '/videos/A-002-back.mp4',
thumbnail: '/images/A-002-back.jpg',
angle: 180,
duration: '00:28',
status: 'pending',
projectName: '风电场A区',
unitNumber: 'A-002',
collector: '李四',
windSpeed: 7.9,
rpm: 14,
time: '2023-11-05 12:00'
}
]
}
]
}
// ...
])
const filteredProjects = computed(() => {
//
return projects.value
.filter(p => !filterForm.projectId || p.id === filterForm.projectId)
.map(project => ({
...project,
units: project.units
.filter(u => !filterForm.unitNumber || u.number === filterForm.unitNumber)
.map(unit => ({
...unit,
videos: unit.videos.filter(v => !filterForm.status || v.status === filterForm.status)
}))
.filter(u => u.videos.length > 0)
}))
.filter(p => p.units.length > 0)
})
function handleFilterChange() {
// API
}
function handlePlayVideo(video: any) {
selectedVideo.value = video
videoModalVisible.value = true
}
function handleViewUnitVideos(unit: any) {
//
Message.info(`查看机组 ${unit.number} 的所有视频`)
}
function handleAnalyzeUnit(unit: any) {
//
Message.success(`已提交机组 ${unit.number} 的分析任务`)
// API
}
function getStatusColor(status: string) {
switch (status) {
case 'completed': return 'green'
case 'pending': return 'gray'
case 'analyzing': return 'blue'
case 'failed': return 'red'
default: return 'gray'
}
}
function getStatusText(status: string) {
switch (status) {
case 'completed': return '已完成'
case 'pending': return '待分析'
case 'analyzing': return '分析中'
case 'failed': return '失败'
default: return '未知'
}
}
function getAnalysisButtonText(status: string) {
switch (status) {
case 'completed': return '重新分析'
case 'pending': return '分析'
case 'analyzing': return '分析中...'
case 'failed': return '重新分析'
default: return '分析'
}
}
function handleBatchAnalysis() {
Message.success('批量分析任务已提交')
}
function handleExportData() {
Message.success('数据导出成功')
}
function handleUpload() {
Message.success('上传成功')
showUploadModal.value = false
}
</script>
<style scoped lang="scss">
.raw-data-container {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
color: #1d2129;
}
.page-subtitle {
font-size: 14px;
color: #86909c;
margin: 0;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.filter-section {
margin-left: 24px;
}
}
.project-sections {
.project-section {
margin-bottom: 32px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px #f0f1f2;
padding: 20px;
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.project-title {
font-size: 20px;
font-weight: 600;
}
.project-stats {
display: flex;
gap: 16px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
color: #86909c;
}
}
}
.units-grid {
display: flex;
gap: 24px;
flex-wrap: wrap;
.unit-card {
background: #fafbfc;
border-radius: 8px;
padding: 16px;
width: 360px;
margin-bottom: 16px;
.unit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.unit-title {
font-size: 16px;
font-weight: 600;
}
.unit-actions {
display: flex;
gap: 8px;
}
}
.videos-list {
display: flex;
gap: 12px;
margin-bottom: 8px;
.video-item {
width: 100px;
.video-thumbnail {
position: relative;
width: 100px;
height: 60px;
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
opacity: 0;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
.video-info {
margin-top: 4px;
.video-name {
font-size: 12px;
font-weight: 500;
color: #1d2129;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.video-meta {
font-size: 11px;
color: #86909c;
display: flex;
gap: 4px;
}
.video-status {
margin-top: 2px;
}
}
}
}
.analysis-progress {
margin-top: 8px;
.progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #86909c;
margin-bottom: 2px;
}
}
}
}
}
}
.video-meta-info {
margin-top: 16px;
font-size: 13px;
color: #4e5969;
p {
margin: 2px 0;
}
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<GiPageLayout>
<!-- 页面标题 -->
<!-- <div class="page-header">
<!-- <div class="page-header">
<h2 class="page-title">图像音频关联查看</h2>
</div>-->
@ -17,62 +17,32 @@
<!-- 项目选择 -->
<div class="filter-item">
<span class="filter-label">项目</span>
<a-select
v-model="filterParams.project"
placeholder="请选择项目"
:options="projectOptions"
allow-search
allow-clear
:loading="loading.project"
style="width: 200px"
@change="handleFilterChange"
/>
<a-select v-model="filterParams.project" placeholder="请选择项目" :options="projectOptions" allow-search
allow-clear :loading="loading.project" style="width: 200px" @change="handleFilterChange" />
</div>
<!-- 机组选择 -->
<div class="filter-item">
<span class="filter-label">机组</span>
<a-select
v-model="filterParams.unit"
placeholder="请先选择项目"
:options="unitOptions"
allow-search
allow-clear
:disabled="!filterParams.project"
:loading="loading.unit"
style="width: 200px"
@change="handleFilterChange"
/>
<a-select v-model="filterParams.unit" placeholder="请先选择项目" :options="unitOptions" allow-search
allow-clear :disabled="!filterParams.project" :loading="loading.unit" style="width: 200px"
@change="handleFilterChange" />
</div>
<!-- 部件选择 -->
<div class="filter-item">
<span class="filter-label">部件</span>
<a-select
v-model="filterParams.component"
placeholder="请先选择机组"
:options="componentOptions"
allow-search
allow-clear
:disabled="!filterParams.unit"
:loading="loading.component"
style="width: 200px"
@change="handleFilterChange"
/>
<a-select v-model="filterParams.component" placeholder="请先选择机组" :options="componentOptions"
allow-search allow-clear :disabled="!filterParams.unit" :loading="loading.component"
style="width: 200px" @change="handleFilterChange" />
</div>
</a-space>
</div>
<!-- 已上传数据列表 -->
<div class="uploaded-files-section">
<a-table
:columns="fileColumns"
:data="imageList"
:pagination="false"
:scroll="{ x: '100%', y: 'calc(100vh - 380px)' }"
:loading="loading.image"
class="scrollable-table"
>
<a-table :columns="fileColumns" :data="imageList" :pagination="false"
:scroll="{ x: '100%', y: 'calc(100vh - 380px)' }" :loading="loading.image" class="scrollable-table">
<!-- 文件类型 -->
<template #type="{ record }">
<a-tag :color="getFileTypeColor(record.type)" size="small">
@ -82,7 +52,7 @@
<!-- 文件大小 -->
<template #size="{ record }">
<span>{{ record.imageTypeLabel}}</span>
<span>{{ record.imageTypeLabel }}</span>
</template>
<!-- 状态 -->
@ -103,6 +73,13 @@
</div>
</div>
</a-tab-pane>
<a-tab-pane key="props" tap="形变" title="形变原数据">
<div class="tab-content">
<raw-data>
</raw-data>
</div>
</a-tab-pane>
</a-tabs>
</div>
@ -112,10 +89,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed,onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import PreviewModal from './components/PreviewModal.vue'
import rawData from './components/raw-data.vue'
import {
getProjectList,
getTurbineList,
@ -127,6 +105,7 @@ import {
batchUploadImages,
uploadImageToPartV2
} from '@/apis/industrial-image'
import DeformationTap from './components/DeformationTap.vue'
//
// const previewModal = ref()
@ -141,9 +120,9 @@ const filterParams = reactive({
})
//
const projectOptions = ref<Array<{label: string, value: string}>>([])
const unitOptions = ref<Array<{label: string, value: string}>>([])
const componentOptions = ref<Array<{label: string, value: string}>>([])
const projectOptions = ref<Array<{ label: string, value: string }>>([])
const unitOptions = ref<Array<{ label: string, value: string }>>([])
const componentOptions = ref<Array<{ label: string, value: string }>>([])
//
const imageList = ref<Array<{
@ -255,33 +234,28 @@ const handleFilterChange = async () => {
loading.image = true
try {
let params = {
projectId: filterParams.project
}
if(filterParams.unit){
params = {
const params: any = {
projectId: filterParams.project,
turbineId: filterParams.unit
}
if (filterParams.unit) {
params.turbineId = filterParams.unit
}
if(filterParams.component){
params = {
projectId: filterParams.project,
turbineId: filterParams.unit,
partId: filterParams.component
}
if (filterParams.component) {
params.partId = filterParams.component
}
const res = await getImageList(params)
imageList.value = res.data.map((item: any) => ({
id: item.imageId,
name: item.imageName,
type: item.imageType?item.imageType:"未指定类型",
type: item.imageType ? item.imageType : "未指定类型",
imageTypeLabel: item.imageTypeLabel,
shootingTime: item.shootingTime,
preTreatment: item.preTreatment?"已审核":"未审核",
preTreatment: item.preTreatment ? "已审核" : "未审核",
imagePath: item.imagePath,
audioList:item.audioList
audioList: item.audioList
}))
Message.success(`获取到 ${imageList.value.length} 条图像数据`)
} catch (error) {
@ -313,14 +287,6 @@ const fileColumns: TableColumnData[] = [
{ title: '操作', slotName: 'action', width: 150, fixed: 'right' }
]
//
const filteredFiles = computed(() => {
return uploadedFiles.value.filter(file =>
(filterParams.project === null || file.project === filterParams.project) &&
(filterParams.unit === null || file.unit === filterParams.unit) &&
(filterParams.component === null || file.component === filterParams.component)
)
})
//
const getFileTypeColor = (type: string) => {
@ -360,6 +326,9 @@ const getImageUrl = (imagePath: string): string => {
//
const previewFile = (file: any) => {
/* previewFileData.value = file
previewModalVisible.value = true*/
const fileObj = {
id: file.id,
name: file.name,
@ -373,12 +342,7 @@ const previewFile = (file: any) => {
//
const deleteFile = (file: any) => {
console.log(index);
const index = uploadedFiles.value.findIndex(f => f.id === file.id)
if (index > -1) {
uploadedFiles.value.splice(index, 1)
Message.success('文件已删除')
}
}
</script>

View File

@ -8,7 +8,7 @@
5. 导入导出团队成员数据
-->
<template>
<GiPageLayout>
<GiPageLayout class="construction-personnel-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-left">
@ -828,15 +828,22 @@ onMounted(() => {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin-bottom: 24px;
max-height: calc(100vh - 300px);
overflow: hidden;
.arco-table-container {
overflow-x: auto;
overflow-y: visible;
overflow-y: auto;
max-height: calc(100vh - 350px);
}
.arco-table {
overflow: visible;
}
.arco-table-body {
overflow-y: auto;
}
}
@ -917,6 +924,28 @@ onMounted(() => {
}
}
//
.construction-personnel-page {
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
:deep(.gi-page-layout) {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
:deep(.gi-page-layout-content) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
//
@media (max-width: 768px) {
.page-header {

View File

@ -237,31 +237,37 @@
<!-- 团队成员明细 -->
<div class="detail-section">
<div class="section-header">
<div class="section-header team-members-header">
<div class="header-left">
<h3>团队成员明细</h3>
<a-button size="small" @click="openPersonnelManagement">
<span class="member-count" v-if="currentProject.teamMembers && currentProject.teamMembers.length > 0">
({{ currentProject.teamMembers.length }})
</span>
</div>
<a-button size="small" @click="openPersonnelManagement" class="add-member-btn">
<template #icon><icon-user-group /></template>
添加成员
</a-button>
</div>
<div class="team-members">
<div
v-for="member in currentProject.teamMembers"
v-for="(member, index) in currentProject.teamMembers"
:key="member.id"
class="member-item"
:style="{ animationDelay: `${index * 0.1}s` }"
@click="editMemberPosition(member)"
>
<div class="member-avatar">
<icon-user />
</div>
<div class="member-info">
<div class="member-name">{{ member.name }}</div>
<div class="member-position">{{ member.position }}</div>
<div class="member-name">{{ member.name || '未设置姓名' }}</div>
<div class="member-position">{{ member.position || '未设置岗位' }}</div>
<div class="member-details">
<span class="member-status" :class="member.status">
{{ member.status === 'available' ? '在线' : '离线' }}
</span>
<span class="member-date">入职: {{ member.joinDate }}</span>
<span class="member-date">入职: {{ member.joinDate || '未设置' }}</span>
</div>
</div>
<div class="member-actions">
@ -453,15 +459,18 @@ const mapProjectRespToProjectCard = (projectResp: any): any => {
// - 使
const teamMembers = projectResp.teamMembers ? projectResp.teamMembers.map((member: any) => {
console.log('处理团队成员数据:', member) //
console.log('成员userName字段:', member.userName)
console.log('成员name字段:', member.name)
const mappedMember = {
id: member.memberId,
name: member.name, // 使
position: member.roleTypeDesc || member.jobCodeDesc,
name: member.userName || member.name || '未设置姓名', // 使userName
position: member.roleTypeDesc || member.jobCodeDesc || '未设置岗位',
phone: member.phone || '', //
email: member.email || '', //
status: member.status === 'ACTIVE' ? 'available' : 'offline',
skills: [], //
joinDate: member.joinDate,
joinDate: member.joinDate || '未设置',
remark: member.remark || member.jobDesc || '',
//
originalData: member
@ -533,18 +542,32 @@ const loadKanbanData = async () => {
...(response.data.pendingProjects || [])
]
console.log('后端返回的所有项目数据:', allProjects)
console.log('后端返回的preparingProjects:', response.data.preparingProjects)
console.log('后端返回的ongoingProjects:', response.data.ongoingProjects)
console.log('后端返回的inProgressProjects:', response.data.inProgressProjects)
console.log('后端返回的pendingProjects:', response.data.pendingProjects)
//
allProjects.forEach(project => {
console.log('处理项目:', project.projectName || project.name)
console.log('项目团队成员:', project.teamMembers)
console.log('项目状态:', project.status, typeof project.status)
const mappedProject = mapProjectRespToProjectCard(project)
console.log('映射后的项目:', mappedProject.name)
console.log('映射后的团队成员:', mappedProject.teamMembers)
//
const status = typeof project.status === 'string' ? parseInt(project.status) : project.status
if (status === 0) {
// status: 0
console.log('添加到准备中项目:', mappedProject.name)
preparingProjects.value.push(mappedProject)
} else if (status === 1) {
// status: 1
console.log('添加到进行中项目:', mappedProject.name)
ongoingProjects.value.push(mappedProject)
}
// status:
@ -629,10 +652,12 @@ const openProjectDetail = async (project: any) => {
try {
loading.value = true
console.log('正在获取项目详情项目ID:', project.id)
console.log('传入的项目数据:', project)
//
if (project.teamMembers && project.teamMembers.length > 0) {
console.log('项目数据已包含团队成员信息,直接使用')
console.log('团队成员数据:', project.teamMembers)
currentProject.value = project
projectDetailVisible.value = true
return
@ -643,7 +668,9 @@ const openProjectDetail = async (project: any) => {
if (response.data) {
// 使
console.log('API返回的原始数据:', response.data)
currentProject.value = mapProjectRespToProjectCard(response.data)
console.log('映射后的项目数据:', currentProject.value)
projectDetailVisible.value = true
} else {
// API使
@ -1135,98 +1162,238 @@ onMounted(async () => {
}
}
.team-members {
.member-item {
.team-members-header {
.header-left {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #f0f0f0;
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
gap: 8px;
h3 {
margin: 0;
color: #1d2129;
font-size: 18px;
font-weight: 600;
}
.member-count {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
}
.add-member-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
border-radius: 8px;
transition: all 0.3s ease;
&:hover {
background: #f8f9fa;
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
}
}
.team-members {
max-height: 400px;
overflow-y: auto;
padding-right: 8px;
//
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 3px;
&:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
}
.member-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
border: 1px solid #e5e6eb;
border-radius: 12px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
animation: fadeInUp 0.6s ease-out forwards;
opacity: 0;
transform: translateY(20px);
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
&:hover {
background: linear-gradient(135deg, #f8f9ff 0%, #e8f2ff 100%);
border-color: #667eea;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.15);
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
.member-avatar {
width: 40px;
height: 40px;
width: 48px;
height: 48px;
border-radius: 50%;
background: #f0f0f0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #86909c;
color: white;
font-size: 20px;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
position: relative;
&::after {
content: '';
position: absolute;
top: -2px;
right: -2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #52c41a;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
}
.member-info {
flex: 1;
min-width: 0;
.member-name {
font-weight: 500;
font-weight: 600;
color: #1d2129;
margin-bottom: 2px;
margin-bottom: 4px;
font-size: 16px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-position {
font-size: 12px;
color: #86909c;
font-size: 13px;
color: #4e5969;
margin-bottom: 6px;
font-weight: 500;
background: linear-gradient(135deg, #f0f2f5 0%, #e5e6eb 100%);
padding: 2px 8px;
border-radius: 6px;
display: inline-block;
}
.member-details {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
font-size: 12px;
color: #86909c;
.member-status {
padding: 2px 6px;
border-radius: 4px;
padding: 4px 8px;
border-radius: 6px;
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
&.available {
background: #e3f2fd;
color: #2196f3;
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
color: #1890ff;
border: 1px solid #91d5ff;
}
&.offline {
background: #f8f9fa;
color: #868e96;
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
color: #8c8c8c;
border: 1px solid #d9d9d9;
}
}
.member-date {
background: #f7f8fa;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #e5e6eb;
}
}
}
.member-actions {
.arco-btn {
font-size: 12px;
border-radius: 8px;
padding: 6px 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
transition: all 0.3s ease;
&:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
}
}
}
.empty-state {
text-align: center;
padding: 40px 0;
padding: 60px 20px;
color: #86909c;
background: linear-gradient(135deg, #fafbfc 0%, #f0f2f5 100%);
border-radius: 12px;
border: 2px dashed #d9d9d9;
margin: 20px 0;
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
font-size: 64px;
margin-bottom: 20px;
color: #bfbfbf;
opacity: 0.6;
}
.empty-text {
font-size: 18px;
font-weight: 500;
margin-bottom: 8px;
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
color: #595959;
}
.empty-desc {
font-size: 14px;
color: #8c8c8c;
line-height: 1.6;
}
}
}