This commit is contained in:
2025-12-12 18:32:14 +08:00
parent e66eb6b575
commit e002f0d989
41 changed files with 36625 additions and 1 deletions

2
.gitignore vendored
View File

@@ -199,4 +199,4 @@ cython_debug/
# PyPI configuration file
.pypirc
江西城市生命线-可交互原型/frontend/node_modules/*

View 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>

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View 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>

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 服务文件
## 技术支持
如有问题,请联系开发团队或查看项目文档。

View 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

View 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

View 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

View 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
**功能状态**: ✅ 已实现

View 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

View 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. **移动端适配**
- 优化移动端布局
- 添加触摸手势支持

View File

@@ -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>

View 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')

View 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

View 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
}
})

View 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
}
})

View 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
}
})

View 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;
}

File diff suppressed because it is too large Load Diff

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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
}
}
}
})