wangrong
2026-09-24 bf9400d4198094194439214b5e90036b2f040052
bug修复,用真实数据
39个文件已修改
1个文件已删除
2个文件已添加
1478 ■■■■■ 已修改文件
index.html 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server.cjs 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/base/config.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/base/pages/error/components/error-page.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/base/pages/login/index.vue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/base/pages/main/components/amenu.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/base/pages/main/components/process.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/base/pages/main/components/topbar.vue 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/base/pages/main/components/views.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/base/pages/main/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/base/static/css/index.scss 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/base/static/css/mobile.scss 253 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/ezuikit/components/ezuikit-player.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/maps/components/map-board.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/monitor/views/cameras.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/monitor/views/device.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/operation/views/traderecord.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/operation/views/wastesale.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/views/auditlog.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/views/cameragps.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/views/compressionweight.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/views/devicealarm.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/views/deviceonoff.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/views/siteweight.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/views/vehiclegps.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/push/views/weight.vue 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/panel-card.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/screen-map-panel.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/screen-shell.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen-demo.ts 210 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen-merge.ts 367 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/hooks/use-screen.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/dailydepartment.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/dailysite.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/dailyvehicle.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/joblog.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/carbon.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/map.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/operation.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/overview.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/participation.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/plugins/view/components/group.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
index.html
@@ -7,8 +7,9 @@
        <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>
server.cjs
New file
@@ -0,0 +1,87 @@
/**
 * 前端静态服务器(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));
src/modules/base/config.ts
@@ -3,6 +3,7 @@
import { config } from '/@/config';
import { t } from '/@/plugins/i18n';
import './static/css/index.scss';
import './static/css/mobile.scss';
export default (): ModuleConfig => {
    return {
src/modules/base/pages/error/components/error-page.vue
@@ -137,4 +137,22 @@
        }
    }
}
@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>
src/modules/base/pages/login/index.vue
@@ -304,4 +304,36 @@
        }
    }
}
@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>
src/modules/base/pages/main/components/amenu.vue
@@ -148,4 +148,16 @@
        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>
src/modules/base/pages/main/components/process.vue
@@ -276,4 +276,18 @@
        }
    }
}
@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>
src/modules/base/pages/main/components/topbar.vue
@@ -188,6 +188,54 @@
        }
    }
}
@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">
src/modules/base/pages/main/components/views.vue
@@ -96,4 +96,14 @@
        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>
src/modules/base/pages/main/index.vue
@@ -67,10 +67,15 @@
    }
    @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);
@@ -78,6 +83,7 @@
        .app-layout__right {
            width: 100%;
            min-width: 0;
        }
        &.is-collapse {
src/modules/base/static/css/index.scss
@@ -1,6 +1,8 @@
#app {
    height: 100vh;
    width: 100vw;
    height: 100dvh;
    width: 100%;
    max-width: 100vw;
    overflow: hidden;
}
src/modules/base/static/css/mobile.scss
New file
@@ -0,0 +1,253 @@
/* 手机浏览器:只在窄屏生效,桌面端样式保持原样 */
@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;
    }
}
src/modules/ezuikit/components/ezuikit-player.vue
@@ -171,4 +171,9 @@
  padding: 0px;
  background: #000;
}
/* 手机扩展区里的圆形云台。SDK 用 deepmerge 合并配置时数组合并,单靠删掉 controls 里的 ptz 去不掉 */
.ez-player-card :deep(.ez-mobile-ptz) {
  display: none;
}
</style>
src/modules/maps/components/map-board.vue
@@ -134,4 +134,29 @@
    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>
src/modules/monitor/views/cameras.vue
@@ -257,4 +257,24 @@
  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>
src/modules/monitor/views/device.vue
@@ -117,4 +117,26 @@
    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>
src/modules/operation/views/traderecord.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <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" />
src/modules/operation/views/wastesale.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <cl-multi-delete-btn />
            <cl-flex1 />
            <cl-filter label="日期时间">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
