| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518 |
- <template>
- <div class="dashboard">
- <div class="stats">
- <div
- v-for="stat in stats"
- :key="stat.label"
- class="stat"
- :class="{ warn: stat.warn }"
- >
- <label>{{ stat.label }}</label>
- <b>
- {{ outputValue(stat.label, stat.count) }}
- </b>
- <small>{{ stat.desc }}</small>
- </div>
- </div>
- <div class="grid2">
- <div class="card">
- <div class="card-head">
- 待办事项
- <!-- <span class="tag tag-danger" style="float: right;">续标优先</span> -->
- </div>
- <div class="card-body">
- <ul class="todo-list">
- <li
- v-for="(item, index) in todoList"
- :key="index"
- class="todo-item"
- >
- <span :class="['todo-dot', { urgent: item.urgent }]"></span>
- <div style="color: var(--primary);cursor: pointer;">
- <strong>{{ item.title }}</strong>
- <span> {{ moment(item.create_time).format('YYYY-MM-DD HH:mm:ss') }}</span>
- </div>
- </li>
- </ul>
- </div>
- </div>
- <div class="card">
- <div class="card-head">快捷入口</div>
- <div class="card-body">
- <div class="quick-links">
- <a
- v-for="link in quickLinks"
- :key="link.title"
- :class="['quick-card', { 'quick-card--primary': link.primary }]"
- @click="handleOpenClick(link.path)"
- >
- <strong>{{ link.title }}</strong>
- <span>{{ link.desc }}</span>
- </a>
- </div>
- </div>
- </div>
- </div>
- <div class="card">
- <div class="card-head">近期项目
- <span class="badge ok" style="float: right;cursor: pointer;" @click="handleClick('/projects')">更多>></span>
- </div>
- <div class="card-body" >
- <table>
- <thead>
- <tr>
- <th>项目名称</th>
- <th>业态类型</th>
- <th>管理区域</th>
- <th>合同起止</th>
- <th>状态</th>
- </tr>
- </thead>
- <tbody>
- <tr v-for="item in projects" :key="item.c_xmmc">
- <td>
- <strong style="cursor: pointer;" @click="handleViewClick(item)">{{ item.c_xmmc }}</strong>
- <div style="font-size:11px;color:var(--text3);cursor: pointer;" @click="handleViewClick(item)">{{ item.c_xmdz }}</div>
- </td>
- <td>{{ item.c_hzyt }}</td>
- <td>{{ item.c_glqynbkj }}</td>
- <td>
- {{ moment(item.c_zgxmhtksrq).format('YYYY-MM-DD') }} - {{ moment(item.c_zgxmhtdqrq).format('YYYY-MM-DD') }}
- <span v-if="item.c_zgxmhtksrq" style="margin-left: 5px;" :class="['badge', item.statusClass]">{{ item.days }}天</span>
- </td>
-
- <td>
- <span :class="['badge', item.statusClass]">{{ item.statusText }}</span>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- </div>
- <!-- <div class="chart-card">
- <div class="card-head">数据统计</div>
- <div class="card-body">
- <div ref="chartRef" class="chart"></div>
- </div>
- </div> -->
- </div>
- </template>
- <script setup lang="ts">
- import moment from 'moment'
- import { ref, onMounted } from 'vue'
- import { useRouter } from 'vue-router'
- const router = useRouter()
- import api from '@/api/common'
- import { COLUMN } from '@/constants/dmsColumns'
- const stats = ref([
- { label: '在管项目', desc: '本年新增77个',count: '213', warn: false },
- { label: '在岗人员', desc: '',count: '2808', warn: false },
- { label: '年化收入', desc: '',count: '2.86亿', warn: false },
- { label: '平均毛利率', desc: '',count: '27.78%', warn: false },
- { label: '待续约', desc: '3个月内到期',count: '72', warn: true },
- { label: '招标信息', desc: '',count: '0', warn: true }
- ])
- const handleOpenClick = (path: string) => {
- router.push({ path: path })
- }
- interface TodoItem {
- title: string
- create_time: string
- urgent?: boolean
- }
- const todoList = ref<TodoItem[]>([
- { title: '合同续签谈判', create_time: '2026-06-07 10:00:00', urgent: false },
- { title: '项目进度汇报', create_time: '2026-06-06 14:00:00', urgent: false },
- { title: '投标文件审核', create_time: '2026-06-06 09:00:00', urgent: false },
- { title: '招标文件分析报告', create_time: '2026-06-05 16:00:00', urgent: false },
- { title: '项目续标方案确认', create_time: '2026-06-05 14:00:00', urgent: false }
- ])
- const quickLinks = ref([
- // { title: '市场拓展中心', desc: '招标 · 标书 · 投标 · 续标', primary: false, path: '/market' },
- { title: '项目续标', desc: '续标项目列表 · 全流程跟进', primary: false, path: '/renewal' },
- // { title: '空间一张图', desc: '数据可视化展示 · 空间一张图', primary: false, path: '/gis' },
- { title: '招标监测', desc: '政府采购网抓取 · 续标监控 · 智能匹配', primary: false, path: '/tender-pool' },
- { title: '开标与中标', desc: '开标监控 · 结果抓取 · 中标推送', primary: false, path: '/bid-result' }
- ])
- interface ProjectItem {
- title: string
- c_xmmc: string
- c_xmdz: string
- c_hzyt: string
- c_glqynbkj: string
- c_zgxmhtksrq: string
- c_zgxmhtdqrq: string
- c_zbjgrzbdrhxsj?: string
- statusClass: string
- statusText: string
- days: string
- }
- const projects = ref<ProjectItem[]>([])
- onMounted(() => {
- // initChart()
- // window.addEventListener('resize', () => {
- // chartInstance?.resize()
- // })
- initData()
- getBasicCount();
- })
- const initData = () => {
- toDoList();
- getProjects();
- }
- const getBasicCount = async () => {
- //测试数据
- // setTimeout(() => {
- // stats.value.forEach((item) => {
- // animateNumber(item.label, item.count)
- // })
- // }, 100)
- const res = await api.getBasicCount()
- if ((res as any).code === 200) {
- const content = (res as any).content
- stats.value.forEach((item) => {
- if(item.label == '在管项目'){
- item.count = content.zgxm || 0
- item.desc = '本年新增'+(content.bnxm || 0) +'个'
- }
- if(item.label == '在岗人员'){
- item.count = content.zgry || 0
- }
- if(item.label == '年化收入'){
- item.count = 2.86 + '亿'
- }
- if(item.label == '平均毛利率'){
- item.count = (parseFloat((parseFloat(content.pjml) * 100).toFixed(2)) || 0) + '%'
- }
- if(item.label == '待续约'){
- item.count = content.dxy || 0
- }
- if(item.label == '招标信息'){
- item.count = content.zbxx || 0
- }
- })
-
- setTimeout(() => {
- stats.value.forEach((item) => {
- animateNumber(item.label, item.count)
- })
- }, 100)
- }
- }
- const animatedStats = ref<Record<string, number>>({})
- const animateNumber = (key: string, targetValue: string | number, duration: number = 1500) => {
- const targetNum = parseFloat(String(targetValue).replace(/[^0-9.]/g, '')) || 0
- const startValue = animatedStats.value[key] || 0
- const startTime = performance.now()
- const animate = (currentTime: number) => {
- const elapsed = currentTime - startTime
- const progress = Math.min(elapsed / duration, 1)
-
- const easeOutQuart = 1 - Math.pow(1 - progress, 4)
- const currentValue = startValue + (targetNum - startValue) * easeOutQuart
-
- animatedStats.value[key] = currentValue
- if (progress < 1) {
- requestAnimationFrame(animate)
- }
- }
- requestAnimationFrame(animate)
- }
- const outputValue = (label: string, value: string): string => {
- if (!animatedStats.value[label]) {
- animatedStats.value[label] = 0
- }
-
- const decimals = String(value).includes('.') ? 2 : 0
- const suffix = String(value).replace(/[0-9.]/g, '')
-
- return animatedStats.value[label].toFixed(decimals) + suffix
- }
- const getProjects = async () => {
- const params = {
- columnId: COLUMN.SOURCE,
- orderBy: JSON.stringify([{ field: "c_zgxmhtdqrq", orderByType: 2 }]),
- states: '0,1,2,3',
- pageSize: 5,
- page: 0
- }
- const res = await getDmsDataList(params)
- if ((res as any).code === 200) {
- projects.value = (res as any).content.data.map((item: any) => ({
- ...item,
- ...calculateRemaining(item.c_zgxmhtksrq, item.c_zgxmhtdqrq)
- }))
- //测试用
- // let testArr = [
- // { title: '群艺馆', c_xmmc: '群艺馆',c_xmdz: '徐汇区古宜路125号(中山西路1551号)',c_hzyt: '公众物业',c_glqynbkj: '中心区域',c_zgxmhtksrq: '2026-03-09',c_zgxmhtdqrq: '2029-03-08'},
- // { title: '区政府', c_xmmc: '区政府',c_xmdz: '松江区园中路1号',c_hzyt: '机关办公楼',c_glqynbkj: '松江区域',c_zgxmhtksrq: '2026-01-01',c_zgxmhtdqrq: '2027-12-31'},
- // { title: '体育局', c_xmmc: '体育局',c_xmdz: '松江区九峰路2号',c_hzyt: '机关办公楼',c_glqynbkj: '松江区域',c_zgxmhtksrq: '2025-07-01',c_zgxmhtdqrq: '2027-06-30'},
- // { title: '红房子医院', c_xmmc: '红房子医院',c_xmdz: '青浦区浦泰路1003号',c_hzyt: '医院物业',c_glqynbkj: '青西片区',c_zgxmhtksrq: '2026-06-01',c_zgxmhtdqrq: '2027-05-31'},
- // { title: '华师大国际软件院', c_xmmc: '华师大国际软件院',c_xmdz: '上海市临港新片区楠木路111号',c_hzyt: '字校物业',c_glqynbkj: '中心区域',c_zgxmhtksrq: '2026-06-01',c_zgxmhtdqrq: '2027-05-31'},
- // ]
- // projects.value = testArr.map((item) => ({
- // ...item,
- // ...calculateRemaining(item.c_zgxmhtksrq, item.c_zgxmhtdqrq)
- // }))
- }
- }
- const calculateRemaining = (st: string, et: string): { days: string; daysClass: string; statusClass: string; statusText: string } => {
- if (!et) return { days: '', daysClass: '', statusClass: '', statusText: ''}
- const startDate = moment(moment(st).format('YYYY-MM-DD'), 'YYYY-MM-DD')
- const expireDate = moment(moment(et).add(0, 'days').format('YYYY-MM-DD'), 'YYYY-MM-DD')
-
- if (!startDate.isValid() || !expireDate.isValid()) {
- return { days: '', daysClass: '', statusClass: '', statusText: ''}
- }
-
- const today = moment().startOf('day')
- const diffDays = expireDate.diff(today, 'days')
-
- if (diffDays < 0) {
- return { days: `${Math.abs(diffDays)}`, daysClass: 'grey', statusClass: 'grey', statusText: '已过期'}
- } else if (diffDays === 0) {
- return { days: `${diffDays}`, daysClass: 'grey', statusClass: 'grey', statusText: '到期'}
- } else if (diffDays <= 60) {
- return { days: `${diffDays}`, daysClass: 'red', statusClass: 'danger', statusText: '续标预警'}
- } else if (diffDays <= 90) {
- return { days: `${diffDays}`, daysClass: 'orange', statusClass: 'orange', statusText: '续标预警'}
- } else if (diffDays <= 180) {
- return { days: `${diffDays}`, daysClass: 'yellow', statusClass: 'green', statusText: '在管'}
- }else {
- return { days: `${diffDays}`, daysClass: 'green', statusClass: 'green', statusText: '在管'}
- }
- }
- // 待办事项
- const toDoList = async () => {
- const params = {
- columnId: COLUMN.TODO,
- orderBy: JSON.stringify([{ field: "update_time", orderByType: 2 }]),
- states: '0,1,2,3',
- pageSize: 5,
- page: 0
- }
- const res = await getDmsDataList(params)
- if ((res as any).code === 200) {
- todoList.value = (res as any).content.data.map((item: any) => ({
- ...item,
- urgent: item.urgent || false
- }))
- }
- }
- const getDmsDataList = async (params: any) => {
- return await api.getDmsDataList(params)
- }
- const handleClick = (path: string) => {
- router.push({ path: path })
- }
- const handleViewClick = (item: any) => {
- sessionStorage.setItem('projectName', item.c_xmmc)
- sessionStorage.setItem('name', item.c_xmmc)
- sessionStorage.setItem('dmsId', item.id)
- router.push(`/project-detail/${item.id}`)
- }
- </script>
- <style scoped>
- .dashboard {
- width: 100%;
- }
- .card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- margin-bottom: 16px;
- }
- .card-head {
- padding: 12px 16px;
- border-bottom: 1px solid var(--border);
- font-weight: 600;
- font-size: 14px;
- }
- .card-body {
- padding: 16px;
- }
- .stats {
- display: grid;
- grid-template-columns: repeat(6, 1fr);
- gap: 12px;
- margin-bottom: 20px;
- }
- .stat {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 14px 16px;
- }
- .stat label {
- font-size: 12px;
- color: var(--text3);
- display: block;
- margin-bottom: 4px;
- }
- .stat b {
- font-size: 22px;
- font-weight: 600;
- }
- .stat small {
- font-size: 11px;
- color: var(--text2);
- display: block;
- margin-top: 4px;
- }
- .stat.warn b {
- color: var(--danger);
- }
- .grid2 {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 16px;
- }
- table {
- width: 100%;
- border-collapse: collapse;
- font-size: 13px;
- }
- th, td {
- text-align: left;
- padding: 10px 12px;
- border-bottom: 1px solid var(--border);
- }
- th {
- color: var(--text3);
- font-size: 12px;
- font-weight: 600;
- }
- .todo-list {
- list-style: none;
- }
- .todo-item {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- padding: 8px 0;
- border-bottom: 1px solid var(--border);
- }
- .todo-item:last-child {
- border-bottom: none;
- }
- .todo-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: var(--primary);
- margin-top: 6px;
- flex-shrink: 0;
- }
- .todo-dot.urgent {
- background: var(--danger);
- animation: pulse 1.5s infinite;
- }
- @keyframes pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.4; }
- }
- .quick-links {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 10px;
- }
- .quick-card {
- display: block;
- padding: 14px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background: #fafbfc;
- text-decoration: none;
- color: inherit;
- }
- .quick-card:hover {
- border-color: var(--primary);
- background: var(--primary-soft);
- }
- .quick-card strong {
- display: block;
- font-size: 14px;
- margin-bottom: 4px;
- }
- .quick-card span {
- font-size: 12px;
- color: var(--text3);
- }
- .quick-card--primary {
- border-color: var(--primary);
- background: var(--primary-soft);
- }
- .chart-card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- }
- .chart {
- height: 300px;
- }
- @media (max-width: 1100px) {
- .stats {
- grid-template-columns: repeat(3, 1fr);
- }
- }
- @media (max-width: 768px) {
- .grid2 {
- grid-template-columns: 1fr;
- }
- }
- </style>
|