原型
This commit is contained in:
269
江西城市生命线-可交互原型/frontend/src/components/Sidebar.vue
Normal file
269
江西城市生命线-可交互原型/frontend/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ collapsed: collapsed }">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<img src="/logo.jpg" alt="Logo" class="logo-img" />
|
||||
<span v-if="!collapsed" class="logo-text">AI数智化平台</span>
|
||||
</div>
|
||||
<div class="collapse-btn" @click="$emit('toggle')">
|
||||
<el-icon><DArrowLeft v-if="!collapsed" /><DArrowRight v-else /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-menu">
|
||||
<div class="nav-section">
|
||||
<div
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
class="nav-item"
|
||||
:class="{ active: activeMenu === item.key }"
|
||||
@click="handleMenuClick(item)"
|
||||
>
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span v-if="!collapsed">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<!-- User Info -->
|
||||
<el-dropdown class="user-section" trigger="click" @command="handleUserCommand">
|
||||
<div class="user-info-wrapper">
|
||||
<div class="user-avatar">
|
||||
<img src="/avatar.svg" alt="User" @error="handleAvatarError" />
|
||||
</div>
|
||||
<span v-if="!collapsed" class="user-name">李志鹏</span>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>
|
||||
个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="admin" divided>
|
||||
<el-icon><Setting /></el-icon>
|
||||
管理后台
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Plus,
|
||||
Collection,
|
||||
Warning,
|
||||
Document,
|
||||
Edit,
|
||||
Tickets,
|
||||
DataLine,
|
||||
Grid,
|
||||
Service,
|
||||
Refresh,
|
||||
ChatDotRound,
|
||||
DArrowLeft,
|
||||
DArrowRight,
|
||||
User,
|
||||
Setting,
|
||||
Connection
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const activeMenu = ref('chat')
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'chat', label: '泰豪AI助手', icon: 'ChatDotRound', path: '/' },
|
||||
{ key: 'apps', label: '全部应用', icon: 'Grid', path: '/apps' },
|
||||
{ key: 'workflow', label: '智能体编排', icon: 'Connection', path: '/workflow' },
|
||||
{ key: 'bidding', label: '招标助手', icon: 'Document', path: '/bidding' },
|
||||
{ key: 'service', label: '泰豪小电(客服)', icon: 'Service', path: '/service' }
|
||||
]
|
||||
|
||||
const emit = defineEmits(['toggle'])
|
||||
|
||||
const handleMenuClick = (item) => {
|
||||
activeMenu.value = item.key
|
||||
router.push(item.path)
|
||||
}
|
||||
|
||||
|
||||
const handleAvatarError = (e) => {
|
||||
e.target.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI1MCIgZmlsbD0iI2ZmZTRjNCIvPjxjaXJjbGUgY3g9IjUwIiBjeT0iNDAiIHI9IjIwIiBmaWxsPSIjZmZkMWExIi8+PGVsbGlwc2UgY3g9IjUwIiBjeT0iODAiIHJ4PSIyNSIgcnk9IjE1IiBmaWxsPSIjZmY2YjZiIi8+PC9zdmc+'
|
||||
}
|
||||
|
||||
const handleUserCommand = (command) => {
|
||||
if (command === 'profile') {
|
||||
router.push('/profile')
|
||||
} else if (command === 'admin') {
|
||||
router.push('/admin')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
background: #F0EAF4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #333;
|
||||
flex-shrink: 0;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px 12px;
|
||||
justify-content: center;
|
||||
|
||||
.logo {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
position: static;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
justify-content: center;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.logo-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
|
||||
.user-section {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(124, 58, 237, 0.05);
|
||||
}
|
||||
|
||||
.user-info-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
江西城市生命线-可交互原型/frontend/src/components/UserPanel.vue
Normal file
79
江西城市生命线-可交互原型/frontend/src/components/UserPanel.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<aside class="user-panel">
|
||||
<div class="user-avatar">
|
||||
<div class="avatar-wrapper">
|
||||
<img src="/avatar.svg" alt="User Avatar" @error="handleAvatarError" />
|
||||
<div class="mic-icon">
|
||||
<el-icon><Microphone /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<span class="user-name">{{ userName }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Microphone } from '@element-plus/icons-vue'
|
||||
|
||||
const userName = ref('李志鹏')
|
||||
|
||||
const handleAvatarError = (e) => {
|
||||
e.target.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI1MCIgZmlsbD0iI2ZmZTRjNCIvPjxjaXJjbGUgY3g9IjUwIiBjeT0iNDAiIHI9IjIwIiBmaWxsPSIjZmZkMWExIi8+PGVsbGlwc2UgY3g9IjUwIiBjeT0iODAiIHJ4PSIyNSIgcnk9IjE1IiBmaWxsPSIjZmY2YjZiIi8+PC9zdmc+'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-panel {
|
||||
width: 100px;
|
||||
background: #f9fafb;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.mic-icon {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
804
江西城市生命线-可交互原型/frontend/src/components/project/BiddingInfo.vue
Normal file
804
江西城市生命线-可交互原型/frontend/src/components/project/BiddingInfo.vue
Normal file
@@ -0,0 +1,804 @@
|
||||
<template>
|
||||
<div class="bidding-info-panel">
|
||||
<!-- 开标会议信息 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Bell /></el-icon>
|
||||
开标会议信息
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="info-grid">
|
||||
<!-- 开标时间 -->
|
||||
<div class="info-section">
|
||||
<h4>📅 开标时间</h4>
|
||||
<div class="info-content">
|
||||
<div class="info-item">
|
||||
<span class="label">日期:</span>
|
||||
<el-date-picker
|
||||
v-model="biddingInfo.dateValue"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">时间:</span>
|
||||
<el-time-picker
|
||||
v-model="biddingInfo.timeValue"
|
||||
placeholder="选择时间"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
style="width: 150px;"
|
||||
/>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">倒计时:</span>
|
||||
<el-tag type="warning" size="large">
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ biddingInfo.countdown }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">状态:</span>
|
||||
<el-tag type="warning" size="large">
|
||||
🟡 {{ biddingInfo.statusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开标地点 -->
|
||||
<div class="info-section">
|
||||
<h4>📍 开标地点</h4>
|
||||
<div class="info-content">
|
||||
<div class="info-item">
|
||||
<span class="label">地址:</span>
|
||||
<el-input
|
||||
v-model="biddingInfo.location.address"
|
||||
placeholder="请输入开标地址"
|
||||
style="width: 300px;"
|
||||
/>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">联系电话:</span>
|
||||
<el-input
|
||||
v-model="biddingInfo.location.phone"
|
||||
placeholder="请输入联系电话"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">备注:</span>
|
||||
<el-input
|
||||
v-model="biddingInfo.location.note"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入备注信息"
|
||||
style="width: 300px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 参会人员 -->
|
||||
<div class="info-section">
|
||||
<h4>👥 参会人员</h4>
|
||||
<div class="info-content">
|
||||
<div
|
||||
v-for="(person, index) in biddingInfo.attendees"
|
||||
:key="index"
|
||||
class="info-item editable-person"
|
||||
>
|
||||
<span class="label">{{ person.role }}:</span>
|
||||
<div class="person-inputs">
|
||||
<el-input
|
||||
v-model="person.name"
|
||||
placeholder="姓名"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<el-input
|
||||
v-model="person.phone"
|
||||
placeholder="电话"
|
||||
style="width: 150px;"
|
||||
/>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="removePerson(index)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
@click="addPerson"
|
||||
style="margin-top: 12px;"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加参会人员
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开标流程 -->
|
||||
<div class="info-section">
|
||||
<h4>📋 开标流程</h4>
|
||||
<div class="info-content">
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="(step, index) in biddingInfo.process"
|
||||
:key="index"
|
||||
:timestamp="step.time"
|
||||
>
|
||||
{{ step.description }}
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 开标记录 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Document /></el-icon>
|
||||
开标记录
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="record-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">参与投标单位:</span>
|
||||
<span class="summary-value">{{ biddingInfo.record.totalBidders }}家</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">我方报价排名:</span>
|
||||
<span class="summary-value highlight">第{{ biddingInfo.record.ourRanking }}名</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">最高报价:</span>
|
||||
<span class="summary-value">{{ formatPrice(biddingInfo.record.highestPrice) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">最低报价:</span>
|
||||
<span class="summary-value">{{ formatPrice(biddingInfo.record.lowestPrice) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">我方报价:</span>
|
||||
<span class="summary-value price">{{ formatPrice(biddingInfo.record.ourPrice) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">控制价:</span>
|
||||
<span class="summary-value">{{ formatPrice(biddingInfo.record.controlPrice) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bidders-table">
|
||||
<div class="table-header">
|
||||
<h4>所有投标单位报价</h4>
|
||||
<el-button type="primary" size="small" @click="addBidder">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加投标单位
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="biddingInfo.record.allBidders" border stripe>
|
||||
<el-table-column label="排名" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.rank"
|
||||
:min="1"
|
||||
:max="99"
|
||||
controls-position="right"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="投标单位" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.company"
|
||||
placeholder="请输入投标单位名称"
|
||||
:class="{ 'our-company-input': row.isOur }"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报价金额" width="180" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1000"
|
||||
controls-position="right"
|
||||
:class="{ 'our-price-input': row.isOur }"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工期(天)" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.workDays"
|
||||
:min="1"
|
||||
:max="999"
|
||||
controls-position="right"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="center">
|
||||
<template #default="{ row, $index }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="markAsOur($index)"
|
||||
:disabled="row.isOur"
|
||||
>
|
||||
{{ row.isOur ? '我方' : '标记为我方' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="removeBidder($index)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="record-actions">
|
||||
<el-button type="success" @click="saveBiddingRecord">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存开标记录
|
||||
</el-button>
|
||||
<el-button type="primary" @click="downloadRecord">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载开标记录表
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 现场照片/视频 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Picture /></el-icon>
|
||||
现场照片/视频
|
||||
</h3>
|
||||
<div class="header-actions">
|
||||
<el-button size="small" @click="uploadPhoto">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传照片
|
||||
</el-button>
|
||||
<el-button size="small" @click="uploadVideo">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
上传视频
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="media-grid">
|
||||
<div v-for="photo in biddingInfo.media.photos" :key="photo.url" class="media-item">
|
||||
<img :src="photo.url" :alt="photo.description" />
|
||||
<div class="media-info">
|
||||
<span class="media-desc">{{ photo.description }}</span>
|
||||
<span class="media-time">{{ photo.uploadTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="biddingInfo.media.videos.length > 0" class="video-list">
|
||||
<div v-for="video in biddingInfo.media.videos" :key="video.url" class="video-item">
|
||||
<el-icon class="video-icon"><VideoCamera /></el-icon>
|
||||
<div class="video-info">
|
||||
<span class="video-desc">{{ video.description }}</span>
|
||||
<span class="video-meta">{{ video.uploadTime }} · {{ video.duration }}</span>
|
||||
</div>
|
||||
<el-button link type="primary" @click="playVideo(video)">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
播放
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<el-button size="large" @click="editBiddingInfo">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑开标信息
|
||||
</el-button>
|
||||
<el-button size="large" @click="setReminder">
|
||||
<el-icon><Bell /></el-icon>
|
||||
设置开标提醒
|
||||
</el-button>
|
||||
<el-button size="large" type="primary" @click="exportReport">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出开标报告
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Bell, Clock, Document, View,
|
||||
Download, Picture, Upload, VideoCamera, VideoPlay, Edit, Delete, Plus, Check
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
// 开标信息数据
|
||||
const biddingInfo = ref({
|
||||
date: '2024-12-15 (星期日)',
|
||||
time: '09:30:00',
|
||||
dateValue: '2024-12-15', // 日期选择器绑定值
|
||||
timeValue: '09:30:00', // 时间选择器绑定值
|
||||
countdown: '还剩6天2小时15分钟',
|
||||
statusText: '待开标',
|
||||
|
||||
location: {
|
||||
address: 'XX市公共资源交易中心3楼开标室A',
|
||||
phone: '0791-88888888',
|
||||
note: '需携带法人授权委托书原件及身份证'
|
||||
},
|
||||
|
||||
attendees: [
|
||||
{ role: '投标代表', name: '张三', phone: '13800138000' },
|
||||
{ role: '技术负责人', name: '李四', phone: '13900139000' },
|
||||
{ role: '备选人员', name: '王五', phone: '13700137000' }
|
||||
],
|
||||
|
||||
process: [
|
||||
{ time: '09:00 - 09:30', description: '签到入场' },
|
||||
{ time: '09:30 - 09:45', description: '宣读开标纪律' },
|
||||
{ time: '09:45 - 10:30', description: '唱标(报价、工期等)' },
|
||||
{ time: '10:30 - 11:00', description: '投标文件密封检查' },
|
||||
{ time: '11:00 - 11:30', description: '答疑与记录' }
|
||||
],
|
||||
|
||||
record: {
|
||||
totalBidders: 8,
|
||||
ourRanking: 3,
|
||||
highestPrice: 3200000.00,
|
||||
lowestPrice: 2650000.00,
|
||||
ourPrice: 2850000.00,
|
||||
controlPrice: 3500000.00,
|
||||
|
||||
allBidders: [
|
||||
{ rank: 1, company: 'XX机电设备有限公司', price: 2650000.00, workDays: 55 },
|
||||
{ rank: 2, company: 'XX电力工程有限公司', price: 2750000.00, workDays: 60 },
|
||||
{ rank: 3, company: '我方(XX电力设备有限公司)', price: 2850000.00, workDays: 60, isOur: true },
|
||||
{ rank: 4, company: 'XX建设集团', price: 2900000.00, workDays: 65 },
|
||||
{ rank: 5, company: 'XX科技股份', price: 2950000.00, workDays: 70 },
|
||||
{ rank: 6, company: 'XX工程公司', price: 3050000.00, workDays: 60 },
|
||||
{ rank: 7, company: 'XX实业有限公司', price: 3100000.00, workDays: 75 },
|
||||
{ rank: 8, company: 'XX集团', price: 3200000.00, workDays: 80 }
|
||||
]
|
||||
},
|
||||
|
||||
media: {
|
||||
photos: [
|
||||
{ url: '/images/bidding-1.jpg', uploadTime: '2024-12-15 09:35:00', description: '签到现场' },
|
||||
{ url: '/images/bidding-2.jpg', uploadTime: '2024-12-15 10:15:00', description: '唱标现场' }
|
||||
],
|
||||
videos: [
|
||||
{ url: '/videos/bidding-record.mp4', uploadTime: '2024-12-15 12:00:00', description: '开标会议录音', duration: '2小时15分' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化价格
|
||||
const formatPrice = (price) => {
|
||||
return '¥' + price.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// 方法
|
||||
// 添加参会人员
|
||||
const addPerson = () => {
|
||||
biddingInfo.value.attendees.push({
|
||||
role: '参会人员',
|
||||
name: '',
|
||||
phone: ''
|
||||
})
|
||||
ElMessage.success('已添加参会人员,请填写信息')
|
||||
}
|
||||
|
||||
// 删除参会人员
|
||||
const removePerson = (index) => {
|
||||
ElMessageBox.confirm('确定要删除这个参会人员吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
biddingInfo.value.attendees.splice(index, 1)
|
||||
ElMessage.success('删除成功')
|
||||
}).catch(() => {
|
||||
ElMessage.info('已取消删除')
|
||||
})
|
||||
}
|
||||
|
||||
// 添加投标单位
|
||||
const addBidder = () => {
|
||||
const newRank = biddingInfo.value.record.allBidders.length + 1
|
||||
biddingInfo.value.record.allBidders.push({
|
||||
rank: newRank,
|
||||
company: '',
|
||||
price: 0,
|
||||
workDays: 60,
|
||||
isOur: false
|
||||
})
|
||||
ElMessage.success('已添加投标单位,请填写信息')
|
||||
}
|
||||
|
||||
// 删除投标单位
|
||||
const removeBidder = (index) => {
|
||||
ElMessageBox.confirm('确定要删除这个投标单位吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
biddingInfo.value.record.allBidders.splice(index, 1)
|
||||
// 重新排序
|
||||
biddingInfo.value.record.allBidders.forEach((bidder, idx) => {
|
||||
bidder.rank = idx + 1
|
||||
})
|
||||
ElMessage.success('删除成功')
|
||||
}).catch(() => {
|
||||
ElMessage.info('已取消删除')
|
||||
})
|
||||
}
|
||||
|
||||
// 标记为我方
|
||||
const markAsOur = (index) => {
|
||||
// 先清除所有的我方标记
|
||||
biddingInfo.value.record.allBidders.forEach(bidder => {
|
||||
bidder.isOur = false
|
||||
})
|
||||
// 标记当前为我方
|
||||
biddingInfo.value.record.allBidders[index].isOur = true
|
||||
ElMessage.success('已标记为我方')
|
||||
}
|
||||
|
||||
// 保存开标记录
|
||||
const saveBiddingRecord = () => {
|
||||
// 验证数据
|
||||
const hasEmpty = biddingInfo.value.record.allBidders.some(
|
||||
b => !b.company || b.price <= 0
|
||||
)
|
||||
|
||||
if (hasEmpty) {
|
||||
ElMessage.warning('请完整填写所有投标单位信息')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('开标记录已保存')
|
||||
emit('update')
|
||||
}
|
||||
|
||||
const downloadRecord = () => {
|
||||
ElMessage.success('开标记录表已下载')
|
||||
}
|
||||
|
||||
const uploadPhoto = () => {
|
||||
ElMessage.info('打开照片上传对话框')
|
||||
}
|
||||
|
||||
const uploadVideo = () => {
|
||||
ElMessage.info('打开视频上传对话框')
|
||||
}
|
||||
|
||||
const playVideo = (video) => {
|
||||
ElMessage.info(`播放视频: ${video.description}`)
|
||||
}
|
||||
|
||||
const editBiddingInfo = () => {
|
||||
ElMessage.info('进入编辑模式')
|
||||
emit('update')
|
||||
}
|
||||
|
||||
const setReminder = () => {
|
||||
ElMessage.success('开标提醒已设置')
|
||||
}
|
||||
|
||||
const exportReport = () => {
|
||||
ElMessage.success('开标报告已导出')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bidding-info-panel {
|
||||
.section-card {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
|
||||
.info-section {
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
|
||||
&.note {
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.action-links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&.editable-person {
|
||||
.person-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.record-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.summary-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
|
||||
&.highlight {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
&.price {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bidders-table {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.our-company-input) {
|
||||
.el-input__inner {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.our-price-input) {
|
||||
.el-input-number__decrease,
|
||||
.el-input-number__increase {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
input {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.our-company {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.our-price {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.media-item {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid #e5e7eb;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #a78bfa;
|
||||
box-shadow: 0 4px 12px rgba(167, 139, 250, 0.1);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.media-desc {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.media-time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-list {
|
||||
.video-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.video-icon {
|
||||
font-size: 32px;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.video-desc {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
599
江西城市生命线-可交互原型/frontend/src/components/project/FileSubmission.vue
Normal file
599
江西城市生命线-可交互原型/frontend/src/components/project/FileSubmission.vue
Normal file
@@ -0,0 +1,599 @@
|
||||
<template>
|
||||
<div class="file-submission-panel">
|
||||
<!-- 已生成标书文件 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Document /></el-icon>
|
||||
已生成标书文件
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 商务标书 -->
|
||||
<div class="document-item">
|
||||
<div class="doc-icon">📑</div>
|
||||
<div class="doc-content">
|
||||
<div class="doc-header">
|
||||
<h4>商务标书</h4>
|
||||
<el-tag type="success" size="large">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
{{ businessDoc.status }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="doc-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">文件名称:</span>
|
||||
<span class="value">{{ businessDoc.fileName }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">生成时间:</span>
|
||||
<span class="value">{{ businessDoc.generateTime }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">文件大小:</span>
|
||||
<span class="value">{{ businessDoc.fileSize }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">页数:</span>
|
||||
<span class="value">{{ businessDoc.pages }}页</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-actions">
|
||||
<el-button size="small" @click="previewDoc(businessDoc)">
|
||||
<el-icon><View /></el-icon>
|
||||
预览
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click="downloadDoc(businessDoc)">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button size="small" @click="regenerateDoc(businessDoc)">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- 技术标书 -->
|
||||
<div class="document-item">
|
||||
<div class="doc-icon">📘</div>
|
||||
<div class="doc-content">
|
||||
<div class="doc-header">
|
||||
<h4>技术标书</h4>
|
||||
<el-tag type="success" size="large">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
{{ technicalDoc.status }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="doc-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">文件名称:</span>
|
||||
<span class="value">{{ technicalDoc.fileName }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">生成时间:</span>
|
||||
<span class="value">{{ technicalDoc.generateTime }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">文件大小:</span>
|
||||
<span class="value">{{ technicalDoc.fileSize }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">页数:</span>
|
||||
<span class="value">{{ technicalDoc.pages }}页</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-actions">
|
||||
<el-button size="small" @click="previewDoc(technicalDoc)">
|
||||
<el-icon><View /></el-icon>
|
||||
预览
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click="downloadDoc(technicalDoc)">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button size="small" @click="regenerateDoc(technicalDoc)">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</el-card>
|
||||
|
||||
<!-- 关键时间节点 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Calendar /></el-icon>
|
||||
关键时间节点
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="(milestone, index) in timeMilestones"
|
||||
:key="index"
|
||||
:timestamp="milestone.time"
|
||||
:type="milestone.type"
|
||||
>
|
||||
<div class="milestone-content">
|
||||
<span>{{ milestone.label }}</span>
|
||||
<el-tag v-if="milestone.countdown" type="warning" size="small">
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ milestone.countdown }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
|
||||
<!-- 提交前检查清单 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
<el-icon><Warning /></el-icon>
|
||||
提交前检查清单
|
||||
</h3>
|
||||
<div class="checklist-progress">
|
||||
<span>完成度: {{ checklistProgress }}%</span>
|
||||
<el-progress
|
||||
:percentage="checklistProgress"
|
||||
:color="checklistProgress === 100 ? '#10b981' : '#f59e0b'"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="checklist">
|
||||
<el-checkbox-group v-model="checkedItems" @change="updateProgress">
|
||||
<div
|
||||
v-for="item in checklistItems"
|
||||
:key="item.id"
|
||||
class="checklist-item"
|
||||
>
|
||||
<el-checkbox :label="item.id">
|
||||
<span v-if="!item.editing">{{ item.label }}</span>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="item.label"
|
||||
size="small"
|
||||
style="width: 300px;"
|
||||
@blur="item.editing = false"
|
||||
@keyup.enter="item.editing = false"
|
||||
/>
|
||||
</el-checkbox>
|
||||
<div class="item-actions">
|
||||
<el-tag v-if="item.required" type="danger" size="small">必须</el-tag>
|
||||
<el-button
|
||||
v-if="!item.editing"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editItem(item)"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="deleteItem(item.id)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
|
||||
<!-- 添加新项按钮 -->
|
||||
<el-button
|
||||
class="add-item-btn"
|
||||
type="primary"
|
||||
plain
|
||||
@click="showAddItemDialog = true"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加检查项
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
:disabled="checklistProgress < 100"
|
||||
@click="markAsSubmitted"
|
||||
>
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
标记为已提交
|
||||
</el-button>
|
||||
<el-button size="large" @click="setReminder">
|
||||
<el-icon><Bell /></el-icon>
|
||||
设置提醒
|
||||
</el-button>
|
||||
<el-button size="large" @click="exportChecklist">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出提交清单
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加检查项对话框 -->
|
||||
<el-dialog
|
||||
v-model="showAddItemDialog"
|
||||
title="添加检查项"
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="newItemForm" label-width="80px">
|
||||
<el-form-item label="检查项">
|
||||
<el-input
|
||||
v-model="newItemForm.label"
|
||||
placeholder="请输入检查项内容"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否必须">
|
||||
<el-switch v-model="newItemForm.required" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddItemDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="addNewItem">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Document, CircleCheck, View, Download, Refresh,
|
||||
Calendar, Warning, Edit, Bell, Clock, Delete, Plus
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
// 文档数据
|
||||
const businessDoc = ref({
|
||||
fileName: '某数据中心发电机组采购-商务标.pdf',
|
||||
generateTime: '2024-12-08 14:30:25',
|
||||
fileSize: '15.6 MB',
|
||||
pages: 85,
|
||||
status: '已完成'
|
||||
})
|
||||
|
||||
const technicalDoc = ref({
|
||||
fileName: '某数据中心发电机组采购-技术标.pdf',
|
||||
generateTime: '2024-12-08 15:45:10',
|
||||
fileSize: '28.3 MB',
|
||||
pages: 156,
|
||||
status: '已完成'
|
||||
})
|
||||
|
||||
// 时间节点
|
||||
const timeMilestones = ref([
|
||||
{
|
||||
label: '标书生成开始',
|
||||
time: '2024-12-08 09:00:00',
|
||||
type: 'success'
|
||||
},
|
||||
{
|
||||
label: '标书生成完成',
|
||||
time: '2024-12-09 10:30:00',
|
||||
type: 'success'
|
||||
},
|
||||
{
|
||||
label: '内部审核时间',
|
||||
time: '2024-12-09 14:00:00 - 16:30:00',
|
||||
type: 'success'
|
||||
},
|
||||
{
|
||||
label: '投标截止时间',
|
||||
time: '2024-12-14 17:00:00',
|
||||
type: 'warning',
|
||||
countdown: '还剩5天3小时'
|
||||
},
|
||||
{
|
||||
label: '提交方式',
|
||||
time: '线上提交 + 纸质文件',
|
||||
type: 'info'
|
||||
}
|
||||
])
|
||||
|
||||
// 检查清单
|
||||
const checklistItems = ref([
|
||||
{ id: 1, label: '商务标完整性检查', required: true, editing: false },
|
||||
{ id: 2, label: '技术标完整性检查', required: true, editing: false },
|
||||
{ id: 3, label: '保证金缴纳凭证', required: true, editing: false },
|
||||
{ id: 4, label: '资质证书齐全', required: true, editing: false },
|
||||
{ id: 5, label: '授权委托书签字盖章', required: false, editing: false },
|
||||
{ id: 6, label: '纸质文件打印装订', required: false, editing: false },
|
||||
{ id: 7, label: '投标文件密封完好', required: false, editing: false }
|
||||
])
|
||||
|
||||
const checkedItems = ref([1, 2, 3, 4, 5])
|
||||
|
||||
const checklistProgress = computed(() => {
|
||||
return Math.round((checkedItems.value.length / checklistItems.value.length) * 100)
|
||||
})
|
||||
|
||||
// 新增检查项相关
|
||||
const showAddItemDialog = ref(false)
|
||||
const newItemForm = ref({
|
||||
label: '',
|
||||
required: false
|
||||
})
|
||||
|
||||
let nextItemId = 8
|
||||
|
||||
// 方法
|
||||
const previewDoc = (doc) => {
|
||||
ElMessage.info(`预览文件: ${doc.fileName}`)
|
||||
}
|
||||
|
||||
const downloadDoc = (doc) => {
|
||||
ElMessage.success(`下载文件: ${doc.fileName}`)
|
||||
}
|
||||
|
||||
const regenerateDoc = (doc) => {
|
||||
ElMessage.info(`重新生成: ${doc.fileName}`)
|
||||
}
|
||||
|
||||
const updateProgress = () => {
|
||||
// 进度更新逻辑
|
||||
}
|
||||
|
||||
const markAsSubmitted = () => {
|
||||
ElMessage.success('项目已标记为已提交')
|
||||
emit('update')
|
||||
}
|
||||
|
||||
const setReminder = () => {
|
||||
ElMessage.success('提醒已设置')
|
||||
}
|
||||
|
||||
const exportChecklist = () => {
|
||||
ElMessage.success('提交清单已导出')
|
||||
}
|
||||
|
||||
// 编辑检查项
|
||||
const editItem = (item) => {
|
||||
item.editing = true
|
||||
}
|
||||
|
||||
// 删除检查项
|
||||
const deleteItem = (id) => {
|
||||
ElMessageBox.confirm('确定要删除这个检查项吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
const index = checklistItems.value.findIndex(item => item.id === id)
|
||||
if (index > -1) {
|
||||
checklistItems.value.splice(index, 1)
|
||||
// 同时从已选中项中移除
|
||||
const checkedIndex = checkedItems.value.indexOf(id)
|
||||
if (checkedIndex > -1) {
|
||||
checkedItems.value.splice(checkedIndex, 1)
|
||||
}
|
||||
ElMessage.success('删除成功')
|
||||
}
|
||||
}).catch(() => {
|
||||
ElMessage.info('已取消删除')
|
||||
})
|
||||
}
|
||||
|
||||
// 添加新检查项
|
||||
const addNewItem = () => {
|
||||
if (!newItemForm.value.label.trim()) {
|
||||
ElMessage.warning('请输入检查项内容')
|
||||
return
|
||||
}
|
||||
|
||||
checklistItems.value.push({
|
||||
id: nextItemId++,
|
||||
label: newItemForm.value.label,
|
||||
required: newItemForm.value.required,
|
||||
editing: false
|
||||
})
|
||||
|
||||
ElMessage.success('添加成功')
|
||||
showAddItemDialog.value = false
|
||||
newItemForm.value = {
|
||||
label: '',
|
||||
required: false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file-submission-panel {
|
||||
.section-card {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checklist-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.document-item {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #a78bfa;
|
||||
box-shadow: 0 4px 12px rgba(167, 139, 250, 0.1);
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
font-size: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-content {
|
||||
flex: 1;
|
||||
|
||||
.doc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
|
||||
&.price-amount {
|
||||
color: #ef4444;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.milestone-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checklist {
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
|
||||
.item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.el-tag {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-item-btn {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
border-style: dashed;
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
277
江西城市生命线-可交互原型/frontend/src/components/project/ProjectDetail.vue
Normal file
277
江西城市生命线-可交互原型/frontend/src/components/project/ProjectDetail.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="project-detail-panel">
|
||||
<!-- 项目头部 -->
|
||||
<div class="project-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="backToList" link>
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回列表
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<h2>{{ project.name }}</h2>
|
||||
<el-tag :type="getStatusTagType(project.status)" size="large">
|
||||
{{ getStatusText(project.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button @click="exportProject">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出项目
|
||||
</el-button>
|
||||
<el-button type="primary" @click="editProject">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑项目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab切换 -->
|
||||
<el-tabs v-model="activeTab" class="project-tabs">
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<div class="basic-info-panel">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="项目名称">
|
||||
{{ project.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="招标单位">
|
||||
{{ project.client || 'XX科技有限公司' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ project.createTime }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="当前进度">
|
||||
{{ project.progress }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="项目状态">
|
||||
<el-tag :type="getStatusTagType(project.status)">
|
||||
{{ getStatusText(project.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="项目经理">
|
||||
张三
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="project-timeline">
|
||||
<h3>项目流程</h3>
|
||||
<el-steps :active="getActiveStep(project.progress)" align-center>
|
||||
<el-step
|
||||
v-for="(step, index) in projectSteps"
|
||||
:key="index"
|
||||
:title="step.title"
|
||||
:description="step.description"
|
||||
:status="getStepStatus(project.progress, step.key)"
|
||||
>
|
||||
<template #icon>
|
||||
<el-icon v-if="getStepStatus(project.progress, step.key) === 'finish'">
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
<el-icon v-else-if="getStepStatus(project.progress, step.key) === 'process'">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</template>
|
||||
</el-step>
|
||||
</el-steps>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="submission">
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
<el-icon><Document /></el-icon>
|
||||
文件提交
|
||||
</span>
|
||||
</template>
|
||||
<FileSubmission :project="project" @update="handleUpdate" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="bidding">
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
<el-icon><Bell /></el-icon>
|
||||
开标信息
|
||||
</span>
|
||||
</template>
|
||||
<BiddingInfo :project="project" @update="handleUpdate" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="result">
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
<el-icon><Trophy /></el-icon>
|
||||
结果公示
|
||||
</span>
|
||||
</template>
|
||||
<ResultAnnouncement :project="project" @update="handleUpdate" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
ArrowLeft, Download, Edit, Document, Bell, Trophy,
|
||||
CircleCheck, Loading
|
||||
} from '@element-plus/icons-vue'
|
||||
import FileSubmission from './FileSubmission.vue'
|
||||
import BiddingInfo from './BiddingInfo.vue'
|
||||
import ResultAnnouncement from './ResultAnnouncement.vue'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back', 'update'])
|
||||
|
||||
const activeTab = ref('basic')
|
||||
|
||||
// 项目流程步骤
|
||||
const projectSteps = [
|
||||
{ key: 'info-extract', title: '信息提取', description: '招标文件解析' },
|
||||
{ key: 'doc-prepare', title: '文件准备', description: '标书文件生成' },
|
||||
{ key: 'submission', title: '文件提交', description: '投标文件提交' },
|
||||
{ key: 'bidding', title: '开标', description: '开标会议' },
|
||||
{ key: 'evaluation', title: '评标', description: '评标过程' },
|
||||
{ key: 'result', title: '结果公示', description: '中标公示' }
|
||||
]
|
||||
|
||||
// 状态相关
|
||||
const getStatusTagType = (status) => {
|
||||
const typeMap = {
|
||||
'ongoing': '',
|
||||
'pending': 'warning',
|
||||
'won': 'success',
|
||||
'lost': 'info',
|
||||
'failed': 'danger'
|
||||
}
|
||||
return typeMap[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const textMap = {
|
||||
'ongoing': '进行中',
|
||||
'pending': '待提交',
|
||||
'won': '已中标',
|
||||
'lost': '未中标',
|
||||
'failed': '流标',
|
||||
'信息提取': '信息提取',
|
||||
'文件准备': '文件准备',
|
||||
'文件提交': '文件提交',
|
||||
'开标': '开标',
|
||||
'评标': '评标',
|
||||
'结果公示': '结果公示'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
const getActiveStep = (progress) => {
|
||||
const stepMap = {
|
||||
'信息提取': 0,
|
||||
'文件准备': 1,
|
||||
'文件提交': 2,
|
||||
'开标': 3,
|
||||
'评标': 4,
|
||||
'结果公示': 5
|
||||
}
|
||||
return stepMap[progress] || 0
|
||||
}
|
||||
|
||||
const getStepStatus = (progress, stepKey) => {
|
||||
const currentStep = getActiveStep(progress)
|
||||
const stepIndex = projectSteps.findIndex(s => s.key === stepKey)
|
||||
|
||||
if (stepIndex < currentStep) return 'finish'
|
||||
if (stepIndex === currentStep) return 'process'
|
||||
return 'wait'
|
||||
}
|
||||
|
||||
// 方法
|
||||
const backToList = () => {
|
||||
emit('back')
|
||||
}
|
||||
|
||||
const exportProject = () => {
|
||||
ElMessage.success('项目信息导出成功')
|
||||
}
|
||||
|
||||
const editProject = () => {
|
||||
ElMessage.info('进入编辑模式')
|
||||
}
|
||||
|
||||
const handleUpdate = () => {
|
||||
ElMessage.success('信息更新成功')
|
||||
emit('update')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-detail-panel {
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-left {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
|
||||
h2 {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.project-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.basic-info-panel {
|
||||
.project-timeline {
|
||||
margin-top: 32px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
227
江西城市生命线-可交互原型/frontend/src/components/project/README.md
Normal file
227
江西城市生命线-可交互原型/frontend/src/components/project/README.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 项目管理组件使用说明
|
||||
|
||||
## 组件概述
|
||||
|
||||
本目录包含招标助手系统的项目管理功能组件,实现了进行中项目的三个核心功能模块:
|
||||
|
||||
1. **文件提交模块** (`FileSubmission.vue`)
|
||||
2. **开标信息模块** (`BiddingInfo.vue`)
|
||||
3. **结果公示模块** (`ResultAnnouncement.vue`)
|
||||
4. **项目详情主组件** (`ProjectDetail.vue`)
|
||||
|
||||
## 组件结构
|
||||
|
||||
```
|
||||
components/project/
|
||||
├── ProjectDetail.vue # 项目详情主组件
|
||||
├── FileSubmission.vue # 文件提交模块
|
||||
├── BiddingInfo.vue # 开标信息模块
|
||||
├── ResultAnnouncement.vue # 结果公示模块
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 在 BiddingView.vue 中集成
|
||||
|
||||
在 `BiddingView.vue` 的项目详情部分引入 `ProjectDetail` 组件:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-if="activeNav === 'project-ongoing'" class="panel">
|
||||
<!-- 项目列表 -->
|
||||
<div v-if="!selectedProject" class="project-list">
|
||||
<!-- 项目卡片列表 -->
|
||||
</div>
|
||||
|
||||
<!-- 项目详情 -->
|
||||
<ProjectDetail
|
||||
v-else
|
||||
:project="selectedProject"
|
||||
@back="selectedProject = null"
|
||||
@update="handleProjectUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProjectDetail from '@/components/project/ProjectDetail.vue'
|
||||
|
||||
const selectedProject = ref(null)
|
||||
|
||||
const handleProjectUpdate = () => {
|
||||
// 刷新项目数据
|
||||
console.log('项目已更新')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. 单独使用各个子组件
|
||||
|
||||
如果需要单独使用某个功能模块:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 只使用文件提交模块 -->
|
||||
<FileSubmission :project="projectData" @update="handleUpdate" />
|
||||
|
||||
<!-- 只使用开标信息模块 -->
|
||||
<BiddingInfo :project="projectData" @update="handleUpdate" />
|
||||
|
||||
<!-- 只使用结果公示模块 -->
|
||||
<ResultAnnouncement :project="projectData" @update="handleUpdate" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FileSubmission from '@/components/project/FileSubmission.vue'
|
||||
import BiddingInfo from '@/components/project/BiddingInfo.vue'
|
||||
import ResultAnnouncement from '@/components/project/ResultAnnouncement.vue'
|
||||
</script>
|
||||
```
|
||||
|
||||
## 组件 Props
|
||||
|
||||
### ProjectDetail
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| project | Object | 是 | 项目对象 |
|
||||
|
||||
### FileSubmission / BiddingInfo / ResultAnnouncement
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| project | Object | 是 | 项目对象 |
|
||||
|
||||
## 组件 Events
|
||||
|
||||
### ProjectDetail
|
||||
|
||||
| 事件名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| back | - | 返回项目列表 |
|
||||
| update | - | 项目信息已更新 |
|
||||
|
||||
### FileSubmission / BiddingInfo / ResultAnnouncement
|
||||
|
||||
| 事件名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| update | - | 模块信息已更新 |
|
||||
|
||||
## 数据结构
|
||||
|
||||
### 项目对象 (Project)
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: Number, // 项目ID
|
||||
name: String, // 项目名称
|
||||
client: String, // 招标单位
|
||||
status: String, // 项目状态: ongoing/pending/won/lost/failed
|
||||
progress: String, // 当前进度: 信息提取/文件准备/文件提交/开标/评标/结果公示
|
||||
createTime: String, // 创建时间
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
项目管理相关的 API 接口已在 `src/api/project.js` 中定义:
|
||||
|
||||
```javascript
|
||||
import { projectApi } from '@/api/project'
|
||||
|
||||
// 获取项目详情
|
||||
const project = await projectApi.getProjectDetail(projectId)
|
||||
|
||||
// 更新文件提交信息
|
||||
await projectApi.updateSubmission(projectId, data)
|
||||
|
||||
// 更新开标信息
|
||||
await projectApi.updateBiddingInfo(projectId, data)
|
||||
|
||||
// 更新结果公示
|
||||
await projectApi.updateResult(projectId, data)
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 文件提交模块
|
||||
|
||||
- ✅ 展示商务标、技术标、报价文件详细信息
|
||||
- ✅ 文件预览、下载、重新生成
|
||||
- ✅ 关键时间节点时间轴展示
|
||||
- ✅ 提交前检查清单(7项)
|
||||
- ✅ 完成度进度条
|
||||
- ✅ 标记为已提交功能
|
||||
|
||||
### 开标信息模块
|
||||
|
||||
- ✅ 开标时间倒计时提醒
|
||||
- ✅ 开标地点地图导航
|
||||
- ✅ 参会人员信息管理
|
||||
- ✅ 开标流程时间轴
|
||||
- ✅ 开标记录详细展示
|
||||
- ✅ 所有投标单位报价对比表
|
||||
- ✅ 现场照片/视频上传
|
||||
|
||||
### 结果公示模块
|
||||
|
||||
- ✅ 三种结果类型切换(中标/未中标/流标)
|
||||
- ✅ **中标情况**:
|
||||
- 公示信息、中标详情
|
||||
- 综合得分展示
|
||||
- 合同签订信息
|
||||
- 项目执行进度跟踪
|
||||
- ✅ **未中标情况**:
|
||||
- 得分差距分析
|
||||
- 失败原因总结
|
||||
- 改进建议列表
|
||||
- ✅ **流标情况**:
|
||||
- 流标原因分析
|
||||
- 后续处理方案
|
||||
- 保证金退还状态
|
||||
|
||||
## 样式定制
|
||||
|
||||
所有组件都使用 SCSS 编写样式,支持自定义主题色:
|
||||
|
||||
```scss
|
||||
// 主色调
|
||||
$primary-color: #7c3aed;
|
||||
$success-color: #10b981;
|
||||
$warning-color: #f59e0b;
|
||||
$danger-color: #ef4444;
|
||||
|
||||
// 可以在组件中覆盖这些变量
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **Element Plus 依赖**: 组件依赖 Element Plus UI 库,确保已正确安装
|
||||
2. **图标组件**: 使用 Element Plus Icons,需要单独引入
|
||||
3. **响应式设计**: 组件已适配不同屏幕尺寸
|
||||
4. **数据模拟**: 当前使用模拟数据,实际使用时需连接后端 API
|
||||
5. **权限控制**: 某些操作可能需要权限验证
|
||||
|
||||
## 开发计划
|
||||
|
||||
- [ ] 添加文件上传进度显示
|
||||
- [ ] 实现实时倒计时功能
|
||||
- [ ] 添加数据导出为 Excel 功能
|
||||
- [ ] 实现地图导航集成
|
||||
- [ ] 添加消息通知提醒
|
||||
- [ ] 优化移动端显示
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (2024-12-08)
|
||||
- ✅ 完成文件提交模块
|
||||
- ✅ 完成开标信息模块
|
||||
- ✅ 完成结果公示模块
|
||||
- ✅ 完成项目详情主组件
|
||||
- ✅ 创建 API 服务文件
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题,请联系开发团队或查看项目文档。
|
||||
1028
江西城市生命线-可交互原型/frontend/src/components/project/ResultAnnouncement.vue
Normal file
1028
江西城市生命线-可交互原型/frontend/src/components/project/ResultAnnouncement.vue
Normal file
File diff suppressed because it is too large
Load Diff
405
江西城市生命线-可交互原型/frontend/src/components/project/开标信息编辑功能说明.md
Normal file
405
江西城市生命线-可交互原型/frontend/src/components/project/开标信息编辑功能说明.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# 开标信息编辑功能说明
|
||||
|
||||
## 📝 功能概述
|
||||
|
||||
开标会议信息现已支持完整的编辑功能,用户可以自定义开标时间、地点、参会人员等信息。
|
||||
|
||||
## ✨ 新增功能
|
||||
|
||||
### 1. **编辑开标时间** 📅
|
||||
|
||||
**日期选择**:
|
||||
- 使用日期选择器选择开标日期
|
||||
- 格式:YYYY-MM-DD
|
||||
- 宽度:200px
|
||||
|
||||
**时间选择**:
|
||||
- 使用时间选择器选择开标时间
|
||||
- 格式:HH:mm:ss
|
||||
- 宽度:150px
|
||||
|
||||
### 2. **编辑开标地点** 📍
|
||||
|
||||
**可编辑字段**:
|
||||
- **地址**: 文本输入框(300px)
|
||||
- **联系电话**: 文本输入框(200px)
|
||||
- **备注**: 多行文本框(2行,300px)
|
||||
|
||||
**删除的功能**:
|
||||
- ❌ 查看地图按钮
|
||||
- ❌ 一键导航按钮
|
||||
|
||||
### 3. **编辑参会人员** 👥
|
||||
|
||||
**编辑功能**:
|
||||
- 每个参会人员可编辑姓名和电话
|
||||
- 姓名输入框:120px
|
||||
- 电话输入框:150px
|
||||
|
||||
**删除功能**:
|
||||
- 每个参会人员旁边有"删除"按钮
|
||||
- 点击删除会弹出确认对话框
|
||||
- 确认后删除该参会人员
|
||||
|
||||
**添加功能**:
|
||||
- 底部有"添加参会人员"按钮
|
||||
- 点击后添加新的空白参会人员
|
||||
- 默认角色为"参会人员"
|
||||
|
||||
## 🎨 界面布局
|
||||
|
||||
### 开标时间区域
|
||||
```
|
||||
📅 开标时间
|
||||
├─ 日期: [日期选择器: 2024-12-15]
|
||||
├─ 时间: [时间选择器: 09:30:00]
|
||||
├─ 倒计时: [还剩6天2小时15分钟]
|
||||
└─ 状态: [🟡 待开标]
|
||||
```
|
||||
|
||||
### 开标地点区域
|
||||
```
|
||||
📍 开标地点
|
||||
├─ 地址: [输入框: XX市公共资源交易中心3楼开标室A]
|
||||
├─ 联系电话: [输入框: 0791-88888888]
|
||||
└─ 备注: [文本框: 需携带法人授权委托书原件及身份证]
|
||||
```
|
||||
|
||||
### 参会人员区域
|
||||
```
|
||||
👥 参会人员
|
||||
├─ 投标代表: [姓名: 张三] [电话: 13800138000] [删除]
|
||||
├─ 技术负责人: [姓名: 李四] [电话: 13900139000] [删除]
|
||||
├─ 备选人员: [姓名: 王五] [电话: 13700137000] [删除]
|
||||
└─ [+ 添加参会人员]
|
||||
```
|
||||
|
||||
## 💻 代码实现
|
||||
|
||||
### 模板结构
|
||||
|
||||
```vue
|
||||
<!-- 开标时间 -->
|
||||
<div class="info-item">
|
||||
<span class="label">日期:</span>
|
||||
<el-date-picker
|
||||
v-model="biddingInfo.dateValue"
|
||||
type="date"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="label">时间:</span>
|
||||
<el-time-picker
|
||||
v-model="biddingInfo.timeValue"
|
||||
format="HH:mm:ss"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 开标地点 -->
|
||||
<div class="info-item">
|
||||
<span class="label">地址:</span>
|
||||
<el-input
|
||||
v-model="biddingInfo.location.address"
|
||||
placeholder="请输入开标地址"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 参会人员 -->
|
||||
<div
|
||||
v-for="(person, index) in biddingInfo.attendees"
|
||||
:key="index"
|
||||
class="info-item editable-person"
|
||||
>
|
||||
<span class="label">{{ person.role }}:</span>
|
||||
<div class="person-inputs">
|
||||
<el-input v-model="person.name" placeholder="姓名" />
|
||||
<el-input v-model="person.phone" placeholder="电话" />
|
||||
<el-button @click="removePerson(index)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button @click="addPerson">
|
||||
添加参会人员
|
||||
</el-button>
|
||||
```
|
||||
|
||||
### 数据结构
|
||||
|
||||
```javascript
|
||||
const biddingInfo = ref({
|
||||
dateValue: '2024-12-15', // 日期选择器绑定值
|
||||
timeValue: '09:30:00', // 时间选择器绑定值
|
||||
countdown: '还剩6天2小时15分钟',
|
||||
statusText: '待开标',
|
||||
|
||||
location: {
|
||||
address: 'XX市公共资源交易中心3楼开标室A',
|
||||
phone: '0791-88888888',
|
||||
note: '需携带法人授权委托书原件及身份证'
|
||||
},
|
||||
|
||||
attendees: [
|
||||
{ role: '投标代表', name: '张三', phone: '13800138000' },
|
||||
{ role: '技术负责人', name: '李四', phone: '13900139000' },
|
||||
{ role: '备选人员', name: '王五', phone: '13700137000' }
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
### 核心方法
|
||||
|
||||
#### 1. 添加参会人员
|
||||
```javascript
|
||||
const addPerson = () => {
|
||||
biddingInfo.value.attendees.push({
|
||||
role: '参会人员',
|
||||
name: '',
|
||||
phone: ''
|
||||
})
|
||||
ElMessage.success('已添加参会人员,请填写信息')
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 删除参会人员
|
||||
```javascript
|
||||
const removePerson = (index) => {
|
||||
ElMessageBox.confirm('确定要删除这个参会人员吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
biddingInfo.value.attendees.splice(index, 1)
|
||||
ElMessage.success('删除成功')
|
||||
}).catch(() => {
|
||||
ElMessage.info('已取消删除')
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 样式实现
|
||||
|
||||
```scss
|
||||
.info-item {
|
||||
&.editable-person {
|
||||
.person-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 已删除的功能
|
||||
|
||||
### 地图导航功能
|
||||
```javascript
|
||||
// ❌ 已删除
|
||||
const viewMap = () => {
|
||||
ElMessage.info('打开地图查看开标地点')
|
||||
}
|
||||
|
||||
const navigate = () => {
|
||||
ElMessage.info('启动导航到开标地点')
|
||||
}
|
||||
```
|
||||
|
||||
**删除原因**:
|
||||
- 简化界面,减少不必要的功能
|
||||
- 用户可以直接复制地址使用第三方地图应用
|
||||
|
||||
## 📊 组件导入变更
|
||||
|
||||
### 新增导入
|
||||
```javascript
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
```
|
||||
|
||||
### 删除导入
|
||||
```javascript
|
||||
// ❌ 已删除
|
||||
import { Location, Position } from '@element-plus/icons-vue'
|
||||
```
|
||||
|
||||
## 🎯 使用场景
|
||||
|
||||
### 场景 1: 修改开标时间
|
||||
项目经理收到通知,开标时间延期。
|
||||
|
||||
**操作步骤**:
|
||||
1. 点击日期选择器,选择新的日期
|
||||
2. 点击时间选择器,选择新的时间
|
||||
3. 系统自动更新倒计时
|
||||
|
||||
### 场景 2: 更新开标地点
|
||||
开标地点临时变更。
|
||||
|
||||
**操作步骤**:
|
||||
1. 在地址输入框中修改新地址
|
||||
2. 更新联系电话
|
||||
3. 在备注中添加变更说明
|
||||
|
||||
### 场景 3: 调整参会人员
|
||||
技术负责人临时有事,需要更换。
|
||||
|
||||
**操作步骤**:
|
||||
1. 找到"技术负责人"行
|
||||
2. 修改姓名和电话
|
||||
3. 或点击"删除"按钮移除
|
||||
4. 点击"添加参会人员"添加新人员
|
||||
|
||||
### 场景 4: 增加参会人员
|
||||
需要增加一名备用人员。
|
||||
|
||||
**操作步骤**:
|
||||
1. 点击"添加参会人员"按钮
|
||||
2. 填写新人员的姓名
|
||||
3. 填写新人员的电话
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 数据验证
|
||||
建议添加数据验证:
|
||||
|
||||
```javascript
|
||||
const addPerson = () => {
|
||||
// 检查是否有未填写完整的人员
|
||||
const hasEmpty = biddingInfo.value.attendees.some(
|
||||
p => !p.name || !p.phone
|
||||
)
|
||||
|
||||
if (hasEmpty) {
|
||||
ElMessage.warning('请先完成当前人员信息的填写')
|
||||
return
|
||||
}
|
||||
|
||||
biddingInfo.value.attendees.push({
|
||||
role: '参会人员',
|
||||
name: '',
|
||||
phone: ''
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 电话号码格式
|
||||
建议添加电话号码格式验证:
|
||||
|
||||
```javascript
|
||||
const validatePhone = (phone) => {
|
||||
const phoneReg = /^1[3-9]\d{9}$/
|
||||
return phoneReg.test(phone)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 最少参会人员
|
||||
建议至少保留一名参会人员:
|
||||
|
||||
```javascript
|
||||
const removePerson = (index) => {
|
||||
if (biddingInfo.value.attendees.length <= 1) {
|
||||
ElMessage.warning('至少需要保留一名参会人员')
|
||||
return
|
||||
}
|
||||
// ... 删除逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 保存提示
|
||||
修改后建议添加保存按钮:
|
||||
|
||||
```vue
|
||||
<el-button type="primary" @click="saveBiddingInfo">
|
||||
保存修改
|
||||
</el-button>
|
||||
```
|
||||
|
||||
## 🚀 后续优化建议
|
||||
|
||||
### 1. 角色选择
|
||||
为参会人员添加角色下拉选择:
|
||||
|
||||
```vue
|
||||
<el-select v-model="person.role" placeholder="选择角色">
|
||||
<el-option label="投标代表" value="投标代表" />
|
||||
<el-option label="技术负责人" value="技术负责人" />
|
||||
<el-option label="备选人员" value="备选人员" />
|
||||
<el-option label="其他" value="其他" />
|
||||
</el-select>
|
||||
```
|
||||
|
||||
### 2. 批量导入
|
||||
支持从 Excel 批量导入参会人员:
|
||||
|
||||
```vue
|
||||
<el-upload
|
||||
accept=".xlsx,.xls"
|
||||
:on-change="importAttendees"
|
||||
>
|
||||
<el-button>批量导入</el-button>
|
||||
</el-upload>
|
||||
```
|
||||
|
||||
### 3. 历史记录
|
||||
记录开标信息的修改历史:
|
||||
|
||||
```javascript
|
||||
const history = ref([])
|
||||
|
||||
const saveBiddingInfo = () => {
|
||||
history.value.push({
|
||||
time: new Date(),
|
||||
data: JSON.parse(JSON.stringify(biddingInfo.value))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 自动提醒
|
||||
根据开标时间自动设置提醒:
|
||||
|
||||
```javascript
|
||||
watch(() => biddingInfo.value.dateValue, (newDate) => {
|
||||
// 自动设置提前1天、3小时、30分钟的提醒
|
||||
setReminders(newDate)
|
||||
})
|
||||
```
|
||||
|
||||
## 📸 效果对比
|
||||
|
||||
### 修改前
|
||||
```
|
||||
📍 开标地点
|
||||
地址: XX市公共资源交易中心3楼开标室A
|
||||
联系电话: 0791-88888888
|
||||
导航: [查看地图] [一键导航] ← 删除
|
||||
备注: 需携带法人授权委托书原件及身份证
|
||||
|
||||
👥 参会人员
|
||||
投标代表: 张三 (13800138000) ← 不可编辑
|
||||
技术负责人: 李四 (13900139000)
|
||||
备选人员: 王五 (13700137000)
|
||||
```
|
||||
|
||||
### 修改后
|
||||
```
|
||||
📍 开标地点
|
||||
地址: [输入框] ← 可编辑
|
||||
联系电话: [输入框]
|
||||
备注: [文本框]
|
||||
|
||||
👥 参会人员
|
||||
投标代表: [姓名] [电话] [删除] ← 可编辑、可删除
|
||||
技术负责人: [姓名] [电话] [删除]
|
||||
备选人员: [姓名] [电话] [删除]
|
||||
[+ 添加参会人员] ← 可添加
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**版本**: v1.4.0
|
||||
**更新时间**: 2024-12-08
|
||||
**功能状态**: ✅ 已实现
|
||||
**适用模块**: BiddingInfo.vue
|
||||
428
江西城市生命线-可交互原型/frontend/src/components/project/投标单位报价编辑功能说明.md
Normal file
428
江西城市生命线-可交互原型/frontend/src/components/project/投标单位报价编辑功能说明.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# 投标单位报价编辑功能说明
|
||||
|
||||
## 📝 功能概述
|
||||
|
||||
开标记录中的"所有投标单位报价"表格现已支持完整的编辑功能,用户可以添加、编辑、删除投标单位信息,并标记我方单位。
|
||||
|
||||
## ✨ 新增功能
|
||||
|
||||
### 1. **编辑投标单位信息** ✏️
|
||||
|
||||
**可编辑字段**:
|
||||
- **排名**: 数字输入框(1-99)
|
||||
- **投标单位**: 文本输入框
|
||||
- **报价金额**: 数字输入框(精度2位小数,步长1000)
|
||||
- **工期(天)**: 数字输入框(1-999)
|
||||
|
||||
### 2. **添加投标单位** ➕
|
||||
|
||||
**操作方式**:
|
||||
- 点击表格右上角的"添加投标单位"按钮
|
||||
- 自动添加新行,排名自动递增
|
||||
- 填写单位名称、报价金额、工期
|
||||
|
||||
**默认值**:
|
||||
```javascript
|
||||
{
|
||||
rank: 自动递增,
|
||||
company: '',
|
||||
price: 0,
|
||||
workDays: 60,
|
||||
isOur: false
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **删除投标单位** 🗑️
|
||||
|
||||
**操作方式**:
|
||||
- 点击每行的"删除"按钮
|
||||
- 弹出确认对话框
|
||||
- 确认后删除该投标单位
|
||||
- 自动重新排序(rank 重新计算)
|
||||
|
||||
### 4. **标记为我方** 🏷️
|
||||
|
||||
**操作方式**:
|
||||
- 点击"标记为我方"按钮
|
||||
- 自动清除其他单位的我方标记
|
||||
- 标记当前单位为我方
|
||||
- 我方单位名称和报价高亮显示
|
||||
|
||||
**视觉效果**:
|
||||
- 单位名称:紫色加粗(#7c3aed)
|
||||
- 报价金额:红色加粗(#ef4444)
|
||||
- 按钮文字:显示"我方"并禁用
|
||||
|
||||
### 5. **保存开标记录** 💾
|
||||
|
||||
**操作方式**:
|
||||
- 点击"保存开标记录"按钮
|
||||
- 验证所有单位信息是否完整
|
||||
- 保存成功后触发更新事件
|
||||
|
||||
**验证规则**:
|
||||
- 单位名称不能为空
|
||||
- 报价金额必须大于0
|
||||
|
||||
## 🎨 界面布局
|
||||
|
||||
### 表格头部
|
||||
```
|
||||
所有投标单位报价 [+ 添加投标单位]
|
||||
```
|
||||
|
||||
### 表格内容
|
||||
```
|
||||
┌────┬──────────────┬────────┬────────┬──────────────┐
|
||||
│排名│ 投标单位 │报价金额│工期(天)│ 操作 │
|
||||
├────┼──────────────┼────────┼────────┼──────────────┤
|
||||
│ 1 │[输入框] │[数字] │[数字] │[标记][删除] │
|
||||
│ 2 │[输入框] │[数字] │[数字] │[标记][删除] │
|
||||
│ 3 │[输入框]★ │[数字]★ │[数字] │[我方][删除] │
|
||||
└────┴──────────────┴────────┴────────┴──────────────┘
|
||||
★ = 我方单位高亮显示
|
||||
```
|
||||
|
||||
### 操作按钮
|
||||
```
|
||||
[✓ 保存开标记录] [📥 下载开标记录表]
|
||||
```
|
||||
|
||||
## 💻 代码实现
|
||||
|
||||
### 模板结构
|
||||
|
||||
```vue
|
||||
<div class="bidders-table">
|
||||
<!-- 表头 -->
|
||||
<div class="table-header">
|
||||
<h4>所有投标单位报价</h4>
|
||||
<el-button @click="addBidder">添加投标单位</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 可编辑表格 -->
|
||||
<el-table :data="biddingInfo.record.allBidders">
|
||||
<!-- 排名 -->
|
||||
<el-table-column label="排名">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.rank" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 投标单位 -->
|
||||
<el-table-column label="投标单位">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.company"
|
||||
:class="{ 'our-company-input': row.isOur }"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 报价金额 -->
|
||||
<el-table-column label="报价金额">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.price"
|
||||
:class="{ 'our-price-input': row.isOur }"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 工期 -->
|
||||
<el-table-column label="工期(天)">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.workDays" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 操作 -->
|
||||
<el-table-column label="操作">
|
||||
<template #default="{ row, $index }">
|
||||
<el-button
|
||||
@click="markAsOur($index)"
|
||||
:disabled="row.isOur"
|
||||
>
|
||||
{{ row.isOur ? '我方' : '标记为我方' }}
|
||||
</el-button>
|
||||
<el-button @click="removeBidder($index)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="record-actions">
|
||||
<el-button type="success" @click="saveBiddingRecord">
|
||||
保存开标记录
|
||||
</el-button>
|
||||
<el-button type="primary" @click="downloadRecord">
|
||||
下载开标记录表
|
||||
</el-button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 核心方法
|
||||
|
||||
#### 1. 添加投标单位
|
||||
```javascript
|
||||
const addBidder = () => {
|
||||
const newRank = biddingInfo.value.record.allBidders.length + 1
|
||||
biddingInfo.value.record.allBidders.push({
|
||||
rank: newRank,
|
||||
company: '',
|
||||
price: 0,
|
||||
workDays: 60,
|
||||
isOur: false
|
||||
})
|
||||
ElMessage.success('已添加投标单位,请填写信息')
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 删除投标单位
|
||||
```javascript
|
||||
const removeBidder = (index) => {
|
||||
ElMessageBox.confirm('确定要删除这个投标单位吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
biddingInfo.value.record.allBidders.splice(index, 1)
|
||||
// 重新排序
|
||||
biddingInfo.value.record.allBidders.forEach((bidder, idx) => {
|
||||
bidder.rank = idx + 1
|
||||
})
|
||||
ElMessage.success('删除成功')
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 标记为我方
|
||||
```javascript
|
||||
const markAsOur = (index) => {
|
||||
// 先清除所有的我方标记
|
||||
biddingInfo.value.record.allBidders.forEach(bidder => {
|
||||
bidder.isOur = false
|
||||
})
|
||||
// 标记当前为我方
|
||||
biddingInfo.value.record.allBidders[index].isOur = true
|
||||
ElMessage.success('已标记为我方')
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 保存开标记录
|
||||
```javascript
|
||||
const saveBiddingRecord = () => {
|
||||
// 验证数据
|
||||
const hasEmpty = biddingInfo.value.record.allBidders.some(
|
||||
b => !b.company || b.price <= 0
|
||||
)
|
||||
|
||||
if (hasEmpty) {
|
||||
ElMessage.warning('请完整填写所有投标单位信息')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('开标记录已保存')
|
||||
emit('update')
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 样式实现
|
||||
|
||||
### 表头样式
|
||||
```scss
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
```
|
||||
|
||||
### 我方单位高亮
|
||||
```scss
|
||||
// 单位名称高亮
|
||||
:deep(.our-company-input) {
|
||||
.el-input__inner {
|
||||
color: #7c3aed; // 紫色
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// 报价金额高亮
|
||||
:deep(.our-price-input) {
|
||||
input {
|
||||
color: #ef4444; // 红色
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 数字输入框
|
||||
```scss
|
||||
:deep(.el-input-number) {
|
||||
width: 100%; // 填满单元格
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 使用场景
|
||||
|
||||
### 场景 1: 录入开标结果
|
||||
开标会议结束后,需要录入所有投标单位的报价信息。
|
||||
|
||||
**操作步骤**:
|
||||
1. 点击"添加投标单位"按钮8次(假设8家单位)
|
||||
2. 依次填写每家单位的名称、报价、工期
|
||||
3. 找到我方单位,点击"标记为我方"
|
||||
4. 点击"保存开标记录"
|
||||
|
||||
### 场景 2: 修正错误信息
|
||||
发现某家单位的报价录入错误。
|
||||
|
||||
**操作步骤**:
|
||||
1. 找到对应的单位行
|
||||
2. 修改报价金额
|
||||
3. 点击"保存开标记录"
|
||||
|
||||
### 场景 3: 删除无效单位
|
||||
某家单位临时弃标,需要删除。
|
||||
|
||||
**操作步骤**:
|
||||
1. 找到对应的单位行
|
||||
2. 点击"删除"按钮
|
||||
3. 确认删除
|
||||
4. 系统自动重新排序
|
||||
5. 点击"保存开标记录"
|
||||
|
||||
### 场景 4: 调整排名
|
||||
根据最终评分调整排名顺序。
|
||||
|
||||
**操作步骤**:
|
||||
1. 修改各单位的排名数字
|
||||
2. 点击"保存开标记录"
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 数据验证
|
||||
保存时会验证:
|
||||
- 单位名称不能为空
|
||||
- 报价金额必须大于0
|
||||
|
||||
### 2. 自动排序
|
||||
删除单位后会自动重新排序,确保排名连续。
|
||||
|
||||
### 3. 唯一我方
|
||||
同时只能有一个单位被标记为"我方",标记新的会自动清除旧的。
|
||||
|
||||
### 4. 报价格式
|
||||
报价金额支持两位小数,步长为1000元。
|
||||
|
||||
## 🚀 后续优化建议
|
||||
|
||||
### 1. 批量导入
|
||||
支持从 Excel 批量导入投标单位信息:
|
||||
|
||||
```vue
|
||||
<el-upload
|
||||
accept=".xlsx,.xls"
|
||||
:on-change="importBidders"
|
||||
>
|
||||
<el-button>批量导入</el-button>
|
||||
</el-upload>
|
||||
```
|
||||
|
||||
### 2. 自动排序
|
||||
根据报价金额自动排序:
|
||||
|
||||
```javascript
|
||||
const autoSort = () => {
|
||||
biddingInfo.value.record.allBidders.sort((a, b) => a.price - b.price)
|
||||
biddingInfo.value.record.allBidders.forEach((bidder, idx) => {
|
||||
bidder.rank = idx + 1
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 数据导出
|
||||
导出为 Excel 格式:
|
||||
|
||||
```javascript
|
||||
const exportToExcel = () => {
|
||||
// 使用 xlsx 库导出
|
||||
const data = biddingInfo.value.record.allBidders.map(b => ({
|
||||
'排名': b.rank,
|
||||
'投标单位': b.company,
|
||||
'报价金额': b.price,
|
||||
'工期': b.workDays
|
||||
}))
|
||||
// ... 导出逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 历史对比
|
||||
对比上次开标记录:
|
||||
|
||||
```javascript
|
||||
const compareWithHistory = () => {
|
||||
// 显示与历史记录的差异
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 价格区间验证
|
||||
验证报价是否在合理区间:
|
||||
|
||||
```javascript
|
||||
const validatePrice = (price) => {
|
||||
const controlPrice = biddingInfo.value.record.controlPrice
|
||||
if (price > controlPrice) {
|
||||
ElMessage.warning('报价超过控制价')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📸 效果对比
|
||||
|
||||
### 修改前
|
||||
```
|
||||
所有投标单位报价
|
||||
|
||||
排名 | 投标单位 | 报价金额 | 工期
|
||||
-----|----------|----------|------
|
||||
1 | XX公司 | ¥2,650,000 | 55
|
||||
2 | XX公司 | ¥2,750,000 | 60
|
||||
3 | 我方 | ¥2,850,000 | 60 ← 只读显示
|
||||
```
|
||||
|
||||
### 修改后
|
||||
```
|
||||
所有投标单位报价 [+ 添加投标单位]
|
||||
|
||||
排名 | 投标单位 | 报价金额 | 工期 | 操作
|
||||
-----|----------|----------|------|-------------
|
||||
[1] | [输入框] | [数字] | [60] | [标记][删除]
|
||||
[2] | [输入框] | [数字] | [60] | [标记][删除]
|
||||
[3] | [输入框]★| [数字]★ | [60] | [我方][删除] ← 可编辑
|
||||
|
||||
[✓ 保存开标记录] [📥 下载开标记录表]
|
||||
```
|
||||
|
||||
## 🔄 按钮变更
|
||||
|
||||
### 删除的按钮
|
||||
- ❌ "查看详细记录"
|
||||
|
||||
### 新增的按钮
|
||||
- ✅ "添加投标单位"
|
||||
- ✅ "保存开标记录"
|
||||
- ✅ "标记为我方"
|
||||
- ✅ "删除"(每行)
|
||||
|
||||
---
|
||||
|
||||
**版本**: v1.5.0
|
||||
**更新时间**: 2024-12-08
|
||||
**功能状态**: ✅ 已实现
|
||||
**适用模块**: BiddingInfo.vue
|
||||
185
江西城市生命线-可交互原型/frontend/src/components/project/更新日志.md
Normal file
185
江西城市生命线-可交互原型/frontend/src/components/project/更新日志.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 项目管理组件更新日志
|
||||
|
||||
## v1.1.0 (2024-12-08)
|
||||
|
||||
### 🎨 界面优化
|
||||
|
||||
#### FileSubmission.vue - 文件提交模块
|
||||
|
||||
**变更内容**:
|
||||
|
||||
1. **删除报价文件部分**
|
||||
- 移除了报价文件的展示区域
|
||||
- 删除了相关的数据定义 (`priceDoc`)
|
||||
- 删除了"修改报价"功能 (`modifyPrice`)
|
||||
|
||||
2. **调整状态显示布局**
|
||||
- 将状态标签从详情列表中移到标题旁边
|
||||
- 状态标签尺寸从 `small` 改为 `large`
|
||||
- 添加了 `doc-header` 容器,使标题和状态在同一行
|
||||
- 在标题和详情之间添加了分隔线
|
||||
|
||||
**视觉效果**:
|
||||
```
|
||||
之前:
|
||||
┌─────────────────────────────┐
|
||||
│ 📑 商务标书 │
|
||||
│ │
|
||||
│ 文件名称: xxx.pdf │
|
||||
│ 生成时间: 2024-12-08 │
|
||||
│ 文件大小: 15.6 MB │
|
||||
│ 页数: 85页 │
|
||||
│ 状态: [已完成] │
|
||||
│ │
|
||||
│ [预览] [下载] [重新生成] │
|
||||
└─────────────────────────────┘
|
||||
|
||||
现在:
|
||||
┌─────────────────────────────┐
|
||||
│ 📑 商务标书 [已完成] │
|
||||
│ ─────────────────────────── │
|
||||
│ 文件名称: xxx.pdf │
|
||||
│ 生成时间: 2024-12-08 │
|
||||
│ 文件大小: 15.6 MB │
|
||||
│ 页数: 85页 │
|
||||
│ │
|
||||
│ [预览] [下载] [重新生成] │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**样式变更**:
|
||||
- 新增 `.doc-header` 样式
|
||||
- `display: flex` - 横向布局
|
||||
- `justify-content: space-between` - 两端对齐
|
||||
- `border-bottom: 2px solid #e5e7eb` - 底部分隔线
|
||||
- `padding-bottom: 12px` - 底部内边距
|
||||
|
||||
**数据结构变更**:
|
||||
```javascript
|
||||
// 删除
|
||||
const priceDoc = ref({
|
||||
fileName: '某数据中心发电机组采购-报价表.xlsx',
|
||||
generateTime: '2024-12-09 10:20:15',
|
||||
fileSize: '2.1 MB',
|
||||
amount: '¥2,850,000.00',
|
||||
status: '已完成'
|
||||
})
|
||||
|
||||
// 删除
|
||||
const modifyPrice = () => {
|
||||
ElMessage.info('打开报价修改对话框')
|
||||
}
|
||||
```
|
||||
|
||||
### 📊 影响范围
|
||||
|
||||
**受影响的功能**:
|
||||
- ✅ 文件提交模块 - 界面优化
|
||||
- ⚠️ 报价文件相关功能 - 已移除
|
||||
|
||||
**不受影响的功能**:
|
||||
- ✅ 商务标书展示和操作
|
||||
- ✅ 技术标书展示和操作
|
||||
- ✅ 时间节点展示
|
||||
- ✅ 检查清单功能
|
||||
- ✅ 提交功能
|
||||
|
||||
### 🔄 迁移指南
|
||||
|
||||
如果您的代码中引用了报价文件相关功能,请进行以下调整:
|
||||
|
||||
1. **移除报价文件引用**
|
||||
```javascript
|
||||
// 删除这些引用
|
||||
priceDoc.value
|
||||
modifyPrice()
|
||||
```
|
||||
|
||||
2. **更新文档数量**
|
||||
```javascript
|
||||
// 之前: 3个文件(商务标、技术标、报价文件)
|
||||
// 现在: 2个文件(商务标、技术标)
|
||||
```
|
||||
|
||||
3. **更新检查清单**
|
||||
```javascript
|
||||
// 如果检查清单中包含"报价文件核对",建议移除或调整
|
||||
```
|
||||
|
||||
### 🎯 优化效果
|
||||
|
||||
**优点**:
|
||||
1. ✅ 界面更简洁,状态一目了然
|
||||
2. ✅ 减少了冗余信息
|
||||
3. ✅ 状态标签更突出
|
||||
4. ✅ 视觉层次更清晰
|
||||
|
||||
**注意事项**:
|
||||
1. ⚠️ 如果业务需要报价文件,需要在其他地方展示
|
||||
2. ⚠️ 确保后端API不再返回报价文件数据
|
||||
3. ⚠️ 更新相关文档和测试用例
|
||||
|
||||
### 📝 文件变更清单
|
||||
|
||||
**修改的文件**:
|
||||
- `FileSubmission.vue` (模板、脚本、样式)
|
||||
|
||||
**修改的行数**:
|
||||
- 删除: ~50 行
|
||||
- 新增: ~15 行
|
||||
- 修改: ~10 行
|
||||
|
||||
**代码差异**:
|
||||
```diff
|
||||
- 报价文件展示区域 (HTML)
|
||||
- priceDoc 数据定义 (JavaScript)
|
||||
- modifyPrice 函数 (JavaScript)
|
||||
+ doc-header 容器 (HTML)
|
||||
+ doc-header 样式 (CSS)
|
||||
```
|
||||
|
||||
### 🧪 测试建议
|
||||
|
||||
1. **视觉测试**
|
||||
- 检查状态标签是否正确显示在标题旁边
|
||||
- 确认分隔线是否正常显示
|
||||
- 验证标签尺寸是否合适
|
||||
|
||||
2. **功能测试**
|
||||
- 测试预览、下载、重新生成按钮
|
||||
- 确认没有报价相关的错误
|
||||
- 验证其他功能正常工作
|
||||
|
||||
3. **响应式测试**
|
||||
- 测试不同屏幕尺寸下的显示效果
|
||||
- 确保移动端显示正常
|
||||
|
||||
### 📸 截图对比
|
||||
|
||||
**优化前**:
|
||||
- 状态标签在详情列表中
|
||||
- 报价文件占用额外空间
|
||||
- 信息密集度高
|
||||
|
||||
**优化后**:
|
||||
- 状态标签在标题旁边,更醒目
|
||||
- 移除报价文件,界面更简洁
|
||||
- 信息层次更清晰
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 (2024-12-08)
|
||||
|
||||
### 🎉 初始版本
|
||||
|
||||
- ✅ 实现文件提交模块
|
||||
- ✅ 实现开标信息模块
|
||||
- ✅ 实现结果公示模块
|
||||
- ✅ 集成到 BiddingView 页面
|
||||
- ✅ 完成文档编写
|
||||
|
||||
---
|
||||
|
||||
**维护者**: Cascade AI Assistant
|
||||
**更新时间**: 2024-12-08
|
||||
**版本**: v1.1.0
|
||||
332
江西城市生命线-可交互原型/frontend/src/components/project/检查清单编辑功能说明.md
Normal file
332
江西城市生命线-可交互原型/frontend/src/components/project/检查清单编辑功能说明.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# 检查清单编辑功能说明
|
||||
|
||||
## 📝 功能概述
|
||||
|
||||
文件提交模块的"提交前检查清单"现已支持完整的编辑功能,用户可以自定义检查项内容。
|
||||
|
||||
## ✨ 新增功能
|
||||
|
||||
### 1. **编辑检查项** ✏️
|
||||
|
||||
**操作方式**:
|
||||
- 鼠标悬停在检查项上,显示"编辑"按钮
|
||||
- 点击"编辑"按钮
|
||||
- 检查项文本变为可编辑的输入框
|
||||
- 修改内容后按 `Enter` 或点击外部区域保存
|
||||
|
||||
**特点**:
|
||||
- ✅ 即时编辑,无需额外对话框
|
||||
- ✅ 支持键盘快捷键(Enter 保存)
|
||||
- ✅ 自动失焦保存
|
||||
|
||||
### 2. **删除检查项** 🗑️
|
||||
|
||||
**操作方式**:
|
||||
- 鼠标悬停在检查项上,显示"删除"按钮
|
||||
- 点击"删除"按钮
|
||||
- 弹出确认对话框
|
||||
- 确认后删除该检查项
|
||||
|
||||
**特点**:
|
||||
- ✅ 二次确认,防止误删
|
||||
- ✅ 自动更新完成度进度
|
||||
- ✅ 同步移除已勾选状态
|
||||
|
||||
**安全机制**:
|
||||
```javascript
|
||||
// 删除时会同时:
|
||||
1. 从检查清单中移除
|
||||
2. 从已勾选项中移除
|
||||
3. 重新计算完成度
|
||||
```
|
||||
|
||||
### 3. **添加检查项** ➕
|
||||
|
||||
**操作方式**:
|
||||
- 点击检查清单底部的"添加检查项"按钮
|
||||
- 在弹出的对话框中输入检查项内容
|
||||
- 设置是否为必须项(开关)
|
||||
- 点击"确定"添加
|
||||
|
||||
**特点**:
|
||||
- ✅ 支持自定义检查项内容
|
||||
- ✅ 可设置必须/非必须
|
||||
- ✅ 字数限制(最多50字)
|
||||
- ✅ 实时显示字数统计
|
||||
|
||||
**对话框字段**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 检查项 | 文本输入 | 最多50字,显示字数统计 |
|
||||
| 是否必须 | 开关 | 开启后显示"必须"标签 |
|
||||
|
||||
## 🎨 界面设计
|
||||
|
||||
### 检查项布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ☑ 商务标完整性检查 [必须] [编辑] [删除] │
|
||||
│ ☑ 技术标完整性检查 [必须] [编辑] [删除] │
|
||||
│ ☑ 保证金缴纳凭证 [必须] [编辑] [删除] │
|
||||
│ ☐ 授权委托书签字盖章 [编辑] [删除] │
|
||||
│ │
|
||||
│ [+ 添加检查项] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 交互效果
|
||||
|
||||
**默认状态**:
|
||||
- 编辑/删除按钮半透明(opacity: 0.6)
|
||||
|
||||
**鼠标悬停**:
|
||||
- 背景色变浅
|
||||
- 编辑/删除按钮完全显示(opacity: 1)
|
||||
|
||||
**编辑状态**:
|
||||
- 文本变为输入框
|
||||
- 输入框宽度 300px
|
||||
- 自动聚焦
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 数据结构
|
||||
|
||||
```javascript
|
||||
const checklistItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
label: '商务标完整性检查',
|
||||
required: true, // 是否必须
|
||||
editing: false // 是否正在编辑
|
||||
},
|
||||
// ...
|
||||
])
|
||||
```
|
||||
|
||||
### 核心方法
|
||||
|
||||
#### 1. 编辑检查项
|
||||
```javascript
|
||||
const editItem = (item) => {
|
||||
item.editing = true // 切换为编辑状态
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 删除检查项
|
||||
```javascript
|
||||
const deleteItem = (id) => {
|
||||
ElMessageBox.confirm('确定要删除这个检查项吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
// 从列表中删除
|
||||
const index = checklistItems.value.findIndex(item => item.id === id)
|
||||
checklistItems.value.splice(index, 1)
|
||||
|
||||
// 从已选中项中移除
|
||||
const checkedIndex = checkedItems.value.indexOf(id)
|
||||
checkedItems.value.splice(checkedIndex, 1)
|
||||
|
||||
ElMessage.success('删除成功')
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 添加检查项
|
||||
```javascript
|
||||
const addNewItem = () => {
|
||||
if (!newItemForm.value.label.trim()) {
|
||||
ElMessage.warning('请输入检查项内容')
|
||||
return
|
||||
}
|
||||
|
||||
checklistItems.value.push({
|
||||
id: nextItemId++,
|
||||
label: newItemForm.value.label,
|
||||
required: newItemForm.value.required,
|
||||
editing: false
|
||||
})
|
||||
|
||||
ElMessage.success('添加成功')
|
||||
showAddItemDialog.value = false
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 完成度计算
|
||||
|
||||
完成度会根据检查项的增删自动更新:
|
||||
|
||||
```javascript
|
||||
const checklistProgress = computed(() => {
|
||||
return Math.round(
|
||||
(checkedItems.value.length / checklistItems.value.length) * 100
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
**示例**:
|
||||
- 总共 7 项检查项
|
||||
- 已勾选 5 项
|
||||
- 完成度 = 5 / 7 × 100% ≈ 71%
|
||||
|
||||
## 🎯 使用场景
|
||||
|
||||
### 场景 1: 自定义检查项
|
||||
项目经理可以根据不同项目的特点,添加特定的检查项。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
原有检查项:
|
||||
- 商务标完整性检查
|
||||
- 技术标完整性检查
|
||||
|
||||
新增检查项:
|
||||
- 环保资质证明
|
||||
- 安全生产许可证
|
||||
- 项目经理证书
|
||||
```
|
||||
|
||||
### 场景 2: 修改检查项描述
|
||||
根据实际情况,调整检查项的描述更加准确。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
修改前: 授权委托书签字盖章
|
||||
修改后: 法人授权委托书签字并加盖公章
|
||||
```
|
||||
|
||||
### 场景 3: 删除不适用的检查项
|
||||
某些检查项可能不适用于当前项目,可以删除。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
删除: 纸质文件打印装订
|
||||
原因: 本项目仅需电子投标
|
||||
```
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 数据持久化
|
||||
当前实现使用前端状态管理,刷新页面后修改会丢失。
|
||||
|
||||
**建议**:
|
||||
- 集成后端 API,保存到数据库
|
||||
- 或使用 localStorage 本地存储
|
||||
|
||||
### 2. 必须项限制
|
||||
标记为"必须"的检查项建议不允许删除,或删除时给予警告。
|
||||
|
||||
**改进方案**:
|
||||
```javascript
|
||||
const deleteItem = (id) => {
|
||||
const item = checklistItems.value.find(i => i.id === id)
|
||||
|
||||
if (item.required) {
|
||||
ElMessageBox.confirm(
|
||||
'这是必须项,删除可能影响提交,确定要删除吗?',
|
||||
'警告',
|
||||
{ type: 'warning' }
|
||||
).then(() => {
|
||||
// 执行删除
|
||||
})
|
||||
} else {
|
||||
// 正常删除
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 字数限制
|
||||
检查项内容限制在 50 字以内,确保界面美观。
|
||||
|
||||
### 4. ID 管理
|
||||
新增检查项使用递增 ID,确保唯一性。
|
||||
|
||||
```javascript
|
||||
let nextItemId = 8 // 从现有最大 ID + 1 开始
|
||||
```
|
||||
|
||||
## 🎨 样式特点
|
||||
|
||||
### 1. 悬停效果
|
||||
```scss
|
||||
.checklist-item {
|
||||
&:hover {
|
||||
background: #f3f4f6; // 背景变浅
|
||||
|
||||
.item-actions {
|
||||
opacity: 1; // 按钮完全显示
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 添加按钮
|
||||
```scss
|
||||
.add-item-btn {
|
||||
width: 100%;
|
||||
border-style: dashed; // 虚线边框
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed; // 紫色边框
|
||||
color: #7c3aed; // 紫色文字
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 操作按钮
|
||||
```scss
|
||||
.item-actions {
|
||||
opacity: 0.6; // 默认半透明
|
||||
transition: opacity 0.2s; // 平滑过渡
|
||||
}
|
||||
```
|
||||
|
||||
## 📸 效果展示
|
||||
|
||||
### 默认状态
|
||||
- 检查项正常显示
|
||||
- 操作按钮半透明
|
||||
|
||||
### 悬停状态
|
||||
- 背景色变浅
|
||||
- 操作按钮完全显示
|
||||
|
||||
### 编辑状态
|
||||
- 文本变为输入框
|
||||
- 可直接修改内容
|
||||
|
||||
### 添加对话框
|
||||
- 简洁的表单界面
|
||||
- 字数统计提示
|
||||
- 必须项开关
|
||||
|
||||
## 🚀 后续优化建议
|
||||
|
||||
1. **拖拽排序**
|
||||
- 支持拖拽调整检查项顺序
|
||||
- 使用 `vue-draggable` 库
|
||||
|
||||
2. **批量操作**
|
||||
- 批量删除
|
||||
- 批量设置必须项
|
||||
|
||||
3. **模板功能**
|
||||
- 保存常用检查清单为模板
|
||||
- 快速应用模板
|
||||
|
||||
4. **历史记录**
|
||||
- 记录修改历史
|
||||
- 支持撤销/重做
|
||||
|
||||
5. **权限控制**
|
||||
- 不同角色有不同编辑权限
|
||||
- 普通用户只能查看,管理员可编辑
|
||||
|
||||
---
|
||||
|
||||
**版本**: v1.2.0
|
||||
**更新时间**: 2024-12-08
|
||||
**功能状态**: ✅ 已实现
|
||||
353
江西城市生命线-可交互原型/frontend/src/components/project/流程步骤条说明.md
Normal file
353
江西城市生命线-可交互原型/frontend/src/components/project/流程步骤条说明.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# 项目流程步骤条说明
|
||||
|
||||
## 📍 功能概述
|
||||
|
||||
在文件提交模块顶部添加了项目信息头部和流程步骤条,清晰展示当前项目所处的阶段。
|
||||
|
||||
## 🎨 界面布局
|
||||
|
||||
### 1. 项目信息头部
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 某数据中心发电机组采购项目 │
|
||||
│ 创建时间:2024-11-20 09:30:00 [进行中] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- 🎨 渐变紫色背景(#667eea → #764ba2)
|
||||
- 📝 显示项目名称
|
||||
- 📅 显示创建时间
|
||||
- 🏷️ 显示项目状态标签
|
||||
|
||||
### 2. 流程步骤条
|
||||
|
||||
```
|
||||
招标信息提取 → 撰写投标文件 → 文件提交 → 开标 → 结果公示
|
||||
(完成) (完成) (进行中) (待进行) (待进行)
|
||||
✓ ✓ ⟳ 4 5
|
||||
```
|
||||
|
||||
**5个步骤**:
|
||||
1. **招标信息提取** - 已完成 ✓
|
||||
2. **撰写投标文件** - 已完成 ✓
|
||||
3. **文件提交** - 进行中 ⟳ (当前)
|
||||
4. **开标** - 待进行
|
||||
5. **结果公示** - 待进行
|
||||
|
||||
## 🎯 步骤状态
|
||||
|
||||
### 已完成步骤
|
||||
- **图标**: 绿色对勾 ✓
|
||||
- **颜色**: #10b981 (绿色)
|
||||
- **描述**: "已完成"
|
||||
|
||||
### 进行中步骤 (当前)
|
||||
- **图标**: 紫色旋转加载图标 ⟳
|
||||
- **颜色**: #7c3aed (紫色)
|
||||
- **描述**: "进行中"
|
||||
- **动画**: 持续旋转
|
||||
|
||||
### 待进行步骤
|
||||
- **图标**: 数字序号
|
||||
- **颜色**: 默认灰色
|
||||
- **描述**: "待进行"
|
||||
|
||||
## 💻 代码实现
|
||||
|
||||
### 模板结构
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="file-submission-panel">
|
||||
<!-- 项目信息头部 -->
|
||||
<div class="project-header">
|
||||
<h2>{{ project.name }}</h2>
|
||||
<div class="project-meta">
|
||||
<span>创建时间:{{ project.createTime }}</span>
|
||||
<el-tag type="warning" size="large">进行中</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目流程步骤条 -->
|
||||
<el-card class="timeline-card">
|
||||
<div class="project-timeline-header">
|
||||
<h3>项目流程</h3>
|
||||
</div>
|
||||
<el-steps :active="2" align-center>
|
||||
<!-- 步骤1: 招标信息提取 -->
|
||||
<el-step title="招标信息提取" description="已完成">
|
||||
<template #icon>
|
||||
<el-icon color="#10b981"><CircleCheck /></el-icon>
|
||||
</template>
|
||||
</el-step>
|
||||
|
||||
<!-- 步骤2: 撰写投标文件 -->
|
||||
<el-step title="撰写投标文件" description="已完成">
|
||||
<template #icon>
|
||||
<el-icon color="#10b981"><CircleCheck /></el-icon>
|
||||
</template>
|
||||
</el-step>
|
||||
|
||||
<!-- 步骤3: 文件提交 (当前) -->
|
||||
<el-step title="文件提交" description="进行中" status="process">
|
||||
<template #icon>
|
||||
<el-icon color="#7c3aed">
|
||||
<Loading class="rotating" />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-step>
|
||||
|
||||
<!-- 步骤4: 开标 -->
|
||||
<el-step title="开标" description="待进行">
|
||||
<template #icon>
|
||||
<span>4</span>
|
||||
</template>
|
||||
</el-step>
|
||||
|
||||
<!-- 步骤5: 结果公示 -->
|
||||
<el-step title="结果公示" description="待进行">
|
||||
<template #icon>
|
||||
<span>5</span>
|
||||
</template>
|
||||
</el-step>
|
||||
</el-steps>
|
||||
</el-card>
|
||||
|
||||
<!-- 其他内容... -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 样式实现
|
||||
|
||||
```scss
|
||||
// 项目信息头部
|
||||
.project-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 24px 32px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
color: white;
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
// 流程步骤卡片
|
||||
.timeline-card {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.project-timeline-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-steps) {
|
||||
.el-step__title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-step__description {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-step.is-process {
|
||||
.el-step__title {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.rotating {
|
||||
animation: rotate 2s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 旋转动画
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 视觉效果
|
||||
|
||||
### 项目头部
|
||||
- **背景**: 渐变紫色(从 #667eea 到 #764ba2)
|
||||
- **文字**: 白色
|
||||
- **圆角**: 12px
|
||||
- **内边距**: 24px 32px
|
||||
|
||||
### 步骤条
|
||||
- **对齐**: 居中对齐
|
||||
- **当前步骤**: 紫色高亮
|
||||
- **完成步骤**: 绿色对勾
|
||||
- **待进行**: 灰色数字
|
||||
|
||||
### 动画效果
|
||||
- **旋转图标**: 2秒一圈,无限循环
|
||||
- **平滑过渡**: 所有状态变化都有过渡效果
|
||||
|
||||
## 📊 步骤索引说明
|
||||
|
||||
```javascript
|
||||
:active="2" // 当前激活的步骤索引(从0开始)
|
||||
```
|
||||
|
||||
**索引对应**:
|
||||
- 0 = 招标信息提取
|
||||
- 1 = 撰写投标文件
|
||||
- 2 = 文件提交 ← 当前
|
||||
- 3 = 开标
|
||||
- 4 = 结果公示
|
||||
|
||||
## 🔄 其他模块适配
|
||||
|
||||
### BiddingInfo.vue (开标信息)
|
||||
```vue
|
||||
<el-steps :active="3" align-center>
|
||||
<!-- 前3步已完成 -->
|
||||
<el-step title="开标" description="进行中" status="process">
|
||||
<!-- 当前步骤 -->
|
||||
</el-step>
|
||||
</el-steps>
|
||||
```
|
||||
|
||||
### ResultAnnouncement.vue (结果公示)
|
||||
```vue
|
||||
<el-steps :active="4" align-center>
|
||||
<!-- 前4步已完成 -->
|
||||
<el-step title="结果公示" description="进行中" status="process">
|
||||
<!-- 当前步骤 -->
|
||||
</el-step>
|
||||
</el-steps>
|
||||
```
|
||||
|
||||
## 💡 使用建议
|
||||
|
||||
### 1. 动态步骤索引
|
||||
根据项目实际进度动态设置 `active` 值:
|
||||
|
||||
```javascript
|
||||
const currentStepIndex = computed(() => {
|
||||
const stepMap = {
|
||||
'招标信息提取': 0,
|
||||
'撰写投标文件': 1,
|
||||
'文件提交': 2,
|
||||
'开标': 3,
|
||||
'结果公示': 4
|
||||
}
|
||||
return stepMap[project.value.progress] || 0
|
||||
})
|
||||
```
|
||||
|
||||
### 2. 步骤点击跳转
|
||||
可以为步骤添加点击事件,实现快速跳转:
|
||||
|
||||
```vue
|
||||
<el-step
|
||||
@click="jumpToStep(0)"
|
||||
title="招标信息提取"
|
||||
>
|
||||
</el-step>
|
||||
```
|
||||
|
||||
### 3. 响应式设计
|
||||
在移动端可以使用垂直步骤条:
|
||||
|
||||
```vue
|
||||
<el-steps
|
||||
:active="2"
|
||||
:direction="isMobile ? 'vertical' : 'horizontal'"
|
||||
>
|
||||
</el-steps>
|
||||
```
|
||||
|
||||
## 🎯 优化建议
|
||||
|
||||
### 1. 步骤时间显示
|
||||
在描述中显示完成时间:
|
||||
|
||||
```vue
|
||||
<el-step
|
||||
title="招标信息提取"
|
||||
description="已完成 (2024-11-20)"
|
||||
>
|
||||
</el-step>
|
||||
```
|
||||
|
||||
### 2. 进度百分比
|
||||
在头部显示整体进度:
|
||||
|
||||
```vue
|
||||
<div class="project-meta">
|
||||
<span>创建时间:{{ project.createTime }}</span>
|
||||
<span>整体进度:60%</span>
|
||||
<el-progress :percentage="60" style="width: 100px;" />
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. 步骤详情提示
|
||||
鼠标悬停显示步骤详情:
|
||||
|
||||
```vue
|
||||
<el-tooltip content="已于2024-11-20完成">
|
||||
<el-step title="招标信息提取" />
|
||||
</el-tooltip>
|
||||
```
|
||||
|
||||
## 📸 效果预览
|
||||
|
||||
### 完整界面
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 某数据中心发电机组采购项目 │
|
||||
│ 创建时间:2024-11-20 09:30:00 [进行中] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 项目流程 │
|
||||
│ │
|
||||
│ ✓ ✓ ⟳ 4 5 │
|
||||
│ 招标信息 撰写投标 文件提交 开标 结果公示 │
|
||||
│ 提取 文件 │
|
||||
│ 已完成 已完成 进行中 待进行 待进行 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 已生成标书文件 │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**版本**: v1.3.0
|
||||
**更新时间**: 2024-12-08
|
||||
**功能状态**: ✅ 已实现
|
||||
**适用模块**: FileSubmission.vue
|
||||
335
江西城市生命线-可交互原型/frontend/src/components/project/集成示例.md
Normal file
335
江西城市生命线-可交互原型/frontend/src/components/project/集成示例.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# 项目管理模块集成示例
|
||||
|
||||
## 在 BiddingView.vue 中集成项目详情组件
|
||||
|
||||
### 步骤 1: 导入组件
|
||||
|
||||
在 `BiddingView.vue` 的 `<script setup>` 部分添加导入:
|
||||
|
||||
```javascript
|
||||
import ProjectDetail from '@/components/project/ProjectDetail.vue'
|
||||
```
|
||||
|
||||
### 步骤 2: 修改模板
|
||||
|
||||
找到 `project-ongoing` 部分,修改为:
|
||||
|
||||
```vue
|
||||
<!-- 进行中项目 -->
|
||||
<div v-if="activeNav === 'project-ongoing'" class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>进行中项目</h3>
|
||||
<div class="header-actions">
|
||||
<el-input
|
||||
v-model="projectSearch"
|
||||
placeholder="搜索项目名称..."
|
||||
style="width: 200px; margin-right: 12px;"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="showCreateProjectDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
创建项目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<div v-if="!selectedProject" class="project-list">
|
||||
<div
|
||||
v-for="project in filteredOngoingProjects"
|
||||
:key="project.id"
|
||||
class="project-card"
|
||||
@click="viewProjectDetail(project)"
|
||||
>
|
||||
<div class="project-card-header">
|
||||
<div class="project-title">
|
||||
<h4>{{ project.name }}</h4>
|
||||
<el-tag :type="getProgressTagType(project.progress)" size="small">
|
||||
{{ project.progress }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="project-meta">
|
||||
<span class="meta-item">
|
||||
<el-icon><Clock /></el-icon>
|
||||
创建时间:{{ project.createTime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-progress-bar">
|
||||
<div class="progress-info">
|
||||
<span>当前进度</span>
|
||||
<span>{{ getProgressPercentage(project.progress) }}%</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="getProgressPercentage(project.progress)"
|
||||
:color="getProgressColor(project.progress)"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目详情 - 使用新组件 -->
|
||||
<ProjectDetail
|
||||
v-else
|
||||
:project="selectedProject"
|
||||
@back="selectedProject = null"
|
||||
@update="handleProjectUpdate"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 步骤 3: 添加处理函数
|
||||
|
||||
在 `<script setup>` 部分添加:
|
||||
|
||||
```javascript
|
||||
// 项目详情处理
|
||||
const handleProjectUpdate = () => {
|
||||
ElMessage.success('项目信息已更新')
|
||||
// 可以在这里重新加载项目数据
|
||||
// loadProjectData(selectedProject.value.id)
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例代码
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="bidding-system">
|
||||
<!-- 侧边栏保持不变 -->
|
||||
<aside class="bidding-sidebar">
|
||||
<!-- ... 侧边栏内容 ... -->
|
||||
</aside>
|
||||
|
||||
<div class="bidding-content">
|
||||
<header class="content-header">
|
||||
<!-- ... 头部内容 ... -->
|
||||
</header>
|
||||
|
||||
<div class="content-main">
|
||||
<!-- 进行中项目 -->
|
||||
<div v-if="activeNav === 'project-ongoing'" class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>进行中项目</h3>
|
||||
<div class="header-actions">
|
||||
<el-input
|
||||
v-model="projectSearch"
|
||||
placeholder="搜索项目名称..."
|
||||
style="width: 200px; margin-right: 12px;"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="showCreateProjectDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
创建项目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<div v-if="!selectedProject" class="project-list">
|
||||
<div
|
||||
v-for="project in filteredOngoingProjects"
|
||||
:key="project.id"
|
||||
class="project-card"
|
||||
@click="viewProjectDetail(project)"
|
||||
>
|
||||
<div class="project-card-header">
|
||||
<div class="project-title">
|
||||
<h4>{{ project.name }}</h4>
|
||||
<el-tag :type="getProgressTagType(project.progress)" size="small">
|
||||
{{ project.progress }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="project-meta">
|
||||
<span class="meta-item">
|
||||
<el-icon><Clock /></el-icon>
|
||||
创建时间:{{ project.createTime }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-progress-bar">
|
||||
<div class="progress-info">
|
||||
<span>当前进度</span>
|
||||
<span>{{ getProgressPercentage(project.progress) }}%</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="getProgressPercentage(project.progress)"
|
||||
:color="getProgressColor(project.progress)"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目详情 -->
|
||||
<ProjectDetail
|
||||
v-else
|
||||
:project="selectedProject"
|
||||
@back="selectedProject = null"
|
||||
@update="handleProjectUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ProjectDetail from '@/components/project/ProjectDetail.vue'
|
||||
|
||||
// 导航状态
|
||||
const activeNav = ref('kb-overview')
|
||||
const selectedProject = ref(null)
|
||||
const projectSearch = ref('')
|
||||
|
||||
// 项目数据
|
||||
const ongoingProjects = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '某数据中心发电机组采购项目',
|
||||
client: 'XX科技有限公司',
|
||||
status: 'ongoing',
|
||||
progress: '文件提交',
|
||||
createTime: '2024-11-01 10:30:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'XX医院应急电源系统项目',
|
||||
client: 'XX市人民医院',
|
||||
status: 'ongoing',
|
||||
progress: '开标',
|
||||
createTime: '2024-11-05 14:20:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '工业园区配电工程项目',
|
||||
client: 'XX工业园管委会',
|
||||
status: 'won',
|
||||
progress: '结果公示',
|
||||
createTime: '2024-10-15 09:15:00'
|
||||
}
|
||||
])
|
||||
|
||||
// 过滤项目
|
||||
const filteredOngoingProjects = computed(() => {
|
||||
if (!projectSearch.value) return ongoingProjects.value
|
||||
return ongoingProjects.value.filter(p =>
|
||||
p.name.includes(projectSearch.value)
|
||||
)
|
||||
})
|
||||
|
||||
// 查看项目详情
|
||||
const viewProjectDetail = (project) => {
|
||||
selectedProject.value = project
|
||||
}
|
||||
|
||||
// 项目更新处理
|
||||
const handleProjectUpdate = () => {
|
||||
ElMessage.success('项目信息已更新')
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
const getProgressTagType = (progress) => {
|
||||
const typeMap = {
|
||||
'信息提取': 'info',
|
||||
'文件准备': 'warning',
|
||||
'文件提交': 'warning',
|
||||
'开标': '',
|
||||
'评标': '',
|
||||
'结果公示': 'success'
|
||||
}
|
||||
return typeMap[progress] || 'info'
|
||||
}
|
||||
|
||||
const getProgressPercentage = (progress) => {
|
||||
const percentageMap = {
|
||||
'信息提取': 16,
|
||||
'文件准备': 33,
|
||||
'文件提交': 50,
|
||||
'开标': 66,
|
||||
'评标': 83,
|
||||
'结果公示': 100
|
||||
}
|
||||
return percentageMap[progress] || 0
|
||||
}
|
||||
|
||||
const getProgressColor = (progress) => {
|
||||
const percentage = getProgressPercentage(progress)
|
||||
if (percentage >= 80) return '#10b981'
|
||||
if (percentage >= 50) return '#3b82f6'
|
||||
if (percentage >= 30) return '#f59e0b'
|
||||
return '#ef4444'
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 测试步骤
|
||||
|
||||
1. **启动开发服务器**
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. **访问页面**
|
||||
- 打开浏览器访问 `http://localhost:5173`
|
||||
- 进入招标助手系统
|
||||
- 点击左侧导航"项目管理" → "进行中项目"
|
||||
|
||||
3. **测试功能**
|
||||
- 点击任意项目卡片进入详情
|
||||
- 切换不同 Tab 查看各个模块
|
||||
- 测试文件提交模块的检查清单
|
||||
- 测试开标信息模块的报价对比表
|
||||
- 测试结果公示模块的三种结果类型切换
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **确保依赖已安装**
|
||||
```bash
|
||||
npm install element-plus @element-plus/icons-vue
|
||||
```
|
||||
|
||||
2. **检查路径**
|
||||
- 确保组件路径正确: `@/components/project/ProjectDetail.vue`
|
||||
- 如果使用相对路径,根据实际情况调整
|
||||
|
||||
3. **样式冲突**
|
||||
- 新组件的样式使用了 `scoped`,不会影响现有样式
|
||||
- 如有冲突,可以调整组件内的样式
|
||||
|
||||
4. **数据对接**
|
||||
- 当前使用模拟数据
|
||||
- 实际使用时需要对接后端 API
|
||||
- 参考 `src/api/project.js` 中的接口定义
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **性能优化**
|
||||
- 使用虚拟滚动处理大量项目列表
|
||||
- 懒加载项目详情数据
|
||||
|
||||
2. **用户体验**
|
||||
- 添加加载状态提示
|
||||
- 添加错误处理和重试机制
|
||||
- 实现数据缓存
|
||||
|
||||
3. **功能扩展**
|
||||
- 添加项目搜索和筛选
|
||||
- 实现项目排序功能
|
||||
- 添加批量操作功能
|
||||
|
||||
4. **移动端适配**
|
||||
- 优化移动端布局
|
||||
- 添加触摸手势支持
|
||||
@@ -0,0 +1,618 @@
|
||||
<template>
|
||||
<div class="workflow-editor">
|
||||
<!-- 左侧节点面板 -->
|
||||
<aside class="node-panel">
|
||||
<div class="panel-header">
|
||||
<h3>节点库</h3>
|
||||
</div>
|
||||
<div class="node-categories">
|
||||
<div class="category" v-for="category in categories" :key="category.key">
|
||||
<div class="category-title">{{ category.label }}</div>
|
||||
<div class="node-list">
|
||||
<div
|
||||
v-for="nodeType in getNodesByCategory(category.key)"
|
||||
:key="nodeType.type"
|
||||
class="node-item"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, nodeType)"
|
||||
>
|
||||
<div class="node-icon" :style="{ background: nodeType.color }">
|
||||
{{ nodeType.icon }}
|
||||
</div>
|
||||
<span class="node-label">{{ nodeType.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 画布区域 -->
|
||||
<main class="canvas-container">
|
||||
<!-- 工具栏 -->
|
||||
<div class="canvas-toolbar">
|
||||
<el-button-group>
|
||||
<el-button size="small" @click="zoomIn">
|
||||
<el-icon><ZoomIn /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" @click="zoomOut">
|
||||
<el-icon><ZoomOut /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" @click="resetZoom">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
<el-button size="small" type="danger" @click="clearCanvas">
|
||||
<el-icon><Delete /></el-icon>
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 画布 -->
|
||||
<div
|
||||
ref="canvasRef"
|
||||
class="canvas"
|
||||
:style="canvasStyle"
|
||||
@drop="onDrop"
|
||||
@dragover.prevent
|
||||
@click="onCanvasClick"
|
||||
@mousedown="onCanvasMouseDown"
|
||||
@mousemove="onCanvasMouseMove"
|
||||
@mouseup="onCanvasMouseUp"
|
||||
>
|
||||
<!-- 网格背景 -->
|
||||
<svg class="grid-background" width="100%" height="100%">
|
||||
<defs>
|
||||
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e5e7eb" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
|
||||
<!-- 连接线 -->
|
||||
<svg class="connections-layer" width="100%" height="100%">
|
||||
<g v-for="conn in connections" :key="conn.id">
|
||||
<path
|
||||
:d="getConnectionPath(conn)"
|
||||
fill="none"
|
||||
stroke="#94a3b8"
|
||||
stroke-width="2"
|
||||
class="connection-line"
|
||||
@click="onConnectionClick(conn)"
|
||||
/>
|
||||
</g>
|
||||
<!-- 临时连接线 -->
|
||||
<path
|
||||
v-if="tempConnection"
|
||||
:d="tempConnectionPath"
|
||||
fill="none"
|
||||
stroke="#7c3aed"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="5,5"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- 节点 -->
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.id"
|
||||
class="workflow-node"
|
||||
:class="{ selected: selectedNodeId === node.id }"
|
||||
:style="getNodeStyle(node)"
|
||||
@mousedown.stop="onNodeMouseDown($event, node)"
|
||||
>
|
||||
<div class="node-header" :style="{ background: node.color }">
|
||||
<span class="node-icon">{{ node.icon }}</span>
|
||||
<span class="node-title">{{ node.label }}</span>
|
||||
<el-icon class="delete-btn" @click.stop="deleteNode(node.id)"><Close /></el-icon>
|
||||
</div>
|
||||
<div class="node-body">
|
||||
<!-- 输入端口 -->
|
||||
<div
|
||||
v-for="input in node.inputs"
|
||||
:key="input.id"
|
||||
class="port input-port"
|
||||
@mousedown.stop="onPortMouseDown($event, node, input, 'input')"
|
||||
@mouseup.stop="onPortMouseUp($event, node, input, 'input')"
|
||||
>
|
||||
<div class="port-dot"></div>
|
||||
</div>
|
||||
<!-- 输出端口 -->
|
||||
<div
|
||||
v-for="output in node.outputs"
|
||||
:key="output.id"
|
||||
class="port output-port"
|
||||
@mousedown.stop="onPortMouseDown($event, node, output, 'output')"
|
||||
>
|
||||
<div class="port-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 右侧属性面板 -->
|
||||
<aside class="property-panel" v-if="selectedNode">
|
||||
<div class="panel-header">
|
||||
<h3>节点属性</h3>
|
||||
<el-icon class="close-btn" @click="selectNode(null)"><Close /></el-icon>
|
||||
</div>
|
||||
<div class="property-content">
|
||||
<el-form label-position="top" size="small">
|
||||
<el-form-item label="节点类型">
|
||||
<el-input :value="selectedNode.label" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="节点ID">
|
||||
<el-input :value="selectedNode.id" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="位置 X">
|
||||
<el-input-number v-model="selectedNode.x" :step="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="位置 Y">
|
||||
<el-input-number v-model="selectedNode.y" :step="10" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useWorkflowStore } from '@/stores/workflow'
|
||||
import { ZoomIn, ZoomOut, FullScreen, Delete, Close } from '@element-plus/icons-vue'
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
// 响应式数据
|
||||
const canvasRef = ref(null)
|
||||
const scale = ref(1)
|
||||
const panOffset = ref({ x: 0, y: 0 })
|
||||
const isDragging = ref(false)
|
||||
const dragNode = ref(null)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
const tempConnection = ref(null)
|
||||
const isPanning = ref(false)
|
||||
const panStart = ref({ x: 0, y: 0 })
|
||||
|
||||
// 节点分类
|
||||
const categories = [
|
||||
{ key: 'control', label: '流程控制' },
|
||||
{ key: 'ai', label: 'AI 能力' },
|
||||
{ key: 'logic', label: '逻辑处理' },
|
||||
{ key: 'integration', label: '集成' },
|
||||
{ key: 'transform', label: '数据转换' }
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const nodes = computed(() => workflowStore.nodes)
|
||||
const connections = computed(() => workflowStore.connections)
|
||||
const selectedNodeId = computed(() => workflowStore.selectedNodeId)
|
||||
const selectedNode = computed(() => workflowStore.getNode(selectedNodeId.value))
|
||||
const nodeTypes = computed(() => workflowStore.nodeTypes)
|
||||
|
||||
const canvasStyle = computed(() => ({
|
||||
transform: `scale(${scale.value}) translate(${panOffset.value.x}px, ${panOffset.value.y}px)`
|
||||
}))
|
||||
|
||||
// 根据分类获取节点
|
||||
const getNodesByCategory = (category) => {
|
||||
return nodeTypes.value.filter(n => n.category === category)
|
||||
}
|
||||
|
||||
// 节点样式
|
||||
const getNodeStyle = (node) => ({
|
||||
left: `${node.x}px`,
|
||||
top: `${node.y}px`,
|
||||
width: `${node.width}px`
|
||||
})
|
||||
|
||||
// 连接线路径
|
||||
const getConnectionPath = (conn) => {
|
||||
const sourceNode = workflowStore.getNode(conn.sourceNodeId)
|
||||
const targetNode = workflowStore.getNode(conn.targetNodeId)
|
||||
if (!sourceNode || !targetNode) return ''
|
||||
|
||||
const startX = sourceNode.x + sourceNode.width
|
||||
const startY = sourceNode.y + 30
|
||||
const endX = targetNode.x
|
||||
const endY = targetNode.y + 30
|
||||
|
||||
const controlPointOffset = Math.abs(endX - startX) / 2
|
||||
return `M ${startX} ${startY} C ${startX + controlPointOffset} ${startY}, ${endX - controlPointOffset} ${endY}, ${endX} ${endY}`
|
||||
}
|
||||
|
||||
// 临时连接线路径
|
||||
const tempConnectionPath = computed(() => {
|
||||
if (!tempConnection.value) return ''
|
||||
const { startX, startY, endX, endY } = tempConnection.value
|
||||
const controlPointOffset = Math.abs(endX - startX) / 2
|
||||
return `M ${startX} ${startY} C ${startX + controlPointOffset} ${startY}, ${endX - controlPointOffset} ${endY}, ${endX} ${endY}`
|
||||
})
|
||||
|
||||
// 拖拽开始
|
||||
const onDragStart = (event, nodeType) => {
|
||||
event.dataTransfer.setData('nodeType', nodeType.type)
|
||||
}
|
||||
|
||||
// 放置节点
|
||||
const onDrop = (event) => {
|
||||
const nodeType = event.dataTransfer.getData('nodeType')
|
||||
if (!nodeType) return
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
const x = (event.clientX - rect.left) / scale.value - panOffset.value.x
|
||||
const y = (event.clientY - rect.top) / scale.value - panOffset.value.y
|
||||
|
||||
workflowStore.addNode(nodeType, { x, y })
|
||||
}
|
||||
|
||||
// 画布点击
|
||||
const onCanvasClick = () => {
|
||||
workflowStore.selectNode(null)
|
||||
}
|
||||
|
||||
// 画布拖拽
|
||||
const onCanvasMouseDown = (event) => {
|
||||
if (event.button === 1 || event.shiftKey) {
|
||||
isPanning.value = true
|
||||
panStart.value = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
}
|
||||
|
||||
const onCanvasMouseMove = (event) => {
|
||||
// 节点拖拽
|
||||
if (isDragging.value && dragNode.value) {
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
const x = (event.clientX - rect.left) / scale.value - dragOffset.value.x
|
||||
const y = (event.clientY - rect.top) / scale.value - dragOffset.value.y
|
||||
workflowStore.updateNodePosition(dragNode.value.id, x, y)
|
||||
}
|
||||
|
||||
// 画布平移
|
||||
if (isPanning.value) {
|
||||
const dx = event.clientX - panStart.value.x
|
||||
const dy = event.clientY - panStart.value.y
|
||||
panOffset.value.x += dx / scale.value
|
||||
panOffset.value.y += dy / scale.value
|
||||
panStart.value = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
// 连接线拖拽
|
||||
if (tempConnection.value) {
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
tempConnection.value.endX = (event.clientX - rect.left) / scale.value
|
||||
tempConnection.value.endY = (event.clientY - rect.top) / scale.value
|
||||
}
|
||||
}
|
||||
|
||||
const onCanvasMouseUp = () => {
|
||||
isDragging.value = false
|
||||
dragNode.value = null
|
||||
isPanning.value = false
|
||||
tempConnection.value = null
|
||||
}
|
||||
|
||||
// 节点拖拽
|
||||
const onNodeMouseDown = (event, node) => {
|
||||
workflowStore.selectNode(node.id)
|
||||
isDragging.value = true
|
||||
dragNode.value = node
|
||||
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
dragOffset.value = {
|
||||
x: (event.clientX - rect.left) / scale.value - node.x,
|
||||
y: (event.clientY - rect.top) / scale.value - node.y
|
||||
}
|
||||
}
|
||||
|
||||
// 端口操作
|
||||
const onPortMouseDown = (event, node, port, portType) => {
|
||||
if (portType === 'output') {
|
||||
tempConnection.value = {
|
||||
sourceNodeId: node.id,
|
||||
sourcePortId: port.id,
|
||||
startX: node.x + node.width,
|
||||
startY: node.y + 30,
|
||||
endX: node.x + node.width,
|
||||
endY: node.y + 30
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onPortMouseUp = (event, node, port, portType) => {
|
||||
if (portType === 'input' && tempConnection.value) {
|
||||
workflowStore.addConnection(
|
||||
tempConnection.value.sourceNodeId,
|
||||
tempConnection.value.sourcePortId,
|
||||
node.id,
|
||||
port.id
|
||||
)
|
||||
}
|
||||
tempConnection.value = null
|
||||
}
|
||||
|
||||
// 连接线点击
|
||||
const onConnectionClick = (conn) => {
|
||||
workflowStore.deleteConnection(conn.id)
|
||||
}
|
||||
|
||||
// 删除节点
|
||||
const deleteNode = (nodeId) => {
|
||||
workflowStore.deleteNode(nodeId)
|
||||
}
|
||||
|
||||
// 选择节点
|
||||
const selectNode = (nodeId) => {
|
||||
workflowStore.selectNode(nodeId)
|
||||
}
|
||||
|
||||
// 缩放
|
||||
const zoomIn = () => {
|
||||
scale.value = Math.min(scale.value + 0.1, 2)
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
scale.value = Math.max(scale.value - 0.1, 0.5)
|
||||
}
|
||||
|
||||
const resetZoom = () => {
|
||||
scale.value = 1
|
||||
panOffset.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
// 清空画布
|
||||
const clearCanvas = () => {
|
||||
workflowStore.clearCanvas()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.workflow-editor {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
// 左侧节点面板
|
||||
.node-panel {
|
||||
width: 240px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.node-categories {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.category-title {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.node-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.node-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 画布区域
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.canvas-toolbar {
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transform-origin: 0 0;
|
||||
|
||||
.grid-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.connections-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.connection-line {
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
stroke: #ef4444;
|
||||
stroke-width: 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工作流节点
|
||||
.workflow-node {
|
||||
position: absolute;
|
||||
background: #fff;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
|
||||
&.selected {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.node-header {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
|
||||
.node-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.node-body {
|
||||
padding: 8px 12px;
|
||||
min-height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.port {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&.input-port {
|
||||
left: -8px;
|
||||
}
|
||||
|
||||
&.output-port {
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.port-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
border: 2px solid #94a3b8;
|
||||
border-radius: 50%;
|
||||
cursor: crosshair;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧属性面板
|
||||
.property-panel {
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover {
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.property-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user