@@ -260,8 +263,10 @@
        service: service.operation.wastesale
    },
    (app) => {
        service.basicdata.wastetype.page({ isRecyclable: 1, size: 1000 }).then((res) => {
            garbageTypeOptions.value = (res.list || []).map((item: any) => ({
        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,
src/modules/push/views/auditlog.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <cl-multi-delete-btn />
            <cl-flex1 />
            <cl-filter label="日志时间">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
src/modules/push/views/cameragps.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <cl-multi-delete-btn />
            <cl-flex1 />
            <cl-filter label="消息创建时间">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
src/modules/push/views/compressionweight.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <cl-multi-delete-btn />
            <cl-flex1 />
            <cl-filter label="上传时间">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
src/modules/push/views/devicealarm.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <cl-multi-delete-btn />
            <cl-flex1 />
            <cl-filter label="报警时间戳">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
src/modules/push/views/deviceonoff.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <cl-multi-delete-btn />
            <cl-flex1 />
            <cl-filter label="消息时间">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
src/modules/push/views/siteweight.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <cl-multi-delete-btn />
            <cl-flex1 />
            <cl-filter label="上传时间">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
@@ -192,8 +195,10 @@
        service: service.push.siteweight,
    },
    (app) => {
        service.basicdata.wastetype.list().then((res) => {
            garbageTypeOptions.value = (res || []).map((item: any) => ({
        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,
            }));
src/modules/push/views/vehiclegps.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <cl-multi-delete-btn />
            <cl-flex1 />
            <cl-filter label="GPS定位时间">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
src/modules/push/views/weight.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <cl-multi-delete-btn />
            <cl-flex1 />
            <cl-filter label="上传时间">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
@@ -33,12 +36,15 @@
    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({
@@ -98,6 +104,7 @@
            component: { name: "cl-date-text" },
        },
        { label: t("垃圾类型"), prop: "garbageType", minWidth: 120 },
        { label: t("垃圾分类"), prop: "garbageName", minWidth: 140 },
        {
            label: t("垃圾重量"),
            prop: "weight",
@@ -129,7 +136,34 @@
});
// 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(
@@ -137,6 +171,14 @@
        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();
    },
);
src/modules/report/components/panel-card.vue
@@ -73,4 +73,25 @@
        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>
src/modules/report/components/screen-map-panel.vue
@@ -253,6 +253,18 @@
    }
}
@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% {
src/modules/report/components/screen-shell.vue
@@ -62,15 +62,11 @@
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;
    }
    router.replace({ path: SCREEN_HOME_PATH, query });
}
@@ -285,4 +281,38 @@
    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>
src/modules/report/data/screen-demo.ts
File was deleted
src/modules/report/data/screen-merge.ts
@@ -1,12 +1,5 @@
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 };
@@ -18,271 +11,120 @@
    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);
    if (!live) {
        return {
            data: base,
            mode: 'live',
            demoFields: [],
            liveFields: []
        };
    }
    let yearCarbon = demo.yearCarbon;
    if (live?.yearCarbon?.length && sum(live.yearCarbon) > 0) {
        yearCarbon = live.yearCarbon;
    const axis = live.axis?.length ? live.axis : base.axis;
    ctx.markLive('axis');
    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}`);
    });
    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 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');
    } else {
        ctx.markDemo('yearCarbon');
    }
    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 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');
    let participateTrend = alignSeries(demo.participateTrend, axis.length);
    if (live?.participateTrend?.length && sum(live.participateTrend) > 0) {
        participateTrend = alignSeries(live.participateTrend, axis.length);
    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');
    } else {
        ctx.markDemo('participateTrend');
    }
    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 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 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,
@@ -303,21 +145,20 @@
        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';
src/modules/report/hooks/use-screen.ts
@@ -103,7 +103,6 @@
        } catch (e: any) {
            applyResolved(resolveScreenData(null, sharedTimeRange.value));
            const msg = e?.message || '';
            // 首页静默降级到演示数据,不展示登录/权限类错误文案
            if (!/登录失效|无权限/.test(msg)) {
                loadError.value = msg || '驾驶舱接口调用失败';
            }
@@ -124,7 +123,7 @@
    return { load, loading, loadError, dataMode };
}
/** 首页不展示「演示」标识,底层仍可静默使用演示数据填充 */
/** 首页只展示接口汇总结果,不再用演示数据填充 */
export function useScreenFieldSource() {
    const { dataMode, demoFields } = useScreenData();
src/modules/report/views/dailydepartment.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <!--cl-multi-delete-btn /-->
            <cl-flex1 />
            <cl-filter label="日期">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
@@ -504,7 +507,15 @@
});
// 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(
src/modules/report/views/dailysite.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <!--cl-multi-delete-btn /-->
            <cl-flex1 />
            <cl-filter label="日期">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
@@ -508,7 +511,15 @@
});
// 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(
src/modules/report/views/dailyvehicle.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <!--cl-multi-delete-btn /-->
            <cl-flex1 />
            <cl-filter label="数据日期">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
@@ -262,7 +265,15 @@
});
// 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(
src/modules/report/views/joblog.vue
@@ -8,6 +8,9 @@
            <!-- 删除按钮 -->
            <!--cl-multi-delete-btn /-->
            <cl-flex1 />
            <cl-filter label="日期">
                <cl-date-picker type="daterange" />
            </cl-filter>
            <!-- 条件搜索 -->
            <cl-search ref="Search" />
        </cl-row>
@@ -173,7 +176,15 @@
});
// 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(
src/modules/report/views/screen/carbon.vue
@@ -1,6 +1,6 @@
<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>
@@ -45,16 +45,20 @@
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 }
src/modules/report/views/screen/map.vue
@@ -1,7 +1,7 @@
<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>
@@ -57,7 +57,7 @@
                    <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>
@@ -84,9 +84,10 @@
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();
@@ -112,10 +113,13 @@
    { 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 },
@@ -353,4 +357,65 @@
        }
    }
}
@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>
src/modules/report/views/screen/operation.vue
@@ -323,4 +323,15 @@
    }
}
@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>
src/modules/report/views/screen/overview.vue
@@ -1,6 +1,6 @@
<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>
@@ -51,6 +51,7 @@
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();
@@ -72,9 +73,12 @@
    { 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: '分类重量 × 碳减因数' },
src/modules/report/views/screen/participation.vue
@@ -69,10 +69,10 @@
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();
@@ -99,10 +99,14 @@
    { 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 }
@@ -252,4 +256,10 @@
        }
    }
}
@media (max-width: 576px) {
    .screen-page .flow__split {
        grid-template-columns: 1fr;
    }
}
</style>
src/plugins/view/components/group.vue
@@ -680,6 +680,12 @@
    }
    @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;