Ver Fonte

1、标题修改,张江高新区青浦园产业地图。(已调整)
2、图示错误,左侧青浦区应是青浦园。(已调整)
3、地块入住企业有歧义。(后续可能会给新数据,现在的数据有缺少)
4、是否可显示道路,底图白色,参照市科委查询范围的图。(不确定底图是不是标准版政务底图,然后其他的底图不要了?)
5、左侧框缩小,比例尺自己调整(添加主题配置模块,支持修改文字和背景等颜色,支持拖动滑块快捷设置侧边栏宽度)

DESKTOP-6LTVLN7\Liumouren há 2 semanas atrás
pai
commit
79e5a5460b

+ 1 - 0
.gitignore

@@ -1 +1,2 @@
 node_modules/
+dist/

+ 297 - 0
docs/ARCHITECTURE.md

@@ -0,0 +1,297 @@
+# 张江高新区青浦园产业地图 — 架构与业务流程说明
+
+> 本文档面向二次开发与维护,描述当前仓库的技术栈、目录结构、运行时链路、数据与地图逻辑、认证与配置,以及可扩展点。  
+> 仓库 `package.json` 中项目名为 `shqpgov_school_district_inquiry`,与业务展示名称不一致,以页面标题「张江高新区青浦园产业地图」为准。
+
+---
+
+## 1. 项目概览
+
+### 1.1 产品定位
+
+单页大屏类应用:在 **Leaflet 二维地图** 上展示青浦区 **园区边界**、**行政区划** 与 **重点企业点位**,两侧面板提供产业统计(ECharts)、园区列表、图层与图例控制、企业搜索与底图切换。业务数据当前以 **前端静态 GeoJSON/JS 模块** 为主,非实时接口驱动(「立即同步」为前端模拟)。
+
+### 1.2 技术栈
+
+| 类别     | 技术                                                                                   |
+| -------- | -------------------------------------------------------------------------------------- |
+| 框架     | Vue 2.6、Vue Router 3(hash)、Vuex 3                                                  |
+| UI       | Element UI 2                                                                           |
+| 地图     | Leaflet 1.3.1(`public/index.html` 全局引入)、Esri Leaflet 3、jQuery(部分 DOM 操作) |
+| 图表     | ECharts 5                                                                              |
+| 空间分析 | @turf/turf 7、proj4                                                                    |
+| HTTP     | axios(`src/utils/request.js` 封装)                                                   |
+| 构建     | Vue CLI 5、`vue.config.js` 中 `publicPath: './'`、打包文件名带时间戳                   |
+
+### 1.3 外部脚本与全局变量
+
+`public/index.html` 在打包前加载:
+
+- `./static/config/config.js`:定义 **`systemConfig`**、**`map2DViewer`** 等全局对象(非 ES 模块)。
+- Leaflet、Esri Leaflet、proj4、leaflet.draw、shpjs、turf、**`createAuth.js`**(混淆脚本,提供登录密码加密相关 **`AesEncryptUtil`**)。
+- 百度地图 WebGL API(`ak=...`,`callback=initialize`)—— 当前业务主路径以 Leaflet 为主,需确认是否仍依赖百度回调。
+
+Vue 入口 `src/main.js` 将 **`$Coordinate`**(`coordinate`)、**`$Decrypt`**(`aes.js`)、**`$CryptoJS`**(实为 `publicFunction.js` 默认导出)、**`$dayjs`**、**`$proj4`**、**`$bus`** 挂到实例上。
+
+---
+
+## 2. 目录结构(源码层面)
+
+```
+Industry_map/
+├── public/
+│   ├── index.html                 # 全局脚本、Leaflet CSS/JS
+│   └── static/
+│       ├── config/config.js       # systemConfig、地图全局 map2DViewer 等
+│       ├── images/                # 大屏装饰、图例、marker 图标
+│       └── plugins/               # Leaflet 插件、createAuth.js 等
+├── src/
+│   ├── main.js                    # 应用入口
+│   ├── App.vue                    # 根组件:启动时登录
+│   ├── router/index.js            # 路由(实质单页)
+│   ├── store/index.js             # Vuex:token、用户信息、底图类型等
+│   ├── views/
+│   │   ├── qpjyj.vue              # 【主页面】产业地图大屏布局与业务编排
+│   │   ├── industryDatas.js       # 企业 FeatureCollection 式数组(体积大)
+│   │   ├── parkDatas.js           # 园区多边形
+│   │   ├── qpDivisionDatas.js     # 区县边界
+│   │   ├── qpStreetDatas.js       # 乡镇/街道边界
+│   │   └── kewei_cydt_project.js  # 按统一社会信用代码扩展企业工商等字段
+│   ├── components/
+│   │   ├── map/appMap.vue         # Leaflet 地图:底图、marker、面、Popup
+│   │   └── card/                  # 卡片壳、饼图、柱状图
+│   ├── api/common.js              # 当前仅 OAuth 登录
+│   ├── utils/
+│   │   ├── request.js             # axios 实例、拦截器里带 token 头
+│   │   ├── encrypt‌.js            # 登录封装(调用 common.login)
+│   │   ├── publicFunction.js      # Turf 几何包含、面积、坐标处理等
+│   │   ├── aes.js                 # 另一套 AES 加解密(与 encrypt 流程区分)
+│   │   └── coordinate.js          # 坐标相关工具
+│   ├── directives/drag.js
+│   └── assets/                    # 全局样式、字体
+├── vue.config.js                  # 代理 /proxy_oauth、别名 @$、@static
+└── package.json
+```
+
+---
+
+## 3. 应用启动与路由
+
+### 3.1 启动顺序
+
+1. 浏览器加载 `index.html` 中的 `config.js`、`createAuth.js`、Leaflet 等。
+2. Webpack 入口 `main.js`:注册 Element UI、`v-drag`、无缝滚动;`router.beforeEach` 根据 `meta.title` 设置 `document.title`。
+3. 挂载根实例,`App.vue` 在 `mounted` 中调用 **`encrypt‌()`**(无参则走默认账号,见下节)。
+4. 路由 **`/`** 与 **`/*`** 均渲染 **`src/views/qpjyj.vue`**,`mode: 'hash'`。
+
+### 3.2 主页面与地图的显示条件
+
+`qpjyj.vue` 中:
+
+```vue
+<AppMap v-if="$store.state.token" ... />
+```
+
+即 **OAuth 返回 token 写入 Vuex/localStorage 后** 才挂载地图组件,避免无 token 时政务底图 URL 缺少 `proxyToken`。
+
+---
+
+## 4. 认证与网络
+
+### 4.1 默认登录流程(`src/utils/encrypt‌.js`)
+
+- 无参数:使用 `systemConfig.defaultAccount.username`(`config.js` 中为 `user_kwyzt`)与 **`AesEncryptUtil.getPassword()`**(来自 `createAuth.js`)调用 `api.login`。
+- 有参数:校验用户名密码后使用 **`AesEncryptUtil.getPassword(loginObj.password)`** 加密密码再登录;成功则 `sessionStorage.sessionUserInfo` 保存明文账号信息(用于刷新页时复登)。
+
+登录接口:`POST ${systemConfig.oauthServiceUrl}/user/pwd/login`,表单 `application/x-www-form-urlencoded`,字段含 `userName`、`password`、`clientId`。
+
+成功:`result.code == 200` 时 `result.message` 作为 **token** 存入 Vuex 与 `localStorage.TOKEN`,`result.content` 为 **userInfo**。
+
+### 4.2 开发环境代理
+
+`vue.config.js`:
+
+- `devServer.port`: **2024**
+- `/proxy_oauth/` → `http://121.43.55.7:10086/oauth`(`pathRewrite` 去掉前缀)
+
+生产/配置里 `oauthServiceUrl` 与底图域名需与部署环境一致;当前 `config.js` 中 oauth 为直连 IP 端口。
+
+### 4.3 axios 封装(`src/utils/request.js`)
+
+- 请求拦截器:`config.headers.token = localStorage.getItem("TOKEN")`。
+- 导出 `get`、`postform`、`postBody` 等;**`post` 函数内部引用未定义的 `ls`**,若调用会报错——当前业务主要使用 `postform`(登录)。
+
+---
+
+## 5. 状态管理(Vuex)
+
+`src/store/index.js` 核心字段:
+
+| state                                           | 含义                                           |
+| ----------------------------------------------- | ---------------------------------------------- |
+| `token`                                         | 与 localStorage `TOKEN` 同步                   |
+| `userInfo`                                      | 登录返回内容                                   |
+| `baseMapType`                                   | 底图类型枚举(与页面实际切换逻辑部分重叠)     |
+| `windowsSize`                                   | 视口宽高                                       |
+| `mapMethodsCollection`、`treeDataCollection` 等 | 预留/历史地图能力                              |
+| `year`                                          | 首页年份字符串(当前大屏主要统计未绑定该字段) |
+
+变更:`setToken`、`setUserState`、`setUserInfo`。
+
+---
+
+## 6. 主页面业务:`qpjyj.vue`
+
+### 6.1 布局结构
+
+- **顶栏**:标题、数据同步区(日期时间 +「立即同步」)、企业搜索(类型:企业名称 / 企业地址 / 统一社会信用代码)。
+- **主体**:全屏 **`AppMap`**;左侧三张 **Card**(产业分布汇总、产业筛选说明、图层控制);右侧 **Card**(饼图热度、柱状集聚程度、园区列表)。
+- **浮动**:左右收起侧栏、左下底图切换、右下图例多选。
+
+### 6.2 数据流与初始化(`initMap`)
+
+地图组件 `mapInit` 完成后触发 `@mapInit` → `initMap()`:
+
+1. **区县边界**:`qpDivisionDatas` → `pieaddParkPolygon('区县行政边界', item, false)`。
+2. **乡镇边界**:`qpStreetDatas` → `pieaddParkPolygon('乡镇行政边界', item, false)`。
+3. **企业点位**:遍历 `industryDatas`,按 `统一社会信用代码` 与 `kewei_cydt_project` 合并属性(如 `trd_scope` 经营范围等),再 `addCompanyMarker(company)`。
+4. **园区**:`initParkDatas()` 内遍历 `parkDatas`,用 Turf 计算包含关系与统计,绘制 `所有园区范围边界`,并填充 `parkList`;延时后默认 `changePark(parkList[0])`。
+
+### 6.3 园区与统计逻辑(`initParkDatas`)
+
+对每个园区多边形:
+
+- `properties.area`:通过 **`$CryptoJS.calculateMultiPolygonAreaInHectare(geometry)`** 得到公顷数文案。
+- 对每个企业: **`isGeometryAContainsGeometryB(园区, 企业点)`** 判断是否入园。
+- 饼图数据 `echartsDatas`:按 `properties.所属产业` 三类(医药器械制造、高端智能装备、新一代信息技术)计数。
+- 柱状 `barEchartsDatas`:按 `properties.专业` 映射到「规上工业」「规上服务业」「其他」(无 `专业` 则归为其他)。
+- **主导产业**:饼图三项中取 value 最大者写入园区对象的 `主导产业`;`企业数量` 为饼图三项之和。
+
+切换园区 **`changePark`**:`fitBounds` 该园区面,并把该园区上的 `echartsDatas` / `barEchartsDatas` 赋给右侧面板图表。
+
+### 6.4 搜索
+
+`search()`:在 **`industryDatas`** 内存数组上按所选字段 `includes` 关键字过滤,去重信用代码,结果列表点击 **`panToLocation`** 飞行到企业点。
+
+### 6.5 图例与图层
+
+- **图例**:`checkedLegends` 变化时调用 `appMap.changeMarkerState(产业名, state)` 控制对应分组 marker 显隐。
+- **图层开关**:`LayerControls` 中「区县/乡镇/所有园区范围边界」开关 → `appMap.changeLayerControl(item)`,在地图侧批量 `addLayer` / `removeLayer` 面与面心文字 marker。
+
+### 6.6 底图切换
+
+`baseMapServices` 与 `AppMap` 内 `baseMapServices` 键一致:`shmap_blue_web`、`shmap_normal_web`、`arcgisImagery`。政务瓦片 URL 含 **`proxyToken=` + localStorage TOKEN**。
+
+### 6.7 「数据同步」
+
+`handleSync` → `syncData()`:**`setTimeout` 2 秒模拟**,成功后更新 `lastSyncTime`。`startAutoSync`(定时 30 分钟)在 `mounted` 中注释掉,**未对接真实刷新数据接口**;若二次开发对接后端,应替换 `syncData` 并考虑重新拉取或热更新 `industryDatas` 等数据源。
+
+### 6.8 左侧「产业分布」静态数
+
+`industrialDistributionSum` 中三类企业数为 **写死的展示文案**(如 30 / 114 / 124),与地图统计无联动;改版时可改为由 `industryDatas` 聚合或接口返回。
+
+---
+
+## 7. 地图组件:`appMap.vue`
+
+### 7.1 初始化
+
+- `L.map` 中心约 `[31.146..., 121.111...]`,`minZoom` 9,`maxZoom` 18,去掉默认 zoom 控件。
+- `loadBaseMap(activeBaseMap)`:`L.tileLayer` 或预留 `wms` / `esriVector`。
+- `zoomend`:缩放 ≥15 显示企业 marker 上的 `.title` 文字,否则隐藏(jQuery 操作 class)。
+
+### 7.2 企业 Marker
+
+- `addCompanyMarker`:`L.divIcon` 拼 HTML,图标取自 `legendsTypes[所属产业].imageUrl`;**`所属产业` 必须在 `legendsTypes` 中有配置**,否则运行时会报错。
+- Popup:企业名称、信用代码、地址、专业、产业、经营范围(来自合并后的 `trd_scope`)。
+- 按产业分桶:`this.markers[产业名][企业title] = marker`。
+
+### 7.3 面图层
+
+- `pieaddParkPolygon(title, data, toCenter)`:用 `publicFun.latLngsToReverse2` 处理坐标后 `L.geoJSON`,样式颜色来自 `layerColor[title]`。
+- 区县/乡镇/园区:园区面与中心 marker 可点击,`$emit('changePark', data)`;园区 Popup 含主导产业、企业数、`parkInfo[园区名]` 的简介文案(硬编码在 `appMap.vue` 的 `parkInfo` 对象)。
+
+### 7.4 图层显隐
+
+`changeLayerControl(item)`:根据 `item.name` 找到 `layerControlPolygon[item.name]` 下所有 key,同步切换 polygon 与 `markers['polygon'][key]` 标签点。
+
+---
+
+## 8. 数据模块约定
+
+### 8.1 `industryDatas.js`
+
+- GeoJSON **Feature** 数组:`geometry` 为 **Point**,坐标 **[lng, lat]**。
+- `properties` 至少包含:`企业名称`、`企业地址`、`统一社会信用代码`、`所属产业`、`专业`(用于柱状分类)等。
+
+### 8.2 `kewei_cydt_project.js`
+
+- 以 **`uni_sc_id`** 与企业的 **`统一社会信用代码`** 匹配,`Object.assign` 合并到 `properties`,供 Popup 中经营范围等展示。
+
+### 8.3 `parkDatas.js`
+
+- 园区 **MultiPolygon**,`properties.name2` 为园区展示名(与 `parkInfo`、列表、统计 key 一致)。
+
+### 8.4 行政区划数据
+
+- `qpDivisionDatas`:`properties.quxian` 作为 `title`。
+- `qpStreetDatas`:`properties.zhenjie` 作为 `title`。
+
+---
+
+## 9. 公共几何与工具(`publicFunction.js`)
+
+与地图强相关导出包括:
+
+- **`latLngsToReverse2`**:多级坐标数组处理(与 Leaflet GeoJSON .latLng 顺序适配有关,需与现有数据坐标系一致)。
+- **`calculateMultiPolygonAreaInHectare`**:基于 Turf 的面积换算公顷。
+- **`isGeometryAContainsGeometryB`**:判断企业点是否落在园区/行政面内。
+
+二次开发若更换坐标系(WGS84 / GCJ-02 / 地方坐标),需统一修正 **数据** 与 **底图** 或在此处增加纠偏。
+
+`config.js` 中 **`lonCorrectParams` / `latCorrectParams`** 注释为 WGS84→ 上海 2000 类偏移,需在代码中确认是否被实际调用(当前 `qpjyj` 主路径未直接引用)。
+
+---
+
+## 10. 构建与部署注意
+
+- **`publicPath: './'`**:适合静态资源相对路径部署(子目录或本地文件)。
+- **文件名带时间戳**:利于缓存刷新;若 CDN 需同步规则。
+- **`configureWebpack.resolve.fallback.zlib`**:指向 `browserify-zlib`,与部分打包依赖有关。
+
+---
+
+## 11. 二次开发建议清单
+
+1. **数据改为接口驱动**:将 `industryDatas` / `parkDatas` 等改为 API + 前端缓存,同步按钮调用真实任务;注意 TOKEN 与跨域。
+2. **统一产业/图例配置**:避免 `legendsTypes`、左侧静态数、图例 checkbox、`industrialDistribution` 多处硬编码不一致。
+3. **OAuth 与底图环境**:`config.js` 中 URL 抽为环境变量或部署时注入;开发可用 `proxy_oauth` 同源代理。
+4. **依赖清理**:确认百度地图脚本是否必需;`request.js` 中未使用的 `post` / `ls` 引用可修复或删除以免误用。
+5. **TypeScript/模块**:长期可逐步把全局 `systemConfig` 迁入模块并在 `vue.config` 中定义 `ProvidePlugin` 或显式 import,便于类型检查。
+6. **大屏适配**:已有 `windowsSize` state,可在布局中进一步使用;地图 `resize` 已监听 `invalidateSize`。
+
+---
+
+## 12. 昼夜主题与本地配色
+
+昼夜由 **左下角底图** 决定(暗蓝色 → 黑夜,标准版 / 卫星影像 → 白天),**主题配置** 仅编辑 Token;持久化见 **[THEME.md](./THEME.md)**。
+
+## 13. 关键文件索引
+
+| 用途               | 路径                                                        |
+| ------------------ | ----------------------------------------------------------- |
+| 入口               | `src/main.js`                                               |
+| 登录与 token       | `src/App.vue`、`src/utils/encrypt‌.js`、`src/api/common.js` |
+| 全局配置(非打包) | `public/static/config/config.js`                            |
+| 主界面             | `src/views/qpjyj.vue`                                       |
+| 地图               | `src/components/map/appMap.vue`                             |
+| 企业数据           | `src/views/industryDatas.js`                                |
+| 园区数据           | `src/views/parkDatas.js`                                    |
+| 工商扩展           | `src/views/kewei_cydt_project.js`                           |
+| 几何工具           | `src/utils/publicFunction.js`                               |
+| HTTP               | `src/utils/request.js`                                      |
+| 主题 Token         | `src/utils/uiTheme.js`、`docs/THEME.md`                     |
+
+---
+
+_文档版本:与仓库当前代码同步整理;若后续有路由拆分、接口落地或数据迁移,请在本文件追加「变更记录」小节。_

