| | |
| | | <meta name="renderer" content="webkit" /> |
| | | <meta |
| | | name="viewport" |
| | | content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0" |
| | | content="width=device-width, initial-scale=1, viewport-fit=cover" |
| | | /> |
| | | <meta name="format-detection" content="telephone=no" /> |
| | | <title></title> |
| | | <link rel="icon" href="./favicon.ico" /> |
| | | <style> |
| New file |
| | |
| | | /** |
| | | * 前端静态服务器(PM2 托管,对外 :9000) |
| | | * - 托管 dist 静态资源 |
| | | * - /api/* 转发后端(去掉 /api 前缀) |
| | | * - /upload/* 转发后端 |
| | | * - /socket.io 转发后端(支持 WebSocket) |
| | | */ |
| | | const path = require('path'); |
| | | const express = require('express'); |
| | | const { createProxyMiddleware } = require('http-proxy-middleware'); |
| | | |
| | | const PORT = Number(process.env.PORT || 9000); |
| | | const BACKEND = process.env.BACKEND || 'http://127.0.0.1:8001'; |
| | | const DIST = path.join(__dirname, 'dist'); |
| | | |
| | | const onErr = (name) => (err, req, res) => { |
| | | console.error(`[proxy:${name}]`, err && err.message); |
| | | if (res && res.writeHead) { |
| | | res.writeHead(502, { 'Content-Type': 'application/json' }); |
| | | res.end(JSON.stringify({ code: 502, msg: `backend unreachable: ${name}` })); |
| | | } |
| | | }; |
| | | |
| | | const app = express(); |
| | | |
| | | // 1) 业务接口:/api/admin/xxx -> /admin/xxx |
| | | app.use( |
| | | '/api', |
| | | createProxyMiddleware({ |
| | | target: BACKEND, |
| | | changeOrigin: true, |
| | | pathRewrite: { '^/api': '' }, |
| | | ws: false, |
| | | onError: onErr('api'), |
| | | }) |
| | | ); |
| | | |
| | | // 2) 上传文件 |
| | | app.use( |
| | | '/upload', |
| | | createProxyMiddleware({ |
| | | target: BACKEND, |
| | | changeOrigin: true, |
| | | ws: false, |
| | | onError: onErr('upload'), |
| | | }) |
| | | ); |
| | | |
| | | // 3) socket.io(含 WebSocket) |
| | | const socketProxy = createProxyMiddleware({ |
| | | target: BACKEND, |
| | | changeOrigin: true, |
| | | ws: true, |
| | | onError: onErr('socket.io'), |
| | | }); |
| | | app.use('/socket.io', socketProxy); |
| | | |
| | | // 4) 前端静态资源 |
| | | app.use( |
| | | express.static(DIST, { |
| | | maxAge: '7d', |
| | | index: 'index.html', |
| | | setHeaders(res, filePath) { |
| | | if (filePath.endsWith('index.html')) res.setHeader('Cache-Control', 'no-cache'); |
| | | }, |
| | | }) |
| | | ); |
| | | |
| | | // 5) history 路由兜底(hash 模式不会走到这) |
| | | // app.get('*', (req, res, next) => { |
| | | // if (req.path.startsWith('/api') || req.path.startsWith('/upload')) return next(); |
| | | // res.sendFile(path.join(DIST, 'index.html'), (err) => err && next(err)); |
| | | // }); |
| | | app.use((req, res, next) => { |
| | | if (req.path.startsWith('/api') || req.path.startsWith('/upload')) return next(); |
| | | res.sendFile(path.join(DIST, 'index.html'), (err) => err && next(err)); |
| | | }); |
| | | |
| | | const server = app.listen(PORT, '0.0.0.0', () => { |
| | | console.log(`[frontend] http://0.0.0.0:${PORT} -> backend ${BACKEND}`); |
| | | }); |
| | | |
| | | // WebSocket 升级 |
| | | server.on('upgrade', socketProxy.upgrade); |
| | | |
| | | process.on('unhandledRejection', (r) => console.error('[unhandledRejection]', r && r.message)); |
| | | process.on('uncaughtException', (e) => console.error('[uncaughtException]', e && e.message)); |
| | |
| | | import { config } from '/@/config'; |
| | | import { t } from '/@/plugins/i18n'; |
| | | import './static/css/index.scss'; |
| | | import './static/css/mobile.scss'; |
| | | |
| | | export default (): ModuleConfig => { |
| | | return { |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | @media only screen and (max-width: 768px) { |
| | | .error-page { |
| | | overflow: auto; |
| | | |
| | | &__code { |
| | | font-size: 72px; |
| | | margin-top: 0; |
| | | } |
| | | |
| | | &__btns { |
| | | flex-wrap: wrap; |
| | | justify-content: center; |
| | | gap: 10px; |
| | | padding: 0 16px; |
| | | } |
| | | } |
| | | } |
| | | </style> |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | @media screen and (max-width: 768px) { |
| | | .page-login { |
| | | overflow: auto; |
| | | align-items: flex-start; |
| | | |
| | | .bg { |
| | | opacity: 0.25; |
| | | } |
| | | |
| | | .box { |
| | | position: relative; |
| | | height: auto; |
| | | min-height: 100%; |
| | | padding: 48px 20px calc(36px + env(safe-area-inset-bottom)); |
| | | box-sizing: border-box; |
| | | |
| | | .logo span { |
| | | font-size: 26px; |
| | | letter-spacing: 1px; |
| | | } |
| | | |
| | | .desc { |
| | | margin-bottom: 28px; |
| | | } |
| | | |
| | | .form { |
| | | width: min(360px, 100%); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | </style> |
| | |
| | | margin-left: 8px; |
| | | } |
| | | } |
| | | |
| | | @media only screen and (max-width: 768px) { |
| | | .a-menu { |
| | | min-width: 0; |
| | | overflow-x: auto; |
| | | |
| | | &__item { |
| | | flex-shrink: 0; |
| | | padding: 0 10px; |
| | | } |
| | | } |
| | | } |
| | | </style> |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | @media only screen and (max-width: 768px) { |
| | | .app-process { |
| | | padding-left: 6px; |
| | | padding-right: 6px; |
| | | |
| | | &__item .close { |
| | | margin-left: 8px; |
| | | margin-right: -2px; |
| | | width: 16px; |
| | | opacity: 1; |
| | | } |
| | | } |
| | | } |
| | | </style> |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | @media only screen and (max-width: 768px) { |
| | | .app-topbar { |
| | | padding-top: env(safe-area-inset-top); |
| | | padding-right: 6px; |
| | | height: calc(46px + env(safe-area-inset-top)); |
| | | min-width: 0; |
| | | |
| | | .flex1 { |
| | | min-width: 8px; |
| | | } |
| | | |
| | | &__tools { |
| | | flex-shrink: 0; |
| | | margin-right: 4px; |
| | | |
| | | & > li { |
| | | margin-left: 6px; |
| | | } |
| | | } |
| | | |
| | | &__user { |
| | | flex-shrink: 0; |
| | | padding-left: 4px; |
| | | |
| | | :deep(.el-text) { |
| | | display: none; |
| | | } |
| | | } |
| | | |
| | | :deep(.route-nav) { |
| | | min-width: 0; |
| | | max-width: 46vw; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | :deep(.a-menu) { |
| | | min-width: 0; |
| | | overflow-x: auto; |
| | | } |
| | | |
| | | :deep(.cl-comm__icon) { |
| | | width: 32px; |
| | | height: 32px; |
| | | } |
| | | } |
| | | } |
| | | </style> |
| | | |
| | | <style lang="scss"> |
| | |
| | | opacity: 1; |
| | | } |
| | | } |
| | | |
| | | @media only screen and (max-width: 768px) { |
| | | .app-views { |
| | | overflow-x: hidden; |
| | | overflow-y: auto; |
| | | -webkit-overflow-scrolling: touch; |
| | | margin: 0 8px calc(8px + env(safe-area-inset-bottom)) 8px; |
| | | width: calc(100% - 16px); |
| | | } |
| | | } |
| | | </style> |
| | |
| | | } |
| | | |
| | | @media only screen and (max-width: 768px) { |
| | | position: relative; |
| | | |
| | | .app-layout__left { |
| | | position: absolute; |
| | | left: 0; |
| | | top: 0; |
| | | width: min(280px, 86vw); |
| | | z-index: 9999; |
| | | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); |
| | | transition: |
| | | transform 0.3s cubic-bezier(0.7, 0.3, 0.1, 1), |
| | | box-shadow 0.3s cubic-bezier(0.7, 0.3, 0.1, 1); |
| | |
| | | |
| | | .app-layout__right { |
| | | width: 100%; |
| | | min-width: 0; |
| | | } |
| | | |
| | | &.is-collapse { |
| | |
| | | #app { |
| | | height: 100vh; |
| | | width: 100vw; |
| | | height: 100dvh; |
| | | width: 100%; |
| | | max-width: 100vw; |
| | | overflow: hidden; |
| | | } |
| | | |
| New file |
| | |
| | | /* 手机浏览器:只在窄屏生效,桌面端样式保持原样 */ |
| | | @media only screen and (max-width: 768px) { |
| | | html, |
| | | body { |
| | | -webkit-text-size-adjust: 100%; |
| | | } |
| | | |
| | | .el-scrollbar__wrap { |
| | | -webkit-overflow-scrolling: touch; |
| | | } |
| | | |
| | | /* 避免 iOS 聚焦输入框时自动放大页面 */ |
| | | .el-form .el-input__inner, |
| | | .el-form .el-textarea__inner, |
| | | .el-form .el-select__wrapper, |
| | | .el-form .el-input__wrapper input { |
| | | font-size: 16px; |
| | | } |
| | | |
| | | .el-dialog:not(.is-fullscreen) { |
| | | width: calc(100vw - 24px) !important; |
| | | max-width: calc(100vw - 24px); |
| | | margin: 12px auto !important; |
| | | } |
| | | |
| | | .el-drawer { |
| | | width: min(100vw, 420px) !important; |
| | | max-width: 100vw; |
| | | } |
| | | |
| | | .el-dialog__body, |
| | | .cl-dialog .el-dialog__body { |
| | | max-height: calc(100dvh - 140px); |
| | | overflow: auto; |
| | | } |
| | | |
| | | .cl-search, |
| | | .cl-form, |
| | | .el-dialog, |
| | | .el-drawer { |
| | | .el-form--inline { |
| | | .el-form-item { |
| | | display: flex; |
| | | margin-right: 0; |
| | | width: 100%; |
| | | } |
| | | |
| | | .el-form-item__content { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | } |
| | | |
| | | .el-date-editor.el-input, |
| | | .el-date-editor--daterange, |
| | | .el-date-editor--datetimerange, |
| | | .el-select, |
| | | .el-cascader, |
| | | .el-input-number { |
| | | width: 100% !important; |
| | | max-width: 100%; |
| | | box-sizing: border-box; |
| | | } |
| | | } |
| | | |
| | | .cl-form .el-col, |
| | | .el-dialog .el-form .el-col, |
| | | .cl-search .el-col { |
| | | flex: 0 0 100%; |
| | | max-width: 100%; |
| | | } |
| | | |
| | | .cl-search, |
| | | .cl-search-key, |
| | | .cl-search-key__wrap, |
| | | .cl-filter { |
| | | width: 100%; |
| | | max-width: 100%; |
| | | } |
| | | |
| | | .cl-filter { |
| | | margin-left: 0; |
| | | margin-right: 0; |
| | | } |
| | | |
| | | .cl-pagination.el-pagination { |
| | | flex-wrap: wrap; |
| | | justify-content: center; |
| | | row-gap: 8px; |
| | | padding-bottom: 4px; |
| | | } |
| | | |
| | | .el-pagination__sizes, |
| | | .el-pagination__jump { |
| | | display: none !important; |
| | | } |
| | | |
| | | .cl-table, |
| | | .el-table { |
| | | max-width: 100%; |
| | | } |
| | | |
| | | .el-table .cell { |
| | | word-break: break-word; |
| | | } |
| | | |
| | | .el-message-box { |
| | | width: calc(100vw - 32px) !important; |
| | | } |
| | | |
| | | .el-picker__popper, |
| | | .el-select__popper { |
| | | max-width: calc(100vw - 16px); |
| | | } |
| | | |
| | | .el-date-range-picker { |
| | | width: calc(100vw - 16px) !important; |
| | | max-width: calc(100vw - 16px); |
| | | |
| | | .el-picker-panel__body { |
| | | min-width: 0 !important; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .el-date-range-picker__content { |
| | | width: 100%; |
| | | padding: 8px; |
| | | } |
| | | } |
| | | |
| | | .el-dropdown__popper, |
| | | .el-popover.el-popper { |
| | | max-width: calc(100vw - 16px); |
| | | } |
| | | |
| | | /* 所有业务页:限制在屏幕内,内容区自己滚动 */ |
| | | .app-views, |
| | | .app-views > * { |
| | | max-width: 100%; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .cl-crud { |
| | | max-width: 100%; |
| | | } |
| | | |
| | | .cl-crud > .cl-row { |
| | | flex-wrap: wrap; |
| | | row-gap: 8px; |
| | | max-width: 100%; |
| | | } |
| | | |
| | | .cl-crud > .cl-row > * { |
| | | max-width: 100%; |
| | | } |
| | | |
| | | .cl-crud > .cl-row > .cl-flex1 { |
| | | flex: 1 1 100%; |
| | | width: 100%; |
| | | height: 0; |
| | | margin: 0; |
| | | } |
| | | |
| | | .cl-filter { |
| | | flex: 1 1 100%; |
| | | width: 100%; |
| | | max-width: 100%; |
| | | margin: 0; |
| | | box-sizing: border-box; |
| | | |
| | | .el-select, |
| | | .el-date-editor, |
| | | .el-input { |
| | | flex: 1; |
| | | width: 100% !important; |
| | | min-width: 0 !important; |
| | | } |
| | | } |
| | | |
| | | .cl-adv-search, |
| | | .cl-search.is-inline, |
| | | .cl-search-key { |
| | | flex: 1 1 100%; |
| | | } |
| | | |
| | | .el-table { |
| | | width: 100% !important; |
| | | } |
| | | |
| | | .el-table__header-wrapper, |
| | | .el-table__body-wrapper, |
| | | .el-scrollbar__wrap { |
| | | max-width: 100%; |
| | | } |
| | | |
| | | .el-descriptions__label, |
| | | .el-descriptions__content { |
| | | word-break: break-word; |
| | | } |
| | | |
| | | .el-tabs__nav-scroll { |
| | | overflow-x: auto; |
| | | } |
| | | |
| | | .el-dialog:not(.is-fullscreen), |
| | | .cl-dialog:not(.is-fullscreen) { |
| | | max-height: calc(100dvh - 24px); |
| | | } |
| | | |
| | | .cl-dialog__container, |
| | | .cl-dialog .el-scrollbar { |
| | | max-height: calc(100dvh - 132px) !important; |
| | | } |
| | | |
| | | .w-e-toolbar, |
| | | .cl-editor-wang { |
| | | max-width: 100%; |
| | | overflow-x: auto; |
| | | } |
| | | |
| | | .cl-view-group__right .head .title { |
| | | max-width: calc(100vw - 120px); |
| | | padding: 0 36px; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .error-page__code { |
| | | font-size: 72px; |
| | | } |
| | | |
| | | .plugins__header { |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | |
| | | .search { |
| | | width: 100% !important; |
| | | } |
| | | } |
| | | |
| | | .cl-upload-space-inner__header { |
| | | flex-wrap: wrap; |
| | | height: auto; |
| | | min-height: 50px; |
| | | padding: 8px 10px; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .cl-upload-space-inner__file-item { |
| | | width: calc(50% - 10px) !important; |
| | | padding-top: calc(50% - 10px) !important; |
| | | } |
| | | } |
| | |
| | | padding: 0px; |
| | | background: #000; |
| | | } |
| | | |
| | | /* 手机扩展区里的圆形云台。SDK 用 deepmerge 合并配置时数组合并,单靠删掉 controls 里的 ptz 去不掉 */ |
| | | .ez-player-card :deep(.ez-mobile-ptz) { |
| | | display: none; |
| | | } |
| | | </style> |
| | |
| | | width: 100%; |
| | | height: 100%; |
| | | } |
| | | |
| | | @media (max-width: 992px) { |
| | | .map-board { |
| | | flex-direction: column; |
| | | height: 100%; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .map-board__side { |
| | | width: 100%; |
| | | max-height: 240px; |
| | | } |
| | | |
| | | .map-board__map { |
| | | flex: none; |
| | | width: 100%; |
| | | height: 56vh; |
| | | min-height: 320px; |
| | | } |
| | | |
| | | .map-board__panel { |
| | | width: 100%; |
| | | max-height: none; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | border-color: #555 !important; |
| | | color: #888 !important; |
| | | } |
| | | |
| | | @media (max-width: 768px) { |
| | | .camera-page { |
| | | padding: 8px; |
| | | } |
| | | |
| | | .scroll-viewport { |
| | | margin: 0 6px; |
| | | } |
| | | |
| | | .player-card { |
| | | width: min(280px, calc(100vw - 96px)); |
| | | height: 240px; |
| | | } |
| | | |
| | | .arrow { |
| | | width: 28px; |
| | | height: 64px; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | font-weight: 500; |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 768px) { |
| | | .monitor-page { |
| | | flex-direction: column; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .left-tree { |
| | | width: 100%; |
| | | height: 220px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .right-player { |
| | | height: auto; |
| | | min-height: 360px; |
| | | } |
| | | |
| | | .player-grid { |
| | | grid-template-columns: 1fr !important; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | <!-- 删除按钮 --> |
| | | <cl-multi-delete-btn /> |
| | | <cl-flex1 /> |
| | | <cl-filter label="交易时间"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <cl-import-btn tips="导入微信交易记录" :on-config="onConfig" :on-submit="onSubmit" /> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | |
| | | <!-- 删除按钮 --> |
| | | <cl-multi-delete-btn /> |
| | | <cl-flex1 /> |
| | | <cl-filter label="日期时间"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | |
| | | service: service.operation.wastesale |
| | | }, |
| | | (app) => { |
| | | service.basicdata.wastetype.page({ isRecyclable: 1, size: 1000 }).then((res) => { |
| | | garbageTypeOptions.value = (res.list || []).map((item: any) => ({ |
| | | label: item.name, |
| | | value: item.code, |
| | | price: item.price, |
| | | })); |
| | | service.basicdata.wastetype.page({ isRecyclable: 1, status: 1, size: 1000 }).then((res) => { |
| | | garbageTypeOptions.value = (res.list || []) |
| | | .filter((item: any) => item.status === 1) |
| | | .map((item: any) => ({ |
| | | label: item.name, |
| | | value: item.code, |
| | | price: item.price, |
| | | })); |
| | | }); |
| | | app.refresh(); |
| | | } |
| | |
| | | <!-- 删除按钮 --> |
| | | <cl-multi-delete-btn /> |
| | | <cl-flex1 /> |
| | | <cl-filter label="日志时间"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | |
| | | <cl-add-btn /> |
| | | <!-- 删除按钮 --> |
| | | <cl-multi-delete-btn /> |
| | | <cl-flex1 /> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | <cl-flex1 /> |
| | | <cl-filter label="消息创建时间"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | | |
| | | <cl-row> |
| | |
| | | <!-- 删除按钮 --> |
| | | <cl-multi-delete-btn /> |
| | | <cl-flex1 /> |
| | | <cl-filter label="上传时间"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | |
| | | <!-- 删除按钮 --> |
| | | <cl-multi-delete-btn /> |
| | | <cl-flex1 /> |
| | | <cl-filter label="报警时间戳"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | |
| | | <cl-add-btn /> |
| | | <!-- 删除按钮 --> |
| | | <cl-multi-delete-btn /> |
| | | <cl-flex1 /> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | <cl-flex1 /> |
| | | <cl-filter label="消息时间"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | | |
| | | <cl-row> |
| | |
| | | <!-- 删除按钮 --> |
| | | <cl-multi-delete-btn /> |
| | | <cl-flex1 /> |
| | | <cl-filter label="上传时间"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | |
| | | service: service.push.siteweight, |
| | | }, |
| | | (app) => { |
| | | service.basicdata.wastetype.list().then((res) => { |
| | | garbageTypeOptions.value = (res || []).map((item: any) => ({ |
| | | label: item.name, |
| | | value: item.code, |
| | | })); |
| | | service.basicdata.wastetype.list({ status: 1 }).then((res) => { |
| | | garbageTypeOptions.value = (res || []) |
| | | .filter((item: any) => item.status === 1) |
| | | .map((item: any) => ({ |
| | | label: item.name, |
| | | value: item.code, |
| | | })); |
| | | }); |
| | | app.refresh(); |
| | | }, |
| | |
| | | <cl-add-btn /> |
| | | <!-- 删除按钮 --> |
| | | <cl-multi-delete-btn /> |
| | | <cl-flex1 /> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | <cl-flex1 /> |
| | | <cl-filter label="GPS定位时间"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | | |
| | | <cl-row> |
| | |
| | | <!-- 删除按钮 --> |
| | | <cl-multi-delete-btn /> |
| | | <cl-flex1 /> |
| | | <cl-filter label="上传时间"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | |
| | | name: "push-weight", |
| | | }); |
| | | |
| | | import { ref } from "vue"; |
| | | import { useCrud, useTable, useUpsert, useSearch } from "@cool-vue/crud"; |
| | | import { useCool } from "/@/cool"; |
| | | import { useI18n } from "vue-i18n"; |
| | | |
| | | const { service } = useCool(); |
| | | const { t } = useI18n(); |
| | | |
| | | const garbageTypeOptions = ref<{ label: string; value: string }[]>([]); |
| | | |
| | | // cl-upsert |
| | | const Upsert = useUpsert({ |
| | |
| | | component: { name: "cl-date-text" }, |
| | | }, |
| | | { label: t("垃圾类型"), prop: "garbageType", minWidth: 120 }, |
| | | { label: t("垃圾分类"), prop: "garbageName", minWidth: 140 }, |
| | | { |
| | | label: t("垃圾重量"), |
| | | prop: "weight", |
| | |
| | | }); |
| | | |
| | | // cl-search |
| | | const Search = useSearch(); |
| | | const Search = useSearch({ |
| | | items: [ |
| | | { |
| | | prop: "deviceNo", |
| | | component: { |
| | | name: "el-input", |
| | | props: { |
| | | placeholder: t("搜索设备号"), |
| | | clearable: true, |
| | | }, |
| | | style: { width: "150px" }, |
| | | }, |
| | | }, |
| | | { |
| | | prop: "garbageType", |
| | | component: { |
| | | name: "el-select", |
| | | props: { |
| | | placeholder: t("垃圾类型"), |
| | | filterable: true, |
| | | clearable: true, |
| | | }, |
| | | options: garbageTypeOptions, |
| | | style: { width: "180px" }, |
| | | }, |
| | | }, |
| | | ], |
| | | }); |
| | | |
| | | // cl-crud |
| | | const Crud = useCrud( |
| | |
| | | service: service.push.weight, |
| | | }, |
| | | (app) => { |
| | | service.basicdata.wastetype.list({ status: 1 }).then((res) => { |
| | | garbageTypeOptions.value = (res || []) |
| | | .filter((item: any) => item.status === 1) |
| | | .map((item: any) => ({ |
| | | label: item.name, |
| | | value: item.code, |
| | | })); |
| | | }); |
| | | app.refresh(); |
| | | }, |
| | | ); |
| | |
| | | padding: 8px 8px 12px; |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 768px) { |
| | | .panel-card { |
| | | min-height: 220px; |
| | | |
| | | &__header { |
| | | flex-wrap: wrap; |
| | | align-items: flex-start; |
| | | padding: 12px 12px 0; |
| | | } |
| | | |
| | | &__title { |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | &__extra { |
| | | width: 100%; |
| | | overflow-x: auto; |
| | | } |
| | | } |
| | | } |
| | | </style> |
| | |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 768px) { |
| | | .map-panel__bar, |
| | | .map-panel__legend { |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .map-panel__map { |
| | | min-height: 280px; |
| | | } |
| | | } |
| | | |
| | | @keyframes map-pulse { |
| | | 0%, |
| | | 100% { |
| | |
| | | |
| | | const current = computed(() => SCREEN_NAV.find(e => e.key === currentKey.value)); |
| | | |
| | | /** 始终留在首页路径,用 ?page= 切换 pageMap,避免跳到 /overview 丢掉导航壳 */ |
| | | /** 始终留在首页路径,用 ?page= 切换。默认页是地图,overview 也必须带 page,否则会被解析回 map */ |
| | | function go(key: ScreenKey) { |
| | | if (key === currentKey.value) return; |
| | | const query = { ...route.query } as Record<string, any>; |
| | | if (key === 'overview') { |
| | | delete query.page; |
| | | } else { |
| | | query.page = key; |
| | | } |
| | | query.page = key; |
| | | router.replace({ path: SCREEN_HOME_PATH, query }); |
| | | } |
| | | |
| | |
| | | gap: 10px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | @media (max-width: 992px) { |
| | | .green-screen__header { |
| | | flex-wrap: wrap; |
| | | align-items: flex-start; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .brand { |
| | | min-width: 0; |
| | | flex: 1 1 auto; |
| | | } |
| | | |
| | | .nav { |
| | | order: 3; |
| | | flex: 1 1 100%; |
| | | } |
| | | |
| | | .tools { |
| | | max-width: 100%; |
| | | overflow-x: auto; |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 768px) { |
| | | .green-screen { |
| | | min-height: 0; |
| | | height: 100%; |
| | | } |
| | | |
| | | .brand__title { |
| | | font-size: 16px; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | import type { ScreenData, TimeRange } from './screen'; |
| | | import { demoScreen } from './screen-demo'; |
| | | import { |
| | | FieldTracker, |
| | | KPI_GROUPS, |
| | | groupHasLive, |
| | | resolveDataMode, |
| | | type ScreenDataMode |
| | | } from './screen-field-meta'; |
| | | import { FieldTracker, resolveDataMode, type ScreenDataMode } from './screen-field-meta'; |
| | | import { structureScreen } from './screen-structure'; |
| | | |
| | | export type { ScreenDataMode }; |
| | |
| | | liveFields: string[]; |
| | | } |
| | | |
| | | function sum(nums: number[]) { |
| | | return nums.reduce((a, b) => a + (Number(b) || 0), 0); |
| | | function num(v: unknown, fallback = 0) { |
| | | const n = Number(v); |
| | | return Number.isFinite(n) ? n : fallback; |
| | | } |
| | | |
| | | function hasPositive(nums: number[]) { |
| | | return nums.some(v => Number(v) > 0); |
| | | function alignSeries(source: number[] | undefined, len: number) { |
| | | const values = (source || []).map(v => num(v)); |
| | | if (!values.length) return Array.from({ length: len }, () => 0); |
| | | if (values.length === len) return values; |
| | | if (values.length > len) return values.slice(values.length - len); |
| | | return [...Array.from({ length: len - values.length }, () => 0), ...values]; |
| | | } |
| | | |
| | | function hasRankData(list: any[]) { |
| | | return list.length > 0 && list.some(e => Number(e?.people) > 0 || Number(e?.weight) > 0); |
| | | } |
| | | |
| | | function pickKpis( |
| | | live: Record<string, number> | undefined, |
| | | demo: Record<string, number>, |
| | | ctx: FieldTracker |
| | | ) { |
| | | const out = { ...demo }; |
| | | if (!live) { |
| | | Object.values(KPI_GROUPS) |
| | | .flat() |
| | | .forEach(key => ctx.markDemo(`kpis.${key}`)); |
| | | return out; |
| | | } |
| | | |
| | | for (const [group, keys] of Object.entries(KPI_GROUPS)) { |
| | | if (groupHasLive(keys, live)) { |
| | | keys.forEach(key => { |
| | | const v = Number(live[key]); |
| | | out[key] = Number.isFinite(v) ? v : demo[key]; |
| | | ctx.markLive(`kpis.${key}`); |
| | | }); |
| | | } else { |
| | | keys.forEach(key => ctx.markDemo(`kpis.${key}`)); |
| | | } |
| | | } |
| | | return out; |
| | | } |
| | | |
| | | function pickArray<T>( |
| | | field: string, |
| | | live: T[] | undefined, |
| | | demo: T[], |
| | | ctx: FieldTracker, |
| | | valid: (v: T[]) => boolean |
| | | ) { |
| | | if (live && valid(live)) { |
| | | ctx.markLive(field); |
| | | return live; |
| | | } |
| | | ctx.markDemo(field); |
| | | return demo; |
| | | } |
| | | |
| | | function pickTrend( |
| | | field: string, |
| | | live: { weight: number[]; value: number[]; carbon: number[] } | undefined, |
| | | demo: ScreenData['trend'], |
| | | axis: string[], |
| | | ctx: FieldTracker |
| | | ) { |
| | | const valid = |
| | | live && |
| | | live.weight?.length && |
| | | (sum(live.weight) > 0 || sum(live.value) > 0 || sum(live.carbon) > 0); |
| | | if (valid) { |
| | | ctx.markLive(field); |
| | | return { |
| | | weight: alignSeries(live!.weight, axis.length, live!.weight[live!.weight.length - 1] || 0), |
| | | value: alignSeries(live!.value, axis.length, live!.value[live!.value.length - 1] || 0), |
| | | carbon: alignSeries(live!.carbon, axis.length, live!.carbon[live!.carbon.length - 1] || 0) |
| | | }; |
| | | } |
| | | ctx.markDemo(field); |
| | | return alignTrend(demo, axis.length); |
| | | } |
| | | |
| | | function alignSeries(source: number[], len: number, fill = 0) { |
| | | if (!source?.length) return Array.from({ length: len }, () => fill); |
| | | if (source.length === len) return [...source]; |
| | | if (source.length > len) return source.slice(source.length - len); |
| | | return [...Array.from({ length: len - source.length }, () => fill), ...source]; |
| | | } |
| | | |
| | | function alignTrend(trend: ScreenData['trend'], len: number) { |
| | | return { |
| | | weight: alignSeries(trend.weight, len), |
| | | value: alignSeries(trend.value, len), |
| | | carbon: alignSeries(trend.carbon, len) |
| | | }; |
| | | } |
| | | |
| | | function pickWasteMix( |
| | | live: ScreenData['wasteMix'] | undefined, |
| | | demo: ScreenData['wasteMix'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | if ( |
| | | live?.length && |
| | | live.some(e => Number(e.weight) > 0 || Number(e.count) > 0 || Number(e.value) > 0) |
| | | ) { |
| | | ctx.markLive('wasteMix'); |
| | | return live.map((item, i) => ({ |
| | | ...demo[i], |
| | | ...item, |
| | | color: item.color || demo[i]?.color |
| | | })); |
| | | } |
| | | ctx.markDemo('wasteMix'); |
| | | return demo; |
| | | } |
| | | |
| | | function pickRecycleItems( |
| | | live: ScreenData['recycleItems'] | undefined, |
| | | demo: ScreenData['recycleItems'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | if (live?.length && live.some(e => Number(e.weight) > 0 || Number(e.value) > 0 || Number(e.carbon) > 0)) { |
| | | ctx.markLive('recycleItems'); |
| | | return live.map((item, i) => ({ |
| | | ...demo[i], |
| | | ...item, |
| | | color: item.color || demo[i]?.color |
| | | })); |
| | | } |
| | | ctx.markDemo('recycleItems'); |
| | | return demo; |
| | | } |
| | | |
| | | function pickPointFlow( |
| | | live: ScreenData['pointFlow'] | undefined, |
| | | demo: ScreenData['pointFlow'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | const out = { ...demo }; |
| | | if (!live) { |
| | | ctx.markDemo('pointFlow'); |
| | | (['issued', 'transfer', 'donate', 'mall', 'balance'] as const).forEach(key => |
| | | ctx.markDemo(`pointFlow.${key}`) |
| | | ); |
| | | return out; |
| | | } |
| | | |
| | | let hasLive = false; |
| | | (['issued', 'transfer', 'donate', 'mall', 'balance'] as const).forEach(key => { |
| | | if (Number(live[key]) > 0) { |
| | | out[key] = live[key]; |
| | | ctx.markLive(`pointFlow.${key}`); |
| | | hasLive = true; |
| | | } else { |
| | | ctx.markDemo(`pointFlow.${key}`); |
| | | } |
| | | }); |
| | | if (hasLive) ctx.markLive('pointFlow'); |
| | | else ctx.markDemo('pointFlow'); |
| | | return out; |
| | | } |
| | | |
| | | function pickOrdersByType( |
| | | live: ScreenData['ordersByType'] | undefined, |
| | | demo: ScreenData['ordersByType'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | const keys = ['现场回收', '预约上门', '大屏投递', '活动回收'] as const; |
| | | const liveValid = live?.axis?.length && keys.some(k => hasPositive(live[k] || [])); |
| | | if (liveValid) { |
| | | ctx.markLive('ordersByType'); |
| | | return live!; |
| | | } |
| | | ctx.markDemo('ordersByType'); |
| | | return demo; |
| | | } |
| | | |
| | | function pickAppointmentFlow( |
| | | live: ScreenData['appointmentFlow'] | undefined, |
| | | demo: ScreenData['appointmentFlow'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | if (live?.length && live.some(e => Number(e.value) > 0)) { |
| | | ctx.markLive('appointmentFlow'); |
| | | return live; |
| | | } |
| | | ctx.markDemo('appointmentFlow'); |
| | | return demo; |
| | | } |
| | | |
| | | function pickSorterStatus( |
| | | live: ScreenData['sorterStatus'] | undefined, |
| | | demo: ScreenData['sorterStatus'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | if (live?.length && sum(live.map(e => Number(e.value))) > 0) { |
| | | ctx.markLive('sorterStatus'); |
| | | return live; |
| | | } |
| | | ctx.markDemo('sorterStatus'); |
| | | return demo; |
| | | } |
| | | |
| | | function pickAxis( |
| | | live: ScreenData | null | undefined, |
| | | demo: ScreenData, |
| | | structure: ScreenData, |
| | | ctx: FieldTracker |
| | | ) { |
| | | const useLiveAxis = |
| | | live?.axis?.length && |
| | | (sum(live.trend?.weight || []) > 0 || |
| | | sum(live.participateTrend || []) > 0 || |
| | | sum(live.deliverTrend || []) > 0 || |
| | | live.axis.length > 1); |
| | | if (useLiveAxis) { |
| | | ctx.markLive('axis'); |
| | | return live!.axis; |
| | | } |
| | | ctx.markDemo('axis'); |
| | | return demo.axis.length ? demo.axis : structure.axis; |
| | | } |
| | | |
| | | /** 结构 + 演示 + 真实数据按字段/分组合并 */ |
| | | /** 用接口结果覆盖零值结构。缺字段保持 0 或空列表,不再回落到演示数据。 */ |
| | | export function resolveScreenData(live: ScreenData | null | undefined, range: TimeRange): ResolvedScreenData { |
| | | const structure = structureScreen(range); |
| | | const demo = demoScreen(range); |
| | | const base = structureScreen(range); |
| | | const ctx = new FieldTracker(); |
| | | |
| | | const axis = pickAxis(live, demo, structure, ctx); |
| | | const kpis = pickKpis(live?.kpis, demo.kpis, ctx); |
| | | const wasteMix = pickWasteMix(live?.wasteMix, demo.wasteMix, ctx); |
| | | const recycleItems = pickRecycleItems(live?.recycleItems, demo.recycleItems, ctx); |
| | | const trend = pickTrend('trend', live?.trend, demo.trend, axis, ctx); |
| | | |
| | | let yearCarbon = demo.yearCarbon; |
| | | if (live?.yearCarbon?.length && sum(live.yearCarbon) > 0) { |
| | | yearCarbon = live.yearCarbon; |
| | | ctx.markLive('yearCarbon'); |
| | | } else { |
| | | ctx.markDemo('yearCarbon'); |
| | | if (!live) { |
| | | return { |
| | | data: base, |
| | | mode: 'live', |
| | | demoFields: [], |
| | | liveFields: [] |
| | | }; |
| | | } |
| | | |
| | | const deptRank = pickArray('deptRank', live?.deptRank, demo.deptRank, ctx, hasRankData); |
| | | const classRank = pickArray('classRank', live?.classRank, demo.classRank, ctx, hasRankData); |
| | | const majorRank = pickArray('majorRank', live?.majorRank, demo.majorRank, ctx, hasRankData); |
| | | const pointFlow = pickPointFlow(live?.pointFlow, demo.pointFlow, ctx); |
| | | const axis = live.axis?.length ? live.axis : base.axis; |
| | | ctx.markLive('axis'); |
| | | |
| | | let participateTrend = alignSeries(demo.participateTrend, axis.length); |
| | | if (live?.participateTrend?.length && sum(live.participateTrend) > 0) { |
| | | participateTrend = alignSeries(live.participateTrend, axis.length); |
| | | ctx.markLive('participateTrend'); |
| | | } else { |
| | | ctx.markDemo('participateTrend'); |
| | | } |
| | | const kpis = { ...base.kpis }; |
| | | Object.keys(kpis).forEach(key => { |
| | | if (live.kpis && live.kpis[key] != null) { |
| | | kpis[key] = num(live.kpis[key]); |
| | | } |
| | | ctx.markLive(`kpis.${key}`); |
| | | }); |
| | | |
| | | let deliverTrend = alignSeries(demo.deliverTrend, axis.length); |
| | | if (live?.deliverTrend?.length && sum(live.deliverTrend) > 0) { |
| | | deliverTrend = alignSeries(live.deliverTrend, axis.length); |
| | | ctx.markLive('deliverTrend'); |
| | | } else { |
| | | ctx.markDemo('deliverTrend'); |
| | | } |
| | | const wasteMix = live.wasteMix?.length |
| | | ? live.wasteMix.map((item, i) => ({ |
| | | ...base.wasteMix[i], |
| | | ...item, |
| | | weight: num(item.weight), |
| | | value: num(item.value), |
| | | count: num(item.count), |
| | | color: item.color || base.wasteMix[i]?.color |
| | | })) |
| | | : base.wasteMix; |
| | | ctx.markLive('wasteMix'); |
| | | |
| | | const ordersByType = pickOrdersByType(live?.ordersByType, demo.ordersByType, ctx); |
| | | const appointmentFlow = pickAppointmentFlow(live?.appointmentFlow, demo.appointmentFlow, ctx); |
| | | const sorterStatus = pickSorterStatus(live?.sorterStatus, demo.sorterStatus, ctx); |
| | | const vehicles = pickArray('vehicles', live?.vehicles, demo.vehicles, ctx, v => v.length > 0); |
| | | const sites = pickArray('sites', live?.sites, demo.sites, ctx, v => v.length > 0); |
| | | const recycleItems = live.recycleItems?.length |
| | | ? live.recycleItems.map((item, i) => ({ |
| | | ...base.recycleItems[i], |
| | | ...item, |
| | | weight: num(item.weight), |
| | | value: num(item.value), |
| | | carbon: num(item.carbon), |
| | | color: item.color || base.recycleItems[i]?.color |
| | | })) |
| | | : base.recycleItems; |
| | | ctx.markLive('recycleItems'); |
| | | |
| | | const trend = { |
| | | weight: alignSeries(live.trend?.weight, axis.length), |
| | | value: alignSeries(live.trend?.value, axis.length), |
| | | carbon: alignSeries(live.trend?.carbon, axis.length) |
| | | }; |
| | | ctx.markLive('trend'); |
| | | |
| | | const yearCarbon = live.yearCarbon?.length |
| | | ? alignSeries(live.yearCarbon, 12) |
| | | : base.yearCarbon; |
| | | ctx.markLive('yearCarbon'); |
| | | |
| | | const deptRank = Array.isArray(live.deptRank) ? live.deptRank : []; |
| | | const classRank = Array.isArray(live.classRank) ? live.classRank : []; |
| | | const majorRank = Array.isArray(live.majorRank) ? live.majorRank : []; |
| | | ctx.markLive('deptRank'); |
| | | ctx.markLive('classRank'); |
| | | ctx.markLive('majorRank'); |
| | | |
| | | const pointFlow = { |
| | | issued: num(live.pointFlow?.issued), |
| | | transfer: num(live.pointFlow?.transfer), |
| | | donate: num(live.pointFlow?.donate), |
| | | mall: num(live.pointFlow?.mall), |
| | | balance: num(live.pointFlow?.balance) |
| | | }; |
| | | ctx.markLive('pointFlow'); |
| | | |
| | | const participateTrend = alignSeries(live.participateTrend, axis.length); |
| | | const deliverTrend = alignSeries(live.deliverTrend, axis.length); |
| | | ctx.markLive('participateTrend'); |
| | | ctx.markLive('deliverTrend'); |
| | | |
| | | const ordersByType = live.ordersByType?.axis?.length |
| | | ? { |
| | | axis: live.ordersByType.axis, |
| | | 现场回收: alignSeries(live.ordersByType.现场回收, live.ordersByType.axis.length), |
| | | 预约上门: alignSeries(live.ordersByType.预约上门, live.ordersByType.axis.length), |
| | | 大屏投递: alignSeries(live.ordersByType.大屏投递, live.ordersByType.axis.length), |
| | | 活动回收: alignSeries(live.ordersByType.活动回收, live.ordersByType.axis.length) |
| | | } |
| | | : base.ordersByType; |
| | | ctx.markLive('ordersByType'); |
| | | |
| | | const appointmentFlow = live.appointmentFlow?.length ? live.appointmentFlow : base.appointmentFlow; |
| | | const sorterStatus = live.sorterStatus?.length ? live.sorterStatus : base.sorterStatus; |
| | | const vehicles = Array.isArray(live.vehicles) ? live.vehicles : []; |
| | | const sites = Array.isArray(live.sites) ? live.sites : []; |
| | | ctx.markLive('appointmentFlow'); |
| | | ctx.markLive('sorterStatus'); |
| | | ctx.markLive('vehicles'); |
| | | ctx.markLive('sites'); |
| | | |
| | | const data: ScreenData = { |
| | | range, |
| | |
| | | sorterStatus, |
| | | vehicles, |
| | | sites, |
| | | unavailable: live?.unavailable || [] |
| | | unavailable: live.unavailable || [] |
| | | }; |
| | | |
| | | return { |
| | | data, |
| | | mode: resolveDataMode(live, ctx), |
| | | demoFields: ctx.listDemo(), |
| | | demoFields: [], |
| | | liveFields: ctx.listLive() |
| | | }; |
| | | } |
| | | |
| | | /** 接口失败或无 live 数据时的初始展示 */ |
| | | /** 接口尚未返回时先展示零值,避免先闪出演示数字 */ |
| | | export function initialScreenData(range: TimeRange = 'month') { |
| | | return resolveScreenData(null, range); |
| | | } |
| | | |
| | | export { structureScreen, buildScreenAxis } from './screen-structure'; |
| | | export { demoScreen } from './screen-demo'; |
| | |
| | | } catch (e: any) { |
| | | applyResolved(resolveScreenData(null, sharedTimeRange.value)); |
| | | const msg = e?.message || ''; |
| | | // 首页静默降级到演示数据,不展示登录/权限类错误文案 |
| | | if (!/登录失效|无权限/.test(msg)) { |
| | | loadError.value = msg || '驾驶舱接口调用失败'; |
| | | } |
| | |
| | | return { load, loading, loadError, dataMode }; |
| | | } |
| | | |
| | | /** 首页不展示「演示」标识,底层仍可静默使用演示数据填充 */ |
| | | /** 首页只展示接口汇总结果,不再用演示数据填充 */ |
| | | export function useScreenFieldSource() { |
| | | const { dataMode, demoFields } = useScreenData(); |
| | | |
| | |
| | | <!-- 删除按钮 --> |
| | | <!--cl-multi-delete-btn /--> |
| | | <cl-flex1 /> |
| | | <cl-filter label="日期"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | |
| | | }); |
| | | |
| | | // cl-search |
| | | const Search = useSearch(); |
| | | function hideDateSearch({ exposed }: { exposed: any }) { |
| | | const items = exposed.config?.items as { prop?: string }[] | undefined; |
| | | const index = items?.findIndex(e => e.prop === "date") ?? -1; |
| | | if (index >= 0) items?.splice(index, 1); |
| | | } |
| | | |
| | | const Search = useSearch({ |
| | | plugins: [hideDateSearch] |
| | | }); |
| | | |
| | | // cl-crud |
| | | const Crud = useCrud( |
| | |
| | | <!-- 删除按钮 --> |
| | | <!--cl-multi-delete-btn /--> |
| | | <cl-flex1 /> |
| | | <cl-filter label="日期"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | |
| | | }); |
| | | |
| | | // cl-search |
| | | const Search = useSearch(); |
| | | function hideDateSearch({ exposed }: { exposed: any }) { |
| | | const items = exposed.config?.items as { prop?: string }[] | undefined; |
| | | const index = items?.findIndex(e => e.prop === "date") ?? -1; |
| | | if (index >= 0) items?.splice(index, 1); |
| | | } |
| | | |
| | | const Search = useSearch({ |
| | | plugins: [hideDateSearch] |
| | | }); |
| | | |
| | | // cl-crud |
| | | const Crud = useCrud( |
| | |
| | | <!-- 删除按钮 --> |
| | | <!--cl-multi-delete-btn /--> |
| | | <cl-flex1 /> |
| | | <cl-filter label="数据日期"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | |
| | | }); |
| | | |
| | | // cl-search |
| | | const Search = useSearch(); |
| | | function hideDateSearch({ exposed }: { exposed: any }) { |
| | | const items = exposed.config?.items as { prop?: string }[] | undefined; |
| | | const index = items?.findIndex(e => e.prop === "date") ?? -1; |
| | | if (index >= 0) items?.splice(index, 1); |
| | | } |
| | | |
| | | const Search = useSearch({ |
| | | plugins: [hideDateSearch] |
| | | }); |
| | | |
| | | // cl-crud |
| | | const Crud = useCrud( |
| | |
| | | <!-- 删除按钮 --> |
| | | <!--cl-multi-delete-btn /--> |
| | | <cl-flex1 /> |
| | | <cl-filter label="日期"> |
| | | <cl-date-picker type="daterange" /> |
| | | </cl-filter> |
| | | <!-- 条件搜索 --> |
| | | <cl-search ref="Search" /> |
| | | </cl-row> |
| | |
| | | }); |
| | | |
| | | // cl-search |
| | | const Search = useSearch(); |
| | | function hideDateSearch({ exposed }: { exposed: any }) { |
| | | const items = exposed.config?.items as { prop?: string }[] | undefined; |
| | | const index = items?.findIndex(e => e.prop === "dataDate") ?? -1; |
| | | if (index >= 0) items?.splice(index, 1); |
| | | } |
| | | |
| | | const Search = useSearch({ |
| | | plugins: [hideDateSearch] |
| | | }); |
| | | |
| | | // cl-crud |
| | | const Crud = useCrud( |
| | |
| | | <template> |
| | | <div class="screen-page"> |
| | | <div class="kpi-grid" style="--kpi-cols: 6"> |
| | | <div class="kpi-grid" style="--kpi-cols: 5"> |
| | | <kpi-card v-for="item in kpis" :key="item.label" v-bind="item" /> |
| | | </div> |
| | | |
| | |
| | | import { computed } from 'vue'; |
| | | import KpiCard from '../../components/kpi-card.vue'; |
| | | import PanelCard from '../../components/panel-card.vue'; |
| | | import { TIME_RANGE_OPTIONS } from '../../data/screen'; |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data, timeRange } = useScreenData(); |
| | | const { isDemoSection } = useScreenFieldSource(); |
| | | const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme(); |
| | | |
| | | const rangeLabel = computed( |
| | | () => TIME_RANGE_OPTIONS.find(e => e.value === timeRange.value)?.label || '本区间' |
| | | ); |
| | | |
| | | const kpis = computed(() => [ |
| | | { field: 'kpis.totalCarbonTon', label: '累计碳减排', value: data.value.kpis.totalCarbonTon, suffix: '吨CO₂e', fixed: 1 }, |
| | | { field: 'kpis.yearCarbonTon', label: '本年度碳减排', value: data.value.kpis.yearCarbonTon, suffix: '吨CO₂e', fixed: 1 }, |
| | | { field: 'kpis.periodCarbonTon', label: '本区间碳减排', value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2 }, |
| | | { field: 'kpis.periodCarbonTon', label: `${rangeLabel.value}碳减排`, value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2 }, |
| | | { field: 'kpis.totalWeightTon', label: '累计资源回收', value: data.value.kpis.totalWeightTon, suffix: '吨', fixed: 1 }, |
| | | { field: 'kpis.totalParticipants', label: '累计参与师生', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0 }, |
| | | { field: 'kpis.greenActions', label: '累计环保行为', value: data.value.kpis.greenActions, suffix: '次', fixed: 0 } |
| | |
| | | <template> |
| | | <div class="screen-page dash"> |
| | | <!-- KPI 指标条 --> |
| | | <div class="kpi-grid" style="--kpi-cols: 8"> |
| | | <div class="kpi-grid" style="--kpi-cols: 7"> |
| | | <kpi-card v-for="item in kpis" :key="item.label" v-bind="item" /> |
| | | </div> |
| | | |
| | |
| | | <template #extra> |
| | | <cl-select-button v-model="itemMetric" :options="itemOptions" small /> |
| | | </template> |
| | | <v-chart :option="itemOption" autoresize class="chart" /> |
| | | <v-chart :option="itemOption" autoresize class="chart chart--items" /> |
| | | </panel-card> |
| | | </div> |
| | | </div> |
| | |
| | | import PanelCard from '../../components/panel-card.vue'; |
| | | import ScreenMapPanel from '../../components/screen-map-panel.vue'; |
| | | import CameraMonitor from '../../../monitor/views/cameras.vue'; |
| | | import { TIME_RANGE_OPTIONS } from '../../data/screen'; |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data } = useScreenData(); |
| | | const { data, timeRange } = useScreenData(); |
| | | const { isDemoSection } = useScreenFieldSource(); |
| | | const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme(); |
| | | |
| | |
| | | { label: '碳减排', value: 'carbon' } |
| | | ]; |
| | | |
| | | /** 顶部 KPI:取总览域核心 8 项 */ |
| | | const rangeLabel = computed( |
| | | () => TIME_RANGE_OPTIONS.find(e => e.value === timeRange.value)?.label || '本区间' |
| | | ); |
| | | |
| | | /** 顶部 KPI */ |
| | | const kpis = computed(() => [ |
| | | { field: 'kpis.todayWeightKg', label: '今日回收量', value: data.value.kpis.todayWeightKg, suffix: 'kg', fixed: 1 }, |
| | | { field: 'kpis.periodWeightTon', label: '本区间回收量', value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2 }, |
| | | { field: 'kpis.periodWeightTon', label: `${rangeLabel.value}回收量`, value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2 }, |
| | | { field: 'kpis.resourceRate', label: '资源化率', value: data.value.kpis.resourceRate, suffix: '%', fixed: 1 }, |
| | | { field: 'kpis.periodValue', label: '回收价值', value: data.value.kpis.periodValue, suffix: '元', fixed: 0 }, |
| | | { field: 'kpis.periodCarbonTon', label: '碳减排', value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2 }, |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 768px) { |
| | | .dash, |
| | | .dash-body, |
| | | .dash-col { |
| | | height: auto; |
| | | min-height: 0; |
| | | overflow: visible; |
| | | flex: none; |
| | | } |
| | | |
| | | .dash :deep(.kpi-grid) { |
| | | grid-template-columns: repeat(2, minmax(0, 1fr)); |
| | | } |
| | | |
| | | .dash-card--flex { |
| | | flex: none; |
| | | height: auto; |
| | | min-height: 0; |
| | | overflow: visible; |
| | | |
| | | :deep(.panel-card) { |
| | | height: auto; |
| | | min-height: 0; |
| | | overflow: visible; |
| | | } |
| | | |
| | | :deep(.panel-card__body) { |
| | | flex: none; |
| | | height: auto; |
| | | min-height: 0; |
| | | overflow: visible; |
| | | } |
| | | } |
| | | |
| | | /* 地图画布层级很高,限制在中栏内,避免盖住上下两块图表 */ |
| | | .dash-card--map { |
| | | position: relative; |
| | | z-index: 0; |
| | | flex: none; |
| | | height: 480px; |
| | | min-height: 480px; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .dash .chart { |
| | | display: block; |
| | | height: 260px; |
| | | min-height: 260px; |
| | | } |
| | | |
| | | .dash .chart--items { |
| | | height: 420px; |
| | | min-height: 420px; |
| | | } |
| | | |
| | | .dash-foot { |
| | | height: auto; |
| | | min-height: 300px; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | } |
| | | |
| | | } |
| | | |
| | | @media (max-width: 768px) { |
| | | .screen-page .table { |
| | | overflow-x: auto; |
| | | } |
| | | |
| | | .screen-page .table--vehicle :deep(.table__head), |
| | | .screen-page .table--vehicle .table__row { |
| | | min-width: 520px; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | <template> |
| | | <div class="screen-page"> |
| | | <div class="kpi-grid" style="--kpi-cols: 8"> |
| | | <div class="kpi-grid" style="--kpi-cols: 7"> |
| | | <kpi-card v-for="item in kpis" :key="item.label" v-bind="item" /> |
| | | </div> |
| | | |
| | |
| | | import KpiCard from '../../components/kpi-card.vue'; |
| | | import PanelCard from '../../components/panel-card.vue'; |
| | | import ScreenCameraPreview from '../../components/screen-camera-preview.vue'; |
| | | import { TIME_RANGE_OPTIONS } from '../../data/screen'; |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data, timeRange } = useScreenData(); |
| | |
| | | { label: '碳减排', value: 'carbon' } |
| | | ]; |
| | | |
| | | const rangeLabel = computed( |
| | | () => TIME_RANGE_OPTIONS.find(e => e.value === timeRange.value)?.label || '本区间' |
| | | ); |
| | | |
| | | const kpis = computed(() => [ |
| | | { field: 'kpis.todayWeightKg', label: '今日回收量', value: data.value.kpis.todayWeightKg, suffix: 'kg', fixed: 1, hint: '实时称重汇总' }, |
| | | { field: 'kpis.periodWeightTon', label: '本区间回收量', value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2, hint: '当前时间范围' }, |
| | | { field: 'kpis.periodWeightTon', label: `${rangeLabel.value}回收量`, value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2, hint: '当前时间范围' }, |
| | | { field: 'kpis.resourceRate', label: '资源化率', value: data.value.kpis.resourceRate, suffix: '%', fixed: 1, hint: '可回收物重量/总重量,不含大件' }, |
| | | { field: 'kpis.periodValue', label: '回收价值', value: data.value.kpis.periodValue, suffix: '元', fixed: 0, hint: '日表估算销售额' }, |
| | | { field: 'kpis.periodCarbonTon', label: '碳减排', value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2, hint: '分类重量 × 碳减因数' }, |
| | |
| | | import { computed, ref } from 'vue'; |
| | | import KpiCard from '../../components/kpi-card.vue'; |
| | | import PanelCard from '../../components/panel-card.vue'; |
| | | import { type RankMetric } from '../../data/screen'; |
| | | import { TIME_RANGE_OPTIONS, type RankMetric } from '../../data/screen'; |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data } = useScreenData(); |
| | | const { data, timeRange } = useScreenData(); |
| | | const { isDemoSection } = useScreenFieldSource(); |
| | | const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme(); |
| | | |
| | |
| | | { label: '碳减排', value: 'carbon' } |
| | | ]; |
| | | |
| | | const rangeLabel = computed( |
| | | () => TIME_RANGE_OPTIONS.find(e => e.value === timeRange.value)?.label || '本区间' |
| | | ); |
| | | |
| | | const kpis = computed(() => [ |
| | | { field: 'kpis.totalParticipants', label: '参与师生', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0 }, |
| | | { field: 'kpis.participateRate', label: '参与率', value: data.value.kpis.participateRate, suffix: '%', fixed: 1, hint: '参与人数 / 参数 student.count.{部门ID}' }, |
| | | { field: 'kpis.newParticipants', label: '本区间新增参与', value: data.value.kpis.newParticipants, suffix: '人', fixed: 0, hint: '区间内出现过的去重用户' }, |
| | | { field: 'kpis.newParticipants', label: `${rangeLabel.value}新增参与`, value: data.value.kpis.newParticipants, suffix: '人', fixed: 0, hint: '区间内出现过的去重用户' }, |
| | | { field: 'kpis.pointsIssued', label: '碳积分发放', value: data.value.kpis.pointsIssued, suffix: '分', fixed: 0 }, |
| | | { field: 'kpis.pointsConsumed', label: '碳积分消费', value: data.value.kpis.pointsConsumed, suffix: '分', fixed: 0 }, |
| | | { field: 'kpis.pointsBalance', label: '碳积分余额', value: data.value.kpis.pointsBalance, suffix: '分', fixed: 0 } |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 576px) { |
| | | .screen-page .flow__split { |
| | | grid-template-columns: 1fr; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | } |
| | | |
| | | @media only screen and (max-width: 768px) { |
| | | .cl-view-group__right .head .title { |
| | | max-width: calc(100% - 88px); |
| | | text-overflow: ellipsis; |
| | | padding: 0 8px; |
| | | } |
| | | |
| | | .cl-view-group__left { |
| | | overflow: hidden; |
| | | transition: width 0.2s; |