This commit is contained in:
80937518651731347 2026-06-13 10:28:43 +08:00
parent 6ad758130e
commit f5497ab6e8
91 changed files with 10421 additions and 0 deletions

13
.env.development Normal file
View File

@ -0,0 +1,13 @@
# 如需添加更多环境变量,请以 VITE_APP_ 开头声明
# 在代码中使用 import.meta.env.VITE_APP_XXX 获取指定变量
# 环境配置标识
VITE_APP_ENV='development'
# api接口请求地址
VITE_APP_BASE_API='http://test.gopmas.com/rs'
VITE_APP_DS_API='http://test.gopmas.com/ds'

10
.env.production Normal file
View File

@ -0,0 +1,10 @@
# 如需添加更多环境变量,请以 VITE_APP_ 开头声明
# 在代码中使用 import.meta.env.VITE_APP_XXX 获取指定变量
# 环境配置标识
VITE_APP_ENV='production'
# api接口请求地址
VITE_APP_BASE_API='/rescue'

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.zip

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>深海地球物理数据管理可视化平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2928
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "geophysical-data-platform",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.7",
"cesium": "^1.133.1",
"element-plus": "^2.5.6",
"md5": "^2.3.0",
"pinia": "^3.0.3",
"vue": "^3.4.38",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@types/node": "^25.9.2",
"@vitejs/plugin-vue": "^5.1.3",
"sass": "^1.71.1",
"typescript": "^5.5.3",
"vite": "^5.4.2",
"vite-plugin-cesium": "^1.2.23",
"vue-tsc": "^2.1.4"
}
}

BIN
public/vite.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

230
src/App.vue Normal file
View File

@ -0,0 +1,230 @@
<template>
<el-container>
<el-header class="header-bg" :class="{ 'header-home': isHomePage }">
<div class="header-content">
<div class="logo">
<img src="@/assets/logo.png" alt="Logo" />
<span>深海地球物理数据管理可视化平台</span>
</div>
<el-menu
mode="horizontal"
:default-active="activeMenu"
:ellipsis="false"
router
class="main-menu"
>
<!-- 登录后才显示的菜单项 -->
<template v-if="authStore.isLoggedIn">
<el-menu-item index="/">首页</el-menu-item>
<el-menu-item index="/search">数据入库</el-menu-item>
<el-menu-item index="/demand">数据检索</el-menu-item>
<el-menu-item index="/statistics">统计分析</el-menu-item>
<el-menu-item index="/contact">联系我们</el-menu-item>
</template>
<!-- 登录/退出按钮 -->
<el-menu-item v-if="!authStore.isLoggedIn" index="/login">登录</el-menu-item>
<el-menu-item v-else @click="logout">退出登录</el-menu-item>
</el-menu>
</div>
<img class="bg" v-show="activeMenu == '/statistics'" src="@/assets/bg.jpg" alt="背景图片" />
</el-header>
<el-main>
<router-view></router-view>
</el-main>
<el-footer>
<div>
<div class="title">
<img src="@/assets/logo.png" alt="Logo" />
<span>中国科学院深海科学与工程研究所</span>
</div>
<div class="info">
<div class="item">
<div class="icon">
<img src="@/assets/icon_@.png" alt="Logo" />
</div>
<span>联系方式chencx@idsse.ac.cn</span>
</div>
<div class="item">
<div class="icon">
<img src="@/assets/icon_area.png" alt="Logo" />
</div>
<span>地址三亚市鹿回头路28号</span>
</div>
<div class="item">
<div class="icon">
<img src="@/assets/icon_mail.png" alt="Logo" />
</div>
<span>邮编572000</span>
</div>
</div>
</div>
</el-footer>
</el-container>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/store/authStore'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const activeMenu = ref(route.path)
const isHomePage = computed(() => route.path === '/')
//
watch(
() => route.path,
(newPath) => {
activeMenu.value = newPath
}
)
// 退
const logout = () => {
authStore.logout()
router.push('/login')
}
</script>
<style lang="scss" scoped>
.el-container {
min-height: 100vh;
background: #fff;
}
.header-bg {
background-image: url(@/assets/footer_bg.png);
&.header-home {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
background: linear-gradient(180deg, rgba(0, 30, 70, 0.85) 0%, rgba(0, 30, 70, 0.4) 100%);
background-image: none;
}
}
.el-header {
position: relative;
color: white;
height: 100px;
z-index: 0;
.bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: calc(100vh - 120px);
z-index: -1;
}
.header-content {
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
.logo {
display: flex;
align-items: center;
gap: 10px;
margin-right: 40px;
img {
width: 56px;
height: 56px;
}
span {
font-size: 30px;
font-weight: bold;
color: white;
}
}
}
}
.main-menu {
background-color: transparent;
border-bottom: none;
:deep(.el-menu-item) {
color: white;
font-size: 18px;
&.is-active {
background-color: transparent;
color: white;
}
&:hover {
background-color: transparent;
}
}
}
.el-main {
background-color: transparent;
padding: 0;
z-index: 2;
}
.el-footer {
height: 120px;
background-image: url(@/assets/footer_bg.png);
display: flex;
justify-content: center;
align-items: center;
.title {
display: flex;
align-items: center;
font-size: 18px;
color: #fff;
margin-bottom: 20px;
img {
width: 30px;
height: 30px;
margin-right: 10px;
}
}
.info {
display: flex;
font-size: 14px;
color: #fff;
.item {
display: flex;
align-items: center;
margin-right: 20px;
.icon {
width: 30px;
height: 30px;
background: #263954;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
margin-right: 5px;
img {
width: 13px;
height: 13px;
}
}
}
}
}
</style>

234
src/api/data.js Normal file
View File

@ -0,0 +1,234 @@
import { MOCK_GEOPHYSICAL_DATA, MOCK_STATISTICS } from '@/mock/geophysicalData'
import { MOCK_DATA_FILES, nextFileId } from '@/mock/dataFiles'
/** @type {typeof MOCK_GEOPHYSICAL_DATA} */
let dataStore = [...MOCK_GEOPHYSICAL_DATA]
/** @type {Record<string, Array>} */
let fileStore = { ...MOCK_DATA_FILES }
/**
* 数据大类与类型映射
*/
const CATEGORY_TYPE_MAP = {
acoustic: ['ship_multibeam', 'submarine_multibeam', 'sidescan'],
seismic: ['shallow_seismic', 'seismic_2d', 'seismic_3d', 'obn_seismic'],
potential: ['gravity', 'ship_magnetic', 'submarine_magnetic'],
ocean: ['ctd', 'adcp', 'heat_flow']
}
/**
* 分页查询地球物理数据
* @param {Object} params
* @returns {Promise<{page: {records: Array, total: number}}>}
*/
export function dataPage(params = {}) {
const {
start = 0,
limit = 10,
query = '',
data_type = '',
data_status = '',
data_secrecy = '',
data_code = '',
category = ''
} = params
let records = [...MOCK_GEOPHYSICAL_DATA]
if (query) {
const keyword = query.toLowerCase()
records = records.filter((item) =>
item.data_code.toLowerCase().includes(keyword) ||
item.data_name.toLowerCase().includes(keyword) ||
item.voyage_name.toLowerCase().includes(keyword)
)
}
if (category && CATEGORY_TYPE_MAP[category]) {
const types = CATEGORY_TYPE_MAP[category]
records = records.filter((item) => types.includes(item.data_type))
}
if (data_code) {
records = records.filter((item) => item.data_code.includes(data_code))
}
if (data_type) {
records = records.filter((item) => item.data_type === data_type)
}
if (data_status) {
records = records.filter((item) => item.data_status === data_status)
}
if (data_secrecy) {
records = records.filter((item) => item.data_secrecy === data_secrecy)
}
const total = records.length
const pageRecords = records.slice(start, start + limit)
return Promise.resolve({
success: true,
page: {
records: pageRecords,
total
}
})
}
/**
* 获取主页统计数据
* @returns {Promise<{array: Array}>}
*/
export function reportMain() {
return Promise.resolve({
success: true,
array: [MOCK_STATISTICS]
})
}
/**
* 本地 admin 登录
* @param {Object} data
* @returns {Promise<{success: boolean, entity?: string, message?: string}>}
*/
export function localLogin(data) {
const { loginName, loginPass } = data
if (loginName === 'admin' && loginPass === '21232f297a57a5a743894a0e4a801fc3') {
return Promise.resolve({
success: true,
entity: 'admin-token-pdata'
})
}
return Promise.resolve({
success: false,
message: '用户名或密码错误'
})
}
/**
* 查询单条地球物理数据
* @param {Object} params
* @returns {Promise<{success: boolean, entity: Object}>}
*/
export function dataFind(params = {}) {
const { data_id } = params
const entity = dataStore.find((item) => String(item.id) === String(data_id))
if (!entity) {
return Promise.resolve({ success: false, message: '数据不存在' })
}
return Promise.resolve({ success: true, entity: { ...entity } })
}
/**
* 新增或编辑地球物理数据
* @param {Object} data
* @returns {Promise<{success: boolean, entity: Object}>}
*/
export function dataEdit(data = {}) {
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
if (data.id) {
const index = dataStore.findIndex((item) => String(item.id) === String(data.id))
if (index === -1) {
return Promise.resolve({ success: false, message: '数据不存在' })
}
dataStore[index] = { ...dataStore[index], ...data }
return Promise.resolve({ success: true, entity: { ...dataStore[index] } })
}
const newId = Math.max(...dataStore.map((item) => item.id), 0) + 1
const entity = {
id: newId,
data_status: '待审核',
fill_time: data.fill_time || now,
...data
}
dataStore.unshift(entity)
fileStore[newId] = fileStore[newId] || []
return Promise.resolve({ success: true, entity: { ...entity } })
}
/**
* 获取数据关联资源列表
* @param {Object} params
* @returns {Promise<{success: boolean, array: Array}>}
*/
export function dataFileList(params = {}) {
const { data_id } = params
const list = fileStore[data_id] || []
return Promise.resolve({ success: true, array: [...list] })
}
/**
* 上传文件mock
* @param {Object} params
* @returns {Promise<{success: boolean, entity: Object}>}
*/
export function dataFileUpload(params = {}) {
const { data_id, file_name, file_size = 0 } = params
if (!data_id) {
return Promise.resolve({ success: false, message: '请先保存数据信息' })
}
if (!file_name) {
return Promise.resolve({ success: false, message: '请选择文件' })
}
const record = {
id: nextFileId(),
file_name,
file_size: Number(file_size) || 0,
source_type: 'upload',
file_path: `/local/upload/${file_name}`,
create_time: new Date().toISOString().slice(0, 19).replace('T', ' ')
}
if (!fileStore[data_id]) {
fileStore[data_id] = []
}
fileStore[data_id].unshift(record)
return Promise.resolve({ success: true, entity: record })
}
/**
* 指定云资源路径mock
* @param {Object} params
* @returns {Promise<{success: boolean, entity: Object}>}
*/
export function dataCloudPathAdd(params = {}) {
const { data_id, cloud_path, resource_name = '' } = params
if (!data_id) {
return Promise.resolve({ success: false, message: '请先保存数据信息' })
}
if (!cloud_path) {
return Promise.resolve({ success: false, message: '请输入云资源路径' })
}
const fileName = resource_name || cloud_path.split('/').pop() || cloud_path
const record = {
id: nextFileId(),
file_name: fileName,
file_size: 0,
source_type: 'cloud',
file_path: cloud_path,
create_time: new Date().toISOString().slice(0, 19).replace('T', ' ')
}
if (!fileStore[data_id]) {
fileStore[data_id] = []
}
fileStore[data_id].unshift(record)
return Promise.resolve({ success: true, entity: record })
}
/**
* 删除数据资源
* @param {Object} params
* @returns {Promise<{success: boolean}>}
*/
export function dataFileDelete(params = {}) {
const { data_id, file_id } = params
const list = fileStore[data_id] || []
fileStore[data_id] = list.filter((item) => String(item.id) !== String(file_id))
return Promise.resolve({ success: true })
}

54
src/api/dataSpace.js Normal file
View File

@ -0,0 +1,54 @@
import {
DATA_SPACES,
DIRECTORY_TREE,
FILES_BY_FOLDER,
DEFAULT_FOLDER_ID
} from '@/mock/dataSpace.js'
/**
* 获取数据空间列表
* @returns {Promise<{success: boolean, array: Array}>}
*/
export function getDataSpaces() {
return Promise.resolve({ success: true, array: DATA_SPACES })
}
/**
* 获取目录树
* @returns {Promise<{success: boolean, array: Array}>}
*/
export function getDirectoryTree() {
return Promise.resolve({ success: true, array: DIRECTORY_TREE })
}
/**
* 获取目录下文件列表
* @param {Object} params
* @returns {Promise<{success: boolean, array: Array, folder: Object}>}
*/
export function getFolderFiles(params = {}) {
const { folder_id = DEFAULT_FOLDER_ID } = params
const files = FILES_BY_FOLDER[folder_id] || []
return Promise.resolve({
success: true,
folder_id,
array: [...files]
})
}
/**
* 根据节点 ID 查找树节点
* @param {string} nodeId
* @param {Array} nodes
* @returns {Object|null}
*/
export function findTreeNode(nodeId, nodes = DIRECTORY_TREE) {
for (const node of nodes) {
if (node.id === nodeId) return node
if (node.children?.length) {
const found = findTreeNode(nodeId, node.children)
if (found) return found
}
}
return null
}

127
src/api/task.js Normal file
View File

@ -0,0 +1,127 @@
import request from '@/utils/request'
// 登录
export const login = (data) => {
return request({
url: '/frame/optr/login.htm',
method: 'post',
data
})
}
// 获取参数列表
export const getParam = () => {
return request({
url: '/param/list.htm',
method: 'post'
})
}
// 获取航次列表
export const voyageReg = (data) => {
return request({
baseURL: data.baseURL,
url: '/api/voyage/list.htm',
method: 'post'
})
}
// 关联航次
export const sampleAdd = (data) => {
return request({
url: '/rescue/reg/sample/add.htm',
method: 'post',
data
})
}
// 获取已关联航次列表
export const sampleList = (data) => {
return request({
url: '/rescue/reg/sample/list.htm',
method: 'post',
data
})
}
// 删除已关联航次
export const sampleDelete = (data) => {
return request({
url: '/rescue/reg/sample/delete.htm',
method: 'post',
data
})
}
// 获取任务列表
export const regPage = (data) => {
return request({
url: '/rescue/reg/page.htm',
method: 'post',
data
})
}
// 查询任务详情
export const regFind = (data) => {
return request({
url: '/rescue/reg/find.htm',
method: 'post',
data
})
}
// 新增/编辑任务
export const regEdit = (data) => {
return request({
url: '/rescue/reg/edit.htm',
method: 'post',
data
})
}
// 附件列表
export const regFileList = (data) => {
return request({
url: '/rescue/reg/file/list.htm',
method: 'post',
data
})
}
// 查看附件接口
export const regFileView = () => {
return request({
url: '/rescue/reg/file/view.htm',
method: 'post'
})
}
// 附件删除
export const regFileDelete = (data) => {
return request({
url: '/rescue/reg/file/delete.htm',
method: 'post',
data
})
}
// 统计接口
export const reportMain = (data) => {
return request({
url: '/report/main.htm',
method: 'post',
data
})
}
// 完成任务
export const regComplete = (data) => {
return request({
url: '/rescue/reg/complete.htm',
method: 'post',
data
})
}

BIN
src/assets/bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
src/assets/bg2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

BIN
src/assets/footer_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
src/assets/home/adcp.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

BIN
src/assets/home/ctd.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
src/assets/home/gravity.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
src/assets/home/ocean.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
src/assets/home/seismic.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
src/assets/icon_@.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/assets/icon_area.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/assets/icon_mail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
src/assets/icon_pwd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

BIN
src/assets/icon_user.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

BIN
src/assets/icons/三级.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

BIN
src/assets/icons/任务.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
src/assets/icons/区域.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
src/assets/icons/展开.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

BIN
src/assets/icons/折叠.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
src/assets/icons/潜艇.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
src/assets/icons/航次.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
src/assets/loginBg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
src/assets/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -0,0 +1,55 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 文件夹 -->
<g v-if="type === 'folder'">
<path d="M8 18C8 15.7909 9.79086 14 12 14H26L30 18H52C54.2091 18 56 19.7909 56 22V48C56 50.2091 54.2091 52 52 52H12C9.79086 52 8 50.2091 8 48V18Z" fill="#FFC107" opacity="0.85"/>
<path d="M8 22H56V48C56 50.2091 54.2091 52 52 52H12C9.79086 52 8 50.2091 8 48V22Z" fill="#FFD54F"/>
</g>
<!-- RAR 压缩包 -->
<g v-else-if="ext === 'rar' || ext === 'zip'">
<rect x="14" y="8" width="36" height="48" rx="4" fill="#FFC107"/>
<rect x="14" y="8" width="36" height="14" rx="4" fill="#FFB300"/>
<text x="32" y="19" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">{{ ext.toUpperCase() }}</text>
<path d="M22 30H42M22 36H38M22 42H34" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
</g>
<!-- NetCDF / 数据文件 -->
<g v-else-if="ext === 'nc' || ext === 'sgy'">
<rect x="14" y="8" width="36" height="48" rx="4" fill="#42A5F5"/>
<rect x="14" y="8" width="36" height="14" rx="4" fill="#1E88E5"/>
<text x="32" y="19" text-anchor="middle" fill="#fff" font-size="8" font-weight="bold">{{ ext.toUpperCase() }}</text>
<path d="M20 28L32 24L44 28V44H20V28Z" stroke="#fff" stroke-width="1.5" fill="none" opacity="0.8"/>
</g>
<!-- 图片 -->
<g v-else-if="ext === 'png' || ext === 'jpg' || ext === 'tif'">
<rect x="14" y="10" width="36" height="44" rx="4" fill="#66BB6A"/>
<circle cx="24" cy="24" r="4" fill="#fff" opacity="0.8"/>
<path d="M18 46L28 34L36 40L46 28V50H18V46Z" fill="#fff" opacity="0.6"/>
</g>
<!-- PDF -->
<g v-else-if="ext === 'pdf'">
<rect x="14" y="8" width="36" height="48" rx="4" fill="#EF5350"/>
<text x="32" y="36" text-anchor="middle" fill="#fff" font-size="10" font-weight="bold">PDF</text>
</g>
<!-- 默认文件 -->
<g v-else>
<rect x="14" y="8" width="36" height="48" rx="4" fill="#90A4AE"/>
<path d="M22 28H42M22 34H38M22 40H34" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
</g>
</svg>
</template>
<script setup>
/**
* 文件/文件夹类型图标
*/
defineProps({
type: { type: String, default: 'file' },
ext: { type: String, default: '' },
size: { type: [Number, String], default: 64 }
})
</script>

View File

@ -0,0 +1,17 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 14H36V34H12V14Z" stroke="currentColor" stroke-width="2"/>
<path d="M18 20H30" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M18 26H26" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M18 32H22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</template>
<script setup>
/**
* 数据集/记录图标
*/
defineProps({
size: { type: [Number, String], default: 36 }
})
</script>

View File

@ -0,0 +1,17 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="6" width="16" height="16" rx="2" stroke="currentColor" stroke-width="2"/>
<rect x="26" y="6" width="16" height="16" rx="2" stroke="currentColor" stroke-width="2"/>
<rect x="6" y="26" width="16" height="16" rx="2" stroke="currentColor" stroke-width="2"/>
<rect x="26" y="26" width="16" height="16" rx="2" stroke="currentColor" stroke-width="2"/>
</svg>
</template>
<script setup>
/**
* 网格统计图标
*/
defineProps({
size: { type: [Number, String], default: 36 }
})
</script>

View File

@ -0,0 +1,16 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="14" stroke="currentColor" stroke-width="2"/>
<path d="M24 10V24L32 28" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M24 24L16 20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</template>
<script setup>
/**
* 磁力/位场图标
*/
defineProps({
size: { type: [Number, String], default: 36 }
})
</script>

View File

@ -0,0 +1,17 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="24" cy="28" rx="14" ry="8" stroke="currentColor" stroke-width="2"/>
<path d="M10 28C14 20 34 20 38 28" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M24 12V20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="24" cy="10" r="2" fill="currentColor"/>
</svg>
</template>
<script setup>
/**
* 多波束/声学图标
*/
defineProps({
size: { type: [Number, String], default: 36 }
})
</script>

View File

@ -0,0 +1,18 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 36L16 24L24 30L32 18L40 26" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 38H40" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="16" cy="24" r="2" fill="currentColor"/>
<circle cx="24" cy="30" r="2" fill="currentColor"/>
<circle cx="32" cy="18" r="2" fill="currentColor"/>
</svg>
</template>
<script setup>
/**
* 波形/地震数据图标
*/
defineProps({
size: { type: [Number, String], default: 36 }
})
</script>

View File

@ -0,0 +1,125 @@
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<!-- 多波束 -->
<g v-if="type === 'multibeam'">
<path d="M24 8L28 16H36L29.5 21.5L31.5 30L24 25.5L16.5 30L18.5 21.5L12 16H20L24 8Z" fill="url(#stat-blue)" opacity="0.25"/>
<path d="M8 34C12 28 20 26 24 26C28 26 36 28 40 34" stroke="url(#stat-blue)" stroke-width="2.2" stroke-linecap="round"/>
<path d="M16 34C18 31 22 30 24 30C26 30 30 31 32 34" stroke="url(#stat-blue)" stroke-width="1.8" stroke-linecap="round" opacity="0.7"/>
<circle cx="24" cy="14" r="2.5" fill="url(#stat-blue)"/>
<path d="M24 16.5V22" stroke="url(#stat-blue)" stroke-width="2" stroke-linecap="round"/>
</g>
<!-- 地震数据 -->
<g v-else-if="type === 'seismic'">
<rect x="8" y="10" width="32" height="28" rx="4" fill="url(#stat-orange)" opacity="0.15"/>
<path d="M10 30L16 22L22 28L28 16L34 24L38 18" stroke="url(#stat-orange)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16" cy="22" r="2" fill="#e67e22"/>
<circle cx="28" cy="16" r="2" fill="#e67e22"/>
<circle cx="34" cy="24" r="2" fill="#e67e22"/>
<path d="M10 36H38" stroke="url(#stat-orange)" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
</g>
<!-- 磁力数据 -->
<g v-else-if="type === 'magnetic'">
<circle cx="24" cy="24" r="14" stroke="url(#stat-green)" stroke-width="2" opacity="0.35"/>
<path d="M24 10V14M24 34V38M10 24H14M34 24H38" stroke="url(#stat-green)" stroke-width="2" stroke-linecap="round"/>
<path d="M18 18L21 21M27 27L30 30M30 18L27 21M21 27L18 30" stroke="url(#stat-green)" stroke-width="1.8" stroke-linecap="round" opacity="0.6"/>
<circle cx="24" cy="24" r="5" fill="url(#stat-green)"/>
<path d="M24 19V29M19 24H29" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/>
</g>
<!-- 侧扫数据 -->
<g v-else-if="type === 'sidescan'">
<path d="M6 24H42" stroke="url(#stat-purple)" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<path d="M24 8C30 14 34 20 34 24C34 28 30 34 24 40C18 34 14 28 14 24C14 20 18 14 24 8Z" stroke="url(#stat-purple)" stroke-width="2.2" fill="url(#stat-purple)" fill-opacity="0.12"/>
<path d="M24 14C28 18 30 21 30 24C30 27 28 30 24 34C20 30 18 27 18 24C18 21 20 18 24 14Z" stroke="url(#stat-purple)" stroke-width="1.6" opacity="0.7"/>
<circle cx="24" cy="24" r="2.5" fill="url(#stat-purple)"/>
</g>
<!-- 数据集 -->
<g v-else-if="type === 'dataset'">
<rect x="10" y="8" width="22" height="28" rx="3" fill="url(#stat-blue)" opacity="0.15"/>
<rect x="14" y="12" width="22" height="28" rx="3" fill="#fff" stroke="url(#stat-blue)" stroke-width="2"/>
<path d="M20 20H30M20 26H28M20 32H26" stroke="url(#stat-blue)" stroke-width="2" stroke-linecap="round"/>
<circle cx="32" cy="32" r="8" fill="url(#stat-blue)"/>
<path d="M29 32L31 34L35 30" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<!-- 重力数据 -->
<g v-else-if="type === 'gravity'">
<circle cx="24" cy="26" r="12" stroke="url(#stat-teal)" stroke-width="2" fill="url(#stat-teal)" fill-opacity="0.1"/>
<ellipse cx="24" cy="26" rx="12" ry="4" stroke="url(#stat-teal)" stroke-width="1.5" opacity="0.5"/>
<path d="M24 14V18" stroke="url(#stat-teal)" stroke-width="2" stroke-linecap="round"/>
<circle cx="24" cy="12" r="2" fill="url(#stat-teal)"/>
<path d="M18 22L20 24M28 28L30 30M30 22L28 24M20 28L18 30" stroke="url(#stat-teal)" stroke-width="1.5" stroke-linecap="round" opacity="0.55"/>
</g>
<!-- 海洋观测 -->
<g v-else-if="type === 'ocean'">
<path d="M6 32C10 28 14 30 18 28C22 26 26 30 30 28C34 26 38 28 42 32" stroke="url(#stat-cyan)" stroke-width="2.2" stroke-linecap="round"/>
<path d="M6 38C10 34 14 36 18 34C22 32 26 36 30 34C34 32 38 34 42 38" stroke="url(#stat-cyan)" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
<rect x="20" y="10" width="8" height="18" rx="2" fill="url(#stat-cyan)" opacity="0.2" stroke="url(#stat-cyan)" stroke-width="1.8"/>
<circle cx="24" cy="14" r="2" fill="url(#stat-cyan)"/>
<path d="M22 20H26M22 24H26" stroke="url(#stat-cyan)" stroke-width="1.5" stroke-linecap="round"/>
</g>
<!-- 数据记录 -->
<g v-else>
<rect x="8" y="10" width="32" height="28" rx="4" fill="url(#stat-indigo)" opacity="0.12"/>
<path d="M14 18H34M14 24H30M14 30H26" stroke="url(#stat-indigo)" stroke-width="2.2" stroke-linecap="round"/>
<rect x="28" y="8" width="12" height="12" rx="3" fill="url(#stat-indigo)"/>
<path d="M31 14H37M34 11V17" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/>
<circle cx="18" cy="18" r="1.5" fill="url(#stat-indigo)" opacity="0.5"/>
<circle cx="18" cy="24" r="1.5" fill="url(#stat-indigo)" opacity="0.5"/>
<circle cx="18" cy="30" r="1.5" fill="url(#stat-indigo)" opacity="0.5"/>
</g>
<defs>
<linearGradient id="stat-blue" x1="8" y1="8" x2="40" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#1a8fd4"/>
<stop offset="1" stop-color="#0056a4"/>
</linearGradient>
<linearGradient id="stat-orange" x1="8" y1="10" x2="40" y2="38" gradientUnits="userSpaceOnUse">
<stop stop-color="#f5a623"/>
<stop offset="1" stop-color="#e67e22"/>
</linearGradient>
<linearGradient id="stat-green" x1="10" y1="10" x2="38" y2="38" gradientUnits="userSpaceOnUse">
<stop stop-color="#2ecc71"/>
<stop offset="1" stop-color="#1a9d5c"/>
</linearGradient>
<linearGradient id="stat-purple" x1="14" y1="8" x2="34" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#9b59b6"/>
<stop offset="1" stop-color="#7a1ad4"/>
</linearGradient>
<linearGradient id="stat-teal" x1="12" y1="12" x2="36" y2="38" gradientUnits="userSpaceOnUse">
<stop stop-color="#1ad4a8"/>
<stop offset="1" stop-color="#0c8a6a"/>
</linearGradient>
<linearGradient id="stat-cyan" x1="6" y1="10" x2="42" y2="38" gradientUnits="userSpaceOnUse">
<stop stop-color="#4dc9f6"/>
<stop offset="1" stop-color="#1296db"/>
</linearGradient>
<linearGradient id="stat-indigo" x1="8" y1="8" x2="40" y2="38" gradientUnits="userSpaceOnUse">
<stop stop-color="#667eea"/>
<stop offset="1" stop-color="#4a5fc1"/>
</linearGradient>
</defs>
</svg>
</template>
<script setup>
/**
* 首页统计卡片精美图标
*/
defineProps({
type: { type: String, required: true },
size: { type: [Number, String], default: 40 }
})
</script>

102
src/constants/dataTypes.js Normal file
View File

@ -0,0 +1,102 @@
/**
* 地球物理数据类型定义
*/
export const DATA_CATEGORIES = [
{
label: '多波束数据',
value: 'multibeam',
children: [
{ label: '船载多波束', value: 'ship_multibeam' },
{ label: '潜载多波束', value: 'submarine_multibeam' }
]
},
{
label: '侧扫数据',
value: 'sidescan',
children: []
},
{
label: '地震数据',
value: 'seismic',
children: [
{ label: '浅地层地震剖面数据', value: 'shallow_seismic' },
{ label: '2D地震数据', value: 'seismic_2d' },
{ label: '3D地震数据', value: 'seismic_3d' },
{ label: 'OBN地震数据', value: 'obn_seismic' }
]
},
{
label: '重力数据',
value: 'gravity',
children: []
},
{
label: '磁力数据',
value: 'magnetic',
children: [
{ label: '船载磁力', value: 'ship_magnetic' },
{ label: '潜载磁力', value: 'submarine_magnetic' }
]
},
{
label: 'CTD数据',
value: 'ctd',
children: []
},
{
label: 'ADCP数据',
value: 'adcp',
children: []
},
{
label: '海底热流数据',
value: 'heat_flow',
children: []
}
]
/**
* 扁平化数据类型选项含子类型
* @returns {Array<{label: string, value: string, category: string}>}
*/
export function getFlatDataTypeOptions() {
const options = []
DATA_CATEGORIES.forEach((category) => {
if (category.children.length === 0) {
options.push({
label: category.label,
value: category.value,
category: category.value
})
return
}
category.children.forEach((child) => {
options.push({
label: child.label,
value: child.value,
category: category.value
})
})
})
return options
}
/**
* 根据类型值获取显示名称
* @param {string} typeValue
* @returns {string}
*/
export function getDataTypeLabel(typeValue) {
const option = getFlatDataTypeOptions().find((item) => item.value === typeValue)
return option ? option.label : typeValue
}
/**
* 根据类型值获取所属大类
* @param {string} typeValue
* @returns {string}
*/
export function getDataCategory(typeValue) {
const option = getFlatDataTypeOptions().find((item) => item.value === typeValue)
return option ? option.category : ''
}

View File

@ -0,0 +1,59 @@
import acousticImg from '@/assets/home/acoustic.jpg'
import seismicImg from '@/assets/home/seismic.jpg'
import potentialImg from '@/assets/home/potential.jpg'
import oceanImg from '@/assets/home/ocean.jpg'
import multibeamImg from '@/assets/home/multibeam.jpg'
import submarineImg from '@/assets/home/submarine.jpg'
import sidescanImg from '@/assets/home/sidescan.jpg'
import gravityImg from '@/assets/home/gravity.jpg'
import magneticImg from '@/assets/home/magnetic.jpg'
import ctdImg from '@/assets/home/ctd.jpg'
import adcpImg from '@/assets/home/adcp.jpg'
import heatflowImg from '@/assets/home/heatflow.jpg'
/**
* 数据资源大类封面图
*/
export const CATEGORY_IMAGES = {
acoustic: acousticImg,
seismic: seismicImg,
potential: potentialImg,
ocean: oceanImg
}
/**
* 各数据类型封面图
*/
export const DATA_TYPE_IMAGES = {
ship_multibeam: multibeamImg,
submarine_multibeam: submarineImg,
sidescan: sidescanImg,
shallow_seismic: seismicImg,
seismic_2d: seismicImg,
seismic_3d: seismicImg,
obn_seismic: seismicImg,
gravity: gravityImg,
ship_magnetic: magneticImg,
submarine_magnetic: magneticImg,
ctd: ctdImg,
adcp: adcpImg,
heat_flow: heatflowImg
}
/**
* 获取数据类型对应封面图
* @param {string} typeValue
* @returns {string}
*/
export function getDataTypeImage(typeValue) {
return DATA_TYPE_IMAGES[typeValue] || acousticImg
}
/**
* 获取资源大类封面图
* @param {string} categoryId
* @returns {string}
*/
export function getCategoryImage(categoryId) {
return CATEGORY_IMAGES[categoryId] || acousticImg
}

25
src/main.ts Normal file
View File

@ -0,0 +1,25 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'; // 引入中文语言包
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
const pinia = createPinia()
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(ElementPlus, {
locale: zhCn
})
app.use(router)
app.mount('#app')

36
src/mock/dataFiles.js Normal file
View File

@ -0,0 +1,36 @@
/**
* 数据资源 mock 存储文件与云路径
*/
export const MOCK_DATA_FILES = {
1: [
{
id: 101,
file_name: 'multibeam_raw_001.all',
file_size: 2936012800,
source_type: 'upload',
file_path: '/local/upload/multibeam_raw_001.all',
create_time: '2024-03-15 11:00:00'
}
],
6: [
{
id: 601,
file_name: 'seismic_3d_volume.sgy',
file_size: 45097156608,
source_type: 'cloud',
file_path: 's3://geodata-bucket/seismic/qiongdongnan/seismic_3d_volume.sgy',
create_time: '2024-01-21 09:10:00'
}
]
}
let fileIdSeed = 1000
/**
* 生成新文件记录 ID
* @returns {number}
*/
export function nextFileId() {
fileIdSeed += 1
return fileIdSeed
}

198
src/mock/dataSpace.js Normal file
View File

@ -0,0 +1,198 @@
import { DATA_CATEGORIES } from '@/constants/dataTypes.js'
export const DATA_SPACES = [
{ id: 'geophys-main', label: '深海地球物理数据空间' },
{ id: 'geophys-south', label: '南海地球物理调查数据空间' },
{ id: 'geophys-pacific', label: '西太平洋地球物理数据空间' }
]
/**
* 根据数据类型常量生成目录树
* @returns {Array}
*/
function buildDirectoryTree() {
const children = DATA_CATEGORIES.map((category) => {
if (category.children.length > 0) {
return {
id: category.value,
label: category.label,
children: category.children.map((child) => ({
id: child.value,
label: child.label
}))
}
}
return {
id: category.value,
label: category.label
}
})
return [{
id: 'root',
label: '深海地球物理数据空间',
children
}]
}
/**
* 目录树结构按地球物理数据类型组织
*/
export const DIRECTORY_TREE = buildDirectoryTree()
/**
* 各目录下文件
* @type {Record<string, Array>}
*/
export const FILES_BY_FOLDER = {
ship_multibeam: [
{
id: 'mb-ship-1',
name: 'MB-ship-line001.all',
ext: 'all',
size: 2147483648,
update_time: '2024-03-15 10:30:00'
},
{
id: 'mb-ship-2',
name: 'MB-ship-line002.all',
ext: 'all',
size: 1879048192,
update_time: '2024-03-15 11:20:00'
}
],
submarine_multibeam: [
{
id: 'mb-sub-1',
name: 'HROV-MB-survey-001.nc',
ext: 'nc',
size: 536870912,
update_time: '2024-03-18 09:15:00'
}
],
sidescan: [
{
id: 'ss-1',
name: 'SSS-track-001.xtf',
ext: 'xtf',
size: 1048576000,
update_time: '2024-03-20 14:00:00'
},
{
id: 'ss-2',
name: 'SSS-mosaic.tif',
ext: 'tif',
size: 524288000,
update_time: '2024-03-21 09:30:00'
}
],
shallow_seismic: [
{
id: 'sh-se-1',
name: 'CHIRP-profile-001.sgy',
ext: 'sgy',
size: 3221225472,
update_time: '2024-04-02 08:30:00'
}
],
seismic_2d: [
{
id: 'se-2d-1',
name: '2D-line001.sgy',
ext: 'sgy',
size: 4294967296,
update_time: '2024-04-02 09:45:00'
},
{
id: 'se-2d-2',
name: '2D-line002.sgy',
ext: 'sgy',
size: 3758096384,
update_time: '2024-04-03 10:15:00'
}
],
seismic_3d: [
{
id: 'se-3d-1',
name: '3D-cube-volume.segy',
ext: 'segy',
size: 8589934592,
update_time: '2024-04-05 16:20:00'
}
],
obn_seismic: [
{
id: 'obn-1',
name: 'OBN-receiver-nodes.rar',
ext: 'rar',
size: 6442450944,
update_time: '2024-04-08 11:00:00'
}
],
gravity: [
{
id: 'grav-1',
name: 'gravity-anomaly-grid.grd',
ext: 'grd',
size: 67108864,
update_time: '2024-04-10 08:00:00'
}
],
ship_magnetic: [
{
id: 'mag-ship-1',
name: 'MAG-ship-track-001.mgd',
ext: 'mgd',
size: 134217728,
update_time: '2024-04-12 13:45:00'
}
],
submarine_magnetic: [
{
id: 'mag-sub-1',
name: 'HROV-MAG-survey-001.dat',
ext: 'dat',
size: 268435456,
update_time: '2024-04-14 15:20:00'
}
],
ctd: [
{
id: 'ctd-1',
name: 'CTD-cast-001.cnv',
ext: 'cnv',
size: 2097152,
update_time: '2024-04-16 07:30:00'
},
{
id: 'ctd-2',
name: 'CTD-profile-summary.csv',
ext: 'csv',
size: 524288,
update_time: '2024-04-16 08:00:00'
}
],
adcp: [
{
id: 'adcp-1',
name: 'ADCP-current-profile.pd0',
ext: 'pd0',
size: 4194304,
update_time: '2024-04-18 10:00:00'
}
],
heat_flow: [
{
id: 'hf-1',
name: 'heat-flow-station-001.txt',
ext: 'txt',
size: 65536,
update_time: '2024-04-20 14:30:00'
}
]
}
/**
* 默认选中目录
*/
export const DEFAULT_FOLDER_ID = 'ship_multibeam'

211
src/mock/geophysicalData.js Normal file
View File

@ -0,0 +1,211 @@
/**
* 地球物理数据 mock 数据集
*/
export const MOCK_GEOPHYSICAL_DATA = [
{
id: 1,
data_code: 'MB-SH-2024-001',
data_type: 'ship_multibeam',
data_name: '南海北部船载多波束测深数据',
voyage_name: '探索一号2024航次',
data_status: '已入库',
data_lon: 113.52,
data_lat: 18.25,
fill_time: '2024-03-15 10:30:00',
fill_person: '张工',
data_secrecy: '否',
file_size: '2.8 GB',
survey_area: '120 km²'
},
{
id: 2,
data_code: 'MB-SU-2024-002',
data_type: 'submarine_multibeam',
data_name: '西太平洋潜载多波束地形数据',
voyage_name: '奋斗者号2024航次',
data_status: '处理中',
data_lon: 125.18,
data_lat: 12.36,
fill_time: '2024-04-02 14:20:00',
fill_person: '李工',
data_secrecy: '是',
file_size: '5.1 GB',
survey_area: '85 km²'
},
{
id: 3,
data_code: 'SS-2024-003',
data_type: 'sidescan',
data_name: '马里亚纳海沟侧扫声呐影像',
voyage_name: '探索二号2024航次',
data_status: '已入库',
data_lon: 142.2,
data_lat: 11.35,
fill_time: '2024-05-10 09:15:00',
fill_person: '王工',
data_secrecy: '否',
file_size: '1.2 GB',
survey_area: '45 km²'
},
{
id: 4,
data_code: 'SE-SH-2024-004',
data_type: 'shallow_seismic',
data_name: '珠江口浅地层地震剖面',
voyage_name: '海洋六号2024航次',
data_status: '已入库',
data_lon: 114.05,
data_lat: 22.08,
fill_time: '2024-02-28 16:45:00',
fill_person: '赵工',
data_secrecy: '否',
file_size: '3.6 GB',
survey_area: '200 km²'
},
{
id: 5,
data_code: 'SE-2D-2024-005',
data_type: 'seismic_2d',
data_name: '南海北部2D地震测线数据',
voyage_name: '海洋地质十二号2024航次',
data_status: '待审核',
data_lon: 115.32,
data_lat: 19.45,
fill_time: '2024-06-01 11:00:00',
fill_person: '陈工',
data_secrecy: '是',
file_size: '8.4 GB',
survey_area: '350 km²'
},
{
id: 6,
data_code: 'SE-3D-2024-006',
data_type: 'seismic_3d',
data_name: '琼东南盆地3D地震数据体',
voyage_name: '海洋石油7082024航次',
data_status: '已入库',
data_lon: 111.8,
data_lat: 17.92,
fill_time: '2024-01-20 08:30:00',
fill_person: '刘工',
data_secrecy: '是',
file_size: '42.0 GB',
survey_area: '500 km²'
},
{
id: 7,
data_code: 'SE-OBN-2024-007',
data_type: 'obn_seismic',
data_name: 'OBN节点地震采集数据',
voyage_name: '探索三号2024航次',
data_status: '处理中',
data_lon: 118.65,
data_lat: 20.12,
fill_time: '2024-07-08 13:25:00',
fill_person: '周工',
data_secrecy: '是',
file_size: '15.7 GB',
survey_area: '180 km²'
},
{
id: 8,
data_code: 'GR-2024-008',
data_type: 'gravity',
data_name: '南海重力异常测量数据',
voyage_name: '向阳红012024航次',
data_status: '已入库',
data_lon: 112.45,
data_lat: 16.78,
fill_time: '2024-03-22 15:10:00',
fill_person: '吴工',
data_secrecy: '否',
file_size: '680 MB',
survey_area: '600 km²'
},
{
id: 9,
data_code: 'MG-SH-2024-009',
data_type: 'ship_magnetic',
data_name: '船载磁力梯度测量数据',
voyage_name: '海洋四号2024航次',
data_status: '已入库',
data_lon: 119.28,
data_lat: 21.55,
fill_time: '2024-04-18 10:50:00',
fill_person: '郑工',
data_secrecy: '否',
file_size: '420 MB',
survey_area: '280 km²'
},
{
id: 10,
data_code: 'MG-SU-2024-010',
data_type: 'submarine_magnetic',
data_name: '潜载磁力异常探测数据',
voyage_name: '奋斗者号2024航次',
data_status: '待审核',
data_lon: 126.05,
data_lat: 13.88,
fill_time: '2024-05-25 17:30:00',
fill_person: '孙工',
data_secrecy: '是',
file_size: '950 MB',
survey_area: '65 km²'
},
{
id: 11,
data_code: 'CTD-2024-011',
data_type: 'ctd',
data_name: '南海深层CTD温盐深剖面',
voyage_name: '探索一号2024航次',
data_status: '已入库',
data_lon: 114.88,
data_lat: 18.62,
fill_time: '2024-02-14 09:40:00',
fill_person: '钱工',
data_secrecy: '否',
file_size: '128 MB',
survey_area: '—'
},
{
id: 12,
data_code: 'ADCP-2024-012',
data_type: 'adcp',
data_name: '西太平洋ADCP海流观测数据',
voyage_name: '向阳红18号2024航次',
data_status: '已入库',
data_lon: 130.42,
data_lat: 15.23,
fill_time: '2024-06-15 12:00:00',
fill_person: '冯工',
data_secrecy: '否',
file_size: '256 MB',
survey_area: '—'
},
{
id: 13,
data_code: 'HF-2024-013',
data_type: 'heat_flow',
data_name: '南海北部海底热流测量数据',
voyage_name: '探索二号2024航次',
data_status: '处理中',
data_lon: 116.15,
data_lat: 19.08,
fill_time: '2024-07-20 14:55:00',
fill_person: '韩工',
data_secrecy: '否',
file_size: '86 MB',
survey_area: '—'
}
]
/**
* 主页统计数据
*/
export const MOCK_STATISTICS = {
data_total: 13,
area_total: '2850',
voyage_total: 8,
type_total: 8,
volume_total: '80.5'
}

81
src/mock/homeData.js Normal file
View File

@ -0,0 +1,81 @@
import { CATEGORY_IMAGES } from '@/constants/homeImages.js'
/**
* 首页数据资源分类
*/
export const HOME_RESOURCE_CATEGORIES = [
{
id: 'acoustic',
label: '声学测量数据',
desc: '多波束、侧扫声呐',
route: '/demand',
query: { category: 'acoustic' },
count: 3,
cover: CATEGORY_IMAGES.acoustic
},
{
id: 'seismic',
label: '地震勘探数据',
desc: '浅剖、2D/3D、OBN',
route: '/demand',
query: { category: 'seismic' },
count: 4,
cover: CATEGORY_IMAGES.seismic
},
{
id: 'potential',
label: '重磁位场数据',
desc: '重力、磁力测量',
route: '/demand',
query: { category: 'potential' },
count: 3,
cover: CATEGORY_IMAGES.potential
},
{
id: 'ocean',
label: '海洋环境数据',
desc: 'CTD、ADCP、热流',
route: '/demand',
query: { category: 'ocean' },
count: 3,
cover: CATEGORY_IMAGES.ocean
}
]
/**
* 首页 Hero 统计卡片
*/
export const HOME_STAT_CARDS = [
{ key: 'multibeam', label: '多波束', value: '2', unit: '类' },
{ key: 'seismic', label: '地震数据', value: '4', unit: '类' },
{ key: 'magnetic', label: '磁力数据', value: '2', unit: '类' },
{ key: 'sidescan', label: '侧扫数据', value: '1', unit: '类' },
{ key: 'dataset', label: '数据集', value: '13', unit: '个' },
{ key: 'gravity', label: '重力数据', value: '1', unit: '类' },
{ key: 'ocean', label: '海洋观测', value: '3', unit: '类' },
{ key: 'records', label: '数据记录', value: '82,454', unit: '条' }
]
/**
* 热门数据排序字段
*/
export const HOT_DATA_TABS = [
{ key: 'views', label: '浏览量' },
{ key: 'downloads', label: '下载量' },
{ key: 'focus', label: '关注度' }
]
/**
* mock 数据附加热门指标
* @param {Array} dataList
* @returns {Array}
*/
export function enrichHotDataMetrics(dataList) {
return dataList.map((item, index) => ({
...item,
views: 1280 - index * 86,
downloads: 356 - index * 28,
focus: 96 - index * 6,
hotRank: index + 1
}))
}

71
src/router/index.ts Normal file
View File

@ -0,0 +1,71 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useAuthStore } from '@/store/authStore'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { title: '首页', requiresAuth: true }
},
{
path: '/search',
name: 'DataImport',
component: () => import('@/views/DataImport.vue'),
meta: { title: '数据入库', requiresAuth: true }
},
{
path: '/demand',
name: 'DataSearch',
component: () => import('@/views/DataSearch.vue'),
meta: { title: '数据检索', requiresAuth: true }
},
{
path: '/input/:id?',
name: 'DataInput',
component: () => import('@/views/TaskData.vue'),
meta: { title: '数据信息录入', requiresAuth: true }
},
{
path: '/statistics',
name: 'Statistics',
component: () => import('@/views/Statistics.vue'),
meta: { title: '统计分析', requiresAuth: true }
},
{
path: '/contact',
name: 'Contact',
component: () => import('@/views/Contact.vue'),
meta: { title: '联系我们', requiresAuth: true }
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录', requiresAuth: false }
}
]
})
router.beforeEach((to, _from, next) => {
if (to.meta.title) {
document.title = '深海地球物理数据管理可视化平台 - ' + to.meta.title
}
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
next({
path: '/login',
query: { redirect: to.fullPath }
})
} else if (to.path === '/login' && authStore.isLoggedIn) {
next('/')
} else {
next()
}
})
export default router

24
src/store/authStore.js Normal file
View File

@ -0,0 +1,24 @@
// src/store/authStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || null)
// 计算属性:是否已登录
const isLoggedIn = computed(() => !!token.value)
// 登录方法
function login(newToken) {
token.value = newToken
localStorage.setItem('token', newToken)
}
// 退出登录方法
function logout() {
token.value = null
localStorage.removeItem('token')
}
return { token, isLoggedIn, login, logout }
})

35
src/style.css Normal file
View File

@ -0,0 +1,35 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
#app {
width: 100%;
margin: 0 auto;
}

83
src/utils/common.js Normal file
View File

@ -0,0 +1,83 @@
import * as Cesium from "cesium";
export function modifyCesiumMouseWheel(viewer) {
// 地图缩放改为按下 Ctrl + 鼠标滚轮
viewer.scene.screenSpaceCameraController.zoomEventTypes = [{ eventType: Cesium.CameraEventType.WHEEL, modifier: Cesium.KeyboardEventModifier.CTRL }];
// 改写 Cesium 原生鼠标滚轮事件
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((event) => {
if (event < 0) {
window.scrollBy({ left: 0, top: 15, behavior: 'auto' });
} else {
window.scrollBy({ left: 0, top: -15, behavior: 'auto' });
}
}, Cesium.ScreenSpaceEventType.WHEEL);
// 鼠标移动时显示提示信息
handler.setInputAction((event) => {
document.getElementById('mouseWheelTip').style.display = '';
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
}
let imageryViewModels = [];
export function setImageryViewModels(cesiumHostAddr) {
if (imageryViewModels.length == 0) {
// 世界影像地图
const world_imagery = new Cesium.WebMapTileServiceImageryProvider(
{
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer'
// url: 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer'
// url: 'https://services.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer'
}
);
// 彩色地形底图
const colorMap = new Cesium.WebMapTileServiceImageryProvider(
{
url: cesiumHostAddr + 'geoserver/gwc/service/wmts/rest/ougp:world/{TileMatrixSet}/{TileMatrixSet}:{TileMatrix}/{TileRow}/{TileCol}?format=image/jpeg',
layer: 'ougp:world',
style: 'default',
format: 'image/jpeg',
tileMatrixSetID: 'EPSG:4326',
tilingScheme: new Cesium.GeographicTilingScheme()
}
);
// 灰度地形底图
const grayMap = new Cesium.WebMapTileServiceImageryProvider(
{
url: cesiumHostAddr + 'geoserver/gwc/service/wmts/rest/ougp:world_gray/{TileMatrixSet}/{TileMatrixSet}:{TileMatrix}/{TileRow}/{TileCol}?format=image/jpeg',
layer: 'ougp:world_gray',
style: 'default',
format: 'image/jpeg',
tileMatrixSetID: 'EPSG:4326',
tilingScheme: new Cesium.GeographicTilingScheme()
}
);
// 自定义底图
imageryViewModels.push(new Cesium.ProviderViewModel({
name: "世界影像地图",
iconUrl: "static/images/world_imagery.png",
tooltip: "世界影像地图",
creationFunction: function () {
return world_imagery;
}
}));
imageryViewModels.push(new Cesium.ProviderViewModel({
name: "地形地图",
iconUrl: "static/images/world_color.png",
tooltip: "彩色地形底图",
creationFunction: function () {
return colorMap;
}
}));
imageryViewModels.push(new Cesium.ProviderViewModel({
name: "灰度地图",
iconUrl: "static/images/world_gray.png",
tooltip: "灰度地形底图",
creationFunction: function () {
return grayMap;
}
}));
}
return imageryViewModels;
}

228
src/utils/index.js Normal file
View File

@ -0,0 +1,228 @@
/**
* 通用js方法封装处理
* Copyright (c) 2019 ruoyi
*/
// 日期格式化
export function parseTime(time, pattern) {
if (arguments.length === 0 || !time) {
return null
}
const format = pattern || '{y}-{m}-{d} {h}:{i}:{s}'
let date
if (typeof time === 'object') {
date = time
} else {
if ((typeof time === 'string') && (/^[0-9]+$/.test(time))) {
time = parseInt(time)
} else if (typeof time === 'string') {
time = time.replace(new RegExp(/-/gm), '/').replace('T', ' ').replace(new RegExp(/\.[\d]{3}/gm), '')
}
if ((typeof time === 'number') && (time.toString().length === 10)) {
time = time * 1000
}
date = new Date(time)
}
const formatObj = {
y: date.getFullYear(),
m: date.getMonth() + 1,
d: date.getDate(),
h: date.getHours(),
i: date.getMinutes(),
s: date.getSeconds(),
a: date.getDay()
}
const time_str = format.replace(/{(y|m|d|h|i|s|a)+}/g, (result, key) => {
let value = formatObj[key]
// Note: getDay() returns 0 on Sunday
if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value] }
if (result.length > 0 && value < 10) {
value = '0' + value
}
return value || 0
})
return time_str
}
// 表单重置
export function resetForm(refName) {
if (this.$refs[refName]) {
this.$refs[refName].resetFields()
}
}
// 添加日期范围
export function addDateRange(params, dateRange, propName) {
let search = params
search.params = typeof (search.params) === 'object' && search.params !== null && !Array.isArray(search.params) ? search.params : {}
dateRange = Array.isArray(dateRange) ? dateRange : []
if (typeof (propName) === 'undefined') {
search.params['beginTime'] = dateRange[0]
search.params['endTime'] = dateRange[1]
} else {
search.params['begin' + propName] = dateRange[0]
search.params['end' + propName] = dateRange[1]
}
return search
}
// 回显数据字典
export function selectDictLabel(datas, value) {
if (value === undefined) {
return ""
}
var actions = []
Object.keys(datas).some((key) => {
if (datas[key].value == ('' + value)) {
actions.push(datas[key].label)
return true
}
})
if (actions.length === 0) {
actions.push(value)
}
return actions.join('')
}
// 回显数据字典(字符串、数组)
export function selectDictLabels(datas, value, separator) {
if (value === undefined || value.length ===0) {
return ""
}
if (Array.isArray(value)) {
value = value.join(",")
}
var actions = []
var currentSeparator = undefined === separator ? "," : separator
var temp = value.split(currentSeparator)
Object.keys(value.split(currentSeparator)).some((val) => {
var match = false
Object.keys(datas).some((key) => {
if (datas[key].value == ('' + temp[val])) {
actions.push(datas[key].label + currentSeparator)
match = true
}
})
if (!match) {
actions.push(temp[val] + currentSeparator)
}
})
return actions.join('').substring(0, actions.join('').length - 1)
}
// 字符串格式化(%s )
export function sprintf(str) {
var args = arguments, flag = true, i = 1
str = str.replace(/%s/g, function () {
var arg = args[i++]
if (typeof arg === 'undefined') {
flag = false
return ''
}
return arg
})
return flag ? str : ''
}
// 转换字符串undefined,null等转化为""
export function parseStrEmpty(str) {
if (!str || str == "undefined" || str == "null") {
return ""
}
return str
}
// 数据合并
export function mergeRecursive(source, target) {
for (var p in target) {
try {
if (target[p].constructor == Object) {
source[p] = mergeRecursive(source[p], target[p])
} else {
source[p] = target[p]
}
} catch (e) {
source[p] = target[p]
}
}
return source
}
/**
* 构造树型结构数据
* @param {*} data 数据源
* @param {*} id id字段 默认 'id'
* @param {*} parentId 父节点字段 默认 'parentId'
* @param {*} children 孩子节点字段 默认 'children'
*/
export function handleTree(data, id, parentId, children) {
let config = {
id: id || 'id',
parentId: parentId || 'parentId',
childrenList: children || 'children'
}
var childrenListMap = {}
var tree = []
for (let d of data) {
let id = d[config.id]
childrenListMap[id] = d
if (!d[config.childrenList]) {
d[config.childrenList] = []
}
}
for (let d of data) {
let parentId = d[config.parentId]
let parentObj = childrenListMap[parentId]
if (!parentObj) {
tree.push(d)
} else {
parentObj[config.childrenList].push(d)
}
}
return tree
}
/**
* 参数处理
* @param {*} params 参数
*/
export function tansParams(params) {
let result = ''
for (const propName of Object.keys(params)) {
const value = params[propName]
var part = encodeURIComponent(propName) + "="
if (value !== null && value !== "" && typeof (value) !== "undefined") {
if (typeof value === 'object') {
for (const key of Object.keys(value)) {
if (value[key] !== null && value[key] !== "" && typeof (value[key]) !== 'undefined') {
let params = propName + '[' + key + ']'
var subPart = encodeURIComponent(params) + "="
result += subPart + encodeURIComponent(value[key]) + "&"
}
}
} else {
result += part + encodeURIComponent(value) + "&"
}
}
}
return result
}
// 返回项目路径
export function getNormalPath(p) {
if (p.length === 0 || !p || p == 'undefined') {
return p
}
let res = p.replace('//', '/')
if (res[res.length - 1] === '/') {
return res.slice(0, res.length - 1)
}
return res
}
// 验证是否为blob格式
export function blobValidate(data) {
return data.type !== 'application/json'
}

98
src/utils/request.js Normal file
View File

@ -0,0 +1,98 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
axios.defaults.headers['Content-Type'] = 'application/x-www-form-urlencoded'
// 创建axios实例
const service = axios.create({
// axios中请求配置有baseURL选项表示请求URL公共部分
baseURL: import.meta.env.VITE_APP_BASE_API,
// 超时
timeout: 10000
})
// request拦截器
service.interceptors.request.use(config => {
// 过滤掉参数值为空的参数项
if (config.params) {
config.params = filterEmptyParams(config.params);
}
if (config.data && typeof config.data === 'object') {
// 根据Content-Type处理不同格式的数据
if (config.headers['Content-Type'] === 'application/x-www-form-urlencoded') {
// 表单编码数据需要特殊处理
config.data = filterFormData(config.data);
} else {
config.data = filterEmptyParams(config.data);
}
}
return config
}, error => {
console.log(error)
Promise.reject(error)
})
/**
* 过滤空值参数
* @param {Object} params 参数对象
* @returns {Object} 过滤后的参数对象
*/
function filterEmptyParams(params) {
const filtered = {};
for (const key in params) {
const value = params[key];
// 保留非空值非null、非undefined、非空字符串、非空数组
if (value !== null && value !== undefined && value !== '') {
if (Array.isArray(value) && value.length === 0) {
continue; // 跳过空数组
}
filtered[key] = value;
}
}
return filtered;
}
/**
* 过滤表单编码数据
* @param {Object|string} data 表单数据
* @returns {string} 过滤后的表单字符串
*/
function filterFormData(data) {
if (typeof data === 'string') {
// 如果是字符串尝试解析为URLSearchParams
const params = new URLSearchParams(data);
for (const [key, value] of params.entries()) {
if (value === '' || value === 'null' || value === 'undefined') {
params.delete(key);
}
}
return params.toString();
} else if (typeof data === 'object') {
// 如果是对象先过滤空值再转换为URLSearchParams
const filtered = filterEmptyParams(data);
return new URLSearchParams(filtered).toString();
}
return data;
}
// 响应拦截器
service.interceptors.response.use(res => {
const code = res.data.errorCode || 0
const msg = res.data.errorMsg || ''
if (code === 20000) {
ElMessage({ message: msg, type: 'error' })
} else {
return Promise.resolve(res.data)
}
},
error => {
console.log('err' + error)
let { message } = error
ElMessage({ message: message, type: 'error', duration: 5 * 1000 })
return Promise.reject(error)
}
)
export default service

59
src/views/Contact.vue Normal file
View File

@ -0,0 +1,59 @@
<template>
<div class="aboutUs">
<div class="banner">
<p class="bold">关于我们</p>
<p>团队成员主要由海洋地球物理学家软件工程师和数据管理员构成隶属于中国科学院深海科学与工程研究所开展船潜载地球物理数据处理和深海数据系统信息化工具开发的应用基础研究服务支撑fundingOptions深海深渊科考深海考古等国家重大需求</p>
<p class="bold">团队组成</p>
<p><span class="fw-700">陈传绪</span>团队负责人船潜载地球物理数据处理算法开发DSDS架构总设计</p>
<p class="fw-700">* DSDSDeep-Sea Data System</p>
<p><span class="fw-700">邹小珊</span>软件工程师负责 C/S 框架客户端软件和信息化工具开发</p>
<p><span class="fw-700">黄云明</span>软件工程师负责 B/S 框架航次潜次数据门户及应用开发</p>
<p><span class="fw-700">冯万丽</span>数据管理员负责航次和潜次数据预处理清洁规范化归档入库</p>
<p class="fw-700">* OUGPOnboard-Underwater Geophysics Processing</p>
<p><span class="fw-700">张汉羽</span>地球物理船潜载地球物理数据处理算法开发分布式光纤传感方向负责人</p>
<p><span class="fw-700">谢欣彤</span>地球物理船潜载地球物理数据处理和三维可视化</p>
<p><span class="fw-700">李栋</span>地球物理海底地震仪数据处理成像</p>
<p><span class="fw-700">廖佩铃</span>地球物理船潜载声学探测数据处理和成像</p>
<p><span class="fw-700">常瀚文</span>信息安全软件开发和数据安全</p>
<p class="bold">联系我们</p>
<p><span class="fw-700">陈传绪</span>chencx@idsse.ac.cn</p>
<p><span class="fw-700">张汉羽</span>zhanghy@idsse.ac.cn</p>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.aboutUs {
width: 100%;
background: url(@/assets/bg2.png) center no-repeat;
background-size: auto 100%;
display: flex;
justify-content: center;
}
.banner {
width: 1200px;
color: #ffffff;
padding: 100px 0;
font-size: 20px;
}
.bold {
text-align: center;
font-weight: bold;
margin: 40px 0 10px;
font-size: 30px;
}
p {
margin-bottom: 10px;
line-height: 50px;
}
.fw-700 {
font-weight: 700;
}
</style>

495
src/views/DataImport.vue Normal file
View File

@ -0,0 +1,495 @@
<template>
<div class="data-space-page">
<!-- 顶部工具栏 -->
<div class="top-toolbar">
<div class="toolbar-left">
<el-button link type="primary" @click="goBackList">
<el-icon><ArrowLeft /></el-icon>
返回列表
</el-button>
<el-select v-model="currentSpace" class="space-select" size="large">
<el-option
v-for="space in spaceList"
:key="space.id"
:label="space.label"
:value="space.id"
/>
</el-select>
</div>
<div class="toolbar-right">
<el-button-group class="view-toggle">
<el-button :type="viewMode === 'grid' ? 'primary' : 'default'" @click="viewMode = 'grid'">
<el-icon><Grid /></el-icon>
</el-button>
<el-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
<el-icon><List /></el-icon>
</el-button>
</el-button-group>
<el-button type="primary" @click="uploadDialogVisible = true">
<el-icon><Upload /></el-icon>
上传文件
</el-button>
<el-button type="warning" plain @click="importDialogVisible = true">
<el-icon><Download /></el-icon>
空间导入
</el-button>
<el-button type="success" plain @click="ftpDialogVisible = true">
<el-icon><Monitor /></el-icon>
FTP地址
</el-button>
<el-button @click="spaceDialogVisible = true">空间管理</el-button>
</div>
</div>
<!-- 主体目录树 + 文件预览 -->
<div class="main-panel">
<aside class="tree-panel">
<div class="panel-title">
<el-icon><FolderOpened /></el-icon>
文件目录
</div>
<el-tree
ref="treeRef"
:data="treeData"
node-key="id"
:props="treeProps"
:default-expanded-keys="defaultExpandedKeys"
:current-node-key="currentFolderId"
highlight-current
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<span class="tree-node">
<FileTypeIcon type="folder" :size="18" class="tree-folder-icon" />
<span :class="{ 'is-active': data.id === currentFolderId }">{{ node.label }}</span>
</span>
</template>
</el-tree>
</aside>
<section class="preview-panel">
<div class="preview-header">
<span class="path">{{ currentPathLabel }}</span>
<span class="file-count"> {{ fileList.length }} </span>
</div>
<!-- 网格视图 -->
<div v-if="viewMode === 'grid'" class="file-grid">
<div
v-for="file in fileList"
:key="file.id"
class="file-item"
:class="{ selected: selectedFileId === file.id }"
@click="selectFile(file)"
@dblclick="previewFile(file)"
>
<FileTypeIcon :ext="file.ext" :size="72" />
<div class="file-name" :title="file.name">{{ file.name }}</div>
</div>
<el-empty v-if="!fileList.length" description="当前目录暂无文件" />
</div>
<!-- 列表视图 -->
<el-table
v-else
:data="fileList"
border
stripe
highlight-current-row
@row-click="(row) => selectFile(row)"
@row-dblclick="previewFile"
>
<el-table-column label="名称" min-width="260">
<template #default="{ row }">
<div class="list-name">
<FileTypeIcon :ext="row.ext" :size="24" />
<span>{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="ext" label="类型" width="80" />
<el-table-column label="大小" width="120">
<template #default="{ row }">{{ formatFileSize(row.size) }}</template>
</el-table-column>
<el-table-column prop="update_time" label="修改时间" width="180" />
</el-table>
</section>
</div>
<!-- 上传文件弹窗 -->
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="520" destroy-on-close>
<el-upload drag :auto-upload="false" :limit="5">
<el-icon class="upload-icon"><Upload /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">上传至当前目录{{ currentPathLabel }}</div>
</template>
</el-upload>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmUpload">确认上传</el-button>
</template>
</el-dialog>
<!-- 空间导入弹窗 -->
<el-dialog v-model="importDialogVisible" title="空间导入" width="560" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="源空间路径">
<el-input v-model="importForm.sourcePath" placeholder="s3://bucket/path 或 NAS 路径" />
</el-form-item>
<el-form-item label="目标目录">
<el-input :model-value="currentPathLabel" disabled />
</el-form-item>
<el-form-item label="导入说明">
<el-input v-model="importForm.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="importDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmImport">开始导入</el-button>
</template>
</el-dialog>
<!-- FTP 地址弹窗 -->
<el-dialog v-model="ftpDialogVisible" title="FTP 地址" width="520" destroy-on-close>
<el-descriptions :column="1" border>
<el-descriptions-item label="FTP 主机">ftp.idsse.ac.cn</el-descriptions-item>
<el-descriptions-item label="端口">21</el-descriptions-item>
<el-descriptions-item label="当前路径">/geophys/{{ currentFolderId }}</el-descriptions-item>
<el-descriptions-item label="完整地址">ftp://ftp.idsse.ac.cn/geophys/{{ currentFolderId }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="ftpDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="copyFtpPath">复制地址</el-button>
</template>
</el-dialog>
<!-- 空间管理弹窗 -->
<el-dialog v-model="spaceDialogVisible" title="空间管理" width="480">
<p>当前数据空间<strong>{{ currentSpaceLabel }}</strong></p>
<p class="tip-text">支持创建子目录设置权限及配额管理演示功能</p>
<template #footer>
<el-button type="primary" @click="spaceDialogVisible = false">确定</el-button>
</template>
</el-dialog>
<!-- 文件预览弹窗 -->
<el-dialog v-model="previewVisible" :title="previewFileInfo?.name" width="640">
<el-descriptions v-if="previewFileInfo" :column="1" border>
<el-descriptions-item label="文件名">{{ previewFileInfo.name }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ previewFileInfo.ext?.toUpperCase() }}</el-descriptions-item>
<el-descriptions-item label="大小">{{ formatFileSize(previewFileInfo.size) }}</el-descriptions-item>
<el-descriptions-item label="修改时间">{{ previewFileInfo.update_time }}</el-descriptions-item>
<el-descriptions-item label="所在目录">{{ currentPathLabel }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowLeft, Grid, List, Upload, Download,
Monitor, FolderOpened
} from '@element-plus/icons-vue'
import FileTypeIcon from '@/components/dataSpace/FileTypeIcon.vue'
import {
getDataSpaces,
getDirectoryTree,
getFolderFiles
} from '@/api/dataSpace.js'
import { DEFAULT_FOLDER_ID } from '@/mock/dataSpace.js'
const router = useRouter()
const treeRef = ref(null)
const spaceList = ref([])
const treeData = ref([])
const fileList = ref([])
const currentSpace = ref('geophys-main')
const currentFolderId = ref(DEFAULT_FOLDER_ID)
const viewMode = ref('grid')
const selectedFileId = ref('')
const defaultExpandedKeys = ref(['root', 'multibeam', 'seismic', 'magnetic'])
const uploadDialogVisible = ref(false)
const importDialogVisible = ref(false)
const ftpDialogVisible = ref(false)
const spaceDialogVisible = ref(false)
const previewVisible = ref(false)
const previewFileInfo = ref(null)
const importForm = ref({ sourcePath: '', remark: '' })
const treeProps = { label: 'label', children: 'children' }
const currentSpaceLabel = computed(() =>
spaceList.value.find((s) => s.id === currentSpace.value)?.label || ''
)
/**
* 构建当前目录路径显示
*/
const currentPathLabel = computed(() => {
const parts = []
const buildPath = (nodes, targetId, path = []) => {
for (const node of nodes) {
const next = [...path, node.label]
if (node.id === targetId) {
parts.push(...next)
return true
}
if (node.children?.length && buildPath(node.children, targetId, next)) {
return true
}
}
return false
}
buildPath(treeData.value, currentFolderId.value)
return parts.join(' / ') || '深海地球物理数据空间'
})
/**
* 格式化文件大小
* @param {number} bytes
*/
const formatFileSize = (bytes) => {
if (!bytes) return '—'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
const loadSpaces = () => {
getDataSpaces().then((res) => {
spaceList.value = res.array || []
})
}
const loadTree = () => {
getDirectoryTree().then((res) => {
treeData.value = res.array || []
})
}
const loadFiles = () => {
getFolderFiles({ folder_id: currentFolderId.value }).then((res) => {
fileList.value = res.array || []
selectedFileId.value = ''
})
}
const handleNodeClick = (data) => {
if (!data.children?.length) {
currentFolderId.value = data.id
loadFiles()
}
}
const selectFile = (file) => {
selectedFileId.value = file.id
}
const previewFile = (file) => {
previewFileInfo.value = file
previewVisible.value = true
}
const goBackList = () => {
router.push('/demand')
}
const confirmUpload = () => {
ElMessage.success(`文件已上传至:${currentPathLabel.value}`)
uploadDialogVisible.value = false
}
const confirmImport = () => {
if (!importForm.value.sourcePath) {
ElMessage.warning('请输入源空间路径')
return
}
ElMessage.success('空间导入任务已提交')
importDialogVisible.value = false
}
const copyFtpPath = () => {
const path = `ftp://ftp.idsse.ac.cn/geophys/${currentFolderId.value}`
navigator.clipboard?.writeText(path).then(() => {
ElMessage.success('FTP 地址已复制')
}).catch(() => {
ElMessage.info(path)
})
}
onMounted(() => {
loadSpaces()
loadTree()
loadFiles()
})
</script>
<style lang="scss" scoped>
.data-space-page {
display: flex;
flex-direction: column;
height: calc(100vh - 220px);
min-height: 600px;
background: #fff;
margin: 0;
}
.top-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e8e8e8;
background: #fafafa;
flex-wrap: wrap;
gap: 12px;
.toolbar-left {
display: flex;
align-items: center;
gap: 16px;
.space-select {
width: 280px;
}
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.view-toggle {
margin-right: 4px;
}
}
.main-panel {
flex: 1;
display: flex;
overflow: hidden;
}
.tree-panel {
width: 280px;
border-right: 1px solid #e8e8e8;
overflow-y: auto;
background: #fafafa;
.panel-title {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
font-weight: bold;
color: #333;
border-bottom: 1px solid #eee;
}
.tree-node {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
.is-active {
color: #e6a23c;
font-weight: bold;
}
}
:deep(.el-tree-node__content) {
height: 36px;
}
:deep(.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content) {
background-color: #ecf5ff;
}
}
.preview-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fff;
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
font-size: 13px;
color: #666;
.path {
color: #333;
font-weight: 500;
}
}
}
.file-grid {
flex: 1;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 24px;
padding: 24px;
overflow-y: auto;
.file-item {
width: 120px;
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
&:hover,
&.selected {
background: #f0f7ff;
}
.file-name {
margin-top: 10px;
font-size: 12px;
color: #333;
text-align: center;
word-break: break-all;
line-height: 1.4;
max-width: 110px;
}
}
}
.list-name {
display: flex;
align-items: center;
gap: 8px;
}
.upload-icon {
font-size: 48px;
color: #909399;
}
.tip-text {
color: #999;
font-size: 13px;
}
</style>

404
src/views/DataSearch.vue Normal file
View File

@ -0,0 +1,404 @@
<template>
<div class="container">
<div class="left" v-show="isShow">
<img class="fold" v-show="isShow" src="@/assets/icons/折叠.png" @click="handleFold">
<div class="search">
<p class="rule-text">编号规则:数据类型_航次_4位编号</p>
<div class="input-bar">
<el-input
v-model="dataCode"
style="width: 120px"
size="large"
placeholder="数据编号"
/>
<el-select
v-model="dataType"
placeholder="数据类型"
size="large"
style="width: 140px"
>
<el-option label="全部" value="" />
<el-option
v-for="item in dataTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="dataStatus"
placeholder="入库状态"
size="large"
style="width: 120px"
>
<el-option label="全部" value="" />
<el-option label="已入库" value="已入库" />
<el-option label="处理中" value="处理中" />
<el-option label="待审核" value="待审核" />
</el-select>
</div>
</div>
<div class="btn-bar">
<el-button :icon="RefreshRight" @click="onReset">重置</el-button>
<el-button type="primary" :icon="Search" @click="onSearch">搜索</el-button>
</div>
<div class="line" />
<div class="list">
<div class="item" v-for="item in dataList" :key="item.id">
<div class="name">{{ item.data_code }}</div>
<div class="time">{{ item.fill_time }}</div>
<div class="row">
<div class="col">航次<span>{{ item.voyage_name }}</span></div>
<div class="col">入库状态<span>{{ item.data_status }}</span></div>
</div>
<div class="row">
<div class="col">数据类型<span>{{ getDataTypeLabel(item.data_type) }}</span></div>
<div class="col">数据名称<span>{{ item.data_name }}</span></div>
</div>
<div class="row">
<div class="col">数据量<span>{{ item.file_size }}</span></div>
</div>
</div>
</div>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
layout="total, prev, pager, next"
:total="total"
@current-change="handleCurrentChange"
/>
</div>
</div>
<div class="map">
<div id="cesiumContainer" style="width: 100%; height: 100%;"></div>
<img class="unfold" v-show="!isShow" src="@/assets/icons/展开.png" alt="" @click="handleUnfold">
</div>
<el-dialog
v-model="dialogVisible"
title="数据详情"
width="500"
>
<div class="item" v-if="currentMarker">
<div class="name">{{ currentMarker.data_code }}</div>
<div class="time">{{ currentMarker.fill_time }}</div>
<div class="row">
<div class="col">航次<span>{{ currentMarker.voyage_name }}</span></div>
<div class="col">入库状态<span>{{ currentMarker.data_status }}</span></div>
</div>
<div class="row">
<div class="col">数据类型<span>{{ getDataTypeLabel(currentMarker.data_type) }}</span></div>
<div class="col">数据名称<span>{{ currentMarker.data_name }}</span></div>
</div>
<div class="row">
<div class="col">位置<span>{{ currentMarker.data_lon }}{{ currentMarker.data_lat }}</span></div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import * as Cesium from 'cesium'
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Search, RefreshRight } from '@element-plus/icons-vue'
import { dataPage } from '@/api/data.js'
import { getParam } from '@/api/task.js'
import { modifyCesiumMouseWheel } from '@/utils/common'
import { getFlatDataTypeOptions, getDataTypeLabel } from '@/constants/dataTypes.js'
import markerIcon1 from '@/assets/icons/标注点1.png'
import markerIcon2 from '@/assets/icons/标注点2.png'
import markerIcon3 from '@/assets/icons/标注点3.png'
const route = useRoute()
const isShow = ref(true)
const dataCode = ref('')
const dataType = ref('')
const dataStatus = ref('')
const searchQuery = ref('')
const searchCategory = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const start = ref(0)
const total = ref(0)
const dataList = ref([])
const dialogVisible = ref(false)
const currentMarker = ref(null)
const dataTypeOptions = getFlatDataTypeOptions()
const handleFold = () => {
isShow.value = false
}
const handleUnfold = () => {
isShow.value = true
}
const handleCurrentChange = (val) => {
currentPage.value = val
start.value = pageSize.value * (val - 1)
getDataList()
}
const getDataList = () => {
dataPage({
start: start.value,
limit: 10,
data_type: dataType.value,
data_status: dataStatus.value,
data_code: dataCode.value,
query: searchQuery.value,
category: searchCategory.value
}).then(res => {
dataList.value = res.page.records
total.value = res.page.total
updateMapMarkers()
})
}
const updateMapMarkers = () => {
if (!viewer) return
viewer.entities.removeAll()
dataList.value.forEach(item => {
let icon = markerIcon1
if (item.data_status === '处理中') icon = markerIcon2
if (item.data_status === '已入库') icon = markerIcon3
viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(item.data_lon, item.data_lat),
billboard: {
image: icon,
width: 32,
height: 32
},
properties: item
})
})
}
const onReset = () => {
dataType.value = ''
dataStatus.value = ''
dataCode.value = ''
start.value = 0
getDataList()
}
const onSearch = () => {
start.value = 0
getDataList()
}
const CesiumHostAddr = ref('')
const getParamsList = () => {
if (import.meta.env.VITE_APP_ENV === 'development') {
CesiumHostAddr.value = import.meta.env.VITE_APP_DS_API
initMap()
} else {
getParam().then(res => {
const data = res.array.find(item => item.param_name === 'CesiumHostAddr')
if (data?.param_value) {
CesiumHostAddr.value = data.param_value
} else {
CesiumHostAddr.value = `${window.location.protocol}//${window.location.hostname}:${window.location.port}/tiles/`
}
initMap()
})
}
}
let viewer
Cesium.Ion.defaultAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJkY2Y5NWY2Ni1mODQ1LTQ3YTUtYjc4Zi1jYzhjMGI2YzcxMWYiLCJpZCI6MTI5ODMwLCJpYXQiOjE2Nzk0Njk5NTd9.DTH54ioOH-HLqeNIetBe9hFyrPOX2Vp1AQmZzw8TIZ4'
const cesiumConfig = {
sceneMode: Cesium.SceneMode.SCENE2D,
homeButton: false,
sceneModePicker: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
baseLayerPicker: false,
shadows: true,
shouldAnimate: true,
animation: false,
timeline: false,
geocoder: false,
navigationHelpButton: false,
contextOptions: {
contextType: 2
},
creditContainer: document.createElement('div')
}
const initMap = () => {
viewer = new Cesium.Viewer('cesiumContainer', cesiumConfig)
viewer._cesiumWidget._creditContainer.style.display = 'none'
viewer.scene.sun.show = false
viewer.scene.moon.show = false
viewer.scene.fog.enabled = false
viewer.scene.skyAtmosphere.show = false
viewer.scene.skyBox.show = false
viewer.scene.globe.enableLighting = false
viewer.shadowMap.darkness = 0.8
viewer.scene._sunBloom = false
viewer.scene.globe.showGroundAtmosphere = false
viewer.scene.postProcessStages.fxaa.enabled = true
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(112, 18, 4000000)
})
modifyCesiumMouseWheel(viewer)
if (CesiumHostAddr.value) {
const shipline = new Cesium.WebMapTileServiceImageryProvider({
url: CesiumHostAddr.value + 'geoserver/gwc/service/wmts/rest/ougp:正射影像图_GM输出_20240516/{TileMatrixSet}/{TileMatrixSet}:{TileMatrix}/{TileRow}/{TileCol}?format=image/png',
layer: 'ougp:正射影像图_GM输出_20240516',
style: 'default',
format: 'image/png',
tileMatrixSetID: 'EPSG:4326',
tilingScheme: new Cesium.GeographicTilingScheme()
})
viewer.imageryLayers.addImageryProvider(shipline)
const contour6000 = new Cesium.WebMapTileServiceImageryProvider({
url: CesiumHostAddr.value + 'geoserver/gwc/service/wmts/rest/ougp:contour6000/{TileMatrixSet}/{TileMatrixSet}:{TileMatrix}/{TileRow}/{TileCol}?format=image/png',
layer: 'ougp:contour6000',
style: 'default',
format: 'image/png',
tileMatrixSetID: 'EPSG:4326',
tilingScheme: new Cesium.GeographicTilingScheme()
})
viewer.imageryLayers.addImageryProvider(contour6000)
}
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
handler.setInputAction((click) => {
const picked = viewer.scene.pick(click.position)
if (Cesium.defined(picked) && picked.id?.properties) {
currentMarker.value = picked.id.properties.getValue()
dialogVisible.value = true
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
updateMapMarkers()
}
onMounted(() => {
if (route.query.query) {
searchQuery.value = String(route.query.query)
}
if (route.query.category) {
searchCategory.value = String(route.query.category)
}
getParamsList()
getDataList()
})
</script>
<style lang="scss" scoped>
.container {
height: 820px;
display: flex;
.left {
width: 450px;
position: relative;
padding: 0 30px 30px;
box-sizing: border-box;
.fold {
position: absolute;
top: 350px;
right: 0;
}
.rule-text {
color: #000;
}
.search {
.input-bar {
display: flex;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
}
.btn-bar {
margin-top: 10px;
display: flex;
justify-content: center;
}
.line {
margin-top: 10px;
width: 100%;
height: 1px;
background-color: #ddd;
}
.list {
color: #333;
height: 620px;
overflow-y: scroll;
scrollbar-width: none;
-ms-overflow-style: none;
.item {
padding: 30px 0;
border-bottom: 1px solid #dddddd;
&:hover .name {
color: #409eff;
}
.name {
padding-bottom: 5px;
color: #000;
font-weight: bold;
}
.row {
padding: 5px 0;
display: flex;
gap: 20px;
.col {
flex: 1;
color: #999;
span {
color: #333;
}
}
}
}
}
}
.map {
flex: 1;
width: 100%;
height: 100%;
position: relative;
.unfold {
position: absolute;
top: 350px;
left: 0;
}
}
}
.pagination {
margin: 10px 0 20px;
display: flex;
justify-content: center;
}
</style>

847
src/views/Home.vue Normal file
View File

@ -0,0 +1,847 @@
<template>
<div class="home-container">
<!-- Hero 区域 -->
<section class="hero">
<div class="hero-overlay"></div>
<div class="hero-content">
<div class="search-box">
<div class="search-input-wrap">
<el-icon class="search-icon"><Search /></el-icon>
<input
v-model="searchKeyword"
type="text"
placeholder="请输入检索关键词"
@keyup.enter="handleHeroSearch"
/>
</div>
<button class="search-btn" @click="handleHeroSearch">搜索</button>
</div>
<p class="hero-desc">
深海地球物理数据管理可视化平台面向深海调查与科学研究需求汇聚多波束地震重磁侧扫及海洋环境等多类型地球物理数据
提供数据入库可视化检索与空间分析能力服务数据贡献者科研团队和管理机构
</p>
<div class="stat-grid">
<div class="stat-card" v-for="item in statCards" :key="item.key">
<div class="stat-icon" :class="`stat-icon--${item.key}`">
<StatIcon :type="item.key" :size="44" />
</div>
<div class="stat-info">
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value">
<span>{{ item.value }}</span>{{ item.unit }}
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 数据资源 -->
<section class="section resources-section">
<div class="section-inner">
<div class="section-header">
<span class="section-bar"></span>
<h2>数据资源</h2>
</div>
<div class="resource-grid">
<div
class="resource-card"
v-for="(item, index) in resourceCategories"
:key="item.id"
:class="{ active: activeResource === index }"
@click="handleResourceClick(item, index)"
>
<div class="resource-cover" :style="{ backgroundImage: `url(${item.cover})` }">
<div class="resource-cover-overlay"></div>
<span class="resource-type-tag">{{ item.desc }}</span>
</div>
<div class="resource-footer">
<div class="resource-name">{{ item.label }}</div>
<div class="resource-count">{{ item.count }} 个数据集</div>
</div>
</div>
</div>
</div>
</section>
<!-- 热门数据 -->
<section class="section hot-section">
<div class="section-inner">
<div class="section-header hot-header">
<div class="left">
<span class="section-bar"></span>
<h2>热门数据</h2>
</div>
<div class="hot-tabs">
<button
v-for="tab in hotTabs"
:key="tab.key"
:class="{ active: hotTab === tab.key }"
@click="hotTab = tab.key"
>
{{ tab.label }}
</button>
</div>
</div>
<div class="hot-content">
<div class="hot-featured" v-if="featuredData" @click="goDataDetail(featuredData.id)">
<span class="hot-badge">HOT</span>
<div class="featured-cover">
<img :src="getDataTypeImage(featuredData.data_type)" :alt="featuredData.data_name" />
<div class="featured-cover-overlay"></div>
<div class="featured-type">{{ getDataTypeLabel(featuredData.data_type) }}</div>
</div>
<div class="featured-body">
<h3>{{ featuredData.data_name }}</h3>
<p>{{ featuredData.data_code }} · {{ featuredData.voyage_name }} · {{ featuredData.file_size }}</p>
<div class="meta-grid">
<div class="meta-item">
<span class="meta-label">数据类型</span>
<span class="meta-value">{{ getDataTypeLabel(featuredData.data_type) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">数据编号</span>
<span class="meta-value">{{ featuredData.data_code }}</span>
</div>
<div class="meta-item">
<span class="meta-label">入库时间</span>
<span class="meta-value">{{ featuredData.fill_time }}</span>
</div>
</div>
</div>
</div>
<div class="hot-list">
<div
class="hot-item"
v-for="item in hotListData"
:key="item.id"
@click="goDataDetail(item.id)"
>
<span class="hot-badge small">HOT</span>
<div class="hot-thumb">
<img :src="getDataTypeImage(item.data_type)" :alt="item.data_name" />
</div>
<div class="hot-item-body">
<h4>{{ item.data_name }}</h4>
<p>{{ item.data_code }} · {{ item.voyage_name }}</p>
<div class="meta-grid compact">
<div class="meta-item">
<span class="meta-label">数据类型</span>
<span class="meta-value">{{ getDataTypeLabel(item.data_type) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">数据编号</span>
<span class="meta-value">{{ item.data_code }}</span>
</div>
<div class="meta-item">
<span class="meta-label">入库时间</span>
<span class="meta-value">{{ item.fill_time }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 空间分布地图 -->
<section class="section map-section">
<div class="section-inner">
<div class="section-header center">
<span class="section-bar"></span>
<h2>地球物理数据空间分布</h2>
</div>
<div class="map" id="cesiumContainer"></div>
</div>
</section>
</div>
</template>
<script setup>
import * as Cesium from 'cesium'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { getParam } from '@/api/task.js'
import { dataPage } from '@/api/data.js'
import { modifyCesiumMouseWheel } from '@/utils/common'
import { getDataTypeLabel } from '@/constants/dataTypes.js'
import { getDataTypeImage } from '@/constants/homeImages.js'
import {
HOME_RESOURCE_CATEGORIES,
HOME_STAT_CARDS,
HOT_DATA_TABS,
enrichHotDataMetrics
} from '@/mock/homeData.js'
import StatIcon from '@/components/home/StatIcon.vue'
import markerIcon1 from '@/assets/icons/标注点1.png'
import markerIcon2 from '@/assets/icons/标注点2.png'
import markerIcon3 from '@/assets/icons/标注点3.png'
const router = useRouter()
const searchKeyword = ref('')
const statCards = HOME_STAT_CARDS
const resourceCategories = HOME_RESOURCE_CATEGORIES
const hotTabs = HOT_DATA_TABS
const hotTab = ref('views')
const activeResource = ref(1)
const hotDataList = ref([])
/**
* 按当前 Tab 排序后的热门数据
*/
const sortedHotData = computed(() => {
const list = [...hotDataList.value]
const field = hotTab.value
return list.sort((a, b) => (b[field] || 0) - (a[field] || 0))
})
const featuredData = computed(() => sortedHotData.value[0] || null)
const hotListData = computed(() => sortedHotData.value.slice(1, 4))
const handleHeroSearch = () => {
router.push({
path: '/demand',
query: searchKeyword.value ? { query: searchKeyword.value } : {}
})
}
const handleResourceClick = (item, index) => {
activeResource.value = index
router.push({ path: item.route, query: item.query })
}
const goDataDetail = (id) => {
router.push(`/input/${id}`)
}
const getDataList = () => {
dataPage({ start: 0, limit: 13 }).then(res => {
hotDataList.value = enrichHotDataMetrics(res.page.records)
updateMapMarkers()
})
}
const CesiumHostAddr = ref('')
const getParamsList = () => {
if (import.meta.env.VITE_APP_ENV === 'development') {
CesiumHostAddr.value = import.meta.env.VITE_APP_DS_API
initMap()
} else {
getParam().then(res => {
const data = res.array.find(item => item.param_name === 'CesiumHostAddr')
CesiumHostAddr.value = data?.param_value ||
`${window.location.protocol}//${window.location.hostname}:${window.location.port}/tiles/`
initMap()
})
}
}
let viewer
Cesium.Ion.defaultAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJkY2Y5NWY2Ni1mODQ1LTQ3YTUtYjc4Zi1jYzhjMGI2YzcxMWYiLCJpZCI6MTI5ODMwLCJpYXQiOjE2Nzk0Njk5NTd9.DTH54ioOH-HLqeNIetBe9hFyrPOX2Vp1AQmZzw8TIZ4'
const cesiumConfig = {
sceneMode: Cesium.SceneMode.SCENE2D,
homeButton: false,
sceneModePicker: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
baseLayerPicker: false,
shadows: true,
shouldAnimate: true,
animation: false,
timeline: false,
geocoder: false,
navigationHelpButton: false,
contextOptions: { contextType: 2 },
creditContainer: document.createElement('div')
}
const initMap = () => {
viewer = new Cesium.Viewer('cesiumContainer', cesiumConfig)
viewer._cesiumWidget._creditContainer.style.display = 'none'
viewer.scene.sun.show = false
viewer.scene.moon.show = false
viewer.scene.fog.enabled = false
viewer.scene.skyAtmosphere.show = false
viewer.scene.skyBox.show = false
viewer.scene.globe.enableLighting = false
viewer.shadowMap.darkness = 0.8
viewer.scene._sunBloom = false
viewer.scene.globe.showGroundAtmosphere = false
viewer.scene.postProcessStages.fxaa.enabled = true
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(112, 18, 4000000)
})
modifyCesiumMouseWheel(viewer)
if (CesiumHostAddr.value) {
viewer.imageryLayers.addImageryProvider(new Cesium.WebMapTileServiceImageryProvider({
url: CesiumHostAddr.value + 'geoserver/gwc/service/wmts/rest/ougp:正射影像图_GM输出_20240516/{TileMatrixSet}/{TileMatrixSet}:{TileMatrix}/{TileRow}/{TileCol}?format=image/png',
layer: 'ougp:正射影像图_GM输出_20240516',
style: 'default',
format: 'image/png',
tileMatrixSetID: 'EPSG:4326',
tilingScheme: new Cesium.GeographicTilingScheme()
}))
viewer.imageryLayers.addImageryProvider(new Cesium.WebMapTileServiceImageryProvider({
url: CesiumHostAddr.value + 'geoserver/gwc/service/wmts/rest/ougp:contour6000/{TileMatrixSet}/{TileMatrixSet}:{TileMatrix}/{TileRow}/{TileCol}?format=image/png',
layer: 'ougp:contour6000',
style: 'default',
format: 'image/png',
tileMatrixSetID: 'EPSG:4326',
tilingScheme: new Cesium.GeographicTilingScheme()
}))
}
updateMapMarkers()
}
const updateMapMarkers = () => {
if (!viewer) return
viewer.entities.removeAll()
hotDataList.value.forEach(item => {
let icon = markerIcon1
if (item.data_status === '处理中') icon = markerIcon2
if (item.data_status === '已入库') icon = markerIcon3
viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(item.data_lon, item.data_lat),
billboard: { image: icon, width: 28, height: 28 }
})
})
}
onMounted(() => {
getDataList()
getParamsList()
})
</script>
<style lang="scss" scoped>
.home-container {
background: #f5f7fa;
}
.hero {
position: relative;
margin-top: -100px;
padding: 224px 8% 60px;
min-height: 520px;
background: url(@/assets/loginBg.jpg) center / cover no-repeat;
.hero-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 40, 90, 0.55) 0%, rgba(0, 60, 120, 0.75) 100%);
}
.hero-content {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
}
.search-box {
display: flex;
max-width: 720px;
margin: 8px auto 24px;
background: #fff;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
.search-input-wrap {
flex: 1;
display: flex;
align-items: center;
padding: 0 16px;
.search-icon {
font-size: 20px;
color: #999;
margin-right: 10px;
}
input {
flex: 1;
border: none;
outline: none;
height: 52px;
font-size: 16px;
color: #333;
background: transparent;
&::placeholder {
color: #bbb;
}
}
}
.search-btn {
width: 100px;
border: none;
background: #0056a4;
color: #fff;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #004080;
}
}
}
.hero-desc {
max-width: 900px;
margin: 0 auto 36px;
text-align: center;
font-size: 14px;
line-height: 1.8;
color: rgba(255, 255, 255, 0.92);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 22px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.55);
box-shadow: 0 4px 20px rgba(0, 40, 90, 0.12);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(0, 40, 90, 0.16);
}
.stat-icon {
flex-shrink: 0;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
&--multibeam {
background: linear-gradient(145deg, #eef6fc 0%, #d6ebf9 100%);
}
&--seismic {
background: linear-gradient(145deg, #fef6ec 0%, #fde8cf 100%);
}
&--magnetic {
background: linear-gradient(145deg, #edf9f1 0%, #d4f0de 100%);
}
&--sidescan {
background: linear-gradient(145deg, #f4eefb 0%, #e8dcf5 100%);
}
&--dataset {
background: linear-gradient(145deg, #eef4fc 0%, #d8e6f7 100%);
}
&--gravity {
background: linear-gradient(145deg, #ecf9f5 0%, #d0f0e6 100%);
}
&--ocean {
background: linear-gradient(145deg, #eef9fd 0%, #d4eef9 100%);
}
&--records {
background: linear-gradient(145deg, #f0f2fc 0%, #dde3f7 100%);
}
}
.stat-info {
min-width: 0;
.stat-label {
font-size: 14px;
color: #666;
margin-bottom: 6px;
}
.stat-value {
font-size: 14px;
color: #888;
span {
font-size: 28px;
font-weight: bold;
color: #0056a4;
margin-right: 3px;
line-height: 1;
}
}
}
}
}
}
.section {
padding: 50px 0;
.section-inner {
max-width: 1400px;
margin: 0 auto;
padding: 0 8%;
}
.section-header {
display: flex;
align-items: center;
margin-bottom: 30px;
&.center {
justify-content: center;
}
&.hot-header {
justify-content: space-between;
}
.left {
display: flex;
align-items: center;
}
.section-bar {
display: inline-block;
width: 4px;
height: 22px;
background: #0056a4;
border-radius: 2px;
margin-right: 12px;
}
h2 {
margin: 0;
font-size: 22px;
font-weight: bold;
color: #242424;
}
}
}
.resources-section {
background: #f5f7fa url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23d0d8e0' fill-opacity='0.3'%3E%3Ccircle cx='1' cy='1' r='1'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
.resource-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.resource-card {
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
cursor: pointer;
border: 2px solid transparent;
transition: all 0.25s ease;
&:hover,
&.active {
border-color: #0056a4;
box-shadow: 0 6px 20px rgba(0, 86, 164, 0.18);
transform: translateY(-2px);
}
.resource-cover {
position: relative;
height: 160px;
display: flex;
align-items: flex-end;
padding: 16px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
.resource-cover-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 20, 50, 0.15) 0%, rgba(0, 30, 70, 0.55) 100%);
}
.resource-type-tag {
position: relative;
z-index: 1;
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
background: rgba(0, 0, 0, 0.2);
padding: 4px 10px;
border-radius: 4px;
}
}
.resource-footer {
padding: 16px 18px;
.resource-name {
font-size: 16px;
font-weight: bold;
color: #242424;
margin-bottom: 6px;
}
.resource-count {
font-size: 13px;
color: #888;
}
}
}
}
.hot-section {
background: #fff;
.hot-tabs {
display: flex;
gap: 8px;
button {
padding: 6px 18px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
color: #666;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
&.active {
background: #0056a4;
border-color: #0056a4;
color: #fff;
}
&:hover:not(.active) {
border-color: #0056a4;
color: #0056a4;
}
}
}
.hot-content {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 24px;
}
.hot-badge {
position: absolute;
top: 12px;
left: 12px;
z-index: 2;
background: #f5a623;
color: #fff;
font-size: 11px;
font-weight: bold;
padding: 2px 8px;
border-radius: 3px;
&.small {
top: 8px;
left: 8px;
font-size: 10px;
padding: 1px 6px;
}
}
.hot-featured {
position: relative;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
cursor: pointer;
transition: box-shadow 0.25s;
&:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
.featured-cover {
position: relative;
height: 200px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.featured-cover-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 30, 70, 0.1) 0%, rgba(0, 30, 70, 0.55) 100%);
}
.featured-type {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
font-size: 18px;
font-weight: bold;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
padding: 6px 14px;
border-radius: 4px;
background: rgba(0, 50, 100, 0.45);
backdrop-filter: blur(2px);
}
}
.featured-body {
padding: 20px;
h3 {
margin: 0 0 8px;
font-size: 16px;
color: #0056a4;
line-height: 1.5;
}
p {
margin: 0 0 16px;
font-size: 13px;
color: #888;
}
}
}
.hot-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.hot-item {
position: relative;
display: flex;
gap: 16px;
padding: 16px;
background: #fafbfc;
border-radius: 8px;
border: 1px solid #eee;
cursor: pointer;
transition: all 0.25s;
&:hover {
border-color: #0056a4;
box-shadow: 0 4px 12px rgba(0, 86, 164, 0.1);
}
.hot-thumb {
flex-shrink: 0;
width: 120px;
height: 90px;
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.hot-item-body {
flex: 1;
min-width: 0;
h4 {
margin: 0 0 6px;
font-size: 14px;
color: #0056a4;
line-height: 1.4;
}
p {
margin: 0 0 10px;
font-size: 12px;
color: #999;
}
}
}
.meta-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
&.compact {
gap: 4px;
}
.meta-item {
.meta-label {
display: block;
font-size: 11px;
color: #bbb;
margin-bottom: 2px;
}
.meta-value {
display: block;
font-size: 12px;
color: #444;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.map-section {
background: #f5f7fa;
padding-bottom: 60px;
.map {
width: 100%;
height: 600px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
}
@media (max-width: 1200px) {
.hero .stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.resources-section .resource-grid {
grid-template-columns: repeat(2, 1fr);
}
.hot-section .hot-content {
grid-template-columns: 1fr;
}
}
</style>

189
src/views/Login.vue Normal file
View File

@ -0,0 +1,189 @@
<template>
<div class="container">
<div class="content">
<div class="left">
<p>面向深海地球物理调查需求构建用户体验良好的地球物理数据目录信息平台</p>
<p>提供便捷安全的数据入库统计分析和可视化检索工具服务数据贡献者资助机构和管理机构</p>
</div>
<div class="card">
<div class="login_img">
<img src="@/assets/logo.png" alt="">
<div class="cn">深海地球物理数据管理可视化平台</div>
</div>
<div class="form">
<div class="input">
<img src="@/assets/icon_user.png" alt="">
<input v-model="loginName" type="text" placeholder="请输入用户名">
</div>
<div class="input">
<img src="@/assets/icon_pwd.png" alt="">
<input v-model="loginPass" type="password" placeholder="请输入密码" @keyup.enter="onLogin">
</div>
</div>
<div class="btn">
<el-button @click="onReset">重置</el-button>
<el-button type="primary" @click="onLogin">登录</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { localLogin } from '@/api/data.js'
import { ref, onMounted } from 'vue'
import md5 from 'md5'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/store/authStore'
const router = useRouter()
const authStore = useAuthStore()
const loginName = ref('')
const loginPass = ref('')
onMounted(() => {
if (authStore.isLoggedIn) {
router.push('/')
}
})
const onReset = () => {
loginName.value = ''
loginPass.value = ''
}
const onLogin = () => {
if (!loginName.value) return ElMessage('请输入用户名')
if (!loginPass.value) return ElMessage('请输入密码')
localLogin({
loginName: loginName.value,
loginPass: md5(loginPass.value)
}).then(res => {
if (res.success) {
authStore.login(res.entity)
ElMessage.success('登录成功')
router.push('/')
} else {
ElMessage.error(res.message || '登录失败')
}
})
}
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100vh;
background: url(@/assets/loginBg.jpg) center no-repeat;
background-size: cover;
display: flex;
justify-content: center;
align-items: center;
.content {
max-width: 1400px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
.left {
width: 50%;
height: auto;
display: flex;
flex-direction: column;
p {
font-size: 26px;
color: #fff;
line-height: 50px;
}
}
.card {
width: 540px;
background: #fff;
border-radius: 10px;
box-sizing: border-box;
padding: 45px;
display: flex;
flex-direction: column;
.login_img {
margin-bottom: 30px;
width: 100%;
height: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
img {
width: auto;
height: auto;
}
.cn {
font-size: 28px;
color: #000;
font-weight: bold;
}
}
.form {
width: 100%;
height: auto;
display: flex;
flex-direction: column;
align-items: center;
.input {
margin-bottom: 15px;
width: 100%;
height: 60px;
border: 1px solid #eeeeee;
border-radius: 5px;
box-sizing: border-box;
padding: 20px;
display: flex;
align-items: center;
img {
width: auto;
height: auto;
display: block;
margin-right: 25px;
}
input {
width: 90%;
height: auto;
border: none;
background: none;
outline: none;
font-size: 16px;
color: #666;
}
}
}
.btn {
width: 100%;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20px;
margin-bottom: 40px;
.el-button {
width: 48%;
height: 50px;
font-size: 18px;
}
}
}
}
}
</style>

109
src/views/Statistics.vue Normal file
View File

@ -0,0 +1,109 @@
<template>
<div class="container">
<div class="title">深海应急搜捞 统计分析</div>
<div class="nums">
<div class="item">
<img src="@/assets/icons/任务.png" alt="任务" />
<div class="right">
<div>完成航次</div>
<div><span>{{ rescueTotal }}</span></div>
</div>
</div>
<div class="item">
<img src="@/assets/icons/区域.png" alt="区域" />
<div class="right">
<div>潜水次数</div>
<div><span>{{ divingTotal }}</span></div>
</div>
</div>
<div class="item">
<img src="@/assets/icons/航次.png" alt="航次" />
<div class="right">
<div>挽回经济损失</div>
<div><span>{{ priceTotal }}</span></div>
</div>
</div>
<div class="item">
<img src="@/assets/icons/潜水艇.png" alt="潜水艇" />
<div class="right">
<div>专项调查区域覆盖</div>
<div><span>{{ areaTotal }}</span>平方公里</div>
</div>
</div>
<div class="item">
<img src="@/assets/icons/潜艇.png" alt="潜艇" />
<div class="right">
<div>航行时间</div>
<div><span>{{ hourTotal }}</span>小时</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { reportMain } from '@/api/task.js'
import {ref, onMounted} from "vue";
const areaTotal = ref('')
const divingTotal = ref('')
const hourTotal = ref('')
const mileageTotal = ref('')
const priceTotal = ref('')
const rescueTotal = ref('')
const getData = () => {
reportMain().then(res => {
const obj = res.array[0]
areaTotal.value = obj.area_total
divingTotal.value = obj.diving_total
hourTotal.value = obj.hour_total
mileageTotal.value = obj.mileage_total
priceTotal.value = obj.price_total
rescueTotal.value = obj.rescue_total
})
}
onMounted(() => {
getData()
})
</script>
<style lang="scss" scoped>
.container {
padding: 0 8%;
.title {
margin-top: 100px;
text-align: center;
font-size: 40px;
font-weight: bold;
}
.nums {
margin-top: 200px;
display: flex;
justify-content: space-between;
padding: 30px;
border-radius: 10px;
background-color: rgba(33, 54, 87, 0.7);
.item {
flex: 1;
display: flex;
align-items: center;
img {
width: 60px;
height: 60px;
margin-right: 20px;
}
span {
font-size: 40px;
font-weight: bold;
margin-right: 5px;
}
}
}
}
</style>

77
src/views/TaskData.vue Normal file
View File

@ -0,0 +1,77 @@
<template>
<div class="container">
<div class="title">地球物理数据入库</div>
<el-divider />
<el-tabs v-model="activeName" type="card">
<el-tab-pane label="数据信息" name="data">
<FormData
:data-id="dataId"
@saved="onSaved"
@upload="openUploadDialog"
@cloud-path="openCloudDialog"
/>
</el-tab-pane>
<el-tab-pane label="数据资源" name="resource" v-if="dataId">
<DataResourcePanel ref="resourcePanelRef" :data-id="dataId" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import FormData from '@/views/common/FormData.vue'
import DataResourcePanel from '@/views/common/DataResourcePanel.vue'
const route = useRoute()
const dataId = ref(route.params.id || '')
const activeName = ref('data')
const resourcePanelRef = ref(null)
/**
* 数据保存成功后更新 ID
* @param {Object} entity
*/
const onSaved = (entity) => {
if (entity?.id) {
dataId.value = String(entity.id)
}
}
const openUploadDialog = async () => {
if (!dataId.value) {
ElMessage.warning('请先保存数据基本信息')
return
}
activeName.value = 'resource'
await nextTick()
resourcePanelRef.value?.openUploadDialog?.()
}
const openCloudDialog = async () => {
if (!dataId.value) {
ElMessage.warning('请先保存数据基本信息')
return
}
activeName.value = 'resource'
await nextTick()
resourcePanelRef.value?.openCloudDialog?.()
}
</script>
<style lang="scss" scoped>
.container {
padding: 20px 8%;
color: #242424;
min-height: calc(100vh - 220px);
background-color: rgb(241, 244, 251);
.title {
font-size: 20px;
font-weight: bold;
color: #333;
}
}
</style>

432
src/views/TaskDemand.vue Normal file
View File

@ -0,0 +1,432 @@
<template>
<div class="container">
<div class="left" v-show="isShow">
<img class="fold" v-show="isShow" src="@/assets/icons/折叠.png" @click="handleFold">
<div class="search">
<p class="rule-text">编号规则:任务类型_目标类型_4位编号</p>
<div class="input-bar">
<el-input
v-model="rescueCode"
style="width: 120px"
size="large"
placeholder="任务编号"
/>
<el-select
v-model="rescueType"
placeholder="任务类型"
size="large"
style="width: 120px"
>
<el-option label="应急搜捞" value="SAR" />
<el-option label="专项调查" value="SI" />
</el-select>
<el-select
v-model="rescueResponseLevel"
placeholder="响应等级"
size="large"
style="width: 120px"
>
<el-option label="一级" value="一级" />
<el-option label="二级" value="二级" />
<el-option label="三级" value="三级" />
</el-select>
</div>
</div>
<div class="btn-bar">
<el-button :icon="RefreshRight" @click="onReset">重置</el-button>
<el-button type="primary" :icon="Search" @click="onSearch">搜索</el-button>
</div>
<div class="line" />
<div class="list">
<div class="item" v-for="item in dataList" :key="item.id">
<div class="name">{{ item.rescue_code }}</div>
<div class="time">{{ item.fill_time }}</div>
<div class="row">
<div class="col">航次<span>{{ item.voyage_name }}</span></div>
<div class="col">任务状态<span>{{ item.rescue_status }}</span></div>
</div>
<div class="row">
<div class="col">快速响应等级<span>{{ item.rescue_response_level }}</span></div>
<div class="col">目标名称<span>{{ item.rescue_target_name }}</span></div>
</div>
<div class="row">
<div class="col">任务类型<span>{{ item.rescue_type === 'SAR' ? '应急搜捞' : '专项调查' }}</span></div>
</div>
</div>
</div>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
layout="total, prev, pager, next"
:total="total"
@current-change="handleCurrentChange"
/>
</div>
</div>
<div class="map">
<div id="cesiumContainer" style="width: 100%; height: 100%;"></div>
<img class="unfold" v-show="!isShow" src="@/assets/icons/展开.png" alt="" @click="handleUnfold">
</div>
<el-dialog
v-model="dialogVisible"
title="任务详情"
width="500"
>
<div class="item">
<div class="name">{{ currentMarker.rescue_code }}</div>
<div class="time">{{ currentMarker.fill_time }}</div>
<div class="row">
<div class="col">航次<span>{{ currentMarker.voyage_name }}</span></div>
<div class="col">任务状态<span>{{ currentMarker.rescue_status }}</span></div>
</div>
<div class="row">
<div class="col">快速响应等级<span>{{ currentMarker.rescue_response_level }}</span></div>
<div class="col">目标名称<span>{{ currentMarker.rescue_target_name }}</span></div>
</div>
<div class="row">
<div class="col">任务类型<span>{{ currentMarker.rescue_type === 'SAR' ? '应急搜捞' : '专项调查' }}</span></div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import * as Cesium from "cesium";
import {ref, onMounted} from 'vue'
import { Search, RefreshRight } from '@element-plus/icons-vue'
import { regPage, getParam } from '@/api/task.js'
import { modifyCesiumMouseWheel, setImageryViewModels } from '@/utils/common'
// icon
import markerIcon1 from "@/assets/icons/标注点1.png";
import markerIcon2 from "@/assets/icons/标注点2.png";
import markerIcon3 from "@/assets/icons/标注点3.png";
//
const isShow = ref(true)
const handleFold = () => {
isShow.value = false
}
const handleUnfold = () => {
isShow.value = true
}
const rescueCode = ref('')
const rescueType = ref('')
const rescueResponseLevel = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const start = ref(0)
const total = ref(0)
//
const dataList = ref([])
const markers = ref([])
const handleCurrentChange = (val) => {
currentPage.value = val
start.value = pageSize.value * (val -1)
getDataList()
}
//
const getDataList = () => {
regPage({
start: start.value,
limit: 10,
rescue_type: rescueType.value,
rescue_response_level: rescueResponseLevel.value,
rescue_code: rescueCode.value,
}).then(res => {
dataList.value = res.page.records
const data = []
dataList.value.forEach(item => {
if (item.rescue_status === '任务开始') {
data.push({
position: {
lng: item.rescue_target_lon,
lat: item.rescue_target_lat
},
icon: markerIcon1,
id: item.tsy_id
})
}
if (item.rescue_status === '任务处理中') {
data.push({
position: {
lng: item.rescue_target_lon,
lat: item.rescue_target_lat
},
icon: markerIcon2,
id: item.tsy_id
})
}
if (item.rescue_status === '任务已完成') {
data.push({
position: {
lng: item.rescue_target_lon,
lat: item.rescue_target_lat
},
icon: markerIcon3,
id: item.tsy_id
})
}
})
markers.value = data
total.value = res.page.total
})
}
const onReset = () => {
rescueType.value = ''
rescueResponseLevel.value = ''
rescueCode.value = ''
start.value = 0
getDataList()
}
const onSearch = () => {
start.value = 0
getDataList()
}
//
const CesiumHostAddr = ref('')
//
const getParamsList = () => {
//
if (import.meta.env.VITE_APP_ENV === 'development') {
// 使
CesiumHostAddr.value = import.meta.env.VITE_APP_DS_API
initMap();
} else {
//
getParam().then(res => {
const data = res.array.find(item => item.param_name === 'CesiumHostAddr')
if (data?.param_value) {
CesiumHostAddr.value = data.param_value
} else {
// URL[1](@ref)
CesiumHostAddr.value = `${window.location.protocol}//${window.location.hostname}:${window.location.port}/tiles/`
}
initMap();
})
}
}
let viewer;
Cesium.Ion.defaultAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJkY2Y5NWY2Ni1mODQ1LTQ3YTUtYjc4Zi1jYzhjMGI2YzcxMWYiLCJpZCI6MTI5ODMwLCJpYXQiOjE2Nzk0Njk5NTd9.DTH54ioOH-HLqeNIetBe9hFyrPOX2Vp1AQmZzw8TIZ4';
const cesiumConfig = {
sceneMode: Cesium.SceneMode.SCENE2D,
//
homeButton: false,
//
sceneModePicker: false,
//
fullscreenButton: false,
//
infoBox: false,
//
selectionIndicator: false,
//
baseLayerPicker: false,
//
shadows: true,
//
shouldAnimate: true,
//
animation: false,
// 线
timeline: false,
//
geocoder: false,
//
navigationHelpButton: false,
contextOptions: {
contextType: 2, // Webgl2:2 ; WebGPU:3
},
//
creditContainer: document.createElement('div')
}
const initMap = () => {
viewer = new Cesium.Viewer('cesiumContainer',cesiumConfig)
viewer._cesiumWidget._creditContainer.style.display = 'none'
viewer.scene.sun.show = false
viewer.scene.moon.show = false
viewer.scene.fog.enabled = false
viewer.scene.skyAtmosphere.show = false
viewer.scene.sun.show = false
viewer.scene.skyBox.show = false
viewer.scene.globe.enableLighting = false
viewer.shadowMap.darkness = 0.8
viewer.scene._sunBloom = false
viewer.scene.globe.showGroundAtmosphere = false
viewer.scene.postProcessStages.fxaa.enabled = true
viewer.camera.setView({
//
destination: Cesium.Cartesian3.fromDegrees(112, 18, 4000000)
});
// Cesium Ctrl +
modifyCesiumMouseWheel(viewer);
//
const shipline = new Cesium.WebMapTileServiceImageryProvider(
{
url: CesiumHostAddr.value + 'geoserver/gwc/service/wmts/rest/ougp:正射影像图_GM输出_20240516/{TileMatrixSet}/{TileMatrixSet}:{TileMatrix}/{TileRow}/{TileCol}?format=image/png',
layer: 'ougp:正射影像图_GM输出_20240516',
style: 'default',
format: 'image/png',
tileMatrixSetID: 'EPSG:4326',
tilingScheme: new Cesium.GeographicTilingScheme()
}
)
viewer.imageryLayers.addImageryProvider(shipline)
// 6000线
const contour6000 = new Cesium.WebMapTileServiceImageryProvider(
{
url: CesiumHostAddr.value + 'geoserver/gwc/service/wmts/rest/ougp:contour6000/{TileMatrixSet}/{TileMatrixSet}:{TileMatrix}/{TileRow}/{TileCol}?format=image/png',
layer: 'ougp:contour6000',
style: 'default',
format: 'image/png',
tileMatrixSetID: 'EPSG:4326',
tilingScheme: new Cesium.GeographicTilingScheme()
}
)
viewer.imageryLayers.addImageryProvider(contour6000)
const point_options = {
show: true, //
pixelSize: 10, //
color: Cesium.Color.RED, //
outlineColor: Cesium.Color.SKYBLUE, //
outlineWidth: 2 //
};
viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(113.27, 18.13),
point: point_options
});
}
onMounted(() => {
getParamsList()
getDataList()
})
</script>
<style lang="scss" scoped>
.container {
height: 820px;
display: flex;
.left {
width: 450px;
position: relative;
padding: 0 30px 30px;
box-sizing: border-box;
.fold {
position: absolute;
top: 350px;
right: 0;
}
.rule-text {
color: #000;
}
.search {
.input-bar {
display: flex;
justify-content: space-between;
:deep(.el-input-group__append) {
background-color: #409eff;
color: white;
box-shadow: none;
}
}
}
.btn-bar {
margin-top: 10px;
display: flex;
justify-content: center;
}
.line {
margin-top: 10px;
width: 100%;
height: 1px;
background-color: #ddd;
}
.list {
color: #333;
height: 620px;
overflow-y: scroll;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
.item {
padding: 30px 0;
border-bottom: 1px solid #dddddd;
&:hover .name {
color: #409eff;
}
.name {
padding-bottom: 5px;
color: #000;
font-weight: bold;
}
.row {
padding: 5px 0;
display: flex;
gap: 20px;
.col {
flex: 1;
color: #999;
span {
color: #333;
}
}
}
}
}
}
.map {
flex: 1;
width: 100%;
height: 100%;
position: relative;
.unfold {
position: absolute;
top: 350px;
left: 0;
}
}
}
.pagination {
margin: 10px 0 20px;
display: flex;
justify-content: center;
}
</style>

325
src/views/TaskSearch.vue Normal file
View File

@ -0,0 +1,325 @@
<template>
<div class="task-list">
<div class="search-bar">
<div class="left">
<el-input
v-model="searchQuery"
placeholder="请输入任务关键词"
class="search-input"
size="large"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="rescueType" placeholder="请选择" class="filter-select" size="large">
<template #prefix>任务类型</template>
<el-option label="全部" value="" />
<el-option label="应急搜捞" value="SAR" />
<el-option label="专项调查" value="SI" />
</el-select>
<el-select v-model="rescueResponseLevel" placeholder="请选择" class="filter-select" size="large">
<template #prefix>快速响应等级</template>
<el-option label="全部" value="" />
<el-option label="一级" value="一级" />
<el-option label="二级" value="二级" />
<el-option label="三级" value="三级" />
</el-select>
<el-select v-model="rescueSecrecy" placeholder="请选择" class="filter-select" size="large">
<template #prefix>是否涉密</template>
<el-option label="全部" value="" />
<el-option label="是" value="是" />
<el-option label="否" value="否" />
</el-select>
<el-button size="large" :icon="RefreshLeft" @click="handleReset">重置</el-button>
<el-button size="large" :icon="Search" type="primary" @click="handleSearch" style="margin-left: 0;">检索</el-button>
</div>
<div class="right">
<el-button size="large" type="success" @click="handleInput">任务录入</el-button>
</div>
</div>
<div class="table">
<div class="row" v-for="(item,index) in dataList" :key="index" @click="onDetail(item.tsy_id)">
<div class="info">
<div class="top">
<div class="tag" :class="{tag2: item.rescue_type === 'SI'}">{{ item.rescue_type === 'SAR' ? '应急搜捞' : '专项调查' }}</div>
<div class="code">{{ item.rescue_code }}</div>
</div>
<div class="bottom">
<el-row :gutter="20">
<el-col :span="2">
<div class="label">状态</div>
<div class="value">{{ item.rescue_status }}</div>
</el-col>
<el-col :span="3">
<div class="label">搜寻处置目标</div>
<div class="value">{{ item.rescue_target_name }}</div>
</el-col>
<el-col :span="3">
<div class="label">快速响应等级</div>
<div class="value">{{ item.rescue_response_level }}</div>
</el-col>
<el-col :span="2">
<div class="label">是否涉密</div>
<div class="value">{{ item.rescue_secrecy }}</div>
</el-col>
<el-col :span="2">
<div class="label">联系人</div>
<div class="value">{{ item.fill_person }}</div>
</el-col>
<el-col :span="3">
<div class="label">填报时间</div>
<div class="value">{{ item.fill_time }}</div>
</el-col>
<el-col :span="3">
<div class="label">搜寻处置目标位置</div>
<div class="value">{{ item.rescue_target_lat }}{{ item.rescue_target_lon }}</div>
</el-col>
<el-col :span="3">
<div class="label">建议搜寻区域范围</div>
<div class="value">{{ item.rescue_target_radius }}</div>
</el-col>
<el-col :span="3">
<div class="label">搜寻处置目标价值</div>
<div class="value">{{ item.rescue_target_price }}</div>
</el-col>
</el-row>
</div>
</div>
<div class="right">
<el-icon><ArrowRightBold /></el-icon>
</div>
</div>
</div>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router';
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { regPage } from '@/api/task.js'
interface TaskItem {
tsy_id: number
rescue_type: string
rescue_code: string
rescue_status: string
rescue_target_name: string
rescue_response_level: string
rescue_secrecy: string
fill_person: string
fill_time: string
rescue_target_lat: string
rescue_target_lon: string
rescue_target_radius: string
rescue_target_price: string
}
const router = useRouter();
const searchQuery = ref('')
const rescueType = ref('')
const rescueResponseLevel = ref('')
const rescueSecrecy = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const start = ref(0)
const total = ref(0)
const dataList = ref<TaskItem[]>([])
const handleSearch = () => {
start.value = 0
currentPage.value = 1
getDataList()
}
const handleReset = () => {
searchQuery.value = ''
rescueType.value = ''
rescueResponseLevel.value = ''
rescueSecrecy.value = ''
start.value = 0
currentPage.value = 1
getDataList()
}
const handleInput = () => {
router.push('/input')
}
const handleSizeChange = (val: number) => {
pageSize.value = val
start.value = 0
currentPage.value = 1
getDataList()
}
const handleCurrentChange = (val: number) => {
currentPage.value = val
start.value = pageSize.value * (val -1)
getDataList()
}
//
const getDataList = () => {
;(regPage({
start: start.value,
limit: pageSize.value,
query: searchQuery.value,
rescue_type: rescueType.value,
rescue_response_level: rescueResponseLevel.value,
rescue_secrecy: rescueSecrecy.value,
}) as unknown as Promise<{ page: { records: TaskItem[]; total: number } }>).then((res) => {
dataList.value = res.page.records
total.value = res.page.total
})
}
//
const onDetail = (id: number) => {
router.push(`/input/${id}`)
}
onMounted(() => {
getDataList()
})
</script>
<style lang="scss" scoped>
.task-list {
padding: 20px 8%;
box-sizing: border-box;
min-height: calc(100vh - 220px);
background-color: rgb(241,244,251);
.search-bar {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
.left {
display: flex;
gap: 15px;
.search-input {
width: 200px;
}
.filter-select {
width: 200px;
}
}
}
.table {
color: #242424;
.row {
margin-bottom: 20px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
border-radius: 10px;
border: 2px solid transparent;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); /* 阴影效果 */
&:hover {
cursor: pointer;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
border: 2px solid rgb(18,150,219);
.right {
border: 2px solid transparent;
background-color: rgb(0,160,243);
color: #fff;
}
}
.info {
flex: 1;
.top {
display: flex;
align-items: center;
.tag {
margin-right: 12px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 5px;
padding: 0 8px;
color: #fdbb13;
border: 2px solid #fdbb13;
}
.tag2 {
color: rgb(18,150,219);
border: 2px solid rgb(18,150,219);
}
.code {
color: #000;
font-weight: bold;
}
}
.bottom {
margin-top: 10px;
.label {
font-size: 12px;
color: #bbb;
}
.value {
font-size: 14px;
color: #242424;
}
}
}
.right {
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
border: 2px solid rgb(0,160,243);
color: rgb(0,160,243);
font-size: 26px;
}
}
}
.pagination {
margin: 50px;
display: flex;
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,279 @@
<template>
<div class="resource-panel">
<div class="toolbar" v-if="showToolbar">
<el-button type="primary" :icon="Upload" @click="openUploadDialog">上传文件</el-button>
<el-button type="success" :icon="Link" @click="openCloudDialog">指定云资源路径</el-button>
</div>
<el-table :data="fileData" border style="width: 100%">
<el-table-column prop="file_name" label="资源名称" min-width="180" />
<el-table-column prop="source_type" label="来源类型" width="110">
<template #default="scope">
<el-tag :type="scope.row.source_type === 'cloud' ? 'success' : 'primary'" size="small">
{{ scope.row.source_type === 'cloud' ? '云资源' : '本地上传' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_path" label="路径" min-width="260" show-overflow-tooltip />
<el-table-column prop="file_size" label="文件大小" width="120">
<template #default="scope">
{{ formatFileSize(scope.row.file_size) }}
</template>
</el-table-column>
<el-table-column prop="create_time" label="添加时间" width="170" />
<el-table-column label="操作" width="100" align="center">
<template #default="scope">
<el-popconfirm
confirm-button-text="确定"
cancel-button-text="取消"
title="是否确认删除?"
@confirm="handleDelete(scope.row.id)"
>
<template #reference>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!fileData.length" description="暂无数据资源,请上传文件或指定云资源路径" />
<!-- 上传文件弹窗 -->
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="520" destroy-on-close>
<el-form label-width="90px">
<el-form-item label="选择文件">
<el-upload
ref="uploadRef"
drag
:auto-upload="false"
:limit="1"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<el-icon class="upload-icon"><Upload /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">支持 rawsgynccsv 等地球物理数据格式</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="uploadRemark" type="textarea" :rows="2" placeholder="可选,填写文件说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="uploadLoading" @click="confirmUpload">确认上传</el-button>
</template>
</el-dialog>
<!-- 云资源路径弹窗 -->
<el-dialog v-model="cloudDialogVisible" title="指定云资源路径" width="560" destroy-on-close>
<el-form ref="cloudFormRef" :model="cloudForm" :rules="cloudRules" label-width="110px">
<el-form-item label="云资源路径" prop="cloud_path">
<el-input
v-model="cloudForm.cloud_path"
placeholder="例如s3://geodata-bucket/seismic/2024/data.sgy"
/>
</el-form-item>
<el-form-item label="资源名称" prop="resource_name">
<el-input v-model="cloudForm.resource_name" placeholder="可选,默认取路径末尾文件名" />
</el-form-item>
<el-form-item label="存储类型">
<el-select v-model="cloudForm.storage_type" style="width: 100%">
<el-option label="对象存储 (S3/OSS)" value="object" />
<el-option label="NAS 共享目录" value="nas" />
<el-option label="HDFS" value="hdfs" />
</el-select>
</el-form-item>
<el-form-item label="说明">
<el-input v-model="cloudForm.remark" type="textarea" :rows="2" placeholder="可选,填写云资源说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="cloudDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="cloudLoading" @click="confirmCloudPath">确认添加</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, watch, onMounted } from 'vue'
import { Upload, Link } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { dataFileList, dataFileUpload, dataCloudPathAdd, dataFileDelete } from '@/api/data.js'
const props = defineProps({
dataId: {
type: [String, Number],
default: ''
},
showToolbar: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['updated'])
const fileData = ref([])
const uploadDialogVisible = ref(false)
const cloudDialogVisible = ref(false)
const uploadLoading = ref(false)
const cloudLoading = ref(false)
const uploadRemark = ref('')
const selectedFile = ref(null)
const cloudFormRef = ref(null)
const cloudForm = reactive({
cloud_path: '',
resource_name: '',
storage_type: 'object',
remark: ''
})
const cloudRules = {
cloud_path: [{ required: true, message: '请输入云资源路径', trigger: 'blur' }]
}
/**
* 格式化文件大小
* @param {number} bytes
* @returns {string}
*/
const formatFileSize = (bytes) => {
if (!bytes) return '—'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
const loadFileList = () => {
if (!props.dataId) {
fileData.value = []
return
}
dataFileList({ data_id: props.dataId }).then((res) => {
fileData.value = res.array || []
})
}
const ensureDataId = () => {
if (!props.dataId) {
ElMessage.warning('请先保存数据基本信息')
return false
}
return true
}
const openUploadDialog = () => {
if (!ensureDataId()) return
selectedFile.value = null
uploadRemark.value = ''
uploadDialogVisible.value = true
}
const openCloudDialog = () => {
if (!ensureDataId()) return
cloudForm.cloud_path = ''
cloudForm.resource_name = ''
cloudForm.storage_type = 'object'
cloudForm.remark = ''
cloudDialogVisible.value = true
}
const handleFileChange = (file) => {
selectedFile.value = file.raw
}
const handleFileRemove = () => {
selectedFile.value = null
}
const confirmUpload = () => {
if (!selectedFile.value) {
ElMessage.warning('请选择要上传的文件')
return
}
uploadLoading.value = true
dataFileUpload({
data_id: props.dataId,
file_name: selectedFile.value.name,
file_size: selectedFile.value.size
}).then((res) => {
uploadLoading.value = false
if (res.success) {
ElMessage.success('文件上传成功')
uploadDialogVisible.value = false
loadFileList()
emit('updated')
} else {
ElMessage.error(res.message || '上传失败')
}
})
}
const confirmCloudPath = async () => {
if (!cloudFormRef.value) return
await cloudFormRef.value.validate((valid) => {
if (!valid) return
cloudLoading.value = true
dataCloudPathAdd({
data_id: props.dataId,
cloud_path: cloudForm.cloud_path,
resource_name: cloudForm.resource_name
}).then((res) => {
cloudLoading.value = false
if (res.success) {
ElMessage.success('云资源路径添加成功')
cloudDialogVisible.value = false
loadFileList()
emit('updated')
} else {
ElMessage.error(res.message || '添加失败')
}
})
})
}
const handleDelete = (fileId) => {
dataFileDelete({ data_id: props.dataId, file_id: fileId }).then(() => {
ElMessage.success('删除成功')
loadFileList()
emit('updated')
})
}
watch(() => props.dataId, () => {
loadFileList()
})
onMounted(() => {
loadFileList()
})
defineExpose({
openUploadDialog,
openCloudDialog,
loadFileList
})
</script>
<style lang="scss" scoped>
.resource-panel {
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.upload-icon {
font-size: 48px;
color: #909399;
margin-bottom: 8px;
}
}
</style>

View File

@ -0,0 +1,113 @@
<template>
<div>
<div class="upload-btn">
<el-upload
v-model:file-list="fileList"
class="upload-demo"
:action="uploadUrl"
:show-file-list="false"
:data="{
rescue_id: props.rescueId
}"
:on-success="onSuccess"
>
<el-button type="primary">上传附件</el-button>
</el-upload>
</div>
<el-table :data="fileData" border style="width: 100%">
<el-table-column property="file_org_name" label="文件名称" />
<el-table-column property="file_length" label="文件大小" width="150">
<template #default="scope">
<span class="file-size">{{ formatFileSize(scope.row.file_length) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center">
<template #default="scope">
<el-button link type="primary" size="small" @click="handleDownload(scope.row.tsy_id)">下载</el-button>
<el-popconfirm
confirm-button-text="确定"
cancel-button-text="取消"
:icon="InfoFilled"
icon-color="#626AEF"
title="是否确认删除?"
@confirm="handleDelete(scope.row.tsy_id)"
>
<template #reference>
<el-button link type="primary" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {regFileDelete, regFileList} from "@/api/task.js"
import {InfoFilled} from '@element-plus/icons-vue'
const uploadUrl = import.meta.env.VITE_APP_BASE_API + '/rs/rescue/reg/file/upload.htm'
const fileList = ref([])
const fileData = ref([])
const props = defineProps({
rescueId: {
type: String, //
default: ''
}
})
//
const getFileList = () => {
regFileList({rescue_id: props.rescueId}).then(res => {
fileData.value = res.array
})
}
//
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
//
const onSuccess = () => {
regFileList({rescue_id: props.rescueId}).then(res => {
fileData.value = res.array
})
}
//
const handleDownload = async (fileId) => {
window.open(`${import.meta.env.VITE_APP_BASE_API}/rs/rescue/reg/file/download.htm?file_id=${fileId}`)
}
//
const handleDelete = (fileId) => {
regFileDelete({
file_id: fileId
}).then(res => {
regFileList({rescue_id: props.rescueId}).then(res => {
fileData.value = res.array
})
})
}
onMounted(() => {
if (props.rescueId) {
getFileList()
}
})
</script>
<style scoped lang="scss">
.upload-btn {
text-align: right;
margin-bottom: 20px;
}
</style>

View File

@ -0,0 +1,255 @@
<template>
<el-form
ref="ruleFormRef"
:model="ruleForm"
:rules="rules"
label-width="120px"
class="ruleForm"
:size="formSize"
status-icon
>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="数据编号" prop="data_code">
<el-input v-model="ruleForm.data_code" placeholder="如 MB-SH-2024-001" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="数据类型" prop="data_type">
<el-select v-model="ruleForm.data_type" placeholder="请选择" style="width: 100%">
<el-option
v-for="item in dataTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="入库状态" prop="data_status">
<el-select v-model="ruleForm.data_status" placeholder="请选择" style="width: 100%">
<el-option label="待审核" value="待审核" />
<el-option label="处理中" value="处理中" />
<el-option label="已入库" value="已入库" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="数据名称" prop="data_name">
<el-input v-model="ruleForm.data_name" placeholder="请输入数据名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="航次名称" prop="voyage_name">
<el-input v-model="ruleForm.voyage_name" placeholder="请输入航次名称" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="经度" prop="data_lon">
<el-input v-model="ruleForm.data_lon" placeholder="如 113.52" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="纬度" prop="data_lat">
<el-input v-model="ruleForm.data_lat" placeholder="如 18.25" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="调查区域" prop="survey_area">
<el-input v-model="ruleForm.survey_area" placeholder="如 120 km²" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="数据量" prop="file_size">
<el-input v-model="ruleForm.file_size" placeholder="如 2.8 GB" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="是否涉密" prop="data_secrecy">
<el-select v-model="ruleForm.data_secrecy" style="width: 100%">
<el-option label="是" value="是" />
<el-option label="否" value="否" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="入库时间" prop="fill_time">
<el-date-picker
v-model="ruleForm.fill_time"
type="datetime"
placeholder="请选择"
format="YYYY/MM/DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="录入人" prop="fill_person">
<el-input v-model="ruleForm.fill_person" placeholder="请输入录入人" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系电话" prop="fill_tel">
<el-input v-model="ruleForm.fill_tel" placeholder="请输入联系电话" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="数据来源" prop="data_source">
<el-input v-model="ruleForm.data_source" placeholder="如 船载测量系统" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="数据说明" prop="data_note">
<el-input v-model="ruleForm.data_note" type="textarea" :rows="3" placeholder="请输入数据说明、处理状态等补充信息" />
</el-form-item>
</el-col>
</el-row>
<div class="btn-bar">
<el-button type="primary" @click="submitForm(ruleFormRef)">保存</el-button>
<el-button @click="resetForm">取消</el-button>
<el-button type="primary" plain :icon="Upload" @click="handleUpload">上传文件</el-button>
<el-button type="success" plain :icon="Link" @click="handleCloudPath">指定云资源路径</el-button>
</div>
</el-form>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import type { ComponentSize, FormInstance } from 'element-plus'
import { ElMessage } from 'element-plus'
import { Upload, Link } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { dataEdit, dataFind } from '@/api/data.js'
import { getFlatDataTypeOptions } from '@/constants/dataTypes.js'
const router = useRouter()
const formSize = ref<ComponentSize>('default')
const ruleFormRef = ref<FormInstance>()
const dataTypeOptions = getFlatDataTypeOptions()
const ruleForm = ref({
id: '',
data_code: '',
data_type: '',
data_name: '',
voyage_name: '',
data_status: '待审核',
data_lon: '',
data_lat: '',
survey_area: '',
file_size: '',
data_secrecy: '否',
fill_time: '',
fill_person: '',
fill_tel: '',
data_source: '',
data_note: ''
})
const rules = reactive({
data_code: [{ required: true, message: '请输入数据编号', trigger: 'blur' }],
data_type: [{ required: true, message: '请选择数据类型', trigger: 'change' }],
data_name: [{ required: true, message: '请输入数据名称', trigger: 'blur' }],
voyage_name: [{ required: true, message: '请输入航次名称', trigger: 'blur' }],
data_lon: [{ required: true, message: '请输入经度', trigger: 'blur' }],
data_lat: [{ required: true, message: '请输入纬度', trigger: 'blur' }],
data_secrecy: [{ required: true, message: '请选择是否涉密', trigger: 'change' }],
fill_time: [{ required: true, message: '请选择入库时间', trigger: 'change' }],
fill_person: [{ required: true, message: '请输入录入人', trigger: 'blur' }]
})
const props = defineProps({
dataId: {
type: [String, Number],
default: ''
}
})
const emit = defineEmits(['saved', 'upload', 'cloud-path'])
const submitForm = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid) => {
if (!valid) return
const payload = { ...ruleForm.value }
if (props.dataId) {
payload.id = String(props.dataId)
}
dataEdit(payload).then((res: { success: boolean; entity?: Record<string, unknown>; message?: string }) => {
if (res.success) {
ruleForm.value = { ...ruleForm.value, ...res.entity }
ElMessage.success('保存成功')
emit('saved', res.entity)
if (!props.dataId && res.entity?.id) {
router.replace(`/input/${res.entity.id}`)
}
} else {
ElMessage.error(res.message || '保存失败')
}
})
})
}
const resetForm = () => {
router.push('/search')
}
const handleUpload = () => {
emit('upload')
}
const handleCloudPath = () => {
emit('cloud-path')
}
const getDetail = () => {
dataFind({ data_id: props.dataId }).then((res: { success: boolean; entity?: Record<string, unknown> }) => {
if (res.success && res.entity) {
ruleForm.value = {
...ruleForm.value,
...res.entity,
fill_tel: String(res.entity.fill_tel || ''),
data_source: String(res.entity.data_source || ''),
data_note: String(res.entity.data_note || '')
}
}
})
}
onMounted(() => {
if (props.dataId) {
getDetail()
}
})
</script>
<style lang="scss" scoped>
.ruleForm {
margin-top: 20px;
.btn-bar {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
}
</style>

View File

@ -0,0 +1,208 @@
<template>
<div>
<div class="upload-btn">
<el-button type="primary" @click="handleVoyage">关联航次</el-button>
</div>
<el-table :data="tableData" border style="width: 100%">
<el-table-column prop="voyage_name" label="航次名称" />
<el-table-column prop="diving_sn" label="潜次号" />
<el-table-column prop="dataset_name" label="样品集名称" />
<el-table-column prop="sample_name" label="样品名称" />
<el-table-column prop="conver_area" label="覆盖面积" />
<el-table-column label="操作" width="150" align="center">
<template #default="scope">
<el-popconfirm
confirm-button-text="确定"
cancel-button-text="取消"
:icon="InfoFilled"
icon-color="#626AEF"
title="是否确认删除?"
@confirm="handleDelete(scope.row.tsy_id)"
>
<template #reference>
<el-button link type="primary" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogFormVisible" title="关联航次" width="600">
<el-cascader
v-model="selectedValues"
:options="cascadeData"
:props="{ expandTrigger: 'hover' }"
@change="handleChange"
style="width: 100%;"
/>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogFormVisible = false">取消</el-button>
<el-button type="primary" @click="onConfirm">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {getParam, sampleAdd, sampleDelete, sampleList, voyageReg} from "@/api/task.js"
import {InfoFilled} from "@element-plus/icons-vue";
import {ElMessage} from "element-plus";
const dialogFormVisible = ref(false)
const props = defineProps({
rescueId: {
type: String, //
default: ''
}
})
const VoyageSocketUrl = ref('')
//
const getParamsList = () => {
//
if (import.meta.env.VITE_APP_ENV === 'development') {
// 使
VoyageSocketUrl.value = import.meta.env.VITE_APP_DS_API
voyageList()
} else {
//
getParam().then(res => {
const data = res.array.find(item => item.param_name === 'VoyageSocketUrl')
if (data?.param_value) {
VoyageSocketUrl.value = data.param_value
} else {
// URL[1](@ref)
VoyageSocketUrl.value = `${window.location.protocol}//${window.location.hostname}:${window.location.port}/ds/`
}
voyageList()
})
}
}
const entity = ref()
const allVoyage = ref()
const tableData = ref([])
//
const voyageList = () => {
voyageReg({ baseURL: VoyageSocketUrl.value }).then(res => {
entity.value = res.entity
allVoyage.value = res.entity.VoyageArray
cascadeData.value = convertToCascadeData(res.entity)
})
}
//
const dataList = () => {
sampleList({ rescue_id: props.rescueId }).then(res => {
tableData.value = res.array
})
}
//
const handleVoyage = () => {
dialogFormVisible.value = true
}
//
const handleDelete = (id) => {
sampleDelete({ tsy_id: id }).then(() => {
ElMessage({
message: '删除成功',
type: 'success'
})
dataList()
})
}
const selectedValues = ref('')
const cascadeData = ref([])
//
const convertToCascadeData = (entity) => {
const cascadeData = [];
// VoyageArray
if (entity.VoyageArray && Array.isArray(entity.VoyageArray)) {
entity.VoyageArray.forEach(voyage => {
const voyageName = voyage.voyage_name;
//
const firstLevelItem = {
value: voyageName,
label: `${voyageName} (${voyage.ship_name})`,
children: []
};
// voyage_name
if (entity[voyageName] && Array.isArray(entity[voyageName])) {
entity[voyageName].forEach(dataset => {
const secondLevelItem = {
value: dataset.dataset_id.toString(),
label: dataset.dataset_name
};
firstLevelItem.children.push(secondLevelItem);
});
}
cascadeData.push(firstLevelItem);
});
}
return cascadeData;
}
const selectedDataset = ref('')
//
const handleChange = (value) => {
if (value && value.length === 2) {
const [voyageName, datasetId] = value;
selectedDataset.value = findDatasetById(entity.value, voyageName, datasetId);
}
}
//
const findDatasetById = (entity, voyageName, datasetId) => {
if (!entity[voyageName]) return null;
return entity[voyageName].find(dataset =>
dataset.dataset_id.toString() === datasetId.toString()
)
}
//
const onConfirm = () => {
if (selectedDataset.value) {
selectedDataset.value.rescue_id = props.rescueId
sampleAdd(selectedDataset.value).then(() => {
ElMessage({
message: '操作成功',
type: 'success'
})
dataList()
dialogFormVisible.value = false
selectedValues.value = ''
selectedDataset.value = null
})
}
}
onMounted(() => {
if (props.rescueId) {
getParamsList()
dataList()
}
})
</script>
<style scoped lang="scss">
.upload-btn {
text-align: right;
margin-bottom: 20px;
}
</style>

13
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
declare module '*.js' {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any
export = mod
}

30
tsconfig.app.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"]
},
"allowJs": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"]
}

1
tsconfig.app.tsbuildinfo Normal file
View File

@ -0,0 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/router/index.ts","./src/app.vue","./src/components/helloworld.vue","./src/components/dataspace/filetypeicon.vue","./src/components/home/icondataset.vue","./src/components/home/icongrid.vue","./src/components/home/iconmagnetic.vue","./src/components/home/iconsonar.vue","./src/components/home/iconwave.vue","./src/components/home/staticon.vue","./src/views/contact.vue","./src/views/dataimport.vue","./src/views/datasearch.vue","./src/views/home.vue","./src/views/login.vue","./src/views/statistics.vue","./src/views/taskdata.vue","./src/views/taskdemand.vue","./src/views/tasksearch.vue","./src/views/common/dataresourcepanel.vue","./src/views/common/filelist.vue","./src/views/common/formdata.vue","./src/views/common/relatedvoyage.vue"],"version":"5.9.3"}

12
tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"noEmitOnError": false, // 使 TS
"skipLibCheck": true, //
"strict": false //
}
}

23
tsconfig.node.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["node"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}

16
vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path';
import cesium from 'vite-plugin-cesium';
// https://vitejs.dev/config/
export default defineConfig({
base: './',
plugins: [vue(),cesium()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@components': resolve(__dirname, 'src/components'),
},
},
})

1312
yarn.lock Normal file

File diff suppressed because it is too large Load Diff