+ 103 - 0
docs/THEME.md

@@ -0,0 +1,103 @@
+# 昼夜主题与配色配置
+
+## 功能概述
+
+- **顶栏固定样式**:最上方标题条(背景图、中英文标题、数据同步、界面开关、主题配置、立即同步按钮)**始终为黑夜科技风配色**,不随「白天 / 黑夜」切换或主题 Token 变化;昼夜仅影响其下方的搜索区、侧栏卡片、图例、底图切换条、地图弹窗与 ECharts 等。
+
+- **默认「黑夜」**:与历史大屏一致的深色半透明面板、浅色文字,适合暗色政务底图。
+- **「白天」**:高对比浅底深字,适合「标准版政务底图」等浅色底图,减轻文字与弹窗看不清的问题。
+- **昼夜与底图联动**:**不单独提供**顶栏昼夜开关。左下角切换底图时自动切换主题——**「暗蓝色政务底图」**(`shmap_blue_web`)对应 **黑夜**,**「标准版政务底图」**、**「卫星影像」** 对应 **白天**。常量见 `src/utils/uiTheme.js` 的 `BASE_MAP_KEY_NIGHT`、`getUiModeForBaseMap`。
+- **右上角**:仅保留 **主题配置** 按钮(颜色 Token),不再保留独立的昼夜开关。
+- **持久化**:当前模式(`night` / `day`)与 **按昼夜分别存储** 的覆盖项 `overridesByMode` 写入 **`localStorage`**,键名常量:`industry_map_ui_theme_v1`(定义于 `src/utils/uiTheme.js` 的 `UI_THEME_STORAGE_KEY`)。在白天下调主题只写入 `overridesByMode.day`,黑夜写入 `overridesByMode.night`,互不覆盖。
+
+## 配置流程(面向使用者)
+
+1. 打开页面后,主题在 **`main.js` 启动时** 从缓存读出并应用到 `document.documentElement`(`data-ui-theme` + 内联 CSS 变量)。
+2. 切换 **底图**:在 `qpjyj.vue` 的 `changeMapService` 中更新 `activeMap` 后调用 `syncThemeWithBaseMap()` → `persistTheme(getUiModeForBaseMap(activeMap), overridesByMode, sidebarWidthPx)`,写入缓存并刷新 CSS 变量;ECharts 通过计算属性 `isDayTheme`(由 `activeMap` 推导)同步 `chart-theme`。
+3. 点击 **主题配置**:
+   - **统一表面背景(`--ui-surface-bg`)**:侧栏卡片、搜索框容器、左下角底图切换条、右下角图例、地图详情弹窗主区域与弹窗内信息条等共用同一背景色;支持 **透明度**(`show-alpha` + `rgb` 格式输出 `rgba(...)`)。
+   - **文字分级**:标题、一级~三级、描述、占位;除占位外默认 **十六进制**;占位支持透明度。
+   - **强调色(`--ui-main`)**:十六进制;应用后由 `resolveThemeTokens` 同步描边、链接色、搜索高亮、弹窗强调色等。
+   - **拖动或修改颜色即生效**:立刻更新页面 CSS,并在约 120ms 防抖后写入 `localStorage`;仅当与当前模式**内置默认值**在语义上同色(支持 `#hex` 与 `rgb()` 对照)时,不写入该项覆盖。清空某项表示恢复默认。每次合并 `overrides` 时会移除一批**已废弃的单独背景/文字覆盖键**(见下文)。
+4. **恢复默认**:清空 **当前模式** 在 `overridesByMode` 中的颜色覆盖(另一模式保留);同时将 **侧栏宽度** 恢复为内置默认值 **`DEFAULT_SIDEBAR_WIDTH_PX`**(当前为 `432`,定义于 `src/utils/uiTheme.js`),并写入缓存。
+
+## 缓存 JSON 结构
+
+```json
+{
+  "mode": "day",
+  "overridesByMode": {
+    "night": {},
+    "day": {
+      "--ui-surface-bg": "rgba(255,255,255,0.26)",
+      "--ui-text-title": "#0d7ea0",
+      "--ui-main": "#0d7ea0"
+    }
+  },
+  "sidebarWidthPx": 432
+}
+```
+
+- `mode`:仅允许 `"night"` 或 `"day"`(非法值按 `night` 处理),表示**当前**界面模式(与底图一致)。
+- `overridesByMode.day` / `overridesByMode.night`:各自模式下的覆盖表;键为 CSS 自定义属性名(必须以 `--` 开头)。
+- `sidebarWidthPx`:左右侧悬浮栏宽度(像素),昼夜共用;**默认**与 **`DEFAULT_SIDEBAR_WIDTH_PX`**(432)一致;合法范围见 `SIDEBAR_WIDTH_MIN_PX` / `SIDEBAR_WIDTH_MAX_PX`;运行时写入 `:root` 的 `--ui-sidebar-width`。
+- **旧版缓存**仅含 `overrides` 时,加载时会当作 **当时 `mode` 对应那一份** 使用,另一模式为空,避免历史数据把昼夜绑死成同一套颜色。
+
+## 核心 Token(主题对话框可编辑)
+
+| Token | 含义 |
+|--------|------|
+| `--ui-surface-bg` | 统一表面背景(面板、搜索、图例容器、弹窗主区与弹窗内卡片条等) |
+| `--ui-text-title` | 标题 |
+| `--ui-text-1` | 一级文字(面板正文、弹窗主文、搜索主色等) |
+| `--ui-text-2` | 二级文字(小节标题、图层控制标题、弹窗标签行等) |
+| `--ui-text-3` | 三级文字(搜索结果的地址/信用代码等辅文) |
+| `--ui-text-desc` | 描述/弱说明(弹窗长段落、muted) |
+| `--ui-text-placeholder` | 输入框占位符 |
+| `--ui-main` | 强调色 / 主描边 / 链接 / 搜索命中高亮 |
+
+`resolveThemeTokens()` 会把上述项**派生**到旧变量(如 `--ui-panel-bg`、`--ui-search-bg`、`--ui-popup-inner-bg`、`--ui-panel-text`、`--ui-title-section` 等),便于逐步迁移样式而无需一次改全库。
+
+**合并 overrides 时清理的废弃覆盖键**(若本地仍存有历史配置会被删掉):`--ui-panel-bg`、`--ui-search-bg`、`--ui-popup-inner-bg`、`--ui-popup-card-bg`、`--ui-panel-text`、`--ui-card-title-text`、`--ui-border-accent`、`--ui-popup-accent`、`--ui-link`、`--ui-search-result-active`、`--ui-title-section`、`--ui-title2`、`--ui-popup-text`、`--ui-popup-muted`、`--ui-search-text`、`--ui-search-secondary`。
+
+## 开发者:代码入口
+
+| 环节 | 说明 |
+|------|------|
+| 内置表 | `src/utils/uiTheme.js` 中 `NIGHT`、`DAY` 对象 |
+| 派生合并 | `resolveThemeTokens`、`getBaseTokens`、`getMergedTokens`、`applyUiTheme`、`persistTheme` |
+| 可编辑键列表 | `THEME_CONFIG_PICKER_KEYS` |
+| 启动应用 | `src/main.js` 在挂载 Vue 根实例前 `loadStoredTheme()` + `applyUiTheme` |
+| 页面交互 | `src/views/qpjyj.vue`:`isDayTheme`、`themeOverridesByMode`、`syncThemeWithBaseMap`、`openThemeConfig`、`applyThemeFormLive`(取色拖动即时生效)、`resetThemeDefaults` |
+| 全局样式 | `src/assets/global.css`:Leaflet 弹窗、下拉、地图标注等 |
+| 卡片 | `src/components/card/index.vue` |
+| 图表 | `pieView.vue` / `barView.vue`:`chart-theme` prop(`day` / `night`) |
+| 地图容器底色 | `appMap.vue`:`background: var(--ui-map-bg)` |
+
+完整变量名列表见 `uiTheme.js` 中的 `UI_THEME_VAR_KEYS`。
+
+## 扩展主题配置 UI
+
+1. 在 `uiTheme.js` 的 `NIGHT` / `DAY` 中增加默认值,并在 `resolveThemeTokens` 中如需则派生到旧键。
+2. 将新键加入 `UI_THEME_VAR_KEYS`;若需出现在对话框,加入 `THEME_CONFIG_PICKER_KEYS` 与 `qpjyj.vue` 表单项。
+3. 在样式中优先使用 `var(--ui-text-*)` / `var(--ui-surface-bg)`,并保留 `var(--旧键, 回退)` 以兼容。
+
+## 与底图的关系
+
+主题只负责 **UI 叠层**(头、搜索、侧栏、图例、弹窗、ECharts),**不自动切换** Leaflet 底图。浅色底图对应白天模式;微调颜色请用 **主题配置** 或编辑 `overrides`。
+
+## 白天模式专项视觉优化(实现说明)
+
+以下项在 **`data-ui-theme="day"`** 下单独加强可读性,**不写入**主题 Token(避免与 ColorPicker 配置纠缠);若需改版可改对应源码。
+
+| 区域 | 问题 | 实现位置 |
+|------|------|----------|
+| **青浦园产业分布** 三枚图标 | 静态 PNG 偏暗,浅底卡片上发灰 | `qpjyj.vue`:`.industrial-dist-icon` + `.container.theme-day` 下对 `img` / `.el-image__inner` 使用 `filter` |
+| **核心企业集聚程度** 柱状图 | 浅色画布上阴影/柱渐变过重 | `barView.vue`:白天 `axisPointer`、柱渐变、`itemStyle.opacity` |
+| **园区详情弹窗** pup1 / pup2 | 小图与浅色弹窗底对比不足 | `appMap.vue`:`map-popup-stat-icon`;`global.css`:`html[data-ui-theme="day"]` 下衬底与滤镜 |
+
+黑夜模式保持原有观感;弹窗图标在黑夜下不强制套白天衬底(选择器仅 `day`)。
+
+---
+
+*变更时请同步更新本节与 `ARCHITECTURE.md` 中主题相关条目。*

