原型
This commit is contained in:
13
江西城市生命线-可交互原型/frontend/index.html
Normal file
13
江西城市生命线-可交互原型/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI数智化平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2196
江西城市生命线-可交互原型/frontend/package-lock.json
generated
Normal file
2196
江西城市生命线-可交互原型/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
江西城市生命线-可交互原型/frontend/package.json
Normal file
24
江西城市生命线-可交互原型/frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "urbanmind-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"vue": "^3.3.11",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"element-plus": "^2.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"sass": "^1.69.5",
|
||||
"vite": "^5.0.10"
|
||||
}
|
||||
}
|
||||
8
江西城市生命线-可交互原型/frontend/public/avatar.svg
Normal file
8
江西城市生命线-可交互原型/frontend/public/avatar.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="50" fill="#ffe4c4"/>
|
||||
<circle cx="50" cy="38" r="18" fill="#ffd1a1"/>
|
||||
<ellipse cx="50" cy="75" rx="28" ry="20" fill="#ff6b6b"/>
|
||||
<circle cx="38" cy="35" r="3" fill="#333"/>
|
||||
<circle cx="62" cy="35" r="3" fill="#333"/>
|
||||
<path d="M42 48 Q50 55 58 48" stroke="#333" stroke-width="2" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 399 B |
5
江西城市生命线-可交互原型/frontend/public/favicon.svg
Normal file
5
江西城市生命线-可交互原型/frontend/public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#7c3aed"/>
|
||||
<path d="M30 70V40h10v30H30zm15-30h10v30H45V40zm15 0h10v30H60V40z" fill="white"/>
|
||||
<rect x="25" y="30" width="50" height="5" rx="2" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 278 B |
BIN
江西城市生命线-可交互原型/frontend/public/logo.jpg
Normal file
BIN
江西城市生命线-可交互原型/frontend/public/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
47
江西城市生命线-可交互原型/frontend/src/App.vue
Normal file
47
江西城市生命线-可交互原型/frontend/src/App.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<Sidebar
|
||||
v-if="!isFullPageRoute"
|
||||
:collapsed="sidebarCollapsed"
|
||||
@toggle="sidebarCollapsed = !sidebarCollapsed"
|
||||
/>
|
||||
<main class="main-content" :class="{ 'full-page': isFullPageRoute }">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Sidebar from '@/components/Sidebar.vue'
|
||||
|
||||
const sidebarCollapsed = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
// 判断是否为全页面路由(不需要显示侧边栏)
|
||||
const isFullPageRoute = computed(() => {
|
||||
return route.path === '/admin'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&.full-page {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
221
江西城市生命线-可交互原型/frontend/src/api/project.js
Normal file
221
江西城市生命线-可交互原型/frontend/src/api/project.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 项目管理 API 服务
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
|
||||
// 创建 axios 实例
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
apiClient.interceptors.request.use(
|
||||
config => {
|
||||
// 可以在这里添加 token
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
apiClient.interceptors.response.use(
|
||||
response => {
|
||||
return response.data
|
||||
},
|
||||
error => {
|
||||
console.error('API Error:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 项目管理 API
|
||||
*/
|
||||
export const projectApi = {
|
||||
/**
|
||||
* 获取项目列表
|
||||
* @param {Object} params - 查询参数
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getProjects(params = {}) {
|
||||
return apiClient.get('/projects', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取项目详情
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getProjectDetail(projectId) {
|
||||
return apiClient.get(`/projects/${projectId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
* @param {Object} data - 项目数据
|
||||
* @returns {Promise}
|
||||
*/
|
||||
createProject(data) {
|
||||
return apiClient.post('/projects', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新项目
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @param {Object} data - 项目数据
|
||||
* @returns {Promise}
|
||||
*/
|
||||
updateProject(projectId, data) {
|
||||
return apiClient.put(`/projects/${projectId}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
deleteProject(projectId) {
|
||||
return apiClient.delete(`/projects/${projectId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新文件提交信息
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @param {Object} data - 提交信息
|
||||
* @returns {Promise}
|
||||
*/
|
||||
updateSubmission(projectId, data) {
|
||||
return apiClient.put(`/projects/${projectId}/submission`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记为已提交
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @param {Object} data - 提交确认信息
|
||||
* @returns {Promise}
|
||||
*/
|
||||
markAsSubmitted(projectId, data) {
|
||||
return apiClient.post(`/projects/${projectId}/submit`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新开标信息
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @param {Object} data - 开标信息
|
||||
* @returns {Promise}
|
||||
*/
|
||||
updateBiddingInfo(projectId, data) {
|
||||
return apiClient.put(`/projects/${projectId}/bidding`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传开标媒体文件
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @param {FormData} formData - 文件数据
|
||||
* @returns {Promise}
|
||||
*/
|
||||
uploadBiddingMedia(projectId, formData) {
|
||||
return apiClient.post(`/projects/${projectId}/bidding/media`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新结果公示
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @param {Object} data - 结果信息
|
||||
* @returns {Promise}
|
||||
*/
|
||||
updateResult(projectId, data) {
|
||||
return apiClient.put(`/projects/${projectId}/result`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加经验总结
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @param {Object} data - 经验总结
|
||||
* @returns {Promise}
|
||||
*/
|
||||
addExperience(projectId, data) {
|
||||
return apiClient.post(`/projects/${projectId}/experience`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 归档项目
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @param {Object} data - 归档信息
|
||||
* @returns {Promise}
|
||||
*/
|
||||
archiveProject(projectId, data) {
|
||||
return apiClient.post(`/projects/${projectId}/archive`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出项目信息
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
exportProject(projectId) {
|
||||
return apiClient.get(`/projects/${projectId}/export`, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* @param {Number} projectId - 项目ID
|
||||
* @param {String} fileType - 文件类型
|
||||
* @returns {Promise}
|
||||
*/
|
||||
downloadFile(projectId, fileType) {
|
||||
return apiClient.get(`/projects/${projectId}/files/${fileType}`, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传辅助函数
|
||||
* @param {File} file - 文件对象
|
||||
* @param {String} type - 文件类型
|
||||
* @returns {FormData}
|
||||
*/
|
||||
export function createFileFormData(file, type) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', type)
|
||||
return formData
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件辅助函数
|
||||
* @param {Blob} blob - 文件数据
|
||||
* @param {String} filename - 文件名
|
||||
*/
|
||||
export function downloadBlob(blob, filename) {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export default projectApi
|
||||
269
江西城市生命线-可交互原型/frontend/src/components/Sidebar.vue
Normal file
269
江西城市生命线-可交互原型/frontend/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ collapsed: collapsed }">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<img src="/logo.jpg" alt="Logo" class="logo-img" />
|
||||
<span v-if="!collapsed" class="logo-text">AI数智化平台</span>
|
||||
</div>
|
||||
<div class="collapse-btn" @click="$emit('toggle')">
|
||||
<el-icon><DArrowLeft v-if="!collapsed" /><DArrowRight v-else /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-section">
|
||||
<div
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
class="nav-item"
|
||||
:class="{ active: activeMenu === item.key }"
|
||||
@click="handleMenuClick(item)"
|
||||
>
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span v-if="!collapsed">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<!-- User Info -->
|
||||
<el-dropdown class="user-section" trigger="click" @command="handleUserCommand">
|
||||
<div class="user-info-wrapper">
|
||||
<div class="user-avatar">
|
||||
<img src="/avatar.svg" alt="User" @error="handleAvatarError" />
|
||||
</div>
|
||||
<span v-if="!collapsed" class="user-name">李志鹏</span>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>
|
||||
个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="admin" divided>
|
||||
<el-icon><Setting /></el-icon>
|
||||
管理后台
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Plus,
|
||||
Collection,
|
||||
Warning,
|
||||
Document,
|
||||
Edit,
|
||||
Tickets,
|
||||
DataLine,
|
||||
Grid,
|
||||
Service,
|
||||
Refresh,
|
||||
ChatDotRound,
|
||||
DArrowLeft,
|
||||
DArrowRight,
|
||||
User,
|
||||
Setting,
|
||||
Connection
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const activeMenu = ref('chat')
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'chat', label: '泰豪AI助手', icon: 'ChatDotRound', path: '/' },
|
||||
{ key: 'apps', label: '全部应用', icon: 'Grid', path: '/apps' },
|
||||
{ key: 'workflow', label: '智能体编排', icon: 'Connection', path: '/workflow' },
|
||||
{ key: 'bidding', label: '招标助手', icon: 'Document', path: '/bidding' },
|
||||
{ key: 'service', label: '泰豪小电(客服)', icon: 'Service', path: '/service' }
|
||||
]
|
||||
|
||||
const emit = defineEmits(['toggle'])
|
||||
|
||||
const handleMenuClick = (item) => {
|
||||
activeMenu.value = item.key
|
||||
router.push(item.path)
|
||||
}
|
||||
|
||||
|
||||
const handleAvatarError = (e) => {
|
||||
e.target.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI1MCIgZmlsbD0iI2ZmZTRjNCIvPjxjaXJjbGUgY3g9IjUwIiBjeT0iNDAiIHI9IjIwIiBmaWxsPSIjZmZkMWExIi8+PGVsbGlwc2UgY3g9IjUwIiBjeT0iODAiIHJ4PSIyNSIgcnk9IjE1IiBmaWxsPSIjZmY2YjZiIi8+PC9zdmc+'
|
||||
}
|
||||
|
||||
const handleUserCommand = (command) => {
|
||||
if (command === 'profile') {
|
||||
router.push('/profile')
|
||||
} else if (command === 'admin') {
|
||||
router.push('/admin')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
background: #F0EAF4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #333;
|
||||
flex-shrink: 0;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px 12px;
|
||||
justify-content: center;
|
||||
|
||||
.logo {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
position: static;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
justify-content: center;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.logo-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
|
||||
.user-section {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(124, 58, 237, 0.05);
|
||||
}
|
||||
|
||||
.user-info-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
江西城市生命线-可交互原型/frontend/src/components/UserPanel.vue
Normal file
79
江西城市生命线-可交互原型/frontend/src/components/UserPanel.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<aside class="user-panel">
|
||||
<div class="user-avatar">
|
||||
<div class="avatar-wrapper">
|
||||
<img src="/avatar.svg" alt="User Avatar" @error="handleAvatarError" />
|
||||
<div class="mic-icon">
|
||||
<el-icon><Microphone /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<span class="user-name">{{ userName }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Microphone } from '@element-plus/icons-vue'
|
||||
|
||||
const userName = ref('李志鹏')
|
||||
|
||||
const handleAvatarError = (e) => {
|
||||
e.target.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI1MCIgZmlsbD0iI2ZmZTRjNCIvPjxjaXJjbGUgY3g9IjUwIiBjeT0iNDAiIHI9IjIwIiBmaWxsPSIjZmZkMWExIi8+PGVsbGlwc2UgY3g9IjUwIiBjeT0iODAiIHJ4PSIyNSIgcnk9IjE1IiBmaWxsPSIjZmY2YjZiIi8+PC9zdmc+'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-panel {
|
||||
width: 100px;
|
||||
background: #f9fafb;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.mic-icon {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
804
江西城市生命线-可交互原型/frontend/src/components/project/BiddingInfo.vue
Normal file
804
江西城市生命线-可交互原型/frontend/src/components/project/BiddingInfo.vue
Normal file
@@ -0,0 +1,804 @@
|
||||
<template>
|
||||
<div class="bidding-info-panel">
|
||||
<!-- 开标会议信息 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Bell /></el-icon>
|
||||
开标会议信息
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="info-grid">
|
||||
<!-- 开标时间 -->
|
||||
<div class="info-section">
|
||||
<h4>📅 开标时间</h4>
|
||||
<div class="info-content">
|
||||
<div class="info-item">
|
||||
<span class="label">日期:</span>
|
||||
<el-date-picker
|
||||
v-model="biddingInfo.dateValue"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">时间:</span>
|
||||
<el-time-picker
|
||||
v-model="biddingInfo.timeValue"
|
||||
placeholder="选择时间"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
style="width: 150px;"
|
||||
/>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">倒计时:</span>
|
||||
<el-tag type="warning" size="large">
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ biddingInfo.countdown }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">状态:</span>
|
||||
<el-tag type="warning" size="large">
|
||||
🟡 {{ biddingInfo.statusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开标地点 -->
|
||||
<div class="info-section">
|
||||
<h4>📍 开标地点</h4>
|
||||
<div class="info-content">
|
||||
<div class="info-item">
|
||||
<span class="label">地址:</span>
|
||||
<el-input
|
||||
v-model="biddingInfo.location.address"
|
||||
placeholder="请输入开标地址"
|
||||
style="width: 300px;"
|
||||
/>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">联系电话:</span>
|
||||
<el-input
|
||||
v-model="biddingInfo.location.phone"
|
||||
placeholder="请输入联系电话"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">备注:</span>
|
||||
<el-input
|
||||
v-model="biddingInfo.location.note"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入备注信息"
|
||||
style="width: 300px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 参会人员 -->
|
||||
<div class="info-section">
|
||||
<h4>👥 参会人员</h4>
|
||||
<div class="info-content">
|
||||
<div
|
||||
v-for="(person, index) in biddingInfo.attendees"
|
||||
:key="index"
|
||||
class="info-item editable-person"
|
||||
>
|
||||
<span class="label">{{ person.role }}:</span>
|
||||
<div class="person-inputs">
|
||||
<el-input
|
||||
v-model="person.name"
|
||||
placeholder="姓名"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<el-input
|
||||
v-model="person.phone"
|
||||
placeholder="电话"
|
||||
style="width: 150px;"
|
||||
/>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="removePerson(index)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
@click="addPerson"
|
||||
style="margin-top: 12px;"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加参会人员
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开标流程 -->
|
||||
<div class="info-section">
|
||||
<h4>📋 开标流程</h4>
|
||||
<div class="info-content">
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="(step, index) in biddingInfo.process"
|
||||
:key="index"
|
||||
:timestamp="step.time"
|
||||
>
|
||||
{{ step.description }}
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 开标记录 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Document /></el-icon>
|
||||
开标记录
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="record-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">参与投标单位:</span>
|
||||
<span class="summary-value">{{ biddingInfo.record.totalBidders }}家</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">我方报价排名:</span>
|
||||
<span class="summary-value highlight">第{{ biddingInfo.record.ourRanking }}名</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">最高报价:</span>
|
||||
<span class="summary-value">{{ formatPrice(biddingInfo.record.highestPrice) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">最低报价:</span>
|
||||
<span class="summary-value">{{ formatPrice(biddingInfo.record.lowestPrice) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">我方报价:</span>
|
||||
<span class="summary-value price">{{ formatPrice(biddingInfo.record.ourPrice) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">控制价:</span>
|
||||
<span class="summary-value">{{ formatPrice(biddingInfo.record.controlPrice) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bidders-table">
|
||||
<div class="table-header">
|
||||
<h4>所有投标单位报价</h4>
|
||||
<el-button type="primary" size="small" @click="addBidder">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加投标单位
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="biddingInfo.record.allBidders" border stripe>
|
||||
<el-table-column label="排名" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.rank"
|
||||
:min="1"
|
||||
:max="99"
|
||||
controls-position="right"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="投标单位" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.company"
|
||||
placeholder="请输入投标单位名称"
|
||||
:class="{ 'our-company-input': row.isOur }"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报价金额" width="180" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1000"
|
||||
controls-position="right"
|
||||
:class="{ 'our-price-input': row.isOur }"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工期(天)" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.workDays"
|
||||
:min="1"
|
||||
:max="999"
|
||||
controls-position="right"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="center">
|
||||
<template #default="{ row, $index }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="markAsOur($index)"
|
||||
:disabled="row.isOur"
|
||||
>
|
||||
{{ row.isOur ? '我方' : '标记为我方' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="removeBidder($index)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="record-actions">
|
||||
<el-button type="success" @click="saveBiddingRecord">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存开标记录
|
||||
</el-button>
|
||||
<el-button type="primary" @click="downloadRecord">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载开标记录表
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 现场照片/视频 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Picture /></el-icon>
|
||||
现场照片/视频
|
||||
</h3>
|
||||
<div class="header-actions">
|
||||
<el-button size="small" @click="uploadPhoto">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传照片
|
||||
</el-button>
|
||||
<el-button size="small" @click="uploadVideo">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
上传视频
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="media-grid">
|
||||
<div v-for="photo in biddingInfo.media.photos" :key="photo.url" class="media-item">
|
||||
<img :src="photo.url" :alt="photo.description" />
|
||||
<div class="media-info">
|
||||
<span class="media-desc">{{ photo.description }}</span>
|
||||
<span class="media-time">{{ photo.uploadTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="biddingInfo.media.videos.length > 0" class="video-list">
|
||||
<div v-for="video in biddingInfo.media.videos" :key="video.url" class="video-item">
|
||||
<el-icon class="video-icon"><VideoCamera /></el-icon>
|
||||
<div class="video-info">
|
||||
<span class="video-desc">{{ video.description }}</span>
|
||||
<span class="video-meta">{{ video.uploadTime }} · {{ video.duration }}</span>
|
||||
</div>
|
||||
<el-button link type="primary" @click="playVideo(video)">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
播放
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<el-button size="large" @click="editBiddingInfo">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑开标信息
|
||||
</el-button>
|
||||
<el-button size="large" @click="setReminder">
|
||||
<el-icon><Bell /></el-icon>
|
||||
设置开标提醒
|
||||
</el-button>
|
||||
<el-button size="large" type="primary" @click="exportReport">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出开标报告
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Bell, Clock, Document, View,
|
||||
Download, Picture, Upload, VideoCamera, VideoPlay, Edit, Delete, Plus, Check
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
// 开标信息数据
|
||||
const biddingInfo = ref({
|
||||
date: '2024-12-15 (星期日)',
|
||||
time: '09:30:00',
|
||||
dateValue: '2024-12-15', // 日期选择器绑定值
|
||||
timeValue: '09:30:00', // 时间选择器绑定值
|
||||
countdown: '还剩6天2小时15分钟',
|
||||
statusText: '待开标',
|
||||
|
||||
location: {
|
||||
address: 'XX市公共资源交易中心3楼开标室A',
|
||||
phone: '0791-88888888',
|
||||
note: '需携带法人授权委托书原件及身份证'
|
||||
},
|
||||
|
||||
attendees: [
|
||||
{ role: '投标代表', name: '张三', phone: '13800138000' },
|
||||
{ role: '技术负责人', name: '李四', phone: '13900139000' },
|
||||
{ role: '备选人员', name: '王五', phone: '13700137000' }
|
||||
],
|
||||
|
||||
process: [
|
||||
{ time: '09:00 - 09:30', description: '签到入场' },
|
||||
{ time: '09:30 - 09:45', description: '宣读开标纪律' },
|
||||
{ time: '09:45 - 10:30', description: '唱标(报价、工期等)' },
|
||||
{ time: '10:30 - 11:00', description: '投标文件密封检查' },
|
||||
{ time: '11:00 - 11:30', description: '答疑与记录' }
|
||||
],
|
||||
|
||||
record: {
|
||||
totalBidders: 8,
|
||||
ourRanking: 3,
|
||||
highestPrice: 3200000.00,
|
||||
lowestPrice: 2650000.00,
|
||||
ourPrice: 2850000.00,
|
||||
controlPrice: 3500000.00,
|
||||
|
||||
allBidders: [
|
||||
{ rank: 1, company: 'XX机电设备有限公司', price: 2650000.00, workDays: 55 },
|
||||
{ rank: 2, company: 'XX电力工程有限公司', price: 2750000.00, workDays: 60 },
|
||||
{ rank: 3, company: '我方(XX电力设备有限公司)', price: 2850000.00, workDays: 60, isOur: true },
|
||||
{ rank: 4, company: 'XX建设集团', price: 2900000.00, workDays: 65 },
|
||||
{ rank: 5, company: 'XX科技股份', price: 2950000.00, workDays: 70 },
|
||||
{ rank: 6, company: 'XX工程公司', price: 3050000.00, workDays: 60 },
|
||||
{ rank: 7, company: 'XX实业有限公司', price: 3100000.00, workDays: 75 },
|
||||
{ rank: 8, company: 'XX集团', price: 3200000.00, workDays: 80 }
|
||||
]
|
||||
},
|
||||
|
||||
media: {
|
||||
photos: [
|
||||
{ url: '/images/bidding-1.jpg', uploadTime: '2024-12-15 09:35:00', description: '签到现场' },
|
||||
{ url: '/images/bidding-2.jpg', uploadTime: '2024-12-15 10:15:00', description: '唱标现场' }
|
||||
],
|
||||
videos: [
|
||||
{ url: '/videos/bidding-record.mp4', uploadTime: '2024-12-15 12:00:00', description: '开标会议录音', duration: '2小时15分' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化价格
|
||||
const formatPrice = (price) => {
|
||||
return '¥' + price.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// 方法
|
||||
// 添加参会人员
|
||||
const addPerson = () => {
|
||||
biddingInfo.value.attendees.push({
|
||||
role: '参会人员',
|
||||
name: '',
|
||||
phone: ''
|
||||
})
|
||||
ElMessage.success('已添加参会人员,请填写信息')
|
||||
}
|
||||
|
||||
// 删除参会人员
|
||||
const removePerson = (index) => {
|
||||
ElMessageBox.confirm('确定要删除这个参会人员吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
biddingInfo.value.attendees.splice(index, 1)
|
||||
ElMessage.success('删除成功')
|
||||
}).catch(() => {
|
||||
ElMessage.info('已取消删除')
|
||||
})
|
||||
}
|
||||
|
||||
// 添加投标单位
|
||||
const addBidder = () => {
|
||||
const newRank = biddingInfo.value.record.allBidders.length + 1
|
||||
biddingInfo.value.record.allBidders.push({
|
||||
rank: newRank,
|
||||
company: '',
|
||||
price: 0,
|
||||
workDays: 60,
|
||||
isOur: false
|
||||
})
|
||||
ElMessage.success('已添加投标单位,请填写信息')
|
||||
}
|
||||
|
||||
// 删除投标单位
|
||||
const removeBidder = (index) => {
|
||||
ElMessageBox.confirm('确定要删除这个投标单位吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
biddingInfo.value.record.allBidders.splice(index, 1)
|
||||
// 重新排序
|
||||
biddingInfo.value.record.allBidders.forEach((bidder, idx) => {
|
||||
bidder.rank = idx + 1
|
||||
})
|
||||
ElMessage.success('删除成功')
|
||||
}).catch(() => {
|
||||
ElMessage.info('已取消删除')
|
||||
})
|
||||
}
|
||||
|
||||
// 标记为我方
|
||||
const markAsOur = (index) => {
|
||||
// 先清除所有的我方标记
|
||||
biddingInfo.value.record.allBidders.forEach(bidder => {
|
||||
bidder.isOur = false
|
||||
})
|
||||
// 标记当前为我方
|
||||
biddingInfo.value.record.allBidders[index].isOur = true
|
||||
ElMessage.success('已标记为我方')
|
||||
}
|
||||
|
||||
// 保存开标记录
|
||||
const saveBiddingRecord = () => {
|
||||
// 验证数据
|
||||
const hasEmpty = biddingInfo.value.record.allBidders.some(
|
||||
b => !b.company || b.price <= 0
|
||||
)
|
||||
|
||||
if (hasEmpty) {
|
||||
ElMessage.warning('请完整填写所有投标单位信息')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('开标记录已保存')
|
||||
emit('update')
|
||||
}
|
||||
|
||||
const downloadRecord = () => {
|
||||
ElMessage.success('开标记录表已下载')
|
||||
}
|
||||
|
||||
const uploadPhoto = () => {
|
||||
ElMessage.info('打开照片上传对话框')
|
||||
}
|
||||
|
||||
const uploadVideo = () => {
|
||||
ElMessage.info('打开视频上传对话框')
|
||||
}
|
||||
|
||||
const playVideo = (video) => {
|
||||
ElMessage.info(`播放视频: ${video.description}`)
|
||||
}
|
||||
|
||||
const editBiddingInfo = () => {
|
||||
ElMessage.info('进入编辑模式')
|
||||
emit('update')
|
||||
}
|
||||
|
||||
const setReminder = () => {
|
||||
ElMessage.success('开标提醒已设置')
|
||||
}
|
||||
|
||||
const exportReport = () => {
|
||||
ElMessage.success('开标报告已导出')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bidding-info-panel {
|
||||
.section-card {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
|
||||
.info-section {
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
|
||||
&.note {
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.action-links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&.editable-person {
|
||||
.person-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.record-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.summary-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
|
||||
&.highlight {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
&.price {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bidders-table {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.our-company-input) {
|
||||
.el-input__inner {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.our-price-input) {
|
||||
.el-input-number__decrease,
|
||||
.el-input-number__increase {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
input {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.our-company {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.our-price {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.media-item {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid #e5e7eb;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #a78bfa;
|
||||
box-shadow: 0 4px 12px rgba(167, 139, 250, 0.1);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.media-desc {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.media-time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-list {
|
||||
.video-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.video-icon {
|
||||
font-size: 32px;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.video-desc {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
599
江西城市生命线-可交互原型/frontend/src/components/project/FileSubmission.vue
Normal file
599
江西城市生命线-可交互原型/frontend/src/components/project/FileSubmission.vue
Normal file
@@ -0,0 +1,599 @@
|
||||
<template>
|
||||
<div class="file-submission-panel">
|
||||
<!-- 已生成标书文件 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Document /></el-icon>
|
||||
已生成标书文件
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 商务标书 -->
|
||||
<div class="document-item">
|
||||
<div class="doc-icon">📑</div>
|
||||
<div class="doc-content">
|
||||
<div class="doc-header">
|
||||
<h4>商务标书</h4>
|
||||
<el-tag type="success" size="large">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
{{ businessDoc.status }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="doc-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">文件名称:</span>
|
||||
<span class="value">{{ businessDoc.fileName }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">生成时间:</span>
|
||||
<span class="value">{{ businessDoc.generateTime }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">文件大小:</span>
|
||||
<span class="value">{{ businessDoc.fileSize }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">页数:</span>
|
||||
<span class="value">{{ businessDoc.pages }}页</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-actions">
|
||||
<el-button size="small" @click="previewDoc(businessDoc)">
|
||||
<el-icon><View /></el-icon>
|
||||
预览
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click="downloadDoc(businessDoc)">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button size="small" @click="regenerateDoc(businessDoc)">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- 技术标书 -->
|
||||
<div class="document-item">
|
||||
<div class="doc-icon">📘</div>
|
||||
<div class="doc-content">
|
||||
<div class="doc-header">
|
||||
<h4>技术标书</h4>
|
||||
<el-tag type="success" size="large">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
{{ technicalDoc.status }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="doc-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">文件名称:</span>
|
||||
<span class="value">{{ technicalDoc.fileName }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">生成时间:</span>
|
||||
<span class="value">{{ technicalDoc.generateTime }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">文件大小:</span>
|
||||
<span class="value">{{ technicalDoc.fileSize }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">页数:</span>
|
||||
<span class="value">{{ technicalDoc.pages }}页</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-actions">
|
||||
<el-button size="small" @click="previewDoc(technicalDoc)">
|
||||
<el-icon><View /></el-icon>
|
||||
预览
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click="downloadDoc(technicalDoc)">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button size="small" @click="regenerateDoc(technicalDoc)">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</el-card>
|
||||
|
||||
<!-- 关键时间节点 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Calendar /></el-icon>
|
||||
关键时间节点
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="(milestone, index) in timeMilestones"
|
||||
:key="index"
|
||||
:timestamp="milestone.time"
|
||||
:type="milestone.type"
|
||||
>
|
||||
<div class="milestone-content">
|
||||
<span>{{ milestone.label }}</span>
|
||||
<el-tag v-if="milestone.countdown" type="warning" size="small">
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ milestone.countdown }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
|
||||
<!-- 提交前检查清单 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Warning /></el-icon>
|
||||
提交前检查清单
|
||||
</h3>
|
||||
<div class="checklist-progress">
|
||||
<span>完成度: {{ checklistProgress }}%</span>
|
||||
<el-progress
|
||||
:percentage="checklistProgress"
|
||||
:color="checklistProgress === 100 ? '#10b981' : '#f59e0b'"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="checklist">
|
||||
<el-checkbox-group v-model="checkedItems" @change="updateProgress">
|
||||
<div
|
||||
v-for="item in checklistItems"
|
||||
:key="item.id"
|
||||
class="checklist-item"
|
||||
>
|
||||
<el-checkbox :label="item.id">
|
||||
<span v-if="!item.editing">{{ item.label }}</span>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="item.label"
|
||||
size="small"
|
||||
style="width: 300px;"
|
||||
@blur="item.editing = false"
|
||||
@keyup.enter="item.editing = false"
|
||||
/>
|
||||
</el-checkbox>
|
||||
<div class="item-actions">
|
||||
<el-tag v-if="item.required" type="danger" size="small">必须</el-tag>
|
||||
<el-button
|
||||
v-if="!item.editing"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editItem(item)"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="deleteItem(item.id)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
|
||||
<!-- 添加新项按钮 -->
|
||||
<el-button
|
||||
class="add-item-btn"
|
||||
type="primary"
|
||||
plain
|
||||
@click="showAddItemDialog = true"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加检查项
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
:disabled="checklistProgress < 100"
|
||||
@click="markAsSubmitted"
|
||||
>
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
标记为已提交
|
||||
</el-button>
|
||||
<el-button size="large" @click="setReminder">
|
||||
<el-icon><Bell /></el-icon>
|
||||
设置提醒
|
||||
</el-button>
|
||||
<el-button size="large" @click="exportChecklist">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出提交清单
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加检查项对话框 -->
|
||||
<el-dialog
|
||||
v-model="showAddItemDialog"
|
||||
title="添加检查项"
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="newItemForm" label-width="80px">
|
||||
<el-form-item label="检查项">
|
||||
<el-input
|
||||
v-model="newItemForm.label"
|
||||
placeholder="请输入检查项内容"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否必须">
|
||||
<el-switch v-model="newItemForm.required" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddItemDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="addNewItem">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Document, CircleCheck, View, Download, Refresh,
|
||||
Calendar, Warning, Edit, Bell, Clock, Delete, Plus
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
// 文档数据
|
||||
const businessDoc = ref({
|
||||
fileName: '某数据中心发电机组采购-商务标.pdf',
|
||||
generateTime: '2024-12-08 14:30:25',
|
||||
fileSize: '15.6 MB',
|
||||
pages: 85,
|
||||
status: '已完成'
|
||||
})
|
||||
|
||||
const technicalDoc = ref({
|
||||
fileName: '某数据中心发电机组采购-技术标.pdf',
|
||||
generateTime: '2024-12-08 15:45:10',
|
||||
fileSize: '28.3 MB',
|
||||
pages: 156,
|
||||
status: '已完成'
|
||||
})
|
||||
|
||||
// 时间节点
|
||||
const timeMilestones = ref([
|
||||
{
|
||||
label: '标书生成开始',
|
||||
time: '2024-12-08 09:00:00',
|
||||
type: 'success'
|
||||
},
|
||||
{
|
||||
label: '标书生成完成',
|
||||
time: '2024-12-09 10:30:00',
|
||||
type: 'success'
|
||||
},
|
||||
{
|
||||
label: '内部审核时间',
|
||||
time: '2024-12-09 14:00:00 - 16:30:00',
|
||||
type: 'success'
|
||||
},
|
||||
{
|
||||
label: '投标截止时间',
|
||||
time: '2024-12-14 17:00:00',
|
||||
type: 'warning',
|
||||
countdown: '还剩5天3小时'
|
||||
},
|
||||
{
|
||||
label: '提交方式',
|
||||
time: '线上提交 + 纸质文件',
|
||||
type: 'info'
|
||||
}
|
||||
])
|
||||
|
||||
// 检查清单
|
||||
const checklistItems = ref([
|
||||
{ id: 1, label: '商务标完整性检查', required: true, editing: false },
|
||||
{ id: 2, label: '技术标完整性检查', required: true, editing: false },
|
||||
{ id: 3, label: '保证金缴纳凭证', required: true, editing: false },
|
||||
{ id: 4, label: '资质证书齐全', required: true, editing: false },
|
||||
{ id: 5, label: '授权委托书签字盖章', required: false, editing: false },
|
||||
{ id: 6, label: '纸质文件打印装订', required: false, editing: false },
|
||||
{ id: 7, label: '投标文件密封完好', required: false, editing: false }
|
||||
])
|
||||
|
||||
const checkedItems = ref([1, 2, 3, 4, 5])
|
||||
|
||||
const checklistProgress = computed(() => {
|
||||
return Math.round((checkedItems.value.length / checklistItems.value.length) * 100)
|
||||
})
|
||||
|
||||
// 新增检查项相关
|
||||
const showAddItemDialog = ref(false)
|
||||
const newItemForm = ref({
|
||||
label: '',
|
||||
required: false
|
||||
})
|
||||
|
||||
let nextItemId = 8
|
||||
|
||||
// 方法
|
||||
const previewDoc = (doc) => {
|
||||
ElMessage.info(`预览文件: ${doc.fileName}`)
|
||||
}
|
||||
|
||||
const downloadDoc = (doc) => {
|
||||
ElMessage.success(`下载文件: ${doc.fileName}`)
|
||||
}
|
||||
|
||||
const regenerateDoc = (doc) => {
|
||||
ElMessage.info(`重新生成: ${doc.fileName}`)
|
||||
}
|
||||
|
||||
const updateProgress = () => {
|
||||
// 进度更新逻辑
|
||||
}
|
||||
|
||||
const markAsSubmitted = () => {
|
||||
ElMessage.success('项目已标记为已提交')
|
||||
emit('update')
|
||||
}
|
||||
|
||||
const setReminder = () => {
|
||||
ElMessage.success('提醒已设置')
|
||||
}
|
||||
|
||||
const exportChecklist = () => {
|
||||
ElMessage.success('提交清单已导出')
|
||||
}
|
||||
|
||||
// 编辑检查项
|
||||
const editItem = (item) => {
|
||||
item.editing = true
|
||||
}
|
||||
|
||||
// 删除检查项
|
||||
const deleteItem = (id) => {
|
||||
ElMessageBox.confirm('确定要删除这个检查项吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
const index = checklistItems.value.findIndex(item => item.id === id)
|
||||
if (index > -1) {
|
||||
checklistItems.value.splice(index, 1)
|
||||
// 同时从已选中项中移除
|
||||
const checkedIndex = checkedItems.value.indexOf(id)
|
||||
if (checkedIndex > -1) {
|
||||
checkedItems.value.splice(checkedIndex, 1)
|
||||
}
|
||||
ElMessage.success('删除成功')
|
||||
}
|
||||
}).catch(() => {
|
||||
ElMessage.info('已取消删除')
|
||||
})
|
||||
}
|
||||
|
||||
// 添加新检查项
|
||||
const addNewItem = () => {
|
||||
if (!newItemForm.value.label.trim()) {
|
||||
ElMessage.warning('请输入检查项内容')
|
||||
return
|
||||
}
|
||||
|
||||
checklistItems.value.push({
|
||||
id: nextItemId++,
|
||||
label: newItemForm.value.label,
|
||||
required: newItemForm.value.required,
|
||||
editing: false
|
||||
})
|
||||
|
||||
ElMessage.success('添加成功')
|
||||
showAddItemDialog.value = false
|
||||
newItemForm.value = {
|
||||
label: '',
|
||||
required: false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file-submission-panel {
|
||||
.section-card {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checklist-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.document-item {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #a78bfa;
|
||||
box-shadow: 0 4px 12px rgba(167, 139, 250, 0.1);
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
font-size: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-content {
|
||||
flex: 1;
|
||||
|
||||
.doc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
|
||||
&.price-amount {
|
||||
color: #ef4444;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.milestone-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checklist {
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
|
||||
.item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.el-tag {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-item-btn {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
border-style: dashed;
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
277
江西城市生命线-可交互原型/frontend/src/components/project/ProjectDetail.vue
Normal file
277
江西城市生命线-可交互原型/frontend/src/components/project/ProjectDetail.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="project-detail-panel">
|
||||
<!-- 项目头部 -->
|
||||
<div class="project-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="backToList" link>
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回列表
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<h2>{{ project.name }}</h2>
|
||||
<el-tag :type="getStatusTagType(project.status)" size="large">
|
||||
{{ getStatusText(project.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button @click="exportProject">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出项目
|
||||
</el-button>
|
||||
<el-button type="primary" @click="editProject">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑项目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab切换 -->
|
||||
<el-tabs v-model="activeTab" class="project-tabs">
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<div class="basic-info-panel">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="项目名称">
|
||||
{{ project.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="招标单位">
|
||||
{{ project.client || 'XX科技有限公司' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ project.createTime }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="当前进度">
|
||||
{{ project.progress }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="项目状态">
|
||||
<el-tag :type="getStatusTagType(project.status)">
|
||||
{{ getStatusText(project.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="项目经理">
|
||||
张三
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="project-timeline">
|
||||
<h3>项目流程</h3>
|
||||
<el-steps :active="getActiveStep(project.progress)" align-center>
|
||||
<el-step
|
||||
v-for="(step, index) in projectSteps"
|
||||
:key="index"
|
||||
:title="step.title"
|
||||
:description="step.description"
|
||||
:status="getStepStatus(project.progress, step.key)"
|
||||
>
|
||||
<template #icon>
|
||||
<el-icon v-if="getStepStatus(project.progress, step.key) === 'finish'">
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
<el-icon v-else-if="getStepStatus(project.progress, step.key) === 'process'">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</template>
|
||||
</el-step>
|
||||
</el-steps>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="submission">
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
<el-icon><Document /></el-icon>
|
||||
文件提交
|
||||
</span>
|
||||
</template>
|
||||
<FileSubmission :project="project" @update="handleUpdate" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="bidding">
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
<el-icon><Bell /></el-icon>
|
||||
开标信息
|
||||
</span>
|
||||
</template>
|
||||
<BiddingInfo :project="project" @update="handleUpdate" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="result">
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
<el-icon><Trophy /></el-icon>
|
||||
结果公示
|
||||
</span>
|
||||
</template>
|
||||
<ResultAnnouncement :project="project" @update="handleUpdate" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
ArrowLeft, Download, Edit, Document, Bell, Trophy,
|
||||
CircleCheck, Loading
|
||||
} from '@element-plus/icons-vue'
|
||||
import FileSubmission from './FileSubmission.vue'
|
||||
import BiddingInfo from './BiddingInfo.vue'
|
||||
import ResultAnnouncement from './ResultAnnouncement.vue'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back', 'update'])
|
||||
|
||||
const activeTab = ref('basic')
|
||||
|
||||
// 项目流程步骤
|
||||
const projectSteps = [
|
||||
{ key: 'info-extract', title: '信息提取', description: '招标文件解析' },
|
||||
{ key: 'doc-prepare', title: '文件准备', description: '标书文件生成' },
|
||||
{ key: 'submission', title: '文件提交', description: '投标文件提交' },
|
||||
{ key: 'bidding', title: '开标', description: '开标会议' },
|
||||
{ key: 'evaluation', title: '评标', description: '评标过程' },
|
||||
{ key: 'result', title: '结果公示', description: '中标公示' }
|
||||
]
|
||||
|
||||
// 状态相关
|
||||
const getStatusTagType = (status) => {
|
||||
const typeMap = {
|
||||
'ongoing': '',
|
||||
'pending': 'warning',
|
||||
'won': 'success',
|
||||
'lost': 'info',
|
||||
'failed': 'danger'
|
||||
}
|
||||
return typeMap[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const textMap = {
|
||||
'ongoing': '进行中',
|
||||
'pending': '待提交',
|
||||
'won': '已中标',
|
||||
'lost': '未中标',
|
||||
'failed': '流标',
|
||||
'信息提取': '信息提取',
|
||||
'文件准备': '文件准备',
|
||||
'文件提交': '文件提交',
|
||||
'开标': '开标',
|
||||
'评标': '评标',
|
||||
'结果公示': '结果公示'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
const getActiveStep = (progress) => {
|
||||
const stepMap = {
|
||||
'信息提取': 0,
|
||||
'文件准备': 1,
|
||||
'文件提交': 2,
|
||||
'开标': 3,
|
||||
'评标': 4,
|
||||
'结果公示': 5
|
||||
}
|
||||
return stepMap[progress] || 0
|
||||
}
|
||||
|
||||
const getStepStatus = (progress, stepKey) => {
|
||||
const currentStep = getActiveStep(progress)
|
||||
const stepIndex = projectSteps.findIndex(s => s.key === stepKey)
|
||||
|
||||
if (stepIndex < currentStep) return 'finish'
|
||||
if (stepIndex === currentStep) return 'process'
|
||||
return 'wait'
|
||||
}
|
||||
|
||||
// 方法
|
||||
const backToList = () => {
|
||||
emit('back')
|
||||
}
|
||||
|
||||
const exportProject = () => {
|
||||
ElMessage.success('项目信息导出成功')
|
||||
}
|
||||
|
||||
const editProject = () => {
|
||||
ElMessage.info('进入编辑模式')
|
||||
}
|
||||
|
||||
const handleUpdate = () => {
|
||||
ElMessage.success('信息更新成功')
|
||||
emit('update')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-detail-panel {
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
|
||||
h2 {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.project-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.basic-info-panel {
|
||||
.project-timeline {
|
||||
margin-top: 32px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
227
江西城市生命线-可交互原型/frontend/src/components/project/README.md
Normal file
227
江西城市生命线-可交互原型/frontend/src/components/project/README.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 项目管理组件使用说明
|
||||
|
||||
## 组件概述
|
||||
|
||||
本目录包含招标助手系统的项目管理功能组件,实现了进行中项目的三个核心功能模块:
|
||||
|
||||
1. **文件提交模块** (`FileSubmission.vue`)
|
||||
2. **开标信息模块** (`BiddingInfo.vue`)
|
||||
3. **结果公示模块** (`ResultAnnouncement.vue`)
|
||||
4. **项目详情主组件** (`ProjectDetail.vue`)
|
||||
|
||||
## 组件结构
|
||||
|
||||
```
|
||||
components/project/
|
||||
├── ProjectDetail.vue # 项目详情主组件
|
||||
├── FileSubmission.vue # 文件提交模块
|
||||
├── BiddingInfo.vue # 开标信息模块
|
||||
├── ResultAnnouncement.vue # 结果公示模块
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 在 BiddingView.vue 中集成
|
||||
|
||||
在 `BiddingView.vue` 的项目详情部分引入 `ProjectDetail` 组件:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-if="activeNav === 'project-ongoing'" class="panel">
|
||||
<!-- 项目列表 -->
|
||||
<div v-if="!selectedProject" class="project-list">
|
||||
<!-- 项目卡片列表 -->
|
||||
</div>
|
||||
|
||||
<!-- 项目详情 -->
|
||||
<ProjectDetail
|
||||
v-else
|
||||
:project="selectedProject"
|
||||
@back="selectedProject = null"
|
||||
@update="handleProjectUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProjectDetail from '@/components/project/ProjectDetail.vue'
|
||||
|
||||
const selectedProject = ref(null)
|
||||
|
||||
const handleProjectUpdate = () => {
|
||||
// 刷新项目数据
|
||||
console.log('项目已更新')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. 单独使用各个子组件
|
||||
|
||||
如果需要单独使用某个功能模块:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 只使用文件提交模块 -->
|
||||
<FileSubmission :project="projectData" @update="handleUpdate" />
|
||||
|
||||
<!-- 只使用开标信息模块 -->
|
||||
<BiddingInfo :project="projectData" @update="handleUpdate" />
|
||||
|
||||
<!-- 只使用结果公示模块 -->
|
||||
<ResultAnnouncement :project="projectData" @update="handleUpdate" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FileSubmission from '@/components/project/FileSubmission.vue'
|
||||
import BiddingInfo from '@/components/project/BiddingInfo.vue'
|
||||
import ResultAnnouncement from '@/components/project/ResultAnnouncement.vue'
|
||||
</script>
|
||||
```
|
||||
|
||||
## 组件 Props
|
||||
|
||||
### ProjectDetail
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| project | Object | 是 | 项目对象 |
|
||||
|
||||
### FileSubmission / BiddingInfo / ResultAnnouncement
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| project | Object | 是 | 项目对象 |
|
||||
|
||||
## 组件 Events
|
||||
|
||||
### ProjectDetail
|
||||
|
||||
| 事件名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| back | - | 返回项目列表 |
|
||||
| update | - | 项目信息已更新 |
|
||||
|
||||
### FileSubmission / BiddingInfo / ResultAnnouncement
|
||||
|
||||
| 事件名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| update | - | 模块信息已更新 |
|
||||
|
||||
## 数据结构
|
||||
|
||||
### 项目对象 (Project)
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: Number, // 项目ID
|
||||
name: String, // 项目名称
|
||||
client: String, // 招标单位
|
||||
status: String, // 项目状态: ongoing/pending/won/lost/failed
|
||||
progress: String, // 当前进度: 信息提取/文件准备/文件提交/开标/评标/结果公示
|
||||
createTime: String, // 创建时间
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
项目管理相关的 API 接口已在 `src/api/project.js` 中定义:
|
||||
|
||||
```javascript
|
||||
import { projectApi } from '@/api/project'
|
||||
|
||||
// 获取项目详情
|
||||
const project = await projectApi.getProjectDetail(projectId)
|
||||
|
||||
// 更新文件提交信息
|
||||
await projectApi.updateSubmission(projectId, data)
|
||||
|
||||
// 更新开标信息
|
||||
await projectApi.updateBiddingInfo(projectId, data)
|
||||
|
||||
// 更新结果公示
|
||||
await projectApi.updateResult(projectId, data)
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 文件提交模块
|
||||
|
||||
- ✅ 展示商务标、技术标、报价文件详细信息
|
||||
- ✅ 文件预览、下载、重新生成
|
||||
- ✅ 关键时间节点时间轴展示
|
||||
- ✅ 提交前检查清单(7项)
|
||||
- ✅ 完成度进度条
|
||||
- ✅ 标记为已提交功能
|
||||
|
||||
### 开标信息模块
|
||||
|
||||
- ✅ 开标时间倒计时提醒
|
||||
- ✅ 开标地点地图导航
|
||||
- ✅ 参会人员信息管理
|
||||
- ✅ 开标流程时间轴
|
||||
- ✅ 开标记录详细展示
|
||||
- ✅ 所有投标单位报价对比表
|
||||
- ✅ 现场照片/视频上传
|
||||
|
||||
### 结果公示模块
|
||||
|
||||
- ✅ 三种结果类型切换(中标/未中标/流标)
|
||||
- ✅ **中标情况**:
|
||||
- 公示信息、中标详情
|
||||
- 综合得分展示
|
||||
- 合同签订信息
|
||||
- 项目执行进度跟踪
|
||||
- ✅ **未中标情况**:
|
||||
- 得分差距分析
|
||||
- 失败原因总结
|
||||
- 改进建议列表
|
||||
- ✅ **流标情况**:
|
||||
- 流标原因分析
|
||||
- 后续处理方案
|
||||
- 保证金退还状态
|
||||
|
||||
## 样式定制
|
||||
|
||||
所有组件都使用 SCSS 编写样式,支持自定义主题色:
|
||||
|
||||
```scss
|
||||
// 主色调
|
||||
$primary-color: #7c3aed;
|
||||
$success-color: #10b981;
|
||||
$warning-color: #f59e0b;
|
||||
$danger-color: #ef4444;
|
||||
|
||||
// 可以在组件中覆盖这些变量
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **Element Plus 依赖**: 组件依赖 Element Plus UI 库,确保已正确安装
|
||||
2. **图标组件**: 使用 Element Plus Icons,需要单独引入
|
||||
3. **响应式设计**: 组件已适配不同屏幕尺寸
|
||||
4. **数据模拟**: 当前使用模拟数据,实际使用时需连接后端 API
|
||||
5. **权限控制**: 某些操作可能需要权限验证
|
||||
|
||||
## 开发计划
|
||||
|
||||
- [ ] 添加文件上传进度显示
|
||||
- [ ] 实现实时倒计时功能
|
||||
- [ ] 添加数据导出为 Excel 功能
|
||||
- [ ] 实现地图导航集成
|
||||
- [ ] 添加消息通知提醒
|
||||
- [ ] 优化移动端显示
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2024-12-08)
|
||||
- ✅ 完成文件提交模块
|
||||
- ✅ 完成开标信息模块
|
||||
- ✅ 完成结果公示模块
|
||||
- ✅ 完成项目详情主组件
|
||||
- ✅ 创建 API 服务文件
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题,请联系开发团队或查看项目文档。
|
||||
1028
江西城市生命线-可交互原型/frontend/src/components/project/ResultAnnouncement.vue
Normal file
1028
江西城市生命线-可交互原型/frontend/src/components/project/ResultAnnouncement.vue
Normal file
File diff suppressed because it is too large
Load Diff
405
江西城市生命线-可交互原型/frontend/src/components/project/开标信息编辑功能说明.md
Normal file
405
江西城市生命线-可交互原型/frontend/src/components/project/开标信息编辑功能说明.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# 开标信息编辑功能说明
|
||||
|
||||
## 📝 功能概述
|
||||
|
||||
开标会议信息现已支持完整的编辑功能,用户可以自定义开标时间、地点、参会人员等信息。
|
||||
|
||||
## ✨ 新增功能
|
||||
|
||||
### 1. **编辑开标时间** 📅
|
||||
|
||||
**日期选择**:
|
||||
- 使用日期选择器选择开标日期
|
||||
- 格式:YYYY-MM-DD
|
||||
- 宽度:200px
|
||||
|
||||
**时间选择**:
|
||||
- 使用时间选择器选择开标时间
|
||||
- 格式:HH:mm:ss
|
||||
- 宽度:150px
|
||||
|
||||
### 2. **编辑开标地点** 📍
|
||||
|
||||
**可编辑字段**:
|
||||
- **地址**: 文本输入框(300px)
|
||||
- **联系电话**: 文本输入框(200px)
|
||||
- **备注**: 多行文本框(2行,300px)
|
||||
|
||||
**删除的功能**:
|
||||
- ❌ 查看地图按钮
|
||||
- ❌ 一键导航按钮
|
||||
|
||||
### 3. **编辑参会人员** 👥
|
||||
|
||||
**编辑功能**:
|
||||
- 每个参会人员可编辑姓名和电话
|
||||
- 姓名输入框:120px
|
||||
- 电话输入框:150px
|
||||
|
||||
**删除功能**:
|
||||
- 每个参会人员旁边有"删除"按钮
|
||||
- 点击删除会弹出确认对话框
|
||||
- 确认后删除该参会人员
|
||||
|
||||
**添加功能**:
|
||||
- 底部有"添加参会人员"按钮
|
||||
- 点击后添加新的空白参会人员
|
||||
- 默认角色为"参会人员"
|
||||
|
||||
## 🎨 界面布局
|
||||
|
||||
### 开标时间区域
|
||||
```
|
||||
📅 开标时间
|
||||
├─ 日期: [日期选择器: 2024-12-15]
|
||||
├─ 时间: [时间选择器: 09:30:00]
|
||||
├─ 倒计时: [还剩6天2小时15分钟]
|
||||
└─ 状态: [🟡 待开标]
|
||||
```
|
||||
|
||||
### 开标地点区域
|
||||
```
|
||||
📍 开标地点
|
||||
├─ 地址: [输入框: XX市公共资源交易中心3楼开标室A]
|
||||
├─ 联系电话: [输入框: 0791-88888888]
|
||||
└─ 备注: [文本框: 需携带法人授权委托书原件及身份证]
|
||||
```
|
||||
|
||||
### 参会人员区域
|
||||
```
|
||||
👥 参会人员
|
||||
├─ 投标代表: [姓名: 张三] [电话: 13800138000] [删除]
|
||||
├─ 技术负责人: [姓名: 李四] [电话: 13900139000] [删除]
|
||||
├─ 备选人员: [姓名: 王五] [电话: 13700137000] [删除]
|
||||
└─ [+ 添加参会人员]
|
||||
```
|
||||
|
||||
## 💻 代码实现
|
||||
|
||||
### 模板结构
|
||||
|
||||
```vue
|
||||
<!-- 开标时间 -->
|
||||
<div class="info-item">
|
||||
<span class="label">日期:</span>
|
||||
<el-date-picker
|
||||
v-model="biddingInfo.dateValue"
|
||||
type="date"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="label">时间:</span>
|
||||
<el-time-picker
|
||||
v-model="biddingInfo.timeValue"
|
||||
format="HH:mm:ss"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 开标地点 -->
|
||||
<div class="info-item">
|
||||
<span class="label">地址:</span>
|
||||
<el-input
|
||||
v-model="biddingInfo.location.address"
|
||||
placeholder="请输入开标地址"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 参会人员 -->
|
||||
<div
|
||||
v-for="(person, index) in biddingInfo.attendees"
|
||||
:key="index"
|
||||
class="info-item editable-person"
|
||||
>
|
||||
<span class="label">{{ person.role }}:</span>
|
||||
<div class="person-inputs">
|
||||
<el-input v-model="person.name" placeholder="姓名" />
|
||||
<el-input v-model="person.phone" placeholder="电话" />
|
||||
<el-button @click="removePerson(index)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button @click="addPerson">
|
||||
添加参会人员
|
||||
</el-button>
|
||||
```
|
||||
|
||||
### 数据结构
|
||||
|
||||
```javascript
|
||||
const biddingInfo = ref({
|
||||
dateValue: '2024-12-15', // 日期选择器绑定值
|
||||
timeValue: '09:30:00', // 时间选择器绑定值
|
||||
countdown: '还剩6天2小时15分钟',
|
||||
statusText: '待开标',
|
||||
|
||||
location: {
|
||||
address: 'XX市公共资源交易中心3楼开标室A',
|
||||
phone: '0791-88888888',
|
||||
note: '需携带法人授权委托书原件及身份证'
|
||||
},
|
||||
|
||||
attendees: [
|
||||
{ role: '投标代表', name: '张三', phone: '13800138000' },
|
||||
{ role: '技术负责人', name: '李四', phone: '13900139000' },
|
||||
{ role: '备选人员', name: '王五', phone: '13700137000' }
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
### 核心方法
|
||||
|
||||
#### 1. 添加参会人员
|
||||
```javascript
|
||||
const addPerson = () => {
|
||||
biddingInfo.value.attendees.push({
|
||||
role: '参会人员',
|
||||
name: '',
|
||||
phone: ''
|
||||
})
|
||||
ElMessage.success('已添加参会人员,请填写信息')
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 删除参会人员
|
||||
```javascript
|
||||
const removePerson = (index) => {
|
||||
ElMessageBox.confirm('确定要删除这个参会人员吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
biddingInfo.value.attendees.splice(index, 1)
|
||||
ElMessage.success('删除成功')
|
||||
}).catch(() => {
|
||||
ElMessage.info('已取消删除')
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 样式实现
|
||||
|
||||
```scss
|
||||
.info-item {
|
||||
&.editable-person {
|
||||
.person-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 已删除的功能
|
||||
|
||||
### 地图导航功能
|
||||
```javascript
|
||||
// ❌ 已删除
|
||||
const viewMap = () => {
|
||||
ElMessage.info('打开地图查看开标地点')
|
||||
}
|
||||
|
||||
const navigate = () => {
|
||||
ElMessage.info('启动导航到开标地点')
|
||||
}
|
||||
```
|
||||
|
||||
**删除原因**:
|
||||
- 简化界面,减少不必要的功能
|
||||
- 用户可以直接复制地址使用第三方地图应用
|
||||
|
||||
## 📊 组件导入变更
|
||||
|
||||
### 新增导入
|
||||
```javascript
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
```
|
||||
|
||||
### 删除导入
|
||||
```javascript
|
||||
// ❌ 已删除
|
||||
import { Location, Position } from '@element-plus/icons-vue'
|
||||
```
|
||||
|
||||
## 🎯 使用场景
|
||||
|
||||
### 场景 1: 修改开标时间
|
||||
项目经理收到通知,开标时间延期。
|
||||
|
||||
**操作步骤**:
|
||||
1. 点击日期选择器,选择新的日期
|
||||
2. 点击时间选择器,选择新的时间
|
||||
3. 系统自动更新倒计时
|
||||
|
||||
### 场景 2: 更新开标地点
|
||||
开标地点临时变更。
|
||||
|
||||
**操作步骤**:
|
||||
1. 在地址输入框中修改新地址
|
||||
2. 更新联系电话
|
||||
3. 在备注中添加变更说明
|
||||
|
||||
### 场景 3: 调整参会人员
|
||||
技术负责人临时有事,需要更换。
|
||||
|
||||
**操作步骤**:
|
||||
1. 找到"技术负责人"行
|
||||
2. 修改姓名和电话
|
||||
3. 或点击"删除"按钮移除
|
||||
4. 点击"添加参会人员"添加新人员
|
||||
|
||||
### 场景 4: 增加参会人员
|
||||
需要增加一名备用人员。
|
||||
|
||||
**操作步骤**:
|
||||
1. 点击"添加参会人员"按钮
|
||||
2. 填写新人员的姓名
|
||||
3. 填写新人员的电话
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 数据验证
|
||||
建议添加数据验证:
|
||||
|
||||
```javascript
|
||||
const addPerson = () => {
|
||||
// 检查是否有未填写完整的人员
|
||||
const hasEmpty = biddingInfo.value.attendees.some(
|
||||
p => !p.name || !p.phone
|
||||
)
|
||||
|
||||
if (hasEmpty) {
|
||||
ElMessage.warning('请先完成当前人员信息的填写')
|
||||
return
|
||||
}
|
||||
|
||||
biddingInfo.value.attendees.push({
|
||||
role: '参会人员',
|
||||
name: '',
|
||||
phone: ''
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 电话号码格式
|
||||
建议添加电话号码格式验证:
|
||||
|
||||
```javascript
|
||||
const validatePhone = (phone) => {
|
||||
const phoneReg = /^1[3-9]\d{9}$/
|
||||
return phoneReg.test(phone)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 最少参会人员
|
||||
建议至少保留一名参会人员:
|
||||
|
||||
```javascript
|
||||
const removePerson = (index) => {
|
||||
if (biddingInfo.value.attendees.length <= 1) {
|
||||
ElMessage.warning('至少需要保留一名参会人员')
|
||||
return
|
||||
}
|
||||
// ... 删除逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 保存提示
|
||||
修改后建议添加保存按钮:
|
||||
|
||||
```vue
|
||||
<el-button type="primary" @click="saveBiddingInfo">
|
||||
保存修改
|
||||
</el-button>
|
||||
```
|
||||
|
||||
## 🚀 后续优化建议
|
||||
|
||||
### 1. 角色选择
|
||||
为参会人员添加角色下拉选择:
|
||||
|
||||
```vue
|
||||
<el-select v-model="person.role" placeholder="选择角色">
|
||||
<el-option label="投标代表" value="投标代表" />
|
||||
<el-option label="技术负责人" value="技术负责人" />
|
||||
<el-option label="备选人员" value="备选人员" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
```
|
||||
|
||||
### 2. 批量导入
|
||||
支持从 Excel 批量导入参会人员:
|
||||
|
||||
```vue
|
||||
<el-upload
|
||||
accept=".xlsx,.xls"
|
||||
:on-change="importAttendees"
|
||||
>
|
||||
<el-button>批量导入</el-button>
|
||||
</el-upload>
|
||||
```
|
||||
|
||||
### 3. 历史记录
|
||||
记录开标信息的修改历史:
|
||||
|
||||
```javascript
|
||||
const history = ref([])
|
||||
|
||||
const saveBiddingInfo = () => {
|
||||
history.value.push({
|
||||
time: new Date(),
|
||||
data: JSON.parse(JSON.stringify(biddingInfo.value))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 自动提醒
|
||||
根据开标时间自动设置提醒:
|
||||
|
||||
```javascript
|
||||
watch(() => biddingInfo.value.dateValue, (newDate) => {
|
||||
// 自动设置提前1天、3小时、30分钟的提醒
|
||||
setReminders(newDate)
|
||||
})
|
||||
```
|
||||
|
||||
## 📸 效果对比
|
||||
|
||||
### 修改前
|
||||
```
|
||||
📍 开标地点
|
||||
地址: XX市公共资源交易中心3楼开标室A
|
||||
联系电话: 0791-88888888
|
||||
导航: [查看地图] [一键导航] ← 删除
|
||||
备注: 需携带法人授权委托书原件及身份证
|
||||
|
||||
👥 参会人员
|
||||
投标代表: 张三 (13800138000) ← 不可编辑
|
||||
技术负责人: 李四 (13900139000)
|
||||
备选人员: 王五 (13700137000)
|
||||
```
|
||||
|
||||
### 修改后
|
||||
```
|
||||
📍 开标地点
|
||||
地址: [输入框] ← 可编辑
|
||||
联系电话: [输入框]
|
||||
备注: [文本框]
|
||||
|
||||
👥 参会人员
|
||||
投标代表: [姓名] [电话] [删除] ← 可编辑、可删除
|
||||
技术负责人: [姓名] [电话] [删除]
|
||||
备选人员: [姓名] [电话] [删除]
|
||||
[+ 添加参会人员] ← 可添加
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**版本**: v1.4.0
|
||||
**更新时间**: 2024-12-08
|
||||
**功能状态**: ✅ 已实现
|
||||
**适用模块**: BiddingInfo.vue
|
||||
428
江西城市生命线-可交互原型/frontend/src/components/project/投标单位报价编辑功能说明.md
Normal file
428
江西城市生命线-可交互原型/frontend/src/components/project/投标单位报价编辑功能说明.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# 投标单位报价编辑功能说明
|
||||
|
||||
## 📝 功能概述
|
||||
|
||||
开标记录中的"所有投标单位报价"表格现已支持完整的编辑功能,用户可以添加、编辑、删除投标单位信息,并标记我方单位。
|
||||
|
||||
## ✨ 新增功能
|
||||
|
||||
### 1. **编辑投标单位信息** ✏️
|
||||
|
||||
**可编辑字段**:
|
||||
- **排名**: 数字输入框(1-99)
|
||||
- **投标单位**: 文本输入框
|
||||
- **报价金额**: 数字输入框(精度2位小数,步长1000)
|
||||
- **工期(天)**: 数字输入框(1-999)
|
||||
|
||||
### 2. **添加投标单位** ➕
|
||||
|
||||
**操作方式**:
|
||||
- 点击表格右上角的"添加投标单位"按钮
|
||||
- 自动添加新行,排名自动递增
|
||||
- 填写单位名称、报价金额、工期
|
||||
|
||||
**默认值**:
|
||||
```javascript
|
||||
{
|
||||
rank: 自动递增,
|
||||
company: '',
|
||||
price: 0,
|
||||
workDays: 60,
|
||||
isOur: false
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **删除投标单位** 🗑️
|
||||
|
||||
**操作方式**:
|
||||
- 点击每行的"删除"按钮
|
||||
- 弹出确认对话框
|
||||
- 确认后删除该投标单位
|
||||
- 自动重新排序(rank 重新计算)
|
||||
|
||||
### 4. **标记为我方** 🏷️
|
||||
|
||||
**操作方式**:
|
||||
- 点击"标记为我方"按钮
|
||||
- 自动清除其他单位的我方标记
|
||||
- 标记当前单位为我方
|
||||
- 我方单位名称和报价高亮显示
|
||||
|
||||
**视觉效果**:
|
||||
- 单位名称:紫色加粗(#7c3aed)
|
||||
- 报价金额:红色加粗(#ef4444)
|
||||
- 按钮文字:显示"我方"并禁用
|
||||
|
||||
### 5. **保存开标记录** 💾
|
||||
|
||||
**操作方式**:
|
||||
- 点击"保存开标记录"按钮
|
||||
- 验证所有单位信息是否完整
|
||||
- 保存成功后触发更新事件
|
||||
|
||||
**验证规则**:
|
||||
- 单位名称不能为空
|
||||
- 报价金额必须大于0
|
||||
|
||||
## 🎨 界面布局
|
||||
|
||||
### 表格头部
|
||||
```
|
||||
所有投标单位报价 [+ 添加投标单位]
|
||||
```
|
||||
|
||||
### 表格内容
|
||||
```
|
||||
┌────┬──────────────┬────────┬────────┬──────────────┐
|
||||
│排名│ 投标单位 │报价金额│工期(天)│ 操作 │
|
||||
├────┼──────────────┼────────┼────────┼──────────────┤
|
||||
│ 1 │[输入框] │[数字] │[数字] │[标记][删除] │
|
||||
│ 2 │[输入框] │[数字] │[数字] │[标记][删除] │
|
||||
│ 3 │[输入框]★ │[数字]★ │[数字] │[我方][删除] │
|
||||
└────┴──────────────┴────────┴────────┴──────────────┘
|
||||
★ = 我方单位高亮显示
|
||||
```
|
||||
|
||||
### 操作按钮
|
||||
```
|
||||
[✓ 保存开标记录] [📥 下载开标记录表]
|
||||
```
|
||||
|
||||
## 💻 代码实现
|
||||
|
||||
### 模板结构
|
||||
|
||||
```vue
|
||||
<div class="bidders-table">
|
||||
<!-- 表头 -->
|
||||
<div class="table-header">
|
||||
<h4>所有投标单位报价</h4>
|
||||
<el-button @click="addBidder">添加投标单位</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 可编辑表格 -->
|
||||
<el-table :data="biddingInfo.record.allBidders">
|
||||
<!-- 排名 -->
|
||||
<el-table-column label="排名">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.rank" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 投标单位 -->
|
||||
<el-table-column label="投标单位">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.company"
|
||||
:class="{ 'our-company-input': row.isOur }"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 报价金额 -->
|
||||
<el-table-column label="报价金额">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.price"
|
||||
:class="{ 'our-price-input': row.isOur }"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 工期 -->
|
||||
<el-table-column label="工期(天)">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.workDays" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 操作 -->
|
||||
<el-table-column label="操作">
|
||||
<template #default="{ row, $index }">
|
||||
<el-button
|
||||
@click="markAsOur($index)"
|
||||
:disabled="row.isOur"
|
||||
>
|
||||
{{ row.isOur ? '我方' : '标记为我方' }}
|
||||
</el-button>
|
||||
<el-button @click="removeBidder($index)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="record-actions">
|
||||
<el-button type="success" @click="saveBiddingRecord">
|
||||
保存开标记录
|
||||
</el-button>
|
||||
<el-button type="primary" @click="downloadRecord">
|
||||
下载开标记录表
|
||||
</el-button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 核心方法
|
||||
|
||||
#### 1. 添加投标单位
|
||||
```javascript
|
||||
const addBidder = () => {
|
||||
const newRank = biddingInfo.value.record.allBidders.length + 1
|
||||
biddingInfo.value.record.allBidders.push({
|
||||
rank: newRank,
|
||||
company: '',
|
||||
price: 0,
|
||||
workDays: 60,
|
||||
isOur: false
|
||||
})
|
||||
ElMessage.success('已添加投标单位,请填写信息')
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 删除投标单位
|
||||
```javascript
|
||||
const removeBidder = (index) => {
|
||||
ElMessageBox.confirm('确定要删除这个投标单位吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
biddingInfo.value.record.allBidders.splice(index, 1)
|
||||
// 重新排序
|
||||
biddingInfo.value.record.allBidders.forEach((bidder, idx) => {
|
||||
bidder.rank = idx + 1
|
||||
})
|
||||
ElMessage.success('删除成功')
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 标记为我方
|
||||
```javascript
|
||||
const markAsOur = (index) => {
|
||||
// 先清除所有的我方标记
|
||||
biddingInfo.value.record.allBidders.forEach(bidder => {
|
||||
bidder.isOur = false
|
||||
})
|
||||
// 标记当前为我方
|
||||
biddingInfo.value.record.allBidders[index].isOur = true
|
||||
ElMessage.success('已标记为我方')
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 保存开标记录
|
||||
```javascript
|
||||
const saveBiddingRecord = () => {
|
||||
// 验证数据
|
||||
const hasEmpty = biddingInfo.value.record.allBidders.some(
|
||||
b => !b.company || b.price <= 0
|
||||
)
|
||||
|
||||
if (hasEmpty) {
|
||||
ElMessage.warning('请完整填写所有投标单位信息')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('开标记录已保存')
|
||||
emit('update')
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 样式实现
|
||||
|
||||
### 表头样式
|
||||
```scss
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
```
|
||||
|
||||
### 我方单位高亮
|
||||
```scss
|
||||
// 单位名称高亮
|
||||
:deep(.our-company-input) {
|
||||
.el-input__inner {
|
||||
color: #7c3aed; // 紫色
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// 报价金额高亮
|
||||
:deep(.our-price-input) {
|
||||
input {
|
||||
color: #ef4444; // 红色
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 数字输入框
|
||||
```scss
|
||||
:deep(.el-input-number) {
|
||||
width: 100%; // 填满单元格
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 使用场景
|
||||
|
||||
### 场景 1: 录入开标结果
|
||||
开标会议结束后,需要录入所有投标单位的报价信息。
|
||||
|
||||
**操作步骤**:
|
||||
1. 点击"添加投标单位"按钮8次(假设8家单位)
|
||||
2. 依次填写每家单位的名称、报价、工期
|
||||
3. 找到我方单位,点击"标记为我方"
|
||||
4. 点击"保存开标记录"
|
||||
|
||||
### 场景 2: 修正错误信息
|
||||
发现某家单位的报价录入错误。
|
||||
|
||||
**操作步骤**:
|
||||
1. 找到对应的单位行
|
||||
2. 修改报价金额
|
||||
3. 点击"保存开标记录"
|
||||
|
||||
### 场景 3: 删除无效单位
|
||||
某家单位临时弃标,需要删除。
|
||||
|
||||
**操作步骤**:
|
||||
1. 找到对应的单位行
|
||||
2. 点击"删除"按钮
|
||||
3. 确认删除
|
||||
4. 系统自动重新排序
|
||||
5. 点击"保存开标记录"
|
||||
|
||||
### 场景 4: 调整排名
|
||||
根据最终评分调整排名顺序。
|
||||
|
||||
**操作步骤**:
|
||||
1. 修改各单位的排名数字
|
||||
2. 点击"保存开标记录"
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 数据验证
|
||||
保存时会验证:
|
||||
- 单位名称不能为空
|
||||
- 报价金额必须大于0
|
||||
|
||||
### 2. 自动排序
|
||||
删除单位后会自动重新排序,确保排名连续。
|
||||
|
||||
### 3. 唯一我方
|
||||
同时只能有一个单位被标记为"我方",标记新的会自动清除旧的。
|
||||
|
||||
### 4. 报价格式
|
||||
报价金额支持两位小数,步长为1000元。
|
||||
|
||||
## 🚀 后续优化建议
|
||||
|
||||
### 1. 批量导入
|
||||
支持从 Excel 批量导入投标单位信息:
|
||||
|
||||
```vue
|
||||
<el-upload
|
||||
accept=".xlsx,.xls"
|
||||
:on-change="importBidders"
|
||||
>
|
||||
<el-button>批量导入</el-button>
|
||||
</el-upload>
|
||||
```
|
||||
|
||||
### 2. 自动排序
|
||||
根据报价金额自动排序:
|
||||
|
||||
```javascript
|
||||
const autoSort = () => {
|
||||
biddingInfo.value.record.allBidders.sort((a, b) => a.price - b.price)
|
||||
biddingInfo.value.record.allBidders.forEach((bidder, idx) => {
|
||||
bidder.rank = idx + 1
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 数据导出
|
||||
导出为 Excel 格式:
|
||||
|
||||
```javascript
|
||||
const exportToExcel = () => {
|
||||
// 使用 xlsx 库导出
|
||||
const data = biddingInfo.value.record.allBidders.map(b => ({
|
||||
'排名': b.rank,
|
||||
'投标单位': b.company,
|
||||
'报价金额': b.price,
|
||||
'工期': b.workDays
|
||||
}))
|
||||
// ... 导出逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 历史对比
|
||||
对比上次开标记录:
|
||||
|
||||
```javascript
|
||||
const compareWithHistory = () => {
|
||||
// 显示与历史记录的差异
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 价格区间验证
|
||||
验证报价是否在合理区间:
|
||||
|
||||
```javascript
|
||||
const validatePrice = (price) => {
|
||||
const controlPrice = biddingInfo.value.record.controlPrice
|
||||
if (price > controlPrice) {
|
||||
ElMessage.warning('报价超过控制价')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📸 效果对比
|
||||
|
||||
### 修改前
|
||||
```
|
||||
所有投标单位报价
|
||||
|
||||
排名 | 投标单位 | 报价金额 | 工期
|
||||
-----|----------|----------|------
|
||||
1 | XX公司 | ¥2,650,000 | 55
|
||||
2 | XX公司 | ¥2,750,000 | 60
|
||||
3 | 我方 | ¥2,850,000 | 60 ← 只读显示
|
||||
```
|
||||
|
||||
### 修改后
|
||||
```
|
||||
所有投标单位报价 [+ 添加投标单位]
|
||||
|
||||
排名 | 投标单位 | 报价金额 | 工期 | 操作
|
||||
-----|----------|----------|------|-------------
|
||||
[1] | [输入框] | [数字] | [60] | [标记][删除]
|
||||
[2] | [输入框] | [数字] | [60] | [标记][删除]
|
||||
[3] | [输入框]★| [数字]★ | [60] | [我方][删除] ← 可编辑
|
||||
|
||||
[✓ 保存开标记录] [📥 下载开标记录表]
|
||||
```
|
||||
|
||||
## 🔄 按钮变更
|
||||
|
||||
### 删除的按钮
|
||||
- ❌ "查看详细记录"
|
||||
|
||||
### 新增的按钮
|
||||
- ✅ "添加投标单位"
|
||||
- ✅ "保存开标记录"
|
||||
- ✅ "标记为我方"
|
||||
- ✅ "删除"(每行)
|
||||
|
||||
---
|
||||
|
||||
**版本**: v1.5.0
|
||||
**更新时间**: 2024-12-08
|
||||
**功能状态**: ✅ 已实现
|
||||
**适用模块**: BiddingInfo.vue
|
||||
185
江西城市生命线-可交互原型/frontend/src/components/project/更新日志.md
Normal file
185
江西城市生命线-可交互原型/frontend/src/components/project/更新日志.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 项目管理组件更新日志
|
||||
|
||||
## v1.1.0 (2024-12-08)
|
||||
|
||||
### 🎨 界面优化
|
||||
|
||||
#### FileSubmission.vue - 文件提交模块
|
||||
|
||||
**变更内容**:
|
||||
|
||||
1. **删除报价文件部分**
|
||||
- 移除了报价文件的展示区域
|
||||
- 删除了相关的数据定义 (`priceDoc`)
|
||||
- 删除了"修改报价"功能 (`modifyPrice`)
|
||||
|
||||
2. **调整状态显示布局**
|
||||
- 将状态标签从详情列表中移到标题旁边
|
||||
- 状态标签尺寸从 `small` 改为 `large`
|
||||
- 添加了 `doc-header` 容器,使标题和状态在同一行
|
||||
- 在标题和详情之间添加了分隔线
|
||||
|
||||
**视觉效果**:
|
||||
```
|
||||
之前:
|
||||
┌─────────────────────────────┐
|
||||
│ 📑 商务标书 │
|
||||
│ │
|
||||
│ 文件名称: xxx.pdf │
|
||||
│ 生成时间: 2024-12-08 │
|
||||
│ 文件大小: 15.6 MB │
|
||||
│ 页数: 85页 │
|
||||
│ 状态: [已完成] │
|
||||
│ │
|
||||
│ [预览] [下载] [重新生成] │
|
||||
└─────────────────────────────┘
|
||||
|
||||
现在:
|
||||
┌─────────────────────────────┐
|
||||
│ 📑 商务标书 [已完成] │
|
||||
│ ─────────────────────────── │
|
||||
│ 文件名称: xxx.pdf │
|
||||
│ 生成时间: 2024-12-08 │
|
||||
│ 文件大小: 15.6 MB │
|
||||
│ 页数: 85页 │
|
||||
│ │
|
||||
│ [预览] [下载] [重新生成] │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**样式变更**:
|
||||
- 新增 `.doc-header` 样式
|
||||
- `display: flex` - 横向布局
|
||||
- `justify-content: space-between` - 两端对齐
|
||||
- `border-bottom: 2px solid #e5e7eb` - 底部分隔线
|
||||
- `padding-bottom: 12px` - 底部内边距
|
||||
|
||||
**数据结构变更**:
|
||||
```javascript
|
||||
// 删除
|
||||
const priceDoc = ref({
|
||||
fileName: '某数据中心发电机组采购-报价表.xlsx',
|
||||
generateTime: '2024-12-09 10:20:15',
|
||||
fileSize: '2.1 MB',
|
||||
amount: '¥2,850,000.00',
|
||||
status: '已完成'
|
||||
})
|
||||
|
||||
// 删除
|
||||
const modifyPrice = () => {
|
||||
ElMessage.info('打开报价修改对话框')
|
||||
}
|
||||
```
|
||||
|
||||
### 📊 影响范围
|
||||
|
||||
**受影响的功能**:
|
||||
- ✅ 文件提交模块 - 界面优化
|
||||
- ⚠️ 报价文件相关功能 - 已移除
|
||||
|
||||
**不受影响的功能**:
|
||||
- ✅ 商务标书展示和操作
|
||||
- ✅ 技术标书展示和操作
|
||||
- ✅ 时间节点展示
|
||||
- ✅ 检查清单功能
|
||||
- ✅ 提交功能
|
||||
|
||||
### 🔄 迁移指南
|
||||
|
||||
如果您的代码中引用了报价文件相关功能,请进行以下调整:
|
||||
|
||||
1. **移除报价文件引用**
|
||||
```javascript
|
||||
// 删除这些引用
|
||||
priceDoc.value
|
||||
modifyPrice()
|
||||
```
|
||||
|
||||
2. **更新文档数量**
|
||||
```javascript
|
||||
// 之前: 3个文件(商务标、技术标、报价文件)
|
||||
// 现在: 2个文件(商务标、技术标)
|
||||
```
|
||||
|
||||
3. **更新检查清单**
|
||||
```javascript
|
||||
// 如果检查清单中包含"报价文件核对",建议移除或调整
|
||||
```
|
||||
|
||||
### 🎯 优化效果
|
||||
|
||||
**优点**:
|
||||
1. ✅ 界面更简洁,状态一目了然
|
||||
2. ✅ 减少了冗余信息
|
||||
3. ✅ 状态标签更突出
|
||||
4. ✅ 视觉层次更清晰
|
||||
|
||||
**注意事项**:
|
||||
1. ⚠️ 如果业务需要报价文件,需要在其他地方展示
|
||||
2. ⚠️ 确保后端API不再返回报价文件数据
|
||||
3. ⚠️ 更新相关文档和测试用例
|
||||
|
||||
### 📝 文件变更清单
|
||||
|
||||
**修改的文件**:
|
||||
- `FileSubmission.vue` (模板、脚本、样式)
|
||||
|
||||
**修改的行数**:
|
||||
- 删除: ~50 行
|
||||
- 新增: ~15 行
|
||||
- 修改: ~10 行
|
||||
|
||||
**代码差异**:
|
||||
```diff
|
||||
- 报价文件展示区域 (HTML)
|
||||
- priceDoc 数据定义 (JavaScript)
|
||||
- modifyPrice 函数 (JavaScript)
|
||||
+ doc-header 容器 (HTML)
|
||||
+ doc-header 样式 (CSS)
|
||||
```
|
||||
|
||||
### 🧪 测试建议
|
||||
|
||||
1. **视觉测试**
|
||||
- 检查状态标签是否正确显示在标题旁边
|
||||
- 确认分隔线是否正常显示
|
||||
- 验证标签尺寸是否合适
|
||||
|
||||
2. **功能测试**
|
||||
- 测试预览、下载、重新生成按钮
|
||||
- 确认没有报价相关的错误
|
||||
- 验证其他功能正常工作
|
||||
|
||||
3. **响应式测试**
|
||||
- 测试不同屏幕尺寸下的显示效果
|
||||
- 确保移动端显示正常
|
||||
|
||||
### 📸 截图对比
|
||||
|
||||
**优化前**:
|
||||
- 状态标签在详情列表中
|
||||
- 报价文件占用额外空间
|
||||
- 信息密集度高
|
||||
|
||||
**优化后**:
|
||||
- 状态标签在标题旁边,更醒目
|
||||
- 移除报价文件,界面更简洁
|
||||
- 信息层次更清晰
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 (2024-12-08)
|
||||
|
||||
### 🎉 初始版本
|
||||
|
||||
- ✅ 实现文件提交模块
|
||||
- ✅ 实现开标信息模块
|
||||
- ✅ 实现结果公示模块
|
||||
- ✅ 集成到 BiddingView 页面
|
||||
- ✅ 完成文档编写
|
||||
|
||||
---
|
||||
|
||||
**维护者**: Cascade AI Assistant
|
||||
**更新时间**: 2024-12-08
|
||||
**版本**: v1.1.0
|
||||
332
江西城市生命线-可交互原型/frontend/src/components/project/检查清单编辑功能说明.md
Normal file
332
江西城市生命线-可交互原型/frontend/src/components/project/检查清单编辑功能说明.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# 检查清单编辑功能说明
|
||||
|
||||
## 📝 功能概述
|
||||
|
||||
文件提交模块的"提交前检查清单"现已支持完整的编辑功能,用户可以自定义检查项内容。
|
||||
|
||||
## ✨ 新增功能
|
||||
|
||||
### 1. **编辑检查项** ✏️
|
||||
|
||||
**操作方式**:
|
||||
- 鼠标悬停在检查项上,显示"编辑"按钮
|
||||
- 点击"编辑"按钮
|
||||
- 检查项文本变为可编辑的输入框
|
||||
- 修改内容后按 `Enter` 或点击外部区域保存
|
||||
|
||||
**特点**:
|
||||
- ✅ 即时编辑,无需额外对话框
|
||||
- ✅ 支持键盘快捷键(Enter 保存)
|
||||
- ✅ 自动失焦保存
|
||||
|
||||
### 2. **删除检查项** 🗑️
|
||||
|
||||
**操作方式**:
|
||||
- 鼠标悬停在检查项上,显示"删除"按钮
|
||||
- 点击"删除"按钮
|
||||
- 弹出确认对话框
|
||||
- 确认后删除该检查项
|
||||
|
||||
**特点**:
|
||||
- ✅ 二次确认,防止误删
|
||||
- ✅ 自动更新完成度进度
|
||||
- ✅ 同步移除已勾选状态
|
||||
|
||||
**安全机制**:
|
||||
```javascript
|
||||
// 删除时会同时:
|
||||
1. 从检查清单中移除
|
||||
2. 从已勾选项中移除
|
||||
3. 重新计算完成度
|
||||
```
|
||||
|
||||
### 3. **添加检查项** ➕
|
||||
|
||||
**操作方式**:
|
||||
- 点击检查清单底部的"添加检查项"按钮
|
||||
- 在弹出的对话框中输入检查项内容
|
||||
- 设置是否为必须项(开关)
|
||||
- 点击"确定"添加
|
||||
|
||||
**特点**:
|
||||
- ✅ 支持自定义检查项内容
|
||||
- ✅ 可设置必须/非必须
|
||||
- ✅ 字数限制(最多50字)
|
||||
- ✅ 实时显示字数统计
|
||||
|
||||
**对话框字段**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 检查项 | 文本输入 | 最多50字,显示字数统计 |
|
||||
| 是否必须 | 开关 | 开启后显示"必须"标签 |
|
||||
|
||||
## 🎨 界面设计
|
||||
|
||||
### 检查项布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ☑ 商务标完整性检查 [必须] [编辑] [删除] │
|
||||
│ ☑ 技术标完整性检查 [必须] [编辑] [删除] │
|
||||
│ ☑ 保证金缴纳凭证 [必须] [编辑] [删除] │
|
||||
│ ☐ 授权委托书签字盖章 [编辑] [删除] │
|
||||
│ │
|
||||
│ [+ 添加检查项] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 交互效果
|
||||
|
||||
**默认状态**:
|
||||
- 编辑/删除按钮半透明(opacity: 0.6)
|
||||
|
||||
**鼠标悬停**:
|
||||
- 背景色变浅
|
||||
- 编辑/删除按钮完全显示(opacity: 1)
|
||||
|
||||
**编辑状态**:
|
||||
- 文本变为输入框
|
||||
- 输入框宽度 300px
|
||||
- 自动聚焦
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 数据结构
|
||||
|
||||
```javascript
|
||||
const checklistItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
label: '商务标完整性检查',
|
||||
required: true, // 是否必须
|
||||
editing: false // 是否正在编辑
|
||||
},
|
||||
// ...
|
||||
])
|
||||
```
|
||||
|
||||
### 核心方法
|
||||
|
||||
#### 1. 编辑检查项
|
||||
```javascript
|
||||
const editItem = (item) => {
|
||||
item.editing = true // 切换为编辑状态
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 删除检查项
|
||||
```javascript
|
||||
const deleteItem = (id) => {
|
||||
ElMessageBox.confirm('确定要删除这个检查项吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
// 从列表中删除
|
||||
const index = checklistItems.value.findIndex(item => item.id === id)
|
||||
checklistItems.value.splice(index, 1)
|
||||
|
||||
// 从已选中项中移除
|
||||
const checkedIndex = checkedItems.value.indexOf(id)
|
||||
checkedItems.value.splice(checkedIndex, 1)
|
||||
|
||||
ElMessage.success('删除成功')
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 添加检查项
|
||||
```javascript
|
||||
const addNewItem = () => {
|
||||
if (!newItemForm.value.label.trim()) {
|
||||
ElMessage.warning('请输入检查项内容')
|
||||
return
|
||||
}
|
||||
|
||||
checklistItems.value.push({
|
||||
id: nextItemId++,
|
||||
label: newItemForm.value.label,
|
||||
required: newItemForm.value.required,
|
||||
editing: false
|
||||
})
|
||||
|
||||
ElMessage.success('添加成功')
|
||||
showAddItemDialog.value = false
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 完成度计算
|
||||
|
||||
完成度会根据检查项的增删自动更新:
|
||||
|
||||
```javascript
|
||||
const checklistProgress = computed(() => {
|
||||
return Math.round(
|
||||
(checkedItems.value.length / checklistItems.value.length) * 100
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
**示例**:
|
||||
- 总共 7 项检查项
|
||||
- 已勾选 5 项
|
||||
- 完成度 = 5 / 7 × 100% ≈ 71%
|
||||
|
||||
## 🎯 使用场景
|
||||
|
||||
### 场景 1: 自定义检查项
|
||||
项目经理可以根据不同项目的特点,添加特定的检查项。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
原有检查项:
|
||||
- 商务标完整性检查
|
||||
- 技术标完整性检查
|
||||
|
||||
新增检查项:
|
||||
- 环保资质证明
|
||||
- 安全生产许可证
|
||||
- 项目经理证书
|
||||
```
|
||||
|
||||
### 场景 2: 修改检查项描述
|
||||
根据实际情况,调整检查项的描述更加准确。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
修改前: 授权委托书签字盖章
|
||||
修改后: 法人授权委托书签字并加盖公章
|
||||
```
|
||||
|
||||
### 场景 3: 删除不适用的检查项
|
||||
某些检查项可能不适用于当前项目,可以删除。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
删除: 纸质文件打印装订
|
||||
原因: 本项目仅需电子投标
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 数据持久化
|
||||
当前实现使用前端状态管理,刷新页面后修改会丢失。
|
||||
|
||||
**建议**:
|
||||
- 集成后端 API,保存到数据库
|
||||
- 或使用 localStorage 本地存储
|
||||
|
||||
### 2. 必须项限制
|
||||
标记为"必须"的检查项建议不允许删除,或删除时给予警告。
|
||||
|
||||
**改进方案**:
|
||||
```javascript
|
||||
const deleteItem = (id) => {
|
||||
const item = checklistItems.value.find(i => i.id === id)
|
||||
|
||||
if (item.required) {
|
||||
ElMessageBox.confirm(
|
||||
'这是必须项,删除可能影响提交,确定要删除吗?',
|
||||
'警告',
|
||||
{ type: 'warning' }
|
||||
).then(() => {
|
||||
// 执行删除
|
||||
})
|
||||
} else {
|
||||
// 正常删除
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 字数限制
|
||||
检查项内容限制在 50 字以内,确保界面美观。
|
||||
|
||||
### 4. ID 管理
|
||||
新增检查项使用递增 ID,确保唯一性。
|
||||
|
||||
```javascript
|
||||
let nextItemId = 8 // 从现有最大 ID + 1 开始
|
||||
```
|
||||
|
||||
## 🎨 样式特点
|
||||
|
||||
### 1. 悬停效果
|
||||
```scss
|
||||
.checklist-item {
|
||||
&:hover {
|
||||
background: #f3f4f6; // 背景变浅
|
||||
|
||||
.item-actions {
|
||||
opacity: 1; // 按钮完全显示
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 添加按钮
|
||||
```scss
|
||||
.add-item-btn {
|
||||
width: 100%;
|
||||
border-style: dashed; // 虚线边框
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed; // 紫色边框
|
||||
color: #7c3aed; // 紫色文字
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 操作按钮
|
||||
```scss
|
||||
.item-actions {
|
||||
opacity: 0.6; // 默认半透明
|
||||
transition: opacity 0.2s; // 平滑过渡
|
||||
}
|
||||
```
|
||||
|
||||
## 📸 效果展示
|
||||
|
||||
### 默认状态
|
||||
- 检查项正常显示
|
||||
- 操作按钮半透明
|
||||
|
||||
### 悬停状态
|
||||
- 背景色变浅
|
||||
- 操作按钮完全显示
|
||||
|
||||
### 编辑状态
|
||||
- 文本变为输入框
|
||||
- 可直接修改内容
|
||||
|
||||
### 添加对话框
|
||||
- 简洁的表单界面
|
||||
- 字数统计提示
|
||||
- 必须项开关
|
||||
|
||||
## 🚀 后续优化建议
|
||||
|
||||
1. **拖拽排序**
|
||||
- 支持拖拽调整检查项顺序
|
||||
- 使用 `vue-draggable` 库
|
||||
|
||||
2. **批量操作**
|
||||
- 批量删除
|
||||
- 批量设置必须项
|
||||
|
||||
3. **模板功能**
|
||||
- 保存常用检查清单为模板
|
||||
- 快速应用模板
|
||||
|
||||
4. **历史记录**
|
||||
- 记录修改历史
|
||||
- 支持撤销/重做
|
||||
|
||||
5. **权限控制**
|
||||
- 不同角色有不同编辑权限
|
||||
- 普通用户只能查看,管理员可编辑
|
||||
|
||||
---
|
||||
|
||||
**版本**: v1.2.0
|
||||
**更新时间**: 2024-12-08
|
||||
**功能状态**: ✅ 已实现
|
||||
353
江西城市生命线-可交互原型/frontend/src/components/project/流程步骤条说明.md
Normal file
353
江西城市生命线-可交互原型/frontend/src/components/project/流程步骤条说明.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# 项目流程步骤条说明
|
||||
|
||||
## 📍 功能概述
|
||||
|
||||
在文件提交模块顶部添加了项目信息头部和流程步骤条,清晰展示当前项目所处的阶段。
|
||||
|
||||
## 🎨 界面布局
|
||||
|
||||
### 1. 项目信息头部
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 某数据中心发电机组采购项目 │
|
||||
│ 创建时间:2024-11-20 09:30:00 [进行中] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- 🎨 渐变紫色背景(#667eea → #764ba2)
|
||||
- 📝 显示项目名称
|
||||
- 📅 显示创建时间
|
||||
- 🏷️ 显示项目状态标签
|
||||
|
||||
### 2. 流程步骤条
|
||||
|
||||
```
|
||||
招标信息提取 → 撰写投标文件 → 文件提交 → 开标 → 结果公示
|
||||
(完成) (完成) (进行中) (待进行) (待进行)
|
||||
✓ ✓ ⟳ 4 5
|
||||
```
|
||||
|
||||
**5个步骤**:
|
||||
1. **招标信息提取** - 已完成 ✓
|
||||
2. **撰写投标文件** - 已完成 ✓
|
||||
3. **文件提交** - 进行中 ⟳ (当前)
|
||||
4. **开标** - 待进行
|
||||
5. **结果公示** - 待进行
|
||||
|
||||
## 🎯 步骤状态
|
||||
|
||||
### 已完成步骤
|
||||
- **图标**: 绿色对勾 ✓
|
||||
- **颜色**: #10b981 (绿色)
|
||||
- **描述**: "已完成"
|
||||
|
||||
### 进行中步骤 (当前)
|
||||
- **图标**: 紫色旋转加载图标 ⟳
|
||||
- **颜色**: #7c3aed (紫色)
|
||||
- **描述**: "进行中"
|
||||
- **动画**: 持续旋转
|
||||
|
||||
### 待进行步骤
|
||||
- **图标**: 数字序号
|
||||
- **颜色**: 默认灰色
|
||||
- **描述**: "待进行"
|
||||
|
||||
## 💻 代码实现
|
||||
|
||||
### 模板结构
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="file-submission-panel">
|
||||
<!-- 项目信息头部 -->
|
||||
<div class="project-header">
|
||||
<h2>{{ project.name }}</h2>
|
||||
<div class="project-meta">
|
||||
<span>创建时间:{{ project.createTime }}</span>
|
||||
<el-tag type="warning" size="large">进行中</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目流程步骤条 -->
|
||||
<el-card class="timeline-card">
|
||||
<div class="project-timeline-header">
|
||||
<h3>项目流程</h3>
|
||||
</div>
|
||||
<el-steps :active="2" align-center>
|
||||
<!-- 步骤1: 招标信息提取 -->
|
||||
<el-step title="招标信息提取" description="已完成">
|
||||
<template #icon>
|
||||
<el-icon color="#10b981"><CircleCheck /></el-icon>
|
||||
</template>
|
||||
</el-step>
|
||||
|
||||
<!-- 步骤2: 撰写投标文件 -->
|
||||
<el-step title="撰写投标文件" description="已完成">
|
||||
<template #icon>
|
||||
<el-icon color="#10b981"><CircleCheck /></el-icon>
|
||||
</template>
|
||||
</el-step>
|
||||
|
||||
<!-- 步骤3: 文件提交 (当前) -->
|
||||
<el-step title="文件提交" description="进行中" status="process">
|
||||
<template #icon>
|
||||
<el-icon color="#7c3aed">
|
||||
<Loading class="rotating" />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-step>
|
||||
|
||||
<!-- 步骤4: 开标 -->
|
||||
<el-step title="开标" description="待进行">
|
||||
<template #icon>
|
||||
<span>4</span>
|
||||
</template>
|
||||
</el-step>
|
||||
|
||||
<!-- 步骤5: 结果公示 -->
|
||||
<el-step title="结果公示" description="待进行">
|
||||
<template #icon>
|
||||
<span>5</span>
|
||||
</template>
|
||||
</el-step>
|
||||
</el-steps>
|
||||
</el-card>
|
||||
|
||||
<!-- 其他内容... -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 样式实现
|
||||
|
||||
```scss
|
||||
// 项目信息头部
|
||||
.project-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 24px 32px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
color: white;
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
// 流程步骤卡片
|
||||
.timeline-card {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.project-timeline-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-steps) {
|
||||
.el-step__title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-step__description {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-step.is-process {
|
||||
.el-step__title {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.rotating {
|
||||
animation: rotate 2s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 旋转动画
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 视觉效果
|
||||
|
||||
### 项目头部
|
||||
- **背景**: 渐变紫色(从 #667eea 到 #764ba2)
|
||||
- **文字**: 白色
|
||||
- **圆角**: 12px
|
||||
- **内边距**: 24px 32px
|
||||
|
||||
### 步骤条
|
||||
- **对齐**: 居中对齐
|
||||
- **当前步骤**: 紫色高亮
|
||||
- **完成步骤**: 绿色对勾
|
||||
- **待进行**: 灰色数字
|
||||
|
||||
### 动画效果
|
||||
- **旋转图标**: 2秒一圈,无限循环
|
||||
- **平滑过渡**: 所有状态变化都有过渡效果
|
||||
|
||||
## 📊 步骤索引说明
|
||||
|
||||
```javascript
|
||||
:active="2" // 当前激活的步骤索引(从0开始)
|
||||
```
|
||||
|
||||
**索引对应**:
|
||||
- 0 = 招标信息提取
|
||||
- 1 = 撰写投标文件
|
||||
- 2 = 文件提交 ← 当前
|
||||
- 3 = 开标
|
||||
- 4 = 结果公示
|
||||
|
||||
## 🔄 其他模块适配
|
||||
|
||||
### BiddingInfo.vue (开标信息)
|
||||
```vue
|
||||
<el-steps :active="3" align-center>
|
||||
<!-- 前3步已完成 -->
|
||||
<el-step title="开标" description="进行中" status="process">
|
||||
<!-- 当前步骤 -->
|
||||
</el-step>
|
||||
</el-steps>
|
||||
```
|
||||
|
||||
### ResultAnnouncement.vue (结果公示)
|
||||
```vue
|
||||
<el-steps :active="4" align-center>
|
||||
<!-- 前4步已完成 -->
|
||||
<el-step title="结果公示" description="进行中" status="process">
|
||||
<!-- 当前步骤 -->
|
||||
</el-step>
|
||||
</el-steps>
|
||||
```
|
||||
|
||||
## 💡 使用建议
|
||||
|
||||
### 1. 动态步骤索引
|
||||
根据项目实际进度动态设置 `active` 值:
|
||||
|
||||
```javascript
|
||||
const currentStepIndex = computed(() => {
|
||||
const stepMap = {
|
||||
'招标信息提取': 0,
|
||||
'撰写投标文件': 1,
|
||||
'文件提交': 2,
|
||||
'开标': 3,
|
||||
'结果公示': 4
|
||||
}
|
||||
return stepMap[project.value.progress] || 0
|
||||
})
|
||||
```
|
||||
|
||||
### 2. 步骤点击跳转
|
||||
可以为步骤添加点击事件,实现快速跳转:
|
||||
|
||||
```vue
|
||||
<el-step
|
||||
@click="jumpToStep(0)"
|
||||
title="招标信息提取"
|
||||
>
|
||||
</el-step>
|
||||
```
|
||||
|
||||
### 3. 响应式设计
|
||||
在移动端可以使用垂直步骤条:
|
||||
|
||||
```vue
|
||||
<el-steps
|
||||
:active="2"
|
||||
:direction="isMobile ? 'vertical' : 'horizontal'"
|
||||
>
|
||||
</el-steps>
|
||||
```
|
||||
|
||||
## 🎯 优化建议
|
||||
|
||||
### 1. 步骤时间显示
|
||||
在描述中显示完成时间:
|
||||
|
||||
```vue
|
||||
<el-step
|
||||
title="招标信息提取"
|
||||
description="已完成 (2024-11-20)"
|
||||
>
|
||||
</el-step>
|
||||
```
|
||||
|
||||
### 2. 进度百分比
|
||||
在头部显示整体进度:
|
||||
|
||||
```vue
|
||||
<div class="project-meta">
|
||||
<span>创建时间:{{ project.createTime }}</span>
|
||||
<span>整体进度:60%</span>
|
||||
<el-progress :percentage="60" style="width: 100px;" />
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. 步骤详情提示
|
||||
鼠标悬停显示步骤详情:
|
||||
|
||||
```vue
|
||||
<el-tooltip content="已于2024-11-20完成">
|
||||
<el-step title="招标信息提取" />
|
||||
</el-tooltip>
|
||||
```
|
||||
|
||||
## 📸 效果预览
|
||||
|
||||
### 完整界面
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 某数据中心发电机组采购项目 │
|
||||
│ 创建时间:2024-11-20 09:30:00 [进行中] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 项目流程 │
|
||||
│ │
|
||||
│ ✓ ✓ ⟳ 4 5 │
|
||||
│ 招标信息 撰写投标 文件提交 开标 结果公示 │
|
||||
│ 提取 文件 │
|
||||
│ 已完成 已完成 进行中 待进行 待进行 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 已生成标书文件 │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**版本**: v1.3.0
|
||||
**更新时间**: 2024-12-08
|
||||
**功能状态**: ✅ 已实现
|
||||
**适用模块**: FileSubmission.vue
|
||||
335
江西城市生命线-可交互原型/frontend/src/components/project/集成示例.md
Normal file
335
江西城市生命线-可交互原型/frontend/src/components/project/集成示例.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# 项目管理模块集成示例
|
||||
|
||||
## 在 BiddingView.vue 中集成项目详情组件
|
||||
|
||||
### 步骤 1: 导入组件
|
||||
|
||||
在 `BiddingView.vue` 的 `<script setup>` 部分添加导入:
|
||||
|
||||
```javascript
|
||||
import ProjectDetail from '@/components/project/ProjectDetail.vue'
|
||||
```
|
||||
|
||||
### 步骤 2: 修改模板
|
||||
|
||||
找到 `project-ongoing` 部分,修改为:
|
||||
|
||||
```vue
|
||||
<!-- 进行中项目 -->
|
||||
<div v-if="activeNav === 'project-ongoing'" class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>进行中项目</h3>
|
||||
<div class="header-actions">
|
||||
<el-input
|
||||
v-model="projectSearch"
|
||||
placeholder="搜索项目名称..."
|
||||
style="width: 200px; margin-right: 12px;"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="showCreateProjectDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
创建项目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<div v-if="!selectedProject" class="project-list">
|
||||
<div
|
||||
v-for="project in filteredOngoingProjects"
|
||||
:key="project.id"
|
||||
class="project-card"
|
||||
@click="viewProjectDetail(project)"
|
||||
>
|
||||
<div class="project-card-header">
|
||||
<div class="project-title">
|
||||
<h4>{{ project.name }}</h4>
|
||||
<el-tag :type="getProgressTagType(project.progress)" size="small">
|
||||
{{ project.progress }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="project-meta">
|
||||
<span class="meta-item">
|
||||
<el-icon><Clock /></el-icon>
|
||||
创建时间:{{ project.createTime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-progress-bar">
|
||||
<div class="progress-info">
|
||||
<span>当前进度</span>
|
||||
<span>{{ getProgressPercentage(project.progress) }}%</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="getProgressPercentage(project.progress)"
|
||||
:color="getProgressColor(project.progress)"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目详情 - 使用新组件 -->
|
||||
<ProjectDetail
|
||||
v-else
|
||||
:project="selectedProject"
|
||||
@back="selectedProject = null"
|
||||
@update="handleProjectUpdate"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 步骤 3: 添加处理函数
|
||||
|
||||
在 `<script setup>` 部分添加:
|
||||
|
||||
```javascript
|
||||
// 项目详情处理
|
||||
const handleProjectUpdate = () => {
|
||||
ElMessage.success('项目信息已更新')
|
||||
// 可以在这里重新加载项目数据
|
||||
// loadProjectData(selectedProject.value.id)
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例代码
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="bidding-system">
|
||||
<!-- 侧边栏保持不变 -->
|
||||
<aside class="bidding-sidebar">
|
||||
<!-- ... 侧边栏内容 ... -->
|
||||
</aside>
|
||||
|
||||
<div class="bidding-content">
|
||||
<header class="content-header">
|
||||
<!-- ... 头部内容 ... -->
|
||||
</header>
|
||||
|
||||
<div class="content-main">
|
||||
<!-- 进行中项目 -->
|
||||
<div v-if="activeNav === 'project-ongoing'" class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>进行中项目</h3>
|
||||
<div class="header-actions">
|
||||
<el-input
|
||||
v-model="projectSearch"
|
||||
placeholder="搜索项目名称..."
|
||||
style="width: 200px; margin-right: 12px;"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="showCreateProjectDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
创建项目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<div v-if="!selectedProject" class="project-list">
|
||||
<div
|
||||
v-for="project in filteredOngoingProjects"
|
||||
:key="project.id"
|
||||
class="project-card"
|
||||
@click="viewProjectDetail(project)"
|
||||
>
|
||||
<div class="project-card-header">
|
||||
<div class="project-title">
|
||||
<h4>{{ project.name }}</h4>
|
||||
<el-tag :type="getProgressTagType(project.progress)" size="small">
|
||||
{{ project.progress }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="project-meta">
|
||||
<span class="meta-item">
|
||||
<el-icon><Clock /></el-icon>
|
||||
创建时间:{{ project.createTime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-progress-bar">
|
||||
<div class="progress-info">
|
||||
<span>当前进度</span>
|
||||
<span>{{ getProgressPercentage(project.progress) }}%</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="getProgressPercentage(project.progress)"
|
||||
:color="getProgressColor(project.progress)"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目详情 -->
|
||||
<ProjectDetail
|
||||
v-else
|
||||
:project="selectedProject"
|
||||
@back="selectedProject = null"
|
||||
@update="handleProjectUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ProjectDetail from '@/components/project/ProjectDetail.vue'
|
||||
|
||||
// 导航状态
|
||||
const activeNav = ref('kb-overview')
|
||||
const selectedProject = ref(null)
|
||||
const projectSearch = ref('')
|
||||
|
||||
// 项目数据
|
||||
const ongoingProjects = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '某数据中心发电机组采购项目',
|
||||
client: 'XX科技有限公司',
|
||||
status: 'ongoing',
|
||||
progress: '文件提交',
|
||||
createTime: '2024-11-01 10:30:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'XX医院应急电源系统项目',
|
||||
client: 'XX市人民医院',
|
||||
status: 'ongoing',
|
||||
progress: '开标',
|
||||
createTime: '2024-11-05 14:20:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '工业园区配电工程项目',
|
||||
client: 'XX工业园管委会',
|
||||
status: 'won',
|
||||
progress: '结果公示',
|
||||
createTime: '2024-10-15 09:15:00'
|
||||
}
|
||||
])
|
||||
|
||||
// 过滤项目
|
||||
const filteredOngoingProjects = computed(() => {
|
||||
if (!projectSearch.value) return ongoingProjects.value
|
||||
return ongoingProjects.value.filter(p =>
|
||||
p.name.includes(projectSearch.value)
|
||||
)
|
||||
})
|
||||
|
||||
// 查看项目详情
|
||||
const viewProjectDetail = (project) => {
|
||||
selectedProject.value = project
|
||||
}
|
||||
|
||||
// 项目更新处理
|
||||
const handleProjectUpdate = () => {
|
||||
ElMessage.success('项目信息已更新')
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
const getProgressTagType = (progress) => {
|
||||
const typeMap = {
|
||||
'信息提取': 'info',
|
||||
'文件准备': 'warning',
|
||||
'文件提交': 'warning',
|
||||
'开标': '',
|
||||
'评标': '',
|
||||
'结果公示': 'success'
|
||||
}
|
||||
return typeMap[progress] || 'info'
|
||||
}
|
||||
|
||||
const getProgressPercentage = (progress) => {
|
||||
const percentageMap = {
|
||||
'信息提取': 16,
|
||||
'文件准备': 33,
|
||||
'文件提交': 50,
|
||||
'开标': 66,
|
||||
'评标': 83,
|
||||
'结果公示': 100
|
||||
}
|
||||
return percentageMap[progress] || 0
|
||||
}
|
||||
|
||||
const getProgressColor = (progress) => {
|
||||
const percentage = getProgressPercentage(progress)
|
||||
if (percentage >= 80) return '#10b981'
|
||||
if (percentage >= 50) return '#3b82f6'
|
||||
if (percentage >= 30) return '#f59e0b'
|
||||
return '#ef4444'
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 测试步骤
|
||||
|
||||
1. **启动开发服务器**
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. **访问页面**
|
||||
- 打开浏览器访问 `http://localhost:5173`
|
||||
- 进入招标助手系统
|
||||
- 点击左侧导航"项目管理" → "进行中项目"
|
||||
|
||||
3. **测试功能**
|
||||
- 点击任意项目卡片进入详情
|
||||
- 切换不同 Tab 查看各个模块
|
||||
- 测试文件提交模块的检查清单
|
||||
- 测试开标信息模块的报价对比表
|
||||
- 测试结果公示模块的三种结果类型切换
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **确保依赖已安装**
|
||||
```bash
|
||||
npm install element-plus @element-plus/icons-vue
|
||||
```
|
||||
|
||||
2. **检查路径**
|
||||
- 确保组件路径正确: `@/components/project/ProjectDetail.vue`
|
||||
- 如果使用相对路径,根据实际情况调整
|
||||
|
||||
3. **样式冲突**
|
||||
- 新组件的样式使用了 `scoped`,不会影响现有样式
|
||||
- 如有冲突,可以调整组件内的样式
|
||||
|
||||
4. **数据对接**
|
||||
- 当前使用模拟数据
|
||||
- 实际使用时需要对接后端 API
|
||||
- 参考 `src/api/project.js` 中的接口定义
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **性能优化**
|
||||
- 使用虚拟滚动处理大量项目列表
|
||||
- 懒加载项目详情数据
|
||||
|
||||
2. **用户体验**
|
||||
- 添加加载状态提示
|
||||
- 添加错误处理和重试机制
|
||||
- 实现数据缓存
|
||||
|
||||
3. **功能扩展**
|
||||
- 添加项目搜索和筛选
|
||||
- 实现项目排序功能
|
||||
- 添加批量操作功能
|
||||
|
||||
4. **移动端适配**
|
||||
- 优化移动端布局
|
||||
- 添加触摸手势支持
|
||||
@@ -0,0 +1,618 @@
|
||||
<template>
|
||||
<div class="workflow-editor">
|
||||
<!-- 左侧节点面板 -->
|
||||
<aside class="node-panel">
|
||||
<div class="panel-header">
|
||||
<h3>节点库</h3>
|
||||
</div>
|
||||
<div class="node-categories">
|
||||
<div class="category" v-for="category in categories" :key="category.key">
|
||||
<div class="category-title">{{ category.label }}</div>
|
||||
<div class="node-list">
|
||||
<div
|
||||
v-for="nodeType in getNodesByCategory(category.key)"
|
||||
:key="nodeType.type"
|
||||
class="node-item"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, nodeType)"
|
||||
>
|
||||
<div class="node-icon" :style="{ background: nodeType.color }">
|
||||
{{ nodeType.icon }}
|
||||
</div>
|
||||
<span class="node-label">{{ nodeType.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 画布区域 -->
|
||||
<main class="canvas-container">
|
||||
<!-- 工具栏 -->
|
||||
<div class="canvas-toolbar">
|
||||
<el-button-group>
|
||||
<el-button size="small" @click="zoomIn">
|
||||
<el-icon><ZoomIn /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" @click="zoomOut">
|
||||
<el-icon><ZoomOut /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" @click="resetZoom">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
<el-button size="small" type="danger" @click="clearCanvas">
|
||||
<el-icon><Delete /></el-icon>
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 画布 -->
|
||||
<div
|
||||
ref="canvasRef"
|
||||
class="canvas"
|
||||
:style="canvasStyle"
|
||||
@drop="onDrop"
|
||||
@dragover.prevent
|
||||
@click="onCanvasClick"
|
||||
@mousedown="onCanvasMouseDown"
|
||||
@mousemove="onCanvasMouseMove"
|
||||
@mouseup="onCanvasMouseUp"
|
||||
>
|
||||
<!-- 网格背景 -->
|
||||
<svg class="grid-background" width="100%" height="100%">
|
||||
<defs>
|
||||
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
|
||||
<!-- 连接线 -->
|
||||
<svg class="connections-layer" width="100%" height="100%">
|
||||
<g v-for="conn in connections" :key="conn.id">
|
||||
<path
|
||||
:d="getConnectionPath(conn)"
|
||||
fill="none"
|
||||
stroke="#94a3b8"
|
||||
stroke-width="2"
|
||||
class="connection-line"
|
||||
@click="onConnectionClick(conn)"
|
||||
/>
|
||||
</g>
|
||||
<!-- 临时连接线 -->
|
||||
<path
|
||||
v-if="tempConnection"
|
||||
:d="tempConnectionPath"
|
||||
fill="none"
|
||||
stroke="#7c3aed"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="5,5"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- 节点 -->
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.id"
|
||||
class="workflow-node"
|
||||
:class="{ selected: selectedNodeId === node.id }"
|
||||
:style="getNodeStyle(node)"
|
||||
@mousedown.stop="onNodeMouseDown($event, node)"
|
||||
>
|
||||
<div class="node-header" :style="{ background: node.color }">
|
||||
<span class="node-icon">{{ node.icon }}</span>
|
||||
<span class="node-title">{{ node.label }}</span>
|
||||
<el-icon class="delete-btn" @click.stop="deleteNode(node.id)"><Close /></el-icon>
|
||||
</div>
|
||||
<div class="node-body">
|
||||
<!-- 输入端口 -->
|
||||
<div
|
||||
v-for="input in node.inputs"
|
||||
:key="input.id"
|
||||
class="port input-port"
|
||||
@mousedown.stop="onPortMouseDown($event, node, input, 'input')"
|
||||
@mouseup.stop="onPortMouseUp($event, node, input, 'input')"
|
||||
>
|
||||
<div class="port-dot"></div>
|
||||
</div>
|
||||
<!-- 输出端口 -->
|
||||
<div
|
||||
v-for="output in node.outputs"
|
||||
:key="output.id"
|
||||
class="port output-port"
|
||||
@mousedown.stop="onPortMouseDown($event, node, output, 'output')"
|
||||
>
|
||||
<div class="port-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 右侧属性面板 -->
|
||||
<aside class="property-panel" v-if="selectedNode">
|
||||
<div class="panel-header">
|
||||
<h3>节点属性</h3>
|
||||
<el-icon class="close-btn" @click="selectNode(null)"><Close /></el-icon>
|
||||
</div>
|
||||
<div class="property-content">
|
||||
<el-form label-position="top" size="small">
|
||||
<el-form-item label="节点类型">
|
||||
<el-input :value="selectedNode.label" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="节点ID">
|
||||
<el-input :value="selectedNode.id" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="位置 X">
|
||||
<el-input-number v-model="selectedNode.x" :step="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="位置 Y">
|
||||
<el-input-number v-model="selectedNode.y" :step="10" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useWorkflowStore } from '@/stores/workflow'
|
||||
import { ZoomIn, ZoomOut, FullScreen, Delete, Close } from '@element-plus/icons-vue'
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
// 响应式数据
|
||||
const canvasRef = ref(null)
|
||||
const scale = ref(1)
|
||||
const panOffset = ref({ x: 0, y: 0 })
|
||||
const isDragging = ref(false)
|
||||
const dragNode = ref(null)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
const tempConnection = ref(null)
|
||||
const isPanning = ref(false)
|
||||
const panStart = ref({ x: 0, y: 0 })
|
||||
|
||||
// 节点分类
|
||||
const categories = [
|
||||
{ key: 'control', label: '流程控制' },
|
||||
{ key: 'ai', label: 'AI 能力' },
|
||||
{ key: 'logic', label: '逻辑处理' },
|
||||
{ key: 'integration', label: '集成' },
|
||||
{ key: 'transform', label: '数据转换' }
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const nodes = computed(() => workflowStore.nodes)
|
||||
const connections = computed(() => workflowStore.connections)
|
||||
const selectedNodeId = computed(() => workflowStore.selectedNodeId)
|
||||
const selectedNode = computed(() => workflowStore.getNode(selectedNodeId.value))
|
||||
const nodeTypes = computed(() => workflowStore.nodeTypes)
|
||||
|
||||
const canvasStyle = computed(() => ({
|
||||
transform: `scale(${scale.value}) translate(${panOffset.value.x}px, ${panOffset.value.y}px)`
|
||||
}))
|
||||
|
||||
// 根据分类获取节点
|
||||
const getNodesByCategory = (category) => {
|
||||
return nodeTypes.value.filter(n => n.category === category)
|
||||
}
|
||||
|
||||
// 节点样式
|
||||
const getNodeStyle = (node) => ({
|
||||
left: `${node.x}px`,
|
||||
top: `${node.y}px`,
|
||||
width: `${node.width}px`
|
||||
})
|
||||
|
||||
// 连接线路径
|
||||
const getConnectionPath = (conn) => {
|
||||
const sourceNode = workflowStore.getNode(conn.sourceNodeId)
|
||||
const targetNode = workflowStore.getNode(conn.targetNodeId)
|
||||
if (!sourceNode || !targetNode) return ''
|
||||
|
||||
const startX = sourceNode.x + sourceNode.width
|
||||
const startY = sourceNode.y + 30
|
||||
const endX = targetNode.x
|
||||
const endY = targetNode.y + 30
|
||||
|
||||
const controlPointOffset = Math.abs(endX - startX) / 2
|
||||
return `M ${startX} ${startY} C ${startX + controlPointOffset} ${startY}, ${endX - controlPointOffset} ${endY}, ${endX} ${endY}`
|
||||
}
|
||||
|
||||
// 临时连接线路径
|
||||
const tempConnectionPath = computed(() => {
|
||||
if (!tempConnection.value) return ''
|
||||
const { startX, startY, endX, endY } = tempConnection.value
|
||||
const controlPointOffset = Math.abs(endX - startX) / 2
|
||||
return `M ${startX} ${startY} C ${startX + controlPointOffset} ${startY}, ${endX - controlPointOffset} ${endY}, ${endX} ${endY}`
|
||||
})
|
||||
|
||||
// 拖拽开始
|
||||
const onDragStart = (event, nodeType) => {
|
||||
event.dataTransfer.setData('nodeType', nodeType.type)
|
||||
}
|
||||
|
||||
// 放置节点
|
||||
const onDrop = (event) => {
|
||||
const nodeType = event.dataTransfer.getData('nodeType')
|
||||
if (!nodeType) return
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
const x = (event.clientX - rect.left) / scale.value - panOffset.value.x
|
||||
const y = (event.clientY - rect.top) / scale.value - panOffset.value.y
|
||||
|
||||
workflowStore.addNode(nodeType, { x, y })
|
||||
}
|
||||
|
||||
// 画布点击
|
||||
const onCanvasClick = () => {
|
||||
workflowStore.selectNode(null)
|
||||
}
|
||||
|
||||
// 画布拖拽
|
||||
const onCanvasMouseDown = (event) => {
|
||||
if (event.button === 1 || event.shiftKey) {
|
||||
isPanning.value = true
|
||||
panStart.value = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
}
|
||||
|
||||
const onCanvasMouseMove = (event) => {
|
||||
// 节点拖拽
|
||||
if (isDragging.value && dragNode.value) {
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
const x = (event.clientX - rect.left) / scale.value - dragOffset.value.x
|
||||
const y = (event.clientY - rect.top) / scale.value - dragOffset.value.y
|
||||
workflowStore.updateNodePosition(dragNode.value.id, x, y)
|
||||
}
|
||||
|
||||
// 画布平移
|
||||
if (isPanning.value) {
|
||||
const dx = event.clientX - panStart.value.x
|
||||
const dy = event.clientY - panStart.value.y
|
||||
panOffset.value.x += dx / scale.value
|
||||
panOffset.value.y += dy / scale.value
|
||||
panStart.value = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
// 连接线拖拽
|
||||
if (tempConnection.value) {
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
tempConnection.value.endX = (event.clientX - rect.left) / scale.value
|
||||
tempConnection.value.endY = (event.clientY - rect.top) / scale.value
|
||||
}
|
||||
}
|
||||
|
||||
const onCanvasMouseUp = () => {
|
||||
isDragging.value = false
|
||||
dragNode.value = null
|
||||
isPanning.value = false
|
||||
tempConnection.value = null
|
||||
}
|
||||
|
||||
// 节点拖拽
|
||||
const onNodeMouseDown = (event, node) => {
|
||||
workflowStore.selectNode(node.id)
|
||||
isDragging.value = true
|
||||
dragNode.value = node
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
dragOffset.value = {
|
||||
x: (event.clientX - rect.left) / scale.value - node.x,
|
||||
y: (event.clientY - rect.top) / scale.value - node.y
|
||||
}
|
||||
}
|
||||
|
||||
// 端口操作
|
||||
const onPortMouseDown = (event, node, port, portType) => {
|
||||
if (portType === 'output') {
|
||||
tempConnection.value = {
|
||||
sourceNodeId: node.id,
|
||||
sourcePortId: port.id,
|
||||
startX: node.x + node.width,
|
||||
startY: node.y + 30,
|
||||
endX: node.x + node.width,
|
||||
endY: node.y + 30
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onPortMouseUp = (event, node, port, portType) => {
|
||||
if (portType === 'input' && tempConnection.value) {
|
||||
workflowStore.addConnection(
|
||||
tempConnection.value.sourceNodeId,
|
||||
tempConnection.value.sourcePortId,
|
||||
node.id,
|
||||
port.id
|
||||
)
|
||||
}
|
||||
tempConnection.value = null
|
||||
}
|
||||
|
||||
// 连接线点击
|
||||
const onConnectionClick = (conn) => {
|
||||
workflowStore.deleteConnection(conn.id)
|
||||
}
|
||||
|
||||
// 删除节点
|
||||
const deleteNode = (nodeId) => {
|
||||
workflowStore.deleteNode(nodeId)
|
||||
}
|
||||
|
||||
// 选择节点
|
||||
const selectNode = (nodeId) => {
|
||||
workflowStore.selectNode(nodeId)
|
||||
}
|
||||
|
||||
// 缩放
|
||||
const zoomIn = () => {
|
||||
scale.value = Math.min(scale.value + 0.1, 2)
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
scale.value = Math.max(scale.value - 0.1, 0.5)
|
||||
}
|
||||
|
||||
const resetZoom = () => {
|
||||
scale.value = 1
|
||||
panOffset.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
// 清空画布
|
||||
const clearCanvas = () => {
|
||||
workflowStore.clearCanvas()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.workflow-editor {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
// 左侧节点面板
|
||||
.node-panel {
|
||||
width: 240px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.node-categories {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.category-title {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.node-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.node-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 画布区域
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.canvas-toolbar {
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transform-origin: 0 0;
|
||||
|
||||
.grid-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.connections-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.connection-line {
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
stroke: #ef4444;
|
||||
stroke-width: 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工作流节点
|
||||
.workflow-node {
|
||||
position: absolute;
|
||||
background: #fff;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
|
||||
&.selected {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.node-header {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
|
||||
.node-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.node-body {
|
||||
padding: 8px 12px;
|
||||
min-height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.port {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&.input-port {
|
||||
left: -8px;
|
||||
}
|
||||
|
||||
&.output-port {
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.port-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
border: 2px solid #94a3b8;
|
||||
border-radius: 50%;
|
||||
cursor: crosshair;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧属性面板
|
||||
.property-panel {
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover {
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.property-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
20
江西城市生命线-可交互原型/frontend/src/main.js
Normal file
20
江西城市生命线-可交互原型/frontend/src/main.js
Normal file
@@ -0,0 +1,20 @@
|
||||
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 App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/main.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Register all Element Plus icons
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
62
江西城市生命线-可交互原型/frontend/src/router/index.js
Normal file
62
江西城市生命线-可交互原型/frontend/src/router/index.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import ChatView from '@/views/ChatView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'chat',
|
||||
component: ChatView
|
||||
},
|
||||
{
|
||||
path: '/knowledge',
|
||||
name: 'knowledge',
|
||||
component: () => import('@/views/KnowledgeView.vue')
|
||||
},
|
||||
{
|
||||
path: '/apps',
|
||||
name: 'apps',
|
||||
component: () => import('@/views/AppsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/workflow',
|
||||
name: 'workflow',
|
||||
component: () => import('@/views/WorkflowView.vue')
|
||||
},
|
||||
{
|
||||
path: '/service',
|
||||
name: 'service',
|
||||
component: () => import('@/views/ServiceView.vue')
|
||||
},
|
||||
{
|
||||
path: '/bidding',
|
||||
name: 'bidding',
|
||||
component: () => import('@/views/BiddingView.vue')
|
||||
},
|
||||
{
|
||||
path: '/hazard',
|
||||
name: 'hazard',
|
||||
component: () => import('@/views/HazardView.vue')
|
||||
},
|
||||
{
|
||||
path: '/emergency',
|
||||
name: 'emergency',
|
||||
component: () => import('@/views/EmergencyView.vue')
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('@/views/ProfileView.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: () => import('@/views/AdminView.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
91
江西城市生命线-可交互原型/frontend/src/stores/agent.js
Normal file
91
江西城市生命线-可交互原型/frontend/src/stores/agent.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useAgentStore = defineStore('agent', () => {
|
||||
// 智能体列表
|
||||
const agents = ref([
|
||||
{
|
||||
id: 'default',
|
||||
name: '泰豪AI助手',
|
||||
description: '通用AI智能助手,可以回答各类问题',
|
||||
icon: '🤖',
|
||||
color: '#7c3aed',
|
||||
category: 'office',
|
||||
usage: 25000
|
||||
},
|
||||
{
|
||||
id: 'xiaohongshu',
|
||||
name: '小红书文案生成',
|
||||
description: '一键生成爆款小红书文案,支持多种风格,自动添加热门话题标签',
|
||||
icon: '📕',
|
||||
color: '#ff2442',
|
||||
category: 'content',
|
||||
usage: 12580
|
||||
},
|
||||
{
|
||||
id: 'contract',
|
||||
name: '泰豪合同助手',
|
||||
description: '智能合同审核、条款分析、风险提示,提高合同处理效率',
|
||||
icon: '📄',
|
||||
color: '#7c3aed',
|
||||
category: 'business',
|
||||
usage: 8320
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
name: '泰豪短视频助手',
|
||||
description: '短视频脚本创作、文案优化、热门话题推荐',
|
||||
icon: '🎬',
|
||||
color: '#10b981',
|
||||
category: 'content',
|
||||
usage: 5640
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
name: '邮件写作助手',
|
||||
description: '商务邮件、会议邀请、工作汇报等各类邮件智能生成',
|
||||
icon: '✉️',
|
||||
color: '#6366f1',
|
||||
category: 'office',
|
||||
usage: 7230
|
||||
},
|
||||
{
|
||||
id: 'translate',
|
||||
name: '多语言翻译',
|
||||
description: '支持中英日韩等多语言互译,专业术语精准翻译',
|
||||
icon: '🌐',
|
||||
color: '#14b8a6',
|
||||
category: 'office',
|
||||
usage: 11200
|
||||
}
|
||||
])
|
||||
|
||||
// 当前选中的智能体ID
|
||||
const currentAgentId = ref('default')
|
||||
|
||||
// 当前智能体
|
||||
const currentAgent = computed(() => {
|
||||
return agents.value.find(a => a.id === currentAgentId.value) || agents.value[0]
|
||||
})
|
||||
|
||||
// 设置当前智能体
|
||||
const setCurrentAgent = (agentId) => {
|
||||
const agent = agents.value.find(a => a.id === agentId)
|
||||
if (agent) {
|
||||
currentAgentId.value = agentId
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新智能体
|
||||
const addAgent = (agent) => {
|
||||
agents.value.push(agent)
|
||||
}
|
||||
|
||||
return {
|
||||
agents,
|
||||
currentAgentId,
|
||||
currentAgent,
|
||||
setCurrentAgent,
|
||||
addAgent
|
||||
}
|
||||
})
|
||||
99
江西城市生命线-可交互原型/frontend/src/stores/chat.js
Normal file
99
江西城市生命线-可交互原型/frontend/src/stores/chat.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const messages = ref([])
|
||||
const isLoading = ref(false)
|
||||
const currentSession = ref(null)
|
||||
|
||||
// Mock responses for when backend is not available
|
||||
const mockResponses = {
|
||||
'城市生命线': '城市生命线是指城市中维持城市正常运转的关键基础设施系统,包括供水、供电、供气、通信、交通等系统。这些系统如同城市的"生命线",一旦发生故障,将对城市运行和居民生活产生重大影响。',
|
||||
'关键设施': '城市生命线关键设施包括:\n1. 供水系统:水厂、管网、加压站\n2. 供电系统:变电站、配电网\n3. 供气系统:门站、调压站、管网\n4. 通信系统:基站、光纤网络\n5. 交通系统:道路、桥梁、隧道',
|
||||
'消防安全': '消防安全隐患常见问题及处理措施:\n1. 消防通道堵塞 - 立即清理,保持畅通\n2. 灭火器过期 - 及时更换检修\n3. 电气线路老化 - 专业检测更换\n4. 易燃物品堆放 - 规范存储管理\n5. 消防设施损坏 - 定期检查维护',
|
||||
'排水': '如何平衡排水能力和生态环境保护:\n1. 采用海绵城市理念,增加透水面积\n2. 建设生态调蓄池,减缓雨水径流\n3. 推广透水铺装,促进雨水下渗\n4. 保护自然水系,维护生态平衡\n5. 雨污分流改造,提高污水处理效率'
|
||||
}
|
||||
|
||||
const getMockResponse = (input) => {
|
||||
for (const [key, value] of Object.entries(mockResponses)) {
|
||||
if (input.includes(key)) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return `您好!关于"${input}"的问题,我正在为您查询相关资料。作为城市生命线智能助手,我可以帮助您:\n\n1. 查询城市生命线相关知识\n2. 识别潜在安全隐患\n3. 编制应急预案\n4. 撰写公文报告\n\n请问还有什么可以帮助您的?`
|
||||
}
|
||||
|
||||
const sendMessage = async (content) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await api.post('/chat/send', {
|
||||
content,
|
||||
role: 'user',
|
||||
sessionId: currentSession.value
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.log('Backend not available, using mock response')
|
||||
// Return mock response when backend is not available
|
||||
return {
|
||||
id: Date.now().toString(),
|
||||
content: getMockResponse(content),
|
||||
role: 'assistant',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getHistory = async () => {
|
||||
try {
|
||||
const response = await api.get('/chat/history')
|
||||
messages.value = response.data
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.log('Backend not available')
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const getSuggestions = async () => {
|
||||
try {
|
||||
const response = await api.get('/chat/suggestions')
|
||||
return response.data
|
||||
} catch (error) {
|
||||
return [
|
||||
'城市生命线关键设施有哪些?',
|
||||
'消防安全隐患常见问题以及处理措施有哪些?',
|
||||
'如何平衡排水能力和生态环境保护?'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const newChat = async () => {
|
||||
try {
|
||||
await api.post('/chat/new')
|
||||
messages.value = []
|
||||
currentSession.value = Date.now().toString()
|
||||
} catch (error) {
|
||||
messages.value = []
|
||||
currentSession.value = Date.now().toString()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
currentSession,
|
||||
sendMessage,
|
||||
getHistory,
|
||||
getSuggestions,
|
||||
newChat
|
||||
}
|
||||
})
|
||||
140
江西城市生命线-可交互原型/frontend/src/stores/workflow.js
Normal file
140
江西城市生命线-可交互原型/frontend/src/stores/workflow.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useWorkflowStore = defineStore('workflow', () => {
|
||||
// 节点列表
|
||||
const nodes = ref([])
|
||||
|
||||
// 连接线列表
|
||||
const connections = ref([])
|
||||
|
||||
// 选中的节点ID
|
||||
const selectedNodeId = ref(null)
|
||||
|
||||
// 节点ID计数器
|
||||
let nodeIdCounter = 1
|
||||
|
||||
// 节点类型定义
|
||||
const nodeTypes = [
|
||||
{ type: 'start', label: '开始', icon: '▶', color: '#10b981', category: 'control' },
|
||||
{ type: 'end', label: '结束', icon: '⏹', color: '#ef4444', category: 'control' },
|
||||
{ type: 'llm', label: 'LLM 模型', icon: '🤖', color: '#7c3aed', category: 'ai' },
|
||||
{ type: 'knowledge', label: '知识库', icon: '📚', color: '#3b82f6', category: 'ai' },
|
||||
{ type: 'condition', label: '条件判断', icon: '⋔', color: '#f59e0b', category: 'logic' },
|
||||
{ type: 'code', label: '代码执行', icon: '{ }', color: '#6366f1', category: 'logic' },
|
||||
{ type: 'http', label: 'HTTP 请求', icon: '🌐', color: '#14b8a6', category: 'integration' },
|
||||
{ type: 'variable', label: '变量赋值', icon: '𝑥=', color: '#8b5cf6', category: 'logic' },
|
||||
{ type: 'template', label: '模板转换', icon: '📝', color: '#ec4899', category: 'transform' },
|
||||
{ type: 'loop', label: '循环', icon: '↻', color: '#f97316', category: 'logic' }
|
||||
]
|
||||
|
||||
// 添加节点
|
||||
const addNode = (type, position) => {
|
||||
const nodeType = nodeTypes.find(n => n.type === type)
|
||||
if (!nodeType) return null
|
||||
|
||||
const newNode = {
|
||||
id: `node_${nodeIdCounter++}`,
|
||||
type: type,
|
||||
label: nodeType.label,
|
||||
icon: nodeType.icon,
|
||||
color: nodeType.color,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: 180,
|
||||
height: 60,
|
||||
inputs: type !== 'start' ? [{ id: 'in_1', label: '输入' }] : [],
|
||||
outputs: type !== 'end' ? [{ id: 'out_1', label: '输出' }] : []
|
||||
}
|
||||
|
||||
nodes.value.push(newNode)
|
||||
return newNode
|
||||
}
|
||||
|
||||
// 更新节点位置
|
||||
const updateNodePosition = (nodeId, x, y) => {
|
||||
const node = nodes.value.find(n => n.id === nodeId)
|
||||
if (node) {
|
||||
node.x = x
|
||||
node.y = y
|
||||
}
|
||||
}
|
||||
|
||||
// 删除节点
|
||||
const deleteNode = (nodeId) => {
|
||||
const index = nodes.value.findIndex(n => n.id === nodeId)
|
||||
if (index > -1) {
|
||||
nodes.value.splice(index, 1)
|
||||
// 删除相关连接
|
||||
connections.value = connections.value.filter(
|
||||
c => c.sourceNodeId !== nodeId && c.targetNodeId !== nodeId
|
||||
)
|
||||
}
|
||||
if (selectedNodeId.value === nodeId) {
|
||||
selectedNodeId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 添加连接
|
||||
const addConnection = (sourceNodeId, sourcePortId, targetNodeId, targetPortId) => {
|
||||
// 检查是否已存在相同连接
|
||||
const exists = connections.value.some(
|
||||
c => c.sourceNodeId === sourceNodeId &&
|
||||
c.sourcePortId === sourcePortId &&
|
||||
c.targetNodeId === targetNodeId &&
|
||||
c.targetPortId === targetPortId
|
||||
)
|
||||
if (exists) return null
|
||||
|
||||
const newConnection = {
|
||||
id: `conn_${Date.now()}`,
|
||||
sourceNodeId,
|
||||
sourcePortId,
|
||||
targetNodeId,
|
||||
targetPortId
|
||||
}
|
||||
connections.value.push(newConnection)
|
||||
return newConnection
|
||||
}
|
||||
|
||||
// 删除连接
|
||||
const deleteConnection = (connectionId) => {
|
||||
const index = connections.value.findIndex(c => c.id === connectionId)
|
||||
if (index > -1) {
|
||||
connections.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 选中节点
|
||||
const selectNode = (nodeId) => {
|
||||
selectedNodeId.value = nodeId
|
||||
}
|
||||
|
||||
// 清空画布
|
||||
const clearCanvas = () => {
|
||||
nodes.value = []
|
||||
connections.value = []
|
||||
selectedNodeId.value = null
|
||||
nodeIdCounter = 1
|
||||
}
|
||||
|
||||
// 获取节点
|
||||
const getNode = (nodeId) => {
|
||||
return nodes.value.find(n => n.id === nodeId)
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
connections,
|
||||
selectedNodeId,
|
||||
nodeTypes,
|
||||
addNode,
|
||||
updateNodePosition,
|
||||
deleteNode,
|
||||
addConnection,
|
||||
deleteConnection,
|
||||
selectNode,
|
||||
clearCanvas,
|
||||
getNode
|
||||
}
|
||||
})
|
||||
72
江西城市生命线-可交互原型/frontend/src/styles/main.scss
Normal file
72
江西城市生命线-可交互原型/frontend/src/styles/main.scss
Normal file
@@ -0,0 +1,72 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #7c3aed;
|
||||
--primary-light: #a78bfa;
|
||||
--primary-dark: #5b21b6;
|
||||
--sidebar-bg: #4a1d7e;
|
||||
--sidebar-hover: rgba(255, 255, 255, 0.1);
|
||||
--sidebar-active: rgba(255, 255, 255, 0.2);
|
||||
--text-white: #ffffff;
|
||||
--text-gray: #9ca3af;
|
||||
--border-color: #e5e7eb;
|
||||
--card-bg: #ffffff;
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
// Scrollbar styles
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
// Input styles
|
||||
input, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
// Button reset
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
// Link reset
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// List reset
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
}
|
||||
4051
江西城市生命线-可交互原型/frontend/src/views/AdminView.vue
Normal file
4051
江西城市生命线-可交互原型/frontend/src/views/AdminView.vue
Normal file
File diff suppressed because it is too large
Load Diff
453
江西城市生命线-可交互原型/frontend/src/views/AgentChatView.vue
Normal file
453
江西城市生命线-可交互原型/frontend/src/views/AgentChatView.vue
Normal file
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div class="agent-chat-view">
|
||||
<!-- Header -->
|
||||
<header class="chat-header">
|
||||
<div class="header-left">
|
||||
<el-button :icon="ArrowLeft" circle size="small" @click="goBack" />
|
||||
<div class="agent-info">
|
||||
<span class="agent-name">{{ agentName }}</span>
|
||||
<span class="agent-status">在线</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Chat Content -->
|
||||
<div class="chat-content" ref="chatContentRef">
|
||||
<!-- Welcome Message -->
|
||||
<div v-if="messages.length === 0" class="welcome-section">
|
||||
<div class="ai-avatar">
|
||||
<span class="avatar-emoji">{{ agentEmoji }}</span>
|
||||
</div>
|
||||
<h2 class="welcome-title">{{ agentName }}</h2>
|
||||
<p class="welcome-text">{{ agentDescription }}</p>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="quick-actions">
|
||||
<div
|
||||
v-for="(action, index) in quickActions"
|
||||
:key="index"
|
||||
class="action-item"
|
||||
@click="handleQuickAction(action)"
|
||||
>
|
||||
{{ action }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Messages -->
|
||||
<div v-else class="messages-container">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="message"
|
||||
:class="message.role"
|
||||
>
|
||||
<div class="message-avatar">
|
||||
<span v-if="message.role === 'assistant'" class="ai-avatar-small">{{ agentEmoji }}</span>
|
||||
<div v-else class="user-avatar-small">👤</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-text">{{ message.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div v-if="isLoading" class="message assistant">
|
||||
<div class="message-avatar">
|
||||
<span class="ai-avatar-small">{{ agentEmoji }}</span>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="input-area">
|
||||
<div class="input-wrapper">
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
:placeholder="inputPlaceholder"
|
||||
@keydown.enter.prevent="handleSend"
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button class="send-btn" @click="handleSend" :disabled="!inputText.trim()">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Promotion } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const inputText = ref('')
|
||||
const isLoading = ref(false)
|
||||
const chatContentRef = ref(null)
|
||||
const messages = ref([])
|
||||
|
||||
const agentId = computed(() => route.query.id || 'default')
|
||||
const agentName = computed(() => route.query.name || 'AI助手')
|
||||
|
||||
const agentConfig = {
|
||||
xiaohongshu: {
|
||||
emoji: '📕',
|
||||
description: '我是小红书文案生成助手,可以帮你创作爆款笔记文案,支持种草、测评、教程等多种风格。',
|
||||
placeholder: '描述你想要的文案主题,如:推荐一款平价护肤品...',
|
||||
quickActions: ['帮我写一篇美食探店笔记', '生成护肤品种草文案', '写一篇旅行攻略', '创作穿搭分享文案']
|
||||
},
|
||||
contract: {
|
||||
emoji: '📄',
|
||||
description: '我是泰豪合同助手,可以帮你审核合同条款、识别风险点、提供修改建议。',
|
||||
placeholder: '请粘贴合同内容或描述你的需求...',
|
||||
quickActions: ['审核这份合同的风险点', '解读合同关键条款', '生成合同模板', '对比两份合同差异']
|
||||
},
|
||||
video: {
|
||||
emoji: '🎬',
|
||||
description: '我是泰豪短视频助手,专注于短视频脚本创作、文案优化和热门话题推荐。',
|
||||
placeholder: '告诉我你想创作什么类型的短视频...',
|
||||
quickActions: ['写一个产品介绍脚本', '生成热门话题文案', '优化视频标题', '创作口播文案']
|
||||
},
|
||||
bidding: {
|
||||
emoji: '📋',
|
||||
description: '我是招标文件助手,可以帮你解读招标文件、生成投标方案、进行合规性检查。',
|
||||
placeholder: '请描述你的招标需求或粘贴招标文件内容...',
|
||||
quickActions: ['解读招标文件要求', '生成投标技术方案', '检查投标文件合规性', '分析竞争对手情况']
|
||||
},
|
||||
service: {
|
||||
emoji: '⚡',
|
||||
description: '我是泰豪小电智能客服,可以帮您解答电力相关问题、查询用电信息、办理业务咨询。',
|
||||
placeholder: '请描述您的用电问题或业务需求...',
|
||||
quickActions: ['查询本月用电量', '咨询电费账单', '报修故障', '了解优惠政策']
|
||||
},
|
||||
default: {
|
||||
emoji: '🤖',
|
||||
description: '我是您的AI智能助手,有什么可以帮助您的?',
|
||||
placeholder: '请输入您的问题...',
|
||||
quickActions: ['介绍一下你的功能', '帮我写一段文案', '回答一个问题', '帮我分析数据']
|
||||
}
|
||||
}
|
||||
|
||||
const currentConfig = computed(() => agentConfig[agentId.value] || agentConfig.default)
|
||||
const agentEmoji = computed(() => currentConfig.value.emoji)
|
||||
const agentDescription = computed(() => currentConfig.value.description)
|
||||
const inputPlaceholder = computed(() => currentConfig.value.placeholder)
|
||||
const quickActions = computed(() => currentConfig.value.quickActions)
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/apps')
|
||||
}
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
if (chatContentRef.value) {
|
||||
chatContentRef.value.scrollTop = chatContentRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickAction = (action) => {
|
||||
inputText.value = action
|
||||
handleSend()
|
||||
}
|
||||
|
||||
const generateResponse = (input) => {
|
||||
const responses = {
|
||||
xiaohongshu: `好的,我来帮你生成一篇小红书文案!\n\n📝 **${input}**\n\n---\n\n姐妹们!今天必须给你们安利这个宝藏好物!✨\n\n用了一周真的爱不释手~效果绝绝子!\n\n💫 亮点:\n• 性价比超高\n• 效果立竿见影\n• 回购无限次\n\n🏷️ #好物分享 #真实测评 #平价好物 #必入清单\n\n---\n\n这篇文案你觉得怎么样?需要我调整风格或者添加更多内容吗?`,
|
||||
contract: `我已经分析了您的需求,以下是我的建议:\n\n📋 **合同审核要点**\n\n1. **主体资格** - 需确认双方签约主体的合法性\n2. **权利义务** - 条款表述需更加明确\n3. **违约责任** - 建议增加违约金上限条款\n4. **争议解决** - 建议约定明确的管辖法院\n\n⚠️ **风险提示**\n• 付款条件需细化\n• 交付标准需量化\n• 保密条款期限建议延长\n\n需要我针对某个条款详细解读吗?`,
|
||||
video: `好的!我来为您创作短视频脚本:\n\n🎬 **${input}**\n\n---\n\n**【开场 0-3秒】**\n钩子:一句话抓住注意力\n\n**【正文 3-50秒】**\n• 痛点引入\n• 解决方案\n• 效果展示\n\n**【结尾 50-60秒】**\n引导互动 + 关注提示\n\n---\n\n🔥 推荐话题:\n#热门挑战 #干货分享 #涨知识\n\n需要我把脚本内容写得更详细吗?`,
|
||||
bidding: `收到您的招标需求:"${input}"\n\n📋 **招标文件分析**\n\n我已为您解读关键要点:\n\n**1. 资质要求**\n• 企业资质等级要求\n• 业绩证明材料\n• 人员配置要求\n\n**2. 技术要求**\n• 技术方案要点\n• 实施计划安排\n• 质量保证措施\n\n**3. 商务要求**\n• 报价构成说明\n• 付款方式条款\n• 履约保证金\n\n⚠️ **注意事项**\n• 投标截止时间\n• 必须响应的条款\n• 否决性条款\n\n需要我帮您生成投标技术方案吗?`,
|
||||
service: `您好!感谢使用泰豪小电智能客服 ⚡\n\n关于您的问题:"${input}"\n\n📊 **查询结果**\n\n我已为您查询到相关信息:\n\n**用电账户信息**\n• 户号:3201****8856\n• 用户类型:居民用电\n• 电价:0.52元/度\n\n**本月用电情况**\n• 当前读数:2856度\n• 本月用电:186度\n• 预计电费:96.72元\n\n💡 **温馨提示**\n• 您可以通过微信公众号缴费\n• 峰谷电价可节省约15%电费\n• 如有故障可拨打24小时热线\n\n还有其他问题需要帮助吗?`,
|
||||
default: `收到您的问题:"${input}"\n\n我来为您详细解答:\n\n这是一个很好的问题!根据我的分析...\n\n如果您还有其他问题,随时可以问我!`
|
||||
}
|
||||
|
||||
return responses[agentId.value] || responses.default
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || isLoading.value) return
|
||||
|
||||
messages.value.push({
|
||||
id: Date.now().toString(),
|
||||
content: text,
|
||||
role: 'user'
|
||||
})
|
||||
inputText.value = ''
|
||||
|
||||
await scrollToBottom()
|
||||
isLoading.value = true
|
||||
|
||||
// Simulate API delay
|
||||
setTimeout(async () => {
|
||||
messages.value.push({
|
||||
id: Date.now().toString() + '_ai',
|
||||
content: generateResponse(text),
|
||||
role: 'assistant'
|
||||
})
|
||||
isLoading.value = false
|
||||
await scrollToBottom()
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.agent-chat-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.agent-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.agent-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.agent-status {
|
||||
font-size: 12px;
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 40px 80px;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
|
||||
.ai-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.avatar-emoji {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
max-width: 600px;
|
||||
|
||||
.action-item {
|
||||
padding: 10px 20px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
&.user {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-content .message-text {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
flex-shrink: 0;
|
||||
|
||||
.ai-avatar-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.user-avatar-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
|
||||
.message-text {
|
||||
padding: 14px 18px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 16px;
|
||||
color: #1f2937;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 16px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #9ca3af;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: -0.32s; }
|
||||
&:nth-child(2) { animation-delay: -0.16s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.input-area {
|
||||
padding: 20px 80px 30px;
|
||||
background: #fff;
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
min-height: 24px;
|
||||
max-height: 120px;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #7c3aed;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #5b21b6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
473
江西城市生命线-可交互原型/frontend/src/views/AppsView.vue
Normal file
473
江西城市生命线-可交互原型/frontend/src/views/AppsView.vue
Normal file
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<div class="apps-view">
|
||||
<header class="page-header">
|
||||
<h1>全部应用</h1>
|
||||
<p>选择智能体开始对话,助力您的工作效率提升</p>
|
||||
</header>
|
||||
|
||||
<div class="toolbar-section">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索智能体..."
|
||||
size="large"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
class="search-input"
|
||||
/>
|
||||
<el-button type="primary" size="large" @click="showCreateDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增智能体
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="category-tabs">
|
||||
<span
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
class="tab-item"
|
||||
:class="{ active: activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
>
|
||||
{{ cat.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="agents-grid">
|
||||
<div
|
||||
v-for="agent in filteredAgents"
|
||||
:key="agent.id"
|
||||
class="agent-card"
|
||||
@click="handleAgentClick(agent)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="agent-icon" :style="{ background: agent.imageUrl ? 'transparent' : agent.color }">
|
||||
<img v-if="agent.imageUrl" :src="agent.imageUrl" :alt="agent.name" />
|
||||
<span v-else>{{ agent.icon }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="agent-name">{{ agent.name }}</h3>
|
||||
<p class="agent-desc">{{ agent.description }}</p>
|
||||
<div class="card-footer">
|
||||
<span class="usage-count">{{ agent.usage }} 次使用</span>
|
||||
<el-button type="primary" size="small" round>开始对话</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增智能体对话框 -->
|
||||
<el-dialog
|
||||
v-model="showCreateDialog"
|
||||
title="新增智能体"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form :model="newAgent" label-width="80px" label-position="top">
|
||||
<el-form-item label="智能体图片">
|
||||
<div class="upload-area" @click="triggerUpload">
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
accept="image/*"
|
||||
@change="handleFileChange"
|
||||
style="display: none"
|
||||
/>
|
||||
<div v-if="newAgent.imageUrl" class="preview-image">
|
||||
<img :src="newAgent.imageUrl" alt="preview" />
|
||||
<div class="change-tip">点击更换</div>
|
||||
</div>
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<span>上传图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="智能体名称">
|
||||
<el-input v-model="newAgent.name" placeholder="请输入智能体名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="智能体介绍">
|
||||
<el-input
|
||||
v-model="newAgent.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入智能体功能介绍"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="API链接">
|
||||
<el-input v-model="newAgent.apiUrl" placeholder="请输入API接口地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="newAgent.category" placeholder="请选择分类" style="width: 100%">
|
||||
<el-option label="内容创作" value="content" />
|
||||
<el-option label="办公助手" value="office" />
|
||||
<el-option label="业务助手" value="business" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCreateAgent">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search, Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
|
||||
const router = useRouter()
|
||||
const agentStore = useAgentStore()
|
||||
const searchText = ref('')
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const categories = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'content', label: '内容创作' },
|
||||
{ key: 'office', label: '办公助手' },
|
||||
{ key: 'business', label: '业务助手' }
|
||||
]
|
||||
|
||||
const agents = ref([
|
||||
{
|
||||
id: 'xiaohongshu',
|
||||
name: '小红书文案生成',
|
||||
description: '一键生成爆款小红书文案,支持多种风格,自动添加热门话题标签',
|
||||
icon: '📕',
|
||||
color: '#ff2442',
|
||||
category: 'content',
|
||||
usage: 12580
|
||||
},
|
||||
{
|
||||
id: 'contract',
|
||||
name: '泰豪合同助手',
|
||||
description: '智能合同审核、条款分析、风险提示,提高合同处理效率',
|
||||
icon: '📄',
|
||||
color: '#7c3aed',
|
||||
category: 'business',
|
||||
usage: 8320
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
name: '泰豪短视频助手',
|
||||
description: '短视频脚本创作、文案优化、热门话题推荐',
|
||||
icon: '🎬',
|
||||
color: '#10b981',
|
||||
category: 'content',
|
||||
usage: 5640
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
name: '邮件写作助手',
|
||||
description: '商务邮件、会议邀请、工作汇报等各类邮件智能生成',
|
||||
icon: '✉️',
|
||||
color: '#6366f1',
|
||||
category: 'office',
|
||||
usage: 7230
|
||||
},
|
||||
{
|
||||
id: 'translate',
|
||||
name: '多语言翻译',
|
||||
description: '支持中英日韩等多语言互译,专业术语精准翻译',
|
||||
icon: '🌐',
|
||||
color: '#14b8a6',
|
||||
category: 'office',
|
||||
usage: 11200
|
||||
}
|
||||
])
|
||||
|
||||
const filteredAgents = computed(() => {
|
||||
let result = agents.value
|
||||
|
||||
if (activeCategory.value !== 'all') {
|
||||
result = result.filter(a => a.category === activeCategory.value)
|
||||
}
|
||||
|
||||
if (searchText.value) {
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
result = result.filter(a =>
|
||||
a.name.toLowerCase().includes(keyword) ||
|
||||
a.description.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const handleAgentClick = (agent) => {
|
||||
// 先设置当前智能体
|
||||
agentStore.setCurrentAgent(agent.id)
|
||||
router.push({
|
||||
path: '/',
|
||||
query: { agentId: agent.id }
|
||||
})
|
||||
}
|
||||
|
||||
// 新增智能体相关
|
||||
const showCreateDialog = ref(false)
|
||||
const fileInput = ref(null)
|
||||
const newAgent = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
apiUrl: '',
|
||||
category: '',
|
||||
imageUrl: ''
|
||||
})
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
newAgent.value.imageUrl = event.target.result
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateAgent = () => {
|
||||
if (!newAgent.value.name) {
|
||||
ElMessage.warning('请输入智能体名称')
|
||||
return
|
||||
}
|
||||
if (!newAgent.value.description) {
|
||||
ElMessage.warning('请输入智能体介绍')
|
||||
return
|
||||
}
|
||||
|
||||
// 生成随机颜色
|
||||
const colors = ['#7c3aed', '#10b981', '#f59e0b', '#3b82f6', '#ef4444', '#6366f1', '#14b8a6']
|
||||
const randomColor = colors[Math.floor(Math.random() * colors.length)]
|
||||
|
||||
// 添加新智能体
|
||||
agents.value.unshift({
|
||||
id: 'custom_' + Date.now(),
|
||||
name: newAgent.value.name,
|
||||
description: newAgent.value.description,
|
||||
icon: newAgent.value.imageUrl ? '' : '🤖',
|
||||
imageUrl: newAgent.value.imageUrl,
|
||||
color: randomColor,
|
||||
category: newAgent.value.category || 'office',
|
||||
apiUrl: newAgent.value.apiUrl,
|
||||
usage: 0
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
newAgent.value = {
|
||||
name: '',
|
||||
description: '',
|
||||
apiUrl: '',
|
||||
category: '',
|
||||
imageUrl: ''
|
||||
}
|
||||
showCreateDialog.value = false
|
||||
ElMessage.success('智能体创建成功')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.apps-view {
|
||||
padding: 32px 48px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.search-input {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.tab-item {
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agents-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.12);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.agent-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.agent-desc {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.usage-count {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #9ca3af;
|
||||
|
||||
.upload-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.change-tip {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
&:hover .change-tip {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
18847
江西城市生命线-可交互原型/frontend/src/views/BiddingView.vue
Normal file
18847
江西城市生命线-可交互原型/frontend/src/views/BiddingView.vue
Normal file
File diff suppressed because it is too large
Load Diff
899
江西城市生命线-可交互原型/frontend/src/views/ChatView.vue
Normal file
899
江西城市生命线-可交互原型/frontend/src/views/ChatView.vue
Normal file
@@ -0,0 +1,899 @@
|
||||
<template>
|
||||
<div class="chat-view">
|
||||
<!-- Left Sidebar - ChatGPT Style -->
|
||||
<aside class="chat-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-header">
|
||||
<button class="collapse-toggle" @click="sidebarCollapsed = !sidebarCollapsed">
|
||||
<el-icon><Fold v-if="!sidebarCollapsed" /><Expand v-else /></el-icon>
|
||||
<span v-if="!sidebarCollapsed">{{ sidebarCollapsed ? '展开' : '收起' }}</span>
|
||||
</button>
|
||||
<button class="new-chat-btn" @click="handleNewChat">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span v-if="!sidebarCollapsed">新建对话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!sidebarCollapsed" class="conversations-list">
|
||||
<div class="list-section">
|
||||
<div class="section-title">今天</div>
|
||||
<div
|
||||
v-for="conv in todayConversations"
|
||||
:key="conv.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConversationId === conv.id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span class="conv-title">{{ conv.title }}</span>
|
||||
<div class="conv-actions">
|
||||
<el-icon class="action-icon" @click.stop="deleteConversation(conv.id)"><Delete /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-section" v-if="olderConversations.length > 0">
|
||||
<div class="section-title">历史记录</div>
|
||||
<div
|
||||
v-for="conv in olderConversations"
|
||||
:key="conv.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConversationId === conv.id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span class="conv-title">{{ conv.title }}</span>
|
||||
<div class="conv-actions">
|
||||
<el-icon class="action-icon" @click.stop="deleteConversation(conv.id)"><Delete /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<div class="chat-main">
|
||||
<!-- Header -->
|
||||
<header class="chat-header">
|
||||
<el-dropdown trigger="click" @command="handleAgentChange" class="agent-dropdown">
|
||||
<div class="header-title">
|
||||
<span class="agent-icon">{{ currentAgent.icon }}</span>
|
||||
<span>{{ currentAgent.name }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
:command="agent.id"
|
||||
:class="{ 'is-active': agent.id === currentAgent.id }"
|
||||
>
|
||||
<span class="dropdown-agent-icon">{{ agent.icon }}</span>
|
||||
<span>{{ agent.name }}</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</header>
|
||||
|
||||
<!-- Chat Content -->
|
||||
<div class="chat-content" ref="chatContentRef">
|
||||
<!-- Welcome Message -->
|
||||
<div v-if="messages.length === 0" class="welcome-section">
|
||||
<div class="ai-avatar">
|
||||
<div class="avatar-icon" :style="{ background: currentAgent.color }">
|
||||
{{ currentAgent.icon }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="welcome-text">
|
||||
{{ currentAgent.description }}
|
||||
</p>
|
||||
<h2 class="welcome-title">{{ welcomeTitle }}</h2>
|
||||
|
||||
<!-- Suggestion Cards -->
|
||||
<div class="suggestion-cards">
|
||||
<div
|
||||
v-for="(suggestion, index) in currentSuggestions"
|
||||
:key="index"
|
||||
class="suggestion-card"
|
||||
@click="handleSuggestionClick(suggestion)"
|
||||
>
|
||||
<div class="card-icon" :style="{ background: currentAgent.color }">
|
||||
<component :is="cardIcons[index % cardIcons.length]" />
|
||||
</div>
|
||||
<p class="card-text">{{ suggestion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Messages -->
|
||||
<div v-else class="messages-container">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="message"
|
||||
:class="message.role"
|
||||
>
|
||||
<div class="message-avatar">
|
||||
<img v-if="message.role === 'assistant'" src="/logo.jpg" alt="AI" class="ai-avatar-small" />
|
||||
<div v-else class="user-avatar-small">👤</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-text">{{ message.content }}</div>
|
||||
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div v-if="isLoading" class="message assistant">
|
||||
<div class="message-avatar">
|
||||
<img src="/logo.jpg" alt="AI" class="ai-avatar-small" />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="input-area">
|
||||
<div class="input-wrapper">
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
placeholder="请输入内容..."
|
||||
@keydown.enter.prevent="handleSend"
|
||||
rows="1"
|
||||
ref="textareaRef"
|
||||
></textarea>
|
||||
<div class="input-actions">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn" title="附件">
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
</button>
|
||||
<button class="action-btn" title="表情">
|
||||
<el-icon><Star /></el-icon>
|
||||
</button>
|
||||
<button class="action-btn" title="图片">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</button>
|
||||
<button class="action-btn" title="更多">
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</button>
|
||||
<button class="action-btn" title="截图">
|
||||
<el-icon><CameraFilled /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="send-actions">
|
||||
<button class="action-btn" title="语音">
|
||||
<el-icon><Microphone /></el-icon>
|
||||
</button>
|
||||
<button class="send-btn" @click="handleSend" :disabled="!inputText.trim()">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import {
|
||||
ArrowDown,
|
||||
Paperclip,
|
||||
Star,
|
||||
Picture,
|
||||
MoreFilled,
|
||||
CameraFilled,
|
||||
Microphone,
|
||||
Promotion,
|
||||
Mute,
|
||||
OfficeBuilding,
|
||||
Warning,
|
||||
Cloudy,
|
||||
Plus,
|
||||
Fold,
|
||||
Expand,
|
||||
ChatDotRound,
|
||||
Delete
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
|
||||
const route = useRoute()
|
||||
const chatStore = useChatStore()
|
||||
const agentStore = useAgentStore()
|
||||
|
||||
// 智能体相关
|
||||
const agents = computed(() => agentStore.agents)
|
||||
const currentAgent = computed(() => agentStore.currentAgent)
|
||||
|
||||
const inputText = ref('')
|
||||
const isLoading = ref(false)
|
||||
const chatContentRef = ref(null)
|
||||
const textareaRef = ref(null)
|
||||
const sidebarCollapsed = ref(false)
|
||||
const currentConversationId = ref(null)
|
||||
|
||||
// 对话历史记录
|
||||
const conversations = ref([
|
||||
{ id: 1, title: '城市生命线关键设施咨询', date: new Date(), messages: [] },
|
||||
{ id: 2, title: '消防安全隐患处理方案', date: new Date(), messages: [] },
|
||||
{ id: 3, title: '排水系统优化建议', date: new Date(Date.now() - 86400000), messages: [] },
|
||||
{ id: 4, title: '应急预案讨论', date: new Date(Date.now() - 172800000), messages: [] }
|
||||
])
|
||||
|
||||
// 今天的对话
|
||||
const todayConversations = computed(() => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return conversations.value.filter(conv => new Date(conv.date) >= today)
|
||||
})
|
||||
|
||||
// 历史对话
|
||||
const olderConversations = computed(() => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return conversations.value.filter(conv => new Date(conv.date) < today)
|
||||
})
|
||||
|
||||
const messages = ref([])
|
||||
|
||||
// 各智能体的建议内容
|
||||
const agentSuggestions = {
|
||||
default: [
|
||||
'城市生命线关键设施有哪些?',
|
||||
'消防安全隐患常见问题以及处理措施有哪些?',
|
||||
'如何平衡排水能力和生态环境保护?'
|
||||
],
|
||||
xiaohongshu: [
|
||||
'帮我写一篇关于美食探店的小红书文案',
|
||||
'生成一篇旅行打卡的种草笔记',
|
||||
'写一篇护肤心得分享文案'
|
||||
],
|
||||
contract: [
|
||||
'帮我审核这份合同的风险点',
|
||||
'分析合同中的关键条款',
|
||||
'生成一份标准服务合同模板'
|
||||
],
|
||||
video: [
|
||||
'帮我写一个产品介绍短视频脚本',
|
||||
'生成一个美食探店的视频文案',
|
||||
'写一个知识科普类短视频脚本'
|
||||
],
|
||||
email: [
|
||||
'帮我写一封商务合作邀请邮件',
|
||||
'生成一封会议通知邮件',
|
||||
'写一封工作周报汇报邮件'
|
||||
],
|
||||
translate: [
|
||||
'将这段中文翻译成英文',
|
||||
'帮我翻译这份技术文档',
|
||||
'将这段日文翻译成中文'
|
||||
]
|
||||
}
|
||||
|
||||
// 各智能体的欢迎标题
|
||||
const agentWelcomeTitles = {
|
||||
default: '今天需要我帮你做点什么吗?',
|
||||
xiaohongshu: '想要创作什么样的爆款文案?',
|
||||
contract: '需要我帮你处理什么合同?',
|
||||
video: '想要创作什么样的短视频?',
|
||||
email: '需要我帮你写什么邮件?',
|
||||
translate: '需要翻译什么内容?'
|
||||
}
|
||||
|
||||
// 当前智能体的建议
|
||||
const currentSuggestions = computed(() => {
|
||||
return agentSuggestions[currentAgent.value.id] || agentSuggestions.default
|
||||
})
|
||||
|
||||
// 当前欢迎标题
|
||||
const welcomeTitle = computed(() => {
|
||||
return agentWelcomeTitles[currentAgent.value.id] || agentWelcomeTitles.default
|
||||
})
|
||||
|
||||
const cardIcons = [OfficeBuilding, Warning, Cloudy]
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
if (chatContentRef.value) {
|
||||
chatContentRef.value.scrollTop = chatContentRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuggestionClick = async (suggestion) => {
|
||||
inputText.value = suggestion
|
||||
await handleSend()
|
||||
}
|
||||
|
||||
// 新建对话
|
||||
const handleNewChat = () => {
|
||||
const newConv = {
|
||||
id: Date.now(),
|
||||
title: '新对话',
|
||||
date: new Date(),
|
||||
messages: []
|
||||
}
|
||||
conversations.value.unshift(newConv)
|
||||
currentConversationId.value = newConv.id
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
// 选择对话
|
||||
const selectConversation = (conv) => {
|
||||
currentConversationId.value = conv.id
|
||||
messages.value = conv.messages || []
|
||||
}
|
||||
|
||||
// 删除对话
|
||||
const deleteConversation = (id) => {
|
||||
const index = conversations.value.findIndex(c => c.id === id)
|
||||
if (index > -1) {
|
||||
conversations.value.splice(index, 1)
|
||||
if (currentConversationId.value === id) {
|
||||
currentConversationId.value = null
|
||||
messages.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换智能体
|
||||
const handleAgentChange = (agentId) => {
|
||||
agentStore.setCurrentAgent(agentId)
|
||||
// 切换智能体时清空对话
|
||||
messages.value = []
|
||||
currentConversationId.value = null
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || isLoading.value) return
|
||||
|
||||
// Add user message
|
||||
const userMessage = {
|
||||
id: Date.now().toString(),
|
||||
content: text,
|
||||
role: 'user',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
messages.value.push(userMessage)
|
||||
inputText.value = ''
|
||||
|
||||
await scrollToBottom()
|
||||
|
||||
// Show loading
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await chatStore.sendMessage(text)
|
||||
|
||||
// Add assistant message
|
||||
const assistantMessage = {
|
||||
id: response.id || Date.now().toString() + '_ai',
|
||||
content: response.content,
|
||||
role: 'assistant',
|
||||
timestamp: response.timestamp || new Date().toISOString()
|
||||
}
|
||||
messages.value.push(assistantMessage)
|
||||
} catch (error) {
|
||||
console.error('Send message error:', error)
|
||||
// Add error message
|
||||
messages.value.push({
|
||||
id: Date.now().toString() + '_error',
|
||||
content: '抱歉,发送失败,请稍后重试。',
|
||||
role: 'assistant',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
await scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 处理路由参数中的智能体ID
|
||||
const agentId = route.query.agentId
|
||||
if (agentId) {
|
||||
agentStore.setCurrentAgent(agentId)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await chatStore.getSuggestions()
|
||||
if (data && data.length > 0) {
|
||||
suggestions.value = data
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Using default suggestions')
|
||||
}
|
||||
})
|
||||
|
||||
// 监听路由变化
|
||||
watch(() => route.query.agentId, (newAgentId) => {
|
||||
if (newAgentId) {
|
||||
agentStore.setCurrentAgent(newAgentId)
|
||||
messages.value = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-view {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// 左侧侧边栏样式 - ChatGPT Style
|
||||
.chat-sidebar {
|
||||
width: 260px;
|
||||
background: #f7f7f8;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&.collapsed {
|
||||
width: 60px;
|
||||
|
||||
.new-chat-btn {
|
||||
padding: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.list-section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.section-title {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #374151;
|
||||
|
||||
&:hover {
|
||||
background: #e5e7eb;
|
||||
|
||||
.conv-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-actions {
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.action-icon {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover {
|
||||
background: #d1d5db;
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主聊天区域
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
.agent-dropdown {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.agent-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-agent-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item.is-active) {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 40px 80px;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
|
||||
.ai-avatar {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
object-fit: contain;
|
||||
background: #f3f4f6;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.avatar-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-cards {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
max-width: 800px;
|
||||
|
||||
.suggestion-card {
|
||||
width: 220px;
|
||||
padding: 20px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
&.user {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-content {
|
||||
align-items: flex-end;
|
||||
|
||||
.message-text {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
flex-shrink: 0;
|
||||
|
||||
.ai-avatar-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
background: #f3f4f6;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.user-avatar-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 70%;
|
||||
|
||||
.message-text {
|
||||
padding: 12px 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 12px;
|
||||
color: #1f2937;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 16px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #9ca3af;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: -0.32s; }
|
||||
&:nth-child(2) { animation-delay: -0.16s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.input-area {
|
||||
padding: 20px 80px 30px;
|
||||
background: #fff;
|
||||
|
||||
.input-wrapper {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
min-height: 24px;
|
||||
max-height: 120px;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
|
||||
.action-buttons, .send-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #7c3aed;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #5b21b6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
179
江西城市生命线-可交互原型/frontend/src/views/EmergencyView.vue
Normal file
179
江西城市生命线-可交互原型/frontend/src/views/EmergencyView.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="emergency-view">
|
||||
<header class="page-header">
|
||||
<h1>应急预案</h1>
|
||||
<p>城市生命线应急预案管理,支持智能生成和编辑</p>
|
||||
</header>
|
||||
|
||||
<div class="action-bar">
|
||||
<el-button type="primary">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建预案
|
||||
</el-button>
|
||||
<el-button>
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
AI生成
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="plan-grid">
|
||||
<div v-for="plan in plans" :key="plan.id" class="plan-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon" :style="{ background: plan.color }">
|
||||
<el-icon><component :is="plan.icon" /></el-icon>
|
||||
</div>
|
||||
<el-tag size="small" :type="plan.statusType">{{ plan.status }}</el-tag>
|
||||
</div>
|
||||
<h3>{{ plan.title }}</h3>
|
||||
<p>{{ plan.description }}</p>
|
||||
<div class="card-footer">
|
||||
<span class="update-time">更新于 {{ plan.updateTime }}</span>
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" link size="small">编辑</el-button>
|
||||
<el-button type="primary" link size="small">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Plus, MagicStick, Warning, Cloudy, Lightning, SetUp } from '@element-plus/icons-vue'
|
||||
|
||||
const plans = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '供水管网爆管应急预案',
|
||||
description: '针对供水管网突发爆管事故的应急处置流程和措施',
|
||||
icon: 'Cloudy',
|
||||
color: '#3b82f6',
|
||||
status: '已发布',
|
||||
statusType: 'success',
|
||||
updateTime: '2024-01-15'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '燃气泄漏应急预案',
|
||||
description: '燃气管道泄漏事故的紧急响应和处置方案',
|
||||
icon: 'Warning',
|
||||
color: '#ef4444',
|
||||
status: '已发布',
|
||||
statusType: 'success',
|
||||
updateTime: '2024-01-10'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '电力故障应急预案',
|
||||
description: '大面积停电事故的应急响应和恢复方案',
|
||||
icon: 'Lightning',
|
||||
color: '#f59e0b',
|
||||
status: '草稿',
|
||||
statusType: 'info',
|
||||
updateTime: '2024-01-18'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '城市内涝应急预案',
|
||||
description: '暴雨导致城市内涝的预防和应急处置措施',
|
||||
icon: 'SetUp',
|
||||
color: '#10b981',
|
||||
status: '审核中',
|
||||
statusType: 'warning',
|
||||
updateTime: '2024-01-20'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.emergency-view {
|
||||
padding: 32px 48px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.plan-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
|
||||
.plan-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
|
||||
.update-time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
180
江西城市生命线-可交互原型/frontend/src/views/HazardView.vue
Normal file
180
江西城市生命线-可交互原型/frontend/src/views/HazardView.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="hazard-view">
|
||||
<header class="page-header">
|
||||
<h1>隐患识别</h1>
|
||||
<p>智能识别城市生命线潜在安全隐患,提供预警和处置建议</p>
|
||||
</header>
|
||||
|
||||
<div class="stats-section">
|
||||
<div class="stat-card" v-for="stat in stats" :key="stat.label">
|
||||
<div class="stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hazard-list">
|
||||
<div class="list-header">
|
||||
<h2>隐患列表</h2>
|
||||
<el-button type="primary" size="small">
|
||||
<el-icon><Plus /></el-icon>
|
||||
上报隐患
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="hazard-table">
|
||||
<div class="table-header">
|
||||
<span class="col-type">类型</span>
|
||||
<span class="col-desc">描述</span>
|
||||
<span class="col-location">位置</span>
|
||||
<span class="col-level">等级</span>
|
||||
<span class="col-status">状态</span>
|
||||
<span class="col-action">操作</span>
|
||||
</div>
|
||||
<div v-for="item in hazardList" :key="item.id" class="table-row">
|
||||
<span class="col-type">{{ item.type }}</span>
|
||||
<span class="col-desc">{{ item.description }}</span>
|
||||
<span class="col-location">{{ item.location }}</span>
|
||||
<span class="col-level">
|
||||
<el-tag :type="getLevelType(item.level)" size="small">{{ item.level }}</el-tag>
|
||||
</span>
|
||||
<span class="col-status">
|
||||
<el-tag :type="getStatusType(item.status)" size="small">{{ item.status }}</el-tag>
|
||||
</span>
|
||||
<span class="col-action">
|
||||
<el-button type="primary" link size="small">查看</el-button>
|
||||
<el-button type="primary" link size="small">处理</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
|
||||
const stats = [
|
||||
{ label: '待处理隐患', value: 12, color: '#ef4444' },
|
||||
{ label: '处理中', value: 8, color: '#f59e0b' },
|
||||
{ label: '本月已处理', value: 45, color: '#10b981' },
|
||||
{ label: '累计识别', value: 328, color: '#7c3aed' }
|
||||
]
|
||||
|
||||
const hazardList = ref([
|
||||
{ id: 1, type: '供水管网', description: '管道老化存在渗漏风险', location: '红谷滩区丰和大道', level: '高', status: '待处理' },
|
||||
{ id: 2, type: '燃气管道', description: '阀门锈蚀需要更换', location: '东湖区八一大道', level: '中', status: '处理中' },
|
||||
{ id: 3, type: '电力设施', description: '变压器负荷过高', location: '西湖区朝阳路', level: '高', status: '待处理' },
|
||||
{ id: 4, type: '排水系统', description: '雨水井堵塞', location: '青山湖区北京路', level: '低', status: '已处理' }
|
||||
])
|
||||
|
||||
const getLevelType = (level) => {
|
||||
const map = { '高': 'danger', '中': 'warning', '低': 'info' }
|
||||
return map[level] || 'info'
|
||||
}
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const map = { '待处理': 'danger', '处理中': 'warning', '已处理': 'success' }
|
||||
return map[status] || 'info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hazard-view {
|
||||
padding: 32px 48px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hazard-list {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hazard-table {
|
||||
.table-header, .table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 180px 80px 80px 120px;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
202
江西城市生命线-可交互原型/frontend/src/views/KnowledgeView.vue
Normal file
202
江西城市生命线-可交互原型/frontend/src/views/KnowledgeView.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="knowledge-view">
|
||||
<header class="page-header">
|
||||
<h1>知识库</h1>
|
||||
<p>城市生命线领域知识库,包含政策法规、技术标准、案例分析等</p>
|
||||
</header>
|
||||
|
||||
<div class="search-section">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索知识库..."
|
||||
size="large"
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="knowledge-categories">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="category-card"
|
||||
>
|
||||
<div class="card-icon" :style="{ background: category.color }">
|
||||
<el-icon><component :is="category.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h3>{{ category.name }}</h3>
|
||||
<p>{{ category.description }}</p>
|
||||
<span class="doc-count">{{ category.count }} 篇文档</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recent-docs">
|
||||
<h2>最近浏览</h2>
|
||||
<div class="doc-list">
|
||||
<div v-for="doc in recentDocs" :key="doc.id" class="doc-item">
|
||||
<el-icon><Document /></el-icon>
|
||||
<div class="doc-info">
|
||||
<span class="doc-title">{{ doc.title }}</span>
|
||||
<span class="doc-time">{{ doc.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
Search,
|
||||
Document,
|
||||
Reading,
|
||||
Files,
|
||||
DataAnalysis,
|
||||
Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const searchText = ref('')
|
||||
|
||||
const categories = [
|
||||
{ id: 1, name: '政策法规', description: '国家及地方相关政策法规文件', count: 128, color: '#7c3aed', icon: 'Reading' },
|
||||
{ id: 2, name: '技术标准', description: '行业技术标准与规范', count: 86, color: '#10b981', icon: 'Files' },
|
||||
{ id: 3, name: '案例分析', description: '典型案例分析与经验总结', count: 54, color: '#f59e0b', icon: 'DataAnalysis' },
|
||||
{ id: 4, name: '应急管理', description: '应急预案与处置流程', count: 42, color: '#ef4444', icon: 'Warning' }
|
||||
]
|
||||
|
||||
const recentDocs = [
|
||||
{ id: 1, title: '城市供水管网安全运行管理规范', time: '2小时前' },
|
||||
{ id: 2, title: '燃气管道安全检测技术标准', time: '昨天' },
|
||||
{ id: 3, title: '城市内涝应急处置预案模板', time: '3天前' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.knowledge-view {
|
||||
padding: 32px 48px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 32px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.knowledge-categories {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.category-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.doc-count {
|
||||
font-size: 12px;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recent-docs {
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
color: #1f2937;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.doc-list {
|
||||
.doc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
color: #6b7280;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.doc-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
|
||||
.doc-title {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.doc-time {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
276
江西城市生命线-可交互原型/frontend/src/views/ProfileView.vue
Normal file
276
江西城市生命线-可交互原型/frontend/src/views/ProfileView.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="profile-view">
|
||||
<div class="profile-header">
|
||||
<h1>个人中心</h1>
|
||||
<p class="subtitle">管理您的个人信息和账户设置</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-content">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<el-card>
|
||||
<div class="profile-section">
|
||||
<div class="avatar-section">
|
||||
<el-avatar :size="100" src="/avatar.svg">李志鹏</el-avatar>
|
||||
<el-button type="primary" size="small" style="margin-top: 16px;">
|
||||
<el-icon><Upload /></el-icon>
|
||||
更换头像
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-form :model="profileForm" label-width="100px" style="max-width: 600px;">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="profileForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工号">
|
||||
<el-input v-model="profileForm.employeeId" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-input v-model="profileForm.department" />
|
||||
</el-form-item>
|
||||
<el-form-item label="职位">
|
||||
<el-input v-model="profileForm.position" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="profileForm.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="profileForm.email" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveProfile">保存修改</el-button>
|
||||
<el-button @click="resetProfile">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="账户安全" name="security">
|
||||
<el-card>
|
||||
<div class="security-section">
|
||||
<div class="security-item">
|
||||
<div class="security-info">
|
||||
<h3>登录密码</h3>
|
||||
<p>定期更换密码可以提高账户安全性</p>
|
||||
</div>
|
||||
<el-button type="primary" link @click="showPasswordDialog = true">修改密码</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="security-item">
|
||||
<div class="security-info">
|
||||
<h3>双因素认证</h3>
|
||||
<p>开启后登录需要验证码,更加安全</p>
|
||||
</div>
|
||||
<el-switch v-model="twoFactorEnabled" />
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="security-item">
|
||||
<div class="security-info">
|
||||
<h3>登录设备管理</h3>
|
||||
<p>查看和管理您的登录设备</p>
|
||||
</div>
|
||||
<el-button type="primary" link>查看设备</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="通知设置" name="notification">
|
||||
<el-card>
|
||||
<div class="notification-section">
|
||||
<el-form label-width="150px">
|
||||
<el-form-item label="邮件通知">
|
||||
<el-switch v-model="notificationSettings.email" />
|
||||
<span class="setting-desc">接收系统邮件通知</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="短信通知">
|
||||
<el-switch v-model="notificationSettings.sms" />
|
||||
<span class="setting-desc">接收重要事项短信提醒</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="工单提醒">
|
||||
<el-switch v-model="notificationSettings.ticket" />
|
||||
<span class="setting-desc">新工单分配时提醒</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="系统公告">
|
||||
<el-switch v-model="notificationSettings.announcement" />
|
||||
<span class="setting-desc">接收系统公告推送</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="操作日志" name="logs">
|
||||
<el-card>
|
||||
<el-table :data="operationLogs" style="width: 100%">
|
||||
<el-table-column prop="time" label="时间" width="180" />
|
||||
<el-table-column prop="action" label="操作" width="200" />
|
||||
<el-table-column prop="ip" label="IP地址" width="150" />
|
||||
<el-table-column prop="device" label="设备" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<el-dialog v-model="showPasswordDialog" title="修改密码" width="400px">
|
||||
<el-form :model="passwordForm" label-width="100px">
|
||||
<el-form-item label="原密码">
|
||||
<el-input v-model="passwordForm.oldPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="passwordForm.newPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码">
|
||||
<el-input v-model="passwordForm.confirmPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showPasswordDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="changePassword">确认修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Upload } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const activeTab = ref('basic')
|
||||
|
||||
const profileForm = ref({
|
||||
name: '李志鹏',
|
||||
employeeId: 'TH20230001',
|
||||
department: '技术研发部',
|
||||
position: '高级工程师',
|
||||
phone: '13800138000',
|
||||
email: 'lizhipeng@taihao.com'
|
||||
})
|
||||
|
||||
const twoFactorEnabled = ref(false)
|
||||
|
||||
const notificationSettings = ref({
|
||||
email: true,
|
||||
sms: true,
|
||||
ticket: true,
|
||||
announcement: false
|
||||
})
|
||||
|
||||
const operationLogs = ref([
|
||||
{ time: '2024-12-06 18:30:00', action: '登录系统', ip: '192.168.1.100', device: 'Windows 11 / Chrome' },
|
||||
{ time: '2024-12-06 14:20:00', action: '修改个人信息', ip: '192.168.1.100', device: 'Windows 11 / Chrome' },
|
||||
{ time: '2024-12-05 09:15:00', action: '登录系统', ip: '192.168.1.100', device: 'Windows 11 / Chrome' },
|
||||
{ time: '2024-12-04 16:45:00', action: '创建工单', ip: '192.168.1.100', device: 'Windows 11 / Chrome' }
|
||||
])
|
||||
|
||||
const showPasswordDialog = ref(false)
|
||||
const passwordForm = ref({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const saveProfile = () => {
|
||||
ElMessage.success('个人信息保存成功!')
|
||||
}
|
||||
|
||||
const resetProfile = () => {
|
||||
ElMessage.info('已重置为原始信息')
|
||||
}
|
||||
|
||||
const changePassword = () => {
|
||||
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
|
||||
ElMessage.error('两次输入的密码不一致!')
|
||||
return
|
||||
}
|
||||
ElMessage.success('密码修改成功!')
|
||||
showPasswordDialog.value = false
|
||||
passwordForm.value = {
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-view {
|
||||
height: 100vh;
|
||||
background: #f5f7fa;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
padding: 32px 40px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 8px 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-content {
|
||||
padding: 24px 40px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
padding: 24px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.security-section {
|
||||
.security-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
|
||||
.security-info {
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 8px 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-section {
|
||||
.setting-desc {
|
||||
margin-left: 12px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2017
江西城市生命线-可交互原型/frontend/src/views/ServiceView.vue
Normal file
2017
江西城市生命线-可交互原型/frontend/src/views/ServiceView.vue
Normal file
File diff suppressed because it is too large
Load Diff
88
江西城市生命线-可交互原型/frontend/src/views/WorkflowView.vue
Normal file
88
江西城市生命线-可交互原型/frontend/src/views/WorkflowView.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="workflow-view">
|
||||
<div class="workflow-header">
|
||||
<div class="header-left">
|
||||
<h1>智能体编排</h1>
|
||||
<p class="subtitle">可视化工作流编排平台,拖拽节点构建智能工作流</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="saveWorkflow">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存
|
||||
</el-button>
|
||||
<el-button @click="exportWorkflow">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workflow-container">
|
||||
<WorkflowEditor />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Check, Download } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import WorkflowEditor from '@/components/workflow/WorkflowEditor.vue'
|
||||
|
||||
const saveWorkflow = () => {
|
||||
ElMessage.success('工作流保存成功')
|
||||
}
|
||||
|
||||
const exportWorkflow = () => {
|
||||
ElMessage.info('工作流导出功能开发中')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.workflow-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.workflow-header {
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.header-left {
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 4px 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.el-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
22
江西城市生命线-可交互原型/frontend/vite.config.js
Normal file
22
江西城市生命线-可交互原型/frontend/vite.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
allowedHosts:['.trycloudflare.com','.ngrok-free.app','.cpolar.top','.cpolar.cn'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user