+ 1 - 1
public/static/config/config.js

@@ -18,7 +18,7 @@ var systemConfig = {
     zoom: 0,
   },
   // oauth地址
-  oauthServiceUrl: "/proxy_oauth",
+  oauthServiceUrl: "http://121.43.55.7:10086/oauth",
 };
 
 //地图全局变量声明

BIN
public/static/images/map-pront-p.png


+ 258 - 61
src/assets/global.css

@@ -21,6 +21,133 @@
   user-select: none;
   -moz-user-select: none;
 }
+
+/* 左右侧悬浮栏宽度,由 uiTheme.applySidebarWidthPx 同步;无脚本时与默认 432 一致 */
+:root {
+  --ui-sidebar-width: 432px;
+}
+
+/* 主题配置取色:拖动即生效,隐藏「确定」避免二次点击(仍保留清空;输入框失焦仍会提交) */
+.theme-config-color-panel .el-color-dropdown__btn {
+  display: none;
+}
+
+/* 主题配置弹窗:专用 --ui-modal-surface(昼夜均为高不透明度),+ backdrop-filter 虚化背后地图;
+   勿用 --ui-dropdown-bg 作整窗底色:夜间下拉为半透明、白天为实色,会导致昼夜观感不一致 */
+.el-dialog.theme-config-dialog {
+  background: var(--ui-modal-surface);
+  border: 1px solid var(--ui-dropdown-border);
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.22);
+  border-radius: 4px;
+  margin-top: 10vh !important;
+  backdrop-filter: blur(18px) saturate(1.12);
+  -webkit-backdrop-filter: blur(18px) saturate(1.12);
+}
+.theme-config-dialog .el-dialog__header {
+  padding: 16px 20px;
+  background: transparent;
+  border-bottom: 1px solid var(--ui-dropdown-border);
+}
+.theme-config-dialog .el-dialog__title {
+  color: var(--ui-text-title);
+  font-size: 16px;
+  font-weight: 600;
+}
+.theme-config-dialog .el-dialog__headerbtn .el-dialog__close {
+  color: var(--ui-text-2);
+}
+.theme-config-dialog .el-dialog__headerbtn:focus .el-dialog__close,
+.theme-config-dialog .el-dialog__headerbtn:hover .el-dialog__close {
+  color: var(--ui-main);
+}
+.theme-config-dialog .el-dialog__body {
+  padding: 16px 20px;
+  background: transparent;
+  color: var(--ui-text-1);
+}
+.theme-config-dialog .el-dialog__footer {
+  padding: 12px 20px 16px;
+  background: transparent;
+  border-top: 1px solid var(--ui-dropdown-border);
+}
+.theme-config-dialog .el-form-item__label {
+  color: var(--ui-text-2);
+}
+.theme-config-dialog .theme-config-section-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--ui-text-title);
+  margin: 0 0 10px;
+  letter-spacing: 0.02em;
+}
+.theme-config-dialog .el-divider {
+  background-color: var(--ui-dropdown-border);
+}
+.theme-config-dialog .el-divider__text {
+  background-color: var(--ui-modal-surface);
+  color: var(--ui-text-3);
+  font-weight: 500;
+}
+.theme-config-dialog .el-color-picker__trigger {
+  border-color: var(--ui-dropdown-border) !important;
+}
+.theme-config-dialog .el-slider__runway {
+  background-color: var(--ui-dropdown-border);
+}
+.theme-config-dialog .el-slider__bar {
+  background-color: var(--ui-main);
+}
+.theme-config-dialog .el-slider__button {
+  border-color: var(--ui-main);
+}
+.theme-config-dialog .el-slider__button:hover,
+.theme-config-dialog .el-slider__button.hover,
+.theme-config-dialog .el-slider__button.dragging {
+  border-color: var(--ui-main-hover, var(--ui-main));
+}
+.theme-config-dialog .theme-config-slider-row .el-input__inner,
+.theme-config-dialog .theme-config-slider-row .el-input-number__decrease,
+.theme-config-dialog .theme-config-slider-row .el-input-number__increase {
+  background-color: var(--ui-modal-surface);
+  border-color: var(--ui-dropdown-border);
+  color: var(--ui-text-1);
+}
+.theme-config-dialog .theme-config-slider-row .el-input-number__decrease:hover,
+.theme-config-dialog .theme-config-slider-row .el-input-number__increase:hover {
+  color: var(--ui-main);
+}
+.theme-config-dialog .el-button--default {
+  background: transparent;
+  border-color: var(--ui-dropdown-border);
+  color: var(--ui-text-1);
+}
+.theme-config-dialog .el-button--default:hover,
+.theme-config-dialog .el-button--default:focus {
+  background: var(--ui-dropdown-hover);
+  border-color: var(--ui-main);
+  color: var(--ui-main);
+}
+
+/* 主题配置里的取色面板下拉(挂 body) */
+.theme-config-color-panel {
+  background: var(--ui-modal-surface) !important;
+  border: 1px solid var(--ui-dropdown-border) !important;
+  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18) !important;
+  backdrop-filter: blur(14px) saturate(1.1);
+  -webkit-backdrop-filter: blur(14px) saturate(1.1);
+}
+.theme-config-color-panel .el-color-dropdown__value,
+.theme-config-color-panel .el-input__inner,
+.theme-config-color-panel .el-color-predefine__color-selector > div {
+  border-color: var(--ui-dropdown-border) !important;
+}
+.theme-config-color-panel .el-button--text,
+.theme-config-color-panel .el-color-dropdown__link-btn {
+  color: var(--ui-main) !important;
+}
+.theme-config-color-panel .el-color-dropdown__btns {
+  border-top-color: var(--ui-dropdown-border) !important;
+}
 .mapStreetTownName {
   min-width: 5rem;
   box-sizing: border-box;
@@ -33,8 +160,7 @@
 .mapStreetTownName2 {
   font-size: 16px;
   font-weight: bold;
-  color: #fff;
-  /* text-shadow: 1px 1px 1px #fff; */
+  color: var(--ui-marker-label-text, #fff);
 }
 
 .mapStreetTownName_box {
@@ -51,11 +177,12 @@
   max-width: 15rem;
   text-align: center;
   bottom: 25px;
-  background: #00000064;
+  background: var(--ui-marker-label-bg, rgba(0, 0, 0, 0.39));
   border-radius: 5px;
-  box-shadow: 1px 1px 3px #000;
+  box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.25);
   min-width: 8rem;
   font-size: 12px;
+  color: var(--ui-marker-label-text, #fff);
 }
 
 /* 隐藏map自带的底图叠加组件 */
@@ -77,83 +204,95 @@
   max-width: 25vw;
 } */
 .el-select-dropdown {
-  background-color: rgba(0, 204, 255, 0.15) !important;
+  background-color: var(--ui-dropdown-bg, rgba(0, 204, 255, 0.15)) !important;
   backdrop-filter: blur(20px);
-  border-color: rgba(0, 204, 255, 0.3) !important;
+  border-color: var(--ui-dropdown-border, rgba(0, 204, 255, 0.3)) !important;
 }
 .popper__arrow,
 .popper__arrow::after {
-  border-bottom-color: rgba(0, 204, 255, 0.15) !important;
+  border-bottom-color: var(
+    --ui-dropdown-bg,
+    rgba(0, 204, 255, 0.15)
+  ) !important;
 }
 .el-select-dropdown__item {
-  color: #fff;
+  color: var(--ui-dropdown-text, #fff);
 }
 .el-select-dropdown__item:hover {
-  background-color: rgba(0, 204, 255, 0.15) !important;
+  background-color: var(
+    --ui-dropdown-hover,
+    rgba(0, 204, 255, 0.15)
+  ) !important;
 }
 .el-select-dropdown .hover {
   background-color: transparent;
 }
 .el-select-dropdown .selected {
-  color: #fff;
-  background-color: rgba(0, 204, 255, 0.3) !important;
+  color: var(--ui-dropdown-text, #fff);
+  background-color: var(
+    --ui-dropdown-selected-bg,
+    rgba(0, 204, 255, 0.3)
+  ) !important;
 }
 /* 弹窗内容容器 */
 .leaflet-popup-tip {
-  background-color: #1dc8dc !important;
+  background-color: var(--ui-popup-tip-bg, #1dc8dc) !important;
 }
 
 .leaflet-popup-content-wrapper {
-  background: #2082e032;
-  color: #fff;
+  background-color: var(
+    --ui-surface-bg,
+    var(--ui-popup-inner-bg, rgba(32, 130, 224, 0.195))
+  );
+  color: var(--ui-text-1, var(--ui-popup-text, #fff));
   backdrop-filter: blur(20px);
   border-radius: 2px;
   /* 核心:用渐变模拟四个边的虚线边框 */
   background-image: 
   /* 上边框 */ linear-gradient(
       to right,
-      #1dc8dc 0px,
-      #1dc8dc 8px,
+      var(--ui-border-accent, #1dc8dc) 0px,
+      var(--ui-border-accent, #1dc8dc) 8px,
       transparent 8px,
       transparent 24px,
-      #1dc8dc 24px,
-      #1dc8dc 104px,
+      var(--ui-border-accent, #1dc8dc) 24px,
+      var(--ui-border-accent, #1dc8dc) 104px,
       transparent 104px,
       transparent 110px
     ),
     /* 右边框 */
       linear-gradient(
         to bottom,
-        #1dc8dc 0px,
-        #1dc8dc 8px,
+        var(--ui-border-accent, #1dc8dc) 0px,
+        var(--ui-border-accent, #1dc8dc) 8px,
         transparent 8px,
         transparent 24px,
-        #1dc8dc 24px,
-        #1dc8dc 104px,
+        var(--ui-border-accent, #1dc8dc) 24px,
+        var(--ui-border-accent, #1dc8dc) 104px,
         transparent 104px,
         transparent 110px
       ),
     /* 下边框 */
       linear-gradient(
         to left,
-        #1dc8dc 0px,
-        #1dc8dc 8px,
+        var(--ui-border-accent, #1dc8dc) 0px,
+        var(--ui-border-accent, #1dc8dc) 8px,
         transparent 8px,
         transparent 24px,
-        #1dc8dc 24px,
-        #1dc8dc 104px,
+        var(--ui-border-accent, #1dc8dc) 24px,
+        var(--ui-border-accent, #1dc8dc) 104px,
         transparent 104px,
         transparent 110px
       ),
     /* 左边框 */
       linear-gradient(
         to top,
-        #1dc8dc 0px,
-        #1dc8dc 8px,
+        var(--ui-border-accent, #1dc8dc) 0px,
+        var(--ui-border-accent, #1dc8dc) 8px,
         transparent 8px,
         transparent 24px,
-        #1dc8dc 24px,
-        #1dc8dc 104px,
+        var(--ui-border-accent, #1dc8dc) 24px,
+        var(--ui-border-accent, #1dc8dc) 104px,
         transparent 104px,
         transparent 110px
       );
@@ -174,12 +313,13 @@
 .mapParkName2 {
 }
 .mapParkName2 .title {
-  background-color: #fff;
+  background-color: var(--ui-marker-label-bg, #fff);
+  color: var(--ui-marker-label-text, #1a1a1a);
   font-weight: bold;
   width: 5rem;
   padding: 0.2rem 0.5rem;
   border-radius: 1rem;
-  border: 1px solid;
+  border: 1px solid var(--ui-border-accent, #1dc8dc);
   transform: translate(calc(12.5px - 50%), 0);
   width: max-content;
 }
@@ -196,13 +336,13 @@
 }
 .mapParkName_box * {
   user-select: text;
-  color: #dcdfe6;
+  color: var(--ui-popup-muted, #dcdfe6);
   font-size: 12px;
 }
 .mapParkName_box .title {
   font-size: 20px;
   font-weight: bold;
-  color: #fff;
+  color: var(--ui-popup-text, #fff);
 }
 .leaflet-popup-content {
   height: auto !important;
@@ -211,63 +351,65 @@
 .mapParkName_box .title > span {
   margin-left: 1rem;
   font-size: 15px;
-  color: #22e9ffd6;
+  color: var(--ui-main, var(--ui-popup-accent, #22e9ff));
   letter-spacing: 10%;
   line-height: 100%;
   font-weight: 800;
 }
 .mapParkName_box .card {
   padding: 0.5rem;
-  background: rgba(0, 204, 255, 0.15);
-  /* border: 1px solid #22e9ffd6; */
+  background-color: var(
+    --ui-surface-bg,
+    var(--ui-popup-card-bg, rgba(0, 204, 255, 0.15))
+  );
   display: flex;
   gap: 1rem;
   /* 核心:用渐变模拟四个边的虚线边框 */
   background-image: 
   /* 上边框 */ linear-gradient(
       to right,
-      #1dc8dc 0px,
-      #1dc8dc 8px,
+      var(--ui-border-accent, #1dc8dc) 0px,
+      var(--ui-border-accent, #1dc8dc) 8px,
       transparent 8px,
       transparent 24px,
-      #1dc8dc 24px,
-      #1dc8dc 104px,
+      var(--ui-border-accent, #1dc8dc) 24px,
+      var(--ui-border-accent, #1dc8dc) 104px,
       transparent 104px,
       transparent 110px
     ),
     /* 右边框 */
       linear-gradient(
         to bottom,
-        #1dc8dc 0px,
-        #1dc8dc 8px,
+        var(--ui-border-accent, #1dc8dc) 0px,
+        var(--ui-border-accent, #1dc8dc) 8px,
         transparent 8px,
         transparent 24px,
-        #1dc8dc 24px,
-        #1dc8dc 104px,
+        var(--ui-border-accent, #1dc8dc) 24px,
+        var(--ui-border-accent, #1dc8dc) 104px,
         transparent 104px,
         transparent 110px
       ),
     /* 下边框 */
       linear-gradient(
         to left,
-        #1dc8dc 0px,
-        #1dc8dc 8px,
+        var(--ui-border-accent, #1dc8dc) 0px,
+        var(--ui-border-accent, #1dc8dc) 8px,
         transparent 8px,
         transparent 24px,
-        #1dc8dc 24px,
-        #1dc8dc 104px,
+        var(--ui-border-accent, #1dc8dc) 24px,
+        var(--ui-border-accent, #1dc8dc) 104px,
         transparent 104px,
         transparent 110px
       ),
     /* 左边框 */
       linear-gradient(
         to top,
-        #1dc8dc 0px,
-        #1dc8dc 8px,
+        var(--ui-border-accent, #1dc8dc) 0px,
+        var(--ui-border-accent, #1dc8dc) 8px,
         transparent 8px,
         transparent 24px,
-        #1dc8dc 24px,
-        #1dc8dc 104px,
+        var(--ui-border-accent, #1dc8dc) 24px,
+        var(--ui-border-accent, #1dc8dc) 104px,
         transparent 104px,
         transparent 110px
       );
@@ -287,28 +429,83 @@
 .mapParkName_box .card > div > div:first-child {
   font-size: 14px;
   letter-spacing: 10%;
-  color: #22e9ffd6;
+  color: var(--ui-text-2, var(--ui-popup-accent, #22e9ff));
 }
 .mapParkName_box .card > div > div:nth-child(2) {
   font-size: 14px;
   letter-spacing: 10%;
-  color: #fff;
+  color: var(--ui-text-1, var(--ui-popup-text, #fff));
 }
 .mapParkName_box .title2 {
-  border-left: 2px solid #22e9ffd6;
+  border-left: 2px solid var(--ui-main, var(--ui-popup-accent, #22e9ff));
   padding: 0.5rem;
   font-size: 14px;
   font-weight: bold;
   letter-spacing: 10%;
-  color: #fff;
-  background-image: linear-gradient(to left, transparent 0%, #22e9ff32 100%);
+  color: var(--ui-text-title, var(--ui-popup-text, #fff));
+  background-image: linear-gradient(
+    to left,
+    transparent 0%,
+    var(--ui-surface-bg, var(--ui-popup-card-bg, rgba(34, 233, 255, 0.2))) 100%
+  );
+}
+
+/* 园区简介:标题+正文同一区块,避免白天 surface 过浅导致渐变「看不见」 */
+.mapParkName_box .map-popup-intro {
+  padding: 0.35rem 0.5rem 0.6rem;
+  margin-top: 0.15rem;
+  border-radius: 4px;
+  background: linear-gradient(
+    165deg,
+    rgba(34, 233, 255, 0.2) 0%,
+    rgba(0, 55, 72, 0.42) 50%,
+    transparent 100%
+  );
+}
+.mapParkName_box .map-popup-intro .title2 {
+  background-image: none;
+  margin-bottom: 0.15rem;
+}
+.mapParkName_box .map-popup-intro-text {
+  padding: 0 0.35rem 0.15rem;
+  line-height: 1.6;
+  font-size: 12px;
 }
+
 .mapParkName_box .title3 {
-  border-bottom: 1px solid #22e9ffd6;
+  border-bottom: 1px solid var(--ui-main, var(--ui-popup-accent, #22e9ff));
   padding-top: 0.5rem;
   font-size: 14px;
   font-weight: bold;
   letter-spacing: 10%;
-  color: #fff;
-  /* background-image: linear-gradient(to bottom, transparent 0%, #22e9ff32 100%); */
+  color: var(--ui-text-2, var(--ui-popup-text, #fff));
+}
+
+/* 白天:统计卡片底与图标衬底与大白底区分开 */
+html[data-ui-theme="day"] .mapParkName_box .card {
+  background-color: rgba(220, 240, 247, 0.88);
+  box-shadow: inset 0 0 0 1px rgba(13, 126, 160, 0.1);
+}
+
+/* 白天:园区详情弹窗内 pup1/pup2 小图标在浅底上易「发灰」,加衬底与对比 */
+html[data-ui-theme="day"] .mapParkName_box .card img.map-popup-stat-icon {
+  flex-shrink: 0;
+  background: #0d7ea0;
+  border-radius: 8px;
+  padding: 5px;
+  box-sizing: content-box;
+  box-shadow: 0 2px 8px rgba(13, 126, 160, 0.28);
+  border: 1px solid rgba(13, 126, 160, 0.42);
+  filter: contrast(1.3) saturate(1.18);
+  object-fit: contain;
+}
+
+/* 白天:园区简介整块渐变+描边(正文不再贴在纯白底上) */
+html[data-ui-theme="day"] .mapParkName_box .map-popup-intro {
+  background: linear-gradient(
+    165deg,
+    rgb(13 126 160 / 12%) 0%,
+    rgb(189 237 255 / 90%) 45%,
+    rgba(255, 255, 255, 0.88) 100%
+  );
 }

+ 62 - 18
src/components/card/barView.vue

@@ -10,6 +10,10 @@ import * as echarts from "echarts";
 export default {
   name: "BarView",
   props: {
+    chartTheme: {
+      type: String,
+      default: "night",
+    },
     data: {
       type: Array,
       default: () => [],
@@ -18,21 +22,30 @@ export default {
   data() {
     return {
       chart: null,
+      _resizeObserver: null,
     };
   },
   mounted() {
     this.$nextTick(() => {
       this.initChart();
+      this.bindResizeObserver();
     });
     window.addEventListener("resize", this.handleResize);
   },
   beforeDestroy() {
     window.removeEventListener("resize", this.handleResize);
+    if (this._resizeObserver) {
+      this._resizeObserver.disconnect();
+      this._resizeObserver = null;
+    }
     if (this.chart) {
       this.chart.dispose();
     }
   },
   watch: {
+    chartTheme() {
+      this.updateChart(this.data);
+    },
     data: {
       handler(newVal, oldVal) {
         // 检查是否为有效对象
@@ -66,6 +79,27 @@ export default {
       this.chart = chart;
       // 深拷贝配置,避免修改原始props
       const mergedOption = JSON.parse(JSON.stringify(data));
+      const isDay = this.chartTheme === "day";
+      const axisColor = isDay ? "#606266" : "#fff";
+      const splitColor = isDay ? "rgba(0, 0, 0, 0.08)" : "rgba(255, 255, 255, 0.2)";
+      const barGrad = isDay
+        ? new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: "#b8e3ed" },
+            { offset: 1, color: "#5cb3c9" },
+          ])
+        : new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: "#00f5ff" },
+            { offset: 1, color: "#0072ff" },
+          ]);
+      const barGradEm = isDay
+        ? new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: "#c9eef6" },
+            { offset: 1, color: "#6bc0d4" },
+          ])
+        : new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: "#00ffff" },
+            { offset: 1, color: "#0099ff" },
+          ]);
       // 配置项
       const option = {
         backgroundColor: "transparent",
@@ -74,12 +108,19 @@ export default {
           trigger: "axis",
           axisPointer: {
             type: "shadow",
+            shadowStyle: {
+              color: isDay
+                ? "rgba(13, 126, 160, 0.14)"
+                : "rgba(255, 255, 255, 0.12)",
+            },
           },
-          backgroundColor: "rgba(0, 0, 0, 0.85)",
+          backgroundColor: isDay ? "rgba(255, 255, 255, 0.96)" : "rgba(0, 0, 0, 0.85)",
           textStyle: {
-            color: "#fff",
+            color: isDay ? "#303133" : "#fff",
             fontSize: 14,
           },
+          borderColor: isDay ? "#dcdfe6" : "transparent",
+          borderWidth: isDay ? 1 : 0,
           borderRadius: 6,
           padding: [10, 14],
         },
@@ -94,13 +135,13 @@ export default {
           type: "category",
           data: mergedOption.map((item) => item.name),
           axisLabel: {
-            color: "#fff",
+            color: axisColor,
             fontSize: 10,
             // rotate: 30,
           },
           axisLine: {
             lineStyle: {
-              color: "#fff",
+              color: axisColor,
             },
           },
         },
@@ -108,20 +149,20 @@ export default {
           type: "value",
           name: "企业数量",
           nameTextStyle: {
-            color: "#fff",
+            color: axisColor,
           },
           axisLabel: {
-            color: "#fff",
+            color: axisColor,
             fontSize: 10,
           },
           axisLine: {
             lineStyle: {
-              color: "#fff",
+              color: axisColor,
             },
           },
           splitLine: {
             lineStyle: {
-              color: "rgba(255, 255, 255, 0.2)",
+              color: splitColor,
             },
           },
         },
@@ -133,23 +174,17 @@ export default {
             label: {
               show: true,
               position: "top",
-              color: "#fff",
+              color: axisColor,
               fontSize: 10,
             },
             barWidth: "60%",
             itemStyle: {
-              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-                { offset: 0, color: "#00f5ff" },
-                { offset: 1, color: "#0072ff" },
-              ]),
-              opacity: 0.8,
+              color: barGrad,
+              opacity: isDay ? 0.98 : 0.8,
             },
             emphasis: {
               itemStyle: {
-                color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-                  { offset: 0, color: "#00ffff" },
-                  { offset: 1, color: "#0099ff" },
-                ]),
+                color: barGradEm,
                 opacity: 1,
               },
             },
@@ -166,6 +201,15 @@ export default {
         this.chart.resize();
       }
     },
+    bindResizeObserver() {
+      if (typeof ResizeObserver === "undefined") return;
+      const el = this.$refs.chartRef;
+      if (!el) return;
+      this._resizeObserver = new ResizeObserver(() => {
+        this.handleResize();
+      });
+      this._resizeObserver.observe(el);
+    },
   },
 };
 </script>

+ 19 - 26
src/components/card/index.vue

@@ -28,15 +28,8 @@ export default {
 </script>
 
 <style lang="less" scoped>
-// 主颜色
-@mainColor: #1dc8dc;
-// 主颜色悬停
-@mainHoverColor: #22dcf0;
-// 卡片宽度
-@cardWidth: 432px;
-@cardBackColor: #1fc7ff99;
 .cardBox {
-  background: rgba(0, 204, 255, 0.15);
+  background-color: var(--ui-surface-bg, var(--ui-panel-bg, rgba(0, 204, 255, 0.15)));
   backdrop-filter: blur(20px);
   border-radius: 2px;
   padding: 12px;
@@ -44,48 +37,48 @@ export default {
   background-image: 
   /* 上边框 */ linear-gradient(
       to right,
-      @mainColor 0px,
-      @mainColor 8px,
+      var(--ui-border-accent, #1dc8dc) 0px,
+      var(--ui-border-accent, #1dc8dc) 8px,
       transparent 8px,
       transparent 24px,
-      @mainColor 24px,
-      @mainColor 104px,
+      var(--ui-border-accent, #1dc8dc) 24px,
+      var(--ui-border-accent, #1dc8dc) 104px,
       transparent 104px,
       transparent 110px
     ),
     /* 右边框 */
       linear-gradient(
         to bottom,
-        @mainColor 0px,
-        @mainColor 8px,
+        var(--ui-border-accent, #1dc8dc) 0px,
+        var(--ui-border-accent, #1dc8dc) 8px,
         transparent 8px,
         transparent 24px,
-        @mainColor 24px,
-        @mainColor 104px,
+        var(--ui-border-accent, #1dc8dc) 24px,
+        var(--ui-border-accent, #1dc8dc) 104px,
         transparent 104px,
         transparent 110px
       ),
     /* 下边框 */
       linear-gradient(
         to left,
-        @mainColor 0px,
-        @mainColor 8px,
+        var(--ui-border-accent, #1dc8dc) 0px,
+        var(--ui-border-accent, #1dc8dc) 8px,
         transparent 8px,
         transparent 24px,
-        @mainColor 24px,
-        @mainColor 104px,
+        var(--ui-border-accent, #1dc8dc) 24px,
+        var(--ui-border-accent, #1dc8dc) 104px,
         transparent 104px,
         transparent 110px
       ),
     /* 左边框 */
       linear-gradient(
         to top,
-        @mainColor 0px,
-        @mainColor 8px,
+        var(--ui-border-accent, #1dc8dc) 0px,
+        var(--ui-border-accent, #1dc8dc) 8px,
         transparent 8px,
         transparent 24px,
-        @mainColor 24px,
-        @mainColor 104px,
+        var(--ui-border-accent, #1dc8dc) 24px,
+        var(--ui-border-accent, #1dc8dc) 104px,
         transparent 104px,
         transparent 110px
       );
@@ -106,9 +99,9 @@ export default {
     position: relative;
     font-size: 18px;
     font-weight: bold;
-    color: #fff;
+    color: var(--ui-text-title, var(--ui-card-title-text, #fff));
     padding: 0 2rem;
-    text-shadow: 1px 2px 2px black;
+    text-shadow: var(--ui-card-title-shadow, 1px 2px 2px black);
     margin-bottom: 9px;
     &_bg {
       position: absolute;

+ 28 - 3
src/components/card/pieView.vue

@@ -8,6 +8,10 @@ import * as echarts from "echarts";
 export default {
   name: "PieView",
   props: {
+    chartTheme: {
+      type: String,
+      default: "night",
+    },
     data: {
       type: Array,
       default: () => [],
@@ -20,22 +24,31 @@ export default {
   data() {
     return {
       chart: null,
+      _resizeObserver: null,
     };
   },
   mounted() {
     this.$nextTick(() => {
       this.initChart();
+      this.bindResizeObserver();
     });
     window.addEventListener("resize", this.handleResize);
   },
   beforeDestroy() {
     window.removeEventListener("resize", this.handleResize);
+    if (this._resizeObserver) {
+      this._resizeObserver.disconnect();
+      this._resizeObserver = null;
+    }
     if (this.chart) {
       this.chart.dispose();
       this.chart = null;
     }
   },
   watch: {
+    chartTheme() {
+      this.updateChart(this.data);
+    },
     data: {
       handler(newVal, oldVal) {
         // 检查是否为有效对象
@@ -80,16 +93,19 @@ export default {
           },
         }));
 
+      const isDay = this.chartTheme === "day";
       // 配置选项
       const option = {
         tooltip: {
           trigger: "item",
           formatter: "{b}: {c} ({d}%)",
-          backgroundColor: "rgba(0, 0, 0, 0.85)",
+          backgroundColor: isDay ? "rgba(255, 255, 255, 0.96)" : "rgba(0, 0, 0, 0.85)",
           textStyle: {
-            color: "#fff",
+            color: isDay ? "#303133" : "#fff",
             fontSize: 14,
           },
+          borderColor: isDay ? "#dcdfe6" : "transparent",
+          borderWidth: isDay ? 1 : 0,
           borderRadius: 6,
           padding: [10, 14],
         },
@@ -98,7 +114,7 @@ export default {
           bottom: 10,
           left: "center",
           textStyle: {
-            color: "#fff",
+            color: isDay ? "#303133" : "#fff",
           },
           formatter: "{name}",
         },
@@ -123,6 +139,15 @@ export default {
         this.chart.resize();
       }
     },
+    bindResizeObserver() {
+      if (typeof ResizeObserver === "undefined") return;
+      const el = this.$refs.chartRef;
+      if (!el) return;
+      this._resizeObserver = new ResizeObserver(() => {
+        this.handleResize();
+      });
+      this._resizeObserver.observe(el);
+    },
   },
 };
 </script>

+ 7 - 5
src/components/map/appMap.vue

@@ -428,20 +428,22 @@ export default {
               let popupHtml = `<div class="mapParkName_box">
                 <div class="title">${data.title}<span>${data.properties.area}</span></div>
                 <div class="card">
-                  <img src="/static/images/pup1.png" style="width:36px;height:36px"/>
+                  <img class="map-popup-stat-icon" src="/static/images/pup1.png" style="width:36px;height:36px"/>
                   <div><div>主导产业</div>
                   <div>${data.主导产业 ? data.主导产业 : "--"}</div>
                 </div>
                 </div>
                 <div class="card">
-                  <img src="/static/images/pup2.png" style="width:36px;height:36px"/>
+                  <img class="map-popup-stat-icon" src="/static/images/pup2.png" style="width:36px;height:36px"/>
                   <div>
                     <div>入驻企业数量(家)</div>
                     <div>${data.企业数量 ? data.企业数量 : "--"}</div>
                   </div>
                 </div>
-                <div class="title2">园区简介</div>
-                <div>${this.parkInfo[data.title]}</div>
+                <div class="map-popup-intro">
+                  <div class="title2">园区简介</div>
+                  <div class="map-popup-intro-text">${this.parkInfo[data.title]}</div>
+                </div>
                 </div>`;
               polygon.bindPopup(popupHtml);
               polygon.on("click", (e) => {
@@ -503,6 +505,6 @@ export default {
 .appMapViewer {
   width: 100%;
   height: 100%;
-  background: #000;
+  background: var(--ui-map-bg, #000);
 }
 </style>

+ 14 - 0
src/main.js

@@ -8,6 +8,13 @@ import "element-ui/lib/theme-chalk/index.css";
 // 添加字体(优设标题黑)
 import "./assets/font/font.css";
 import "./assets/global.css";
+import {
+  loadStoredTheme,
+  applyUiTheme,
+  applySidebarWidthPx,
+  getUiModeForBaseMap,
+  BASE_MAP_KEY_NIGHT,
+} from "./utils/uiTheme";
 import { drag } from "./directives/drag";
 import { Decrypt } from "./utils/aes.js";
 import CryptoJS from "./utils/publicFunction.js";
@@ -27,6 +34,13 @@ Vue.config.productionTip = false;
 Vue.use(ElementUI);
 Vue.use(scroll);
 Vue.prototype.$store = store;
+
+const _uiTheme = loadStoredTheme();
+/* 首屏与默认底图一致:暗蓝色政务底图 → 黑夜(qpjyj 挂载后会按实际 activeMap 再同步一次) */
+const _bootMode = getUiModeForBaseMap(BASE_MAP_KEY_NIGHT);
+applyUiTheme(_bootMode, (_uiTheme.overridesByMode && _uiTheme.overridesByMode[_bootMode]) || {});
+applySidebarWidthPx(_uiTheme.sidebarWidthPx);
+
 router.beforeEach((to, from, next) => {
   if (to.meta.title) {
     document.title = to.meta.title;

+ 16 - 16
src/router/index.js

@@ -1,31 +1,31 @@
-import Vue from 'vue'
-import VueRouter from 'vue-router'
-import qpjyj from '@/views/qpjyj.vue'
+import Vue from "vue";
+import VueRouter from "vue-router";
+import qpjyj from "@/views/qpjyj.vue";
 
-Vue.use(VueRouter)
+Vue.use(VueRouter);
 
 const routes = [
   {
-    path: '/',
-    name: '青浦区科委产业地图',
+    path: "/",
+    name: "张江高新区青浦园产业地图",
     component: qpjyj,
     meta: {
-      title: '青浦区科委产业地图',
-    }
+      title: "张江高新区青浦园产业地图",
+    },
   },
   {
     path: "/*",
     component: qpjyj,
     meta: {
-      title: '青浦区科委产业地图',
-    }
-  }
-]
+      title: "张江高新区青浦园产业地图",
+    },
+  },
+];
 
 const router = new VueRouter({
-  mode: 'hash',
+  mode: "hash",
   base: process.env.BASE_URL,
-  routes
-})
+  routes,
+});
 
-export default router
+export default router;

+ 348 - 0
src/utils/uiTheme.js

@@ -0,0 +1,348 @@
+/**
+ * 大屏昼夜主题:CSS 变量写入 document.documentElement,配置持久化 localStorage。
+ * 详见 docs/THEME.md
+ */
+export const UI_THEME_STORAGE_KEY = "industry_map_ui_theme_v1";
+
+/** 左右侧悬浮栏宽度(px),与主题一并持久化 */
+export const DEFAULT_SIDEBAR_WIDTH_PX = 432;
+export const SIDEBAR_WIDTH_MIN_PX = 280;
+export const SIDEBAR_WIDTH_MAX_PX = 560;
+
+export function clampSidebarWidthPx(px) {
+  if (px === undefined || px === null || px === "") {
+    return DEFAULT_SIDEBAR_WIDTH_PX;
+  }
+  const n = Math.round(Number(px));
+  if (Number.isNaN(n)) return DEFAULT_SIDEBAR_WIDTH_PX;
+  return Math.min(SIDEBAR_WIDTH_MAX_PX, Math.max(SIDEBAR_WIDTH_MIN_PX, n));
+}
+
+/** 写入 :root CSS 变量,供侧栏/搜索框等 calc 使用 */
+export function applySidebarWidthPx(px) {
+  const w = clampSidebarWidthPx(px);
+  document.documentElement.style.setProperty("--ui-sidebar-width", `${w}px`);
+  return w;
+}
+
+/**
+ * 与 `qpjyj.vue` / `appMap.vue` 中底图 `value` 一致。
+ * 仅当当前底图为该项时使用「黑夜」主题,其它底图使用「白天」主题。
+ */
+export const BASE_MAP_KEY_NIGHT = "shmap_blue_web";
+
+export function getUiModeForBaseMap(mapKey) {
+  return mapKey === BASE_MAP_KEY_NIGHT ? "night" : "day";
+}
+
+/** 主题配置对话框可编辑的 Token(保存时只比对这组,避免写入已废弃键) */
+export const THEME_CONFIG_PICKER_KEYS = [
+  "--ui-surface-bg",
+  "--ui-text-title",
+  "--ui-text-1",
+  "--ui-text-2",
+  "--ui-text-3",
+  "--ui-text-desc",
+  "--ui-text-placeholder",
+  "--ui-main",
+];
+
+/** 与文档约定的变量名(含派生/兼容项) */
+export const UI_THEME_VAR_KEYS = [
+  "--ui-surface-bg",
+  "--ui-text-title",
+  "--ui-text-1",
+  "--ui-text-2",
+  "--ui-text-3",
+  "--ui-text-desc",
+  "--ui-text-placeholder",
+  "--ui-main",
+  "--ui-main-hover",
+  "--ui-border-accent",
+  "--ui-search-bg",
+  "--ui-search-border",
+  "--ui-search-text",
+  "--ui-search-secondary",
+  "--ui-search-result-active",
+  "--ui-panel-bg",
+  "--ui-panel-text",
+  "--ui-card-fill",
+  "--ui-card-title-text",
+  "--ui-card-title-shadow",
+  "--ui-card-back-highlight",
+  "--ui-tag-bg",
+  "--ui-tag-text",
+  "--ui-title-section",
+  "--ui-title2",
+  "--ui-link",
+  "--ui-fold-text",
+  "--ui-fold-border",
+  "--ui-sync-label",
+  "--ui-header-en",
+  "--ui-header-title-solid",
+  "--ui-map-bg",
+  "--ui-marker-label-bg",
+  "--ui-marker-label-text",
+  "--ui-dropdown-bg",
+  "--ui-dropdown-border",
+  "--ui-dropdown-text",
+  "--ui-dropdown-hover",
+  "--ui-dropdown-selected-bg",
+  "--ui-modal-surface",
+  "--ui-popup-tip-bg",
+  "--ui-popup-inner-bg",
+  "--ui-popup-text",
+  "--ui-popup-muted",
+  "--ui-popup-accent",
+  "--ui-popup-card-bg",
+  "--ui-divider-main",
+  "--ui-control-highlight",
+];
+
+const SURFACE_NIGHT = "rgba(0, 204, 255, 0.15)";
+const SURFACE_DAY = "rgba(255, 255, 255, 0.26)";
+
+const NIGHT = {
+  "--ui-surface-bg": SURFACE_NIGHT,
+  "--ui-panel-bg": SURFACE_NIGHT,
+  "--ui-search-bg": SURFACE_NIGHT,
+  "--ui-popup-inner-bg": SURFACE_NIGHT,
+  "--ui-popup-card-bg": SURFACE_NIGHT,
+  "--ui-text-title": "#ffffff",
+  "--ui-text-1": "#ffffff",
+  "--ui-text-2": "#a8e8f0",
+  "--ui-text-3": "#8ec9d4",
+  "--ui-text-desc": "#c5cdd1",
+  "--ui-text-placeholder": "rgba(255, 255, 255, 0.45)",
+  "--ui-main": "#1dc8dc",
+  "--ui-main-hover": "#22dcf0",
+  "--ui-border-accent": "#1dc8dc",
+  "--ui-search-border": "#1dc8dc",
+  "--ui-search-result-active": "#1dc8dc",
+  "--ui-card-fill": "rgba(32, 130, 224, 0.195)",
+  "--ui-card-title-text": "#ffffff",
+  "--ui-card-title-shadow": "1px 2px 2px black",
+  "--ui-card-back-highlight": "rgba(31, 199, 255, 0.6)",
+  "--ui-tag-bg": "#063e4f",
+  "--ui-tag-text": "#ffffff",
+  "--ui-title-section": "#a8e8f0",
+  "--ui-title2": "#a8e8f0",
+  "--ui-link": "#22e9ff",
+  "--ui-fold-text": "#ffffff",
+  "--ui-fold-border": "#22e9ff",
+  "--ui-sync-label": "#22dcf0",
+  "--ui-header-en": "#87d9ff",
+  "--ui-header-title-solid": "#ffffff",
+  "--ui-map-bg": "#000000",
+  "--ui-marker-label-bg": "rgba(0, 0, 0, 0.39)",
+  "--ui-marker-label-text": "#ffffff",
+  "--ui-dropdown-bg": "rgba(0, 204, 255, 0.15)",
+  "--ui-dropdown-border": "rgba(0, 204, 255, 0.3)",
+  "--ui-dropdown-text": "#ffffff",
+  "--ui-dropdown-hover": "rgba(0, 204, 255, 0.15)",
+  "--ui-dropdown-selected-bg": "rgba(0, 204, 255, 0.3)",
+  /* 主题配置等模态:高不透明度 + 与白天一致的毛玻璃思路,避免误用下拉半透明底导致透地图 */
+  "--ui-modal-surface": "rgba(8, 36, 46, 0.88)",
+  "--ui-popup-tip-bg": "#1dc8dc",
+  "--ui-popup-text": "#ffffff",
+  "--ui-popup-muted": "#c5cdd1",
+  "--ui-popup-accent": "#22e9ff",
+  "--ui-divider-main": "#1dc8dc",
+  "--ui-control-highlight": "rgba(0, 204, 255, 0.6)",
+  "--ui-panel-text": "#ffffff",
+};
+
+const DAY = {
+  "--ui-surface-bg": SURFACE_DAY,
+  "--ui-panel-bg": SURFACE_DAY,
+  "--ui-search-bg": SURFACE_DAY,
+  "--ui-popup-inner-bg": SURFACE_DAY,
+  "--ui-popup-card-bg": SURFACE_DAY,
+  "--ui-text-title": "#0d7ea0",
+  "--ui-text-1": "#0e6969",
+  "--ui-text-2": "#3d5a66",
+  "--ui-text-3": "#606266",
+  "--ui-text-desc": "#909399",
+  "--ui-text-placeholder": "#c0c4cc",
+  "--ui-main": "#0d7ea0",
+  "--ui-main-hover": "#0a6580",
+  "--ui-border-accent": "#0d7ea0",
+  "--ui-search-border": "#b8c5cc",
+  "--ui-search-result-active": "#0d7ea0",
+  "--ui-card-fill": "rgba(255, 255, 255, 0.98)",
+  "--ui-card-title-text": "#0d7ea0",
+  "--ui-card-title-shadow": "none",
+  "--ui-card-back-highlight": "rgba(13, 126, 160, 0.12)",
+  "--ui-tag-bg": "#e8eef2",
+  "--ui-tag-text": "#0e6969",
+  "--ui-title-section": "#3d5a66",
+  "--ui-title2": "#3d5a66",
+  "--ui-link": "#0d7ea0",
+  "--ui-fold-text": "#0e6969",
+  "--ui-fold-border": "#0d7ea0",
+  "--ui-sync-label": "#0d7ea0",
+  "--ui-header-en": "#0d7ea0",
+  "--ui-header-title-solid": "#1a3a4a",
+  "--ui-map-bg": "#e8ecef",
+  "--ui-marker-label-bg": "rgba(255, 255, 255, 0.92)",
+  "--ui-marker-label-text": "#1a1a1a",
+  "--ui-dropdown-bg": "#ffffff",
+  "--ui-dropdown-border": "#dcdfe6",
+  "--ui-dropdown-text": "#0e6969",
+  "--ui-dropdown-hover": "#f5f7fa",
+  "--ui-dropdown-selected-bg": "#e6f4f8",
+  "--ui-modal-surface": "rgba(255, 255, 255, 0.9)",
+  "--ui-popup-tip-bg": "#0d7ea0",
+  "--ui-popup-text": "#0e6969",
+  "--ui-popup-muted": "#909399",
+  "--ui-popup-accent": "#0d7ea0",
+  "--ui-divider-main": "#0d7ea0",
+  "--ui-control-highlight": "rgba(13, 126, 160, 0.35)",
+  "--ui-panel-text": "#0e6969",
+};
+
+/**
+ * 将「表面背景」「分级文字」派生到旧 Token,保证全局样式一处配置、多处生效。
+ */
+export function resolveThemeTokens(merged) {
+  const out = { ...merged };
+  const surf =
+    out["--ui-surface-bg"] ||
+    out["--ui-panel-bg"] ||
+    out["--ui-search-bg"] ||
+    out["--ui-popup-inner-bg"];
+  if (surf) {
+    out["--ui-surface-bg"] = surf;
+    out["--ui-panel-bg"] = surf;
+    out["--ui-search-bg"] = surf;
+    out["--ui-popup-inner-bg"] = surf;
+    out["--ui-popup-card-bg"] = surf;
+  }
+  if (out["--ui-text-title"]) {
+    out["--ui-card-title-text"] = out["--ui-text-title"];
+  }
+  if (out["--ui-text-1"]) {
+    out["--ui-panel-text"] = out["--ui-text-1"];
+    out["--ui-popup-text"] = out["--ui-text-1"];
+    out["--ui-search-text"] = out["--ui-text-1"];
+    out["--ui-tag-text"] = out["--ui-text-1"];
+  }
+  if (out["--ui-text-2"]) {
+    out["--ui-title2"] = out["--ui-text-2"];
+    out["--ui-title-section"] = out["--ui-text-2"];
+  }
+  if (out["--ui-text-3"]) {
+    out["--ui-search-secondary"] = out["--ui-text-3"];
+  }
+  if (out["--ui-text-desc"]) {
+    out["--ui-popup-muted"] = out["--ui-text-desc"];
+  }
+  if (out["--ui-main"]) {
+    out["--ui-border-accent"] = out["--ui-main"];
+    out["--ui-popup-accent"] = out["--ui-main"];
+    out["--ui-link"] = out["--ui-main"];
+    out["--ui-search-result-active"] = out["--ui-main"];
+  }
+  return out;
+}
+
+export function getBaseTokens(mode) {
+  const m = mode === "day" ? DAY : NIGHT;
+  return resolveThemeTokens({ ...m });
+}
+
+/** 深拷贝为 { night, day },缺省补空对象 */
+export function normalizeOverridesByMode(raw) {
+  const empty = { night: {}, day: {} };
+  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ...empty };
+  const night =
+    raw.night && typeof raw.night === "object" && !Array.isArray(raw.night)
+      ? { ...raw.night }
+      : {};
+  const day =
+    raw.day && typeof raw.day === "object" && !Array.isArray(raw.day)
+      ? { ...raw.day }
+      : {};
+  return { night, day };
+}
+
+export function loadStoredTheme() {
+  try {
+    const raw = localStorage.getItem(UI_THEME_STORAGE_KEY);
+    if (!raw) {
+      return {
+        mode: "night",
+        overridesByMode: { night: {}, day: {} },
+        sidebarWidthPx: DEFAULT_SIDEBAR_WIDTH_PX,
+      };
+    }
+    const o = JSON.parse(raw);
+    const mode = o.mode === "day" ? "day" : "night";
+    let overridesByMode = { night: {}, day: {} };
+
+    if (
+      o.overridesByMode &&
+      typeof o.overridesByMode === "object" &&
+      !Array.isArray(o.overridesByMode)
+    ) {
+      overridesByMode = normalizeOverridesByMode(o.overridesByMode);
+    } else if (
+      o.overrides &&
+      typeof o.overrides === "object" &&
+      !Array.isArray(o.overrides)
+    ) {
+      /* 旧版:单份 overrides 仅属于当时记录的 mode,避免白天改动污染黑夜 */
+      overridesByMode[mode] = { ...o.overrides };
+    }
+    const sidebarWidthPx = clampSidebarWidthPx(o.sidebarWidthPx);
+    return { mode, overridesByMode, sidebarWidthPx };
+  } catch {
+    return {
+      mode: "night",
+      overridesByMode: { night: {}, day: {} },
+      sidebarWidthPx: DEFAULT_SIDEBAR_WIDTH_PX,
+    };
+  }
+}
+
+export function applyUiTheme(mode, overrides = {}) {
+  const base = mode === "day" ? { ...DAY } : { ...NIGHT };
+  const merged = resolveThemeTokens({ ...base, ...overrides });
+  const root = document.documentElement;
+  root.setAttribute("data-ui-theme", mode);
+  Object.keys(merged).forEach((k) => {
+    if (merged[k] != null && merged[k] !== "") {
+      root.style.setProperty(k, merged[k]);
+    }
+  });
+}
+
+/**
+ * @param {"night"|"day"} mode 当前界面模式(与底图一致)
+ * @param {{ night?: Record<string,string>, day?: Record<string,string> }} overridesByMode 昼夜各自覆盖,互不影响
+ */
+export function persistTheme(mode, overridesByMode, sidebarWidthPx) {
+  let prev = {};
+  try {
+    prev = JSON.parse(localStorage.getItem(UI_THEME_STORAGE_KEY) || "{}");
+  } catch {
+    prev = {};
+  }
+  const obm = normalizeOverridesByMode(overridesByMode);
+  const m = mode === "day" ? "day" : "night";
+  const sw =
+    sidebarWidthPx !== undefined && sidebarWidthPx !== null
+      ? clampSidebarWidthPx(sidebarWidthPx)
+      : clampSidebarWidthPx(prev.sidebarWidthPx);
+  localStorage.setItem(
+    UI_THEME_STORAGE_KEY,
+    JSON.stringify({ mode: m, overridesByMode: obm, sidebarWidthPx: sw })
+  );
+  applyUiTheme(m, obm[m] || {});
+  applySidebarWidthPx(sw);
+}
+
+export function getMergedTokens(mode, overrides = {}) {
+  const base = mode === "day" ? { ...DAY } : { ...NIGHT };
+  return resolveThemeTokens({ ...base, ...overrides });
+}

Diff do ficheiro suprimidas por serem muito extensas
+ 621 - 80
src/views/qpjyj.vue


Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff