机构端、监管端首页修改

dev
huwei 2026-08-03 15:48:45 +08:00
parent ad3307f863
commit de49125981
10 changed files with 395 additions and 57 deletions

2
.gitignore vendored
View File

@ -11,6 +11,8 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dev-server.log
install.log
.idea
yarn.lock
/openspec/

4
.npmrc
View File

@ -1,4 +0,0 @@
public-hoist-pattern[]=*loader
public-hoist-pattern[]=html-webpack-plugin
public-hoist-pattern[]=react-refresh
public-hoist-pattern[]=@rspack/plugin-react-refresh

View File

@ -0,0 +1,252 @@
# 依赖问题排查指南
本文档记录本项目在依赖安装与启动过程中的实际问题、根因与解决方案。
## 项目环境基线
| 项 | 值 |
| --- | --- |
| 包管理器 | **pnpm 11.17.0**(唯一,仓库只有 `pnpm-lock.yaml`,无 `package-lock.json` |
| Node | v22.23.1 |
| 构建工具 | Rspack 1.7.12(经 `@cqsjjb/scripts` 驱动) |
| 开发端口 | **8080**(定义在 `jjb.config.js` |
| 启动命令 | `npm run serve:development` |
| pnpm 配置文件 | **`pnpm-workspace.yaml`**(项目已无 `.npmrc` |
> **最重要的一条**:本项目使用 pnpm 11。**pnpm 11 不再从 `.npmrc` 读取 `public-hoist-pattern`、`hoist`、`node-linker` 等安装配置**,这些配置已迁移到 `pnpm-workspace.yaml`,并改用小驼峰命名(`publicHoistPattern`、`nodeLinker`)。项目原有的 `.npmrc` 因此完全失效,已被删除。
---
## 1. `Unable to resolve loader babel-loader` — loader 未被提升
这是本项目实际遇到的**唯一阻塞启动**的错误。
### 现象
```text
ERROR in × Unable to resolve loader babel-loader??ruleSet[1].rules[2].use[0]
1 ERROR in child compilations
Rspack 1.7.12 compiled with 2 errors
```
### 根因
`babel-loader``@cqsjjb/scripts` 的**传递依赖**,并未写在本项目 `package.json` 中。pnpm 默认采用隔离式isolated`node_modules` 布局,传递依赖只存在于 `node_modules/.pnpm/` 虚拟存储中不会出现在顶层。Rspack 解析 loader 时按名称在顶层查找,因而失败。
项目原本试图用 `.npmrc``public-hoist-pattern[]=*loader` 解决但如上所述pnpm 11 已不读取该文件。可用以下命令验证配置是否真正生效:
```powershell
pnpm config get public-hoist-pattern # 失效时返回 undefined
```
### 解决方案
`pnpm-workspace.yaml` 中声明 `publicHoistPattern`
```yaml
publicHoistPattern:
- '*loader*'
- '*webpack-plugin*'
- 'react-refresh'
- '@babel/*'
- 'postcss'
- 'autoprefixer'
```
注意通配符要写成 `*loader*` 而非 `*loader`,确保能匹配 `babel-loader`、`css-loader` 等带连字符的包名。
修改后必须重新安装才能生效(见下方「配置变更后如何让安装生效」)。
### 验证
```powershell
@('babel-loader','style-loader','css-loader','less-loader','react-refresh','html-webpack-plugin') |
ForEach-Object { "$_ : " + (Test-Path "node_modules/$_") }
```
全部为 `True` 即成功。正常情况下 `node_modules` 顶层目录数约为 44 个(未提升时为 32 个)。
---
## 2. `ERR_PNPM_IGNORED_BUILDS` — build scripts 被忽略
### 现象
```text
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: @parcel/watcher, core-js, es5-ext,
x-data-spreadsheet, zy-react-library
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
```
### 根因
pnpm v10+ 出于安全考虑,默认禁止依赖执行 `postinstall` 等 build scripts需显式放行。
项目 `pnpm-workspace.yaml` 中原先写的是 `allowBuilds`,且值为占位文本 `set this to true or false`,属于无效配置。
### 解决方案
改用 `onlyBuiltDependencies` 数组格式,并删除无效的 `allowBuilds` 块:
```yaml
onlyBuiltDependencies:
- '@parcel/watcher'
- core-js
- es5-ext
- x-data-spreadsheet
- zy-react-library
```
若安装后警告仍然出现,显式执行一次重建即可:
```powershell
pnpm rebuild @parcel/watcher core-js es5-ext x-data-spreadsheet zy-react-library
```
> 该报错会让 `pnpm install` 以退出码 1 结束,但依赖本身已装完,属于**非阻塞**问题,不影响启动。
---
## 3. 配置变更后如何让安装生效
### 现象一:改了配置但没有任何效果
`pnpm install` 在 lockfile 未变化时会跳过重新链接,`publicHoistPattern` 之类的布局配置不会被应用。
**解决**
```powershell
pnpm install --force
```
### 现象二:安装中断,提示需要清空 `node_modules`
```text
The modules directory at "node_modules" will be removed and reinstalled from scratch.
Proceed? (Y/n)
```
改变 hoist 配置会要求重建整个 `node_modules`,而在无 TTY 的自动化环境中该确认无法响应,安装会挂起或中断。
**解决**:以 CI 模式跳过交互确认。
```powershell
$env:CI='true'; pnpm install
```
---
## 4. DVA namespace 缺失
### 现象
控制台错误:
```text
[ERROR] 注册数据层失败,原因:无法匹配路径'xxx'
```
### 根因
`@cqsjjb/jjb-dva-runtime` 会扫描 `src/api/` 下的子目录,并用目录名去匹配 `src/enumerate/namespace/index.js``defineNamespace("xxx")` 声明的字符串,匹配不上则报错。
新增了 `src/api/<name>/` 却没有同步添加 namespace 定义时就会触发。
### 解决方案
`src/enumerate/namespace/index.js` 中补充:
```js
export const NS_XXX = defineNamespace("xxx");
```
### 本项目当前状态
`src/api/` 下共 6 个目录,而 namespace 只定义了 4 个:
| 目录 | namespace | 说明 |
| --- | --- | --- |
| `global` | `NS_GLOBAL` | 已定义 |
| `bi` | `NS_BI` | 已定义 |
| `driver` | `NS_DRIVER` | 已定义 |
| `register` | `NS_REGISTER` | 已定义 |
| `institution` | — | **未定义** |
| `supervision` | — | **未定义** |
`institution``supervision` 导出的是普通 fetch 函数,并非 DVA model 结构,当前启动日志中**未出现**注册报错,因此不影响运行。是否补充 namespace 属于业务设计决策:若后续要把它们改造成 DVA model则必须同步添加定义。
> **约定**:新增 `src/api/<name>/index.js` 并按 DVA model 结构编写时,务必在 `src/enumerate/namespace/index.js` 中添加 `defineNamespace("<name>")`
---
## 5. 端口占用 `EADDRINUSE`
### 现象
```text
Error: listen EADDRINUSE: address already in use 0.0.0.0:8080
```
本项目端口为 **8080**(见 `jjb.config.js`),通常是上次的开发服务器进程未正常退出。
### 解决方案
```powershell
# 查看占用情况
netstat -ano | findstr :8080
# 只杀真正处于 LISTEN 状态的进程
Get-NetTCPConnection -LocalPort 8080 -State Listen -ErrorAction SilentlyContinue |
ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
```
> `netstat` 输出中的 `TIME_WAIT` / `FIN_WAIT` 是待回收的残留连接,**不占用**端口,无需处理。只有 `LISTENING` 才是真正的占用。
---
## 标准启动流程
```powershell
# 1. 安装依赖(首次或配置变更后)
$env:CI='true'; pnpm install
# 2. 若提示 build scripts 被忽略
pnpm rebuild @parcel/watcher core-js es5-ext x-data-spreadsheet zy-react-library
# 3. 启动开发服务器
npm run serve:development
```
成功标志:
```text
Rspack 1.7.12 compiled successfully in 22.97 s
```
服务地址 <http://localhost:8080/>。首次编译约 20-30 秒,日志停在 `wait until bundle finished: /` 属正常现象,需耐心等待打包完成。
---
## 检查清单
- [ ] 确认使用 **pnpm**,不要混用 npm 安装(会破坏 `node_modules` 结构)
- [ ] pnpm 安装类配置一律写在 `pnpm-workspace.yaml`**不要**写进 `.npmrc`
- [ ] 用 `pnpm config get <key>` 验证配置是否真正生效,别假设它已加载
- [ ] 改动 hoist 类配置后使用 `pnpm install --force`,并设 `$env:CI='true'` 跳过交互确认
- [ ] 遇到 `Unable to resolve loader` 时,检查该 loader 是否已提升到 `node_modules` 顶层
- [ ] 新增 DVA model 目录时同步添加 namespace 定义
- [ ] 端口冲突只需关注 `LISTENING` 状态的进程
---
## 附:已废弃的排查思路
以下做法在早期版本文档中出现过,**当前项目不适用**,请勿采纳:
| 废弃做法 | 说明 |
| --- | --- |
| 手动添加 `history@^4.10.1` 作为直接依赖 | `@cqsjjb/jjb-common-lib` 自身已声明 `history@^5.3.0`pnpm 会正确链接到 `.pnpm` store。强行加 v4 会造成版本冲突。 |
| 把 `resolutions` 迁移为 `pnpm.overrides` | 当前 `package.json` 已无 `resolutions` 字段,无需处理。 |
| 改用 `npm install --legacy-peer-deps` | 项目已无 `package-lock.json`,统一使用 pnpm。npm 的扁平化布局会与 pnpm 结构冲突。 |
| 在 `.npmrc` 中设置 `shamefully-hoist` / `hoist` / `node-linker` | pnpm 11 不再从 `.npmrc` 读取这些配置,`.npmrc` 已从项目中删除。 |
| 处理 `react-router` 相关依赖问题 | 项目未依赖 `react-router``src` 中亦无引用。 |

View File

@ -10,7 +10,7 @@ module.exports = {
javaGitBranch: "<branch-name>",
// 接口服务地址(注册/填报开放接口需对接网关,本地后端可通过 sessionStorage.API_HOST 覆盖)
//API_HOST: "https://gbs-gateway.qhdsafety.com",
API_HOST: "http://192.168.0.152",
API_HOST: "http://192.168.0.103",
},
production: {
// 应用后端分支名称,部署上线需要

14
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,14 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- core-js
- es5-ext
- x-data-spreadsheet
- zy-react-library
publicHoistPattern:
- '*loader*'
- '*webpack-plugin*'
- 'react-refresh'
- '@babel/*'
- 'postcss'
- 'autoprefixer'

View File

@ -5,6 +5,12 @@ const getApiHost = () => {
return '';
};
// 与 @cqsjjb/jjb-common-lib http 层保持一致:从 sessionStorage 读取 token 并以同名请求头传递
function getTokenHeader() {
const token = window.sessionStorage.getItem('token');
return token ? { token } : {};
}
async function request(path, params = {}) {
const apiHost = getApiHost();
const url = new URL(path, apiHost);
@ -13,7 +19,7 @@ async function request(path, params = {}) {
url.searchParams.append(k, v);
}
});
const response = await fetch(url.toString());
const response = await fetch(url.toString(), { headers: getTokenHeader() });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

View File

@ -5,6 +5,12 @@ const getApiHost = () => {
return '';
};
// 与 @cqsjjb/jjb-common-lib http 层保持一致:从 sessionStorage 读取 token 并以同名请求头传递
function getTokenHeader() {
const token = window.sessionStorage.getItem('token');
return token ? { token } : {};
}
async function request(path, params = {}) {
const apiHost = getApiHost();
const url = new URL(path, apiHost);
@ -13,7 +19,7 @@ async function request(path, params = {}) {
url.searchParams.append(k, v);
}
});
const response = await fetch(url.toString());
const response = await fetch(url.toString(), { headers: getTokenHeader() });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

View File

@ -24,8 +24,6 @@ import {
} from '~/api/institution';
// ==================== UI 展示常量(与原型保持一致,非数据映射) ====================
const NODE_CHARS = ['险', '合', '组', '场', '报', '内', '技', '控'];
const NODE_COLORS = ['blue', 'violet', 'green', 'orange', 'blue', 'violet', 'orange', 'green'];
function mockIndustryToApiFormat() {
return {
@ -122,22 +120,27 @@ export default function InstitutionDashboard() {
.catch(() => {});
}, []);
// 衍生展示数据API 成功则用 API 字段直接构建,失败则回退 mock
// 衍生展示数据始终渲染固定卡片集合8 个节点 + 已归档 + 项目延期),
// 仅用 API 数据覆盖对应数值,保证无论接口返回多少节点,卡片项数都不减少。
const statusCards = useMemo(() => {
if (!nodeStatsApi) return STATISTIC_CARDS;
const { nodeStats, archivedProjectCount, delayedProjectCount } = nodeStatsApi;
const nodes = (nodeStats || []).map((node, i) => ({
key: node.nodeCode || `NODE_${i}`,
title: node.nodeName,
value: Number(node.pendingCount) || 0,
char: NODE_CHARS[i] || node.nodeName.charAt(1) || '-',
colorClass: NODE_COLORS[i] || 'blue',
}));
nodes.push(
{ key: 'archived', title: '已归档项目', value: Number(archivedProjectCount) || 0, char: '档', colorClass: 'cyan' },
{ key: 'delayed', title: '项目延期', value: Number(delayedProjectCount) || 0, char: '延', colorClass: 'red' },
);
return nodes;
const { nodeStats = [], archivedProjectCount = 0, delayedProjectCount = 0 } = nodeStatsApi || {};
const nodeByName = new Map();
(nodeStats || []).forEach((n) => {
if (n && n.nodeName) nodeByName.set(n.nodeName, n);
});
return STATISTIC_CARDS.map((card) => {
let value = card.value;
if (card.key === 'archived') {
value = Number(archivedProjectCount) || 0;
} else if (card.key === 'delayed') {
value = Number(delayedProjectCount) || 0;
} else {
const node = nodeByName.get(card.title);
if (node) value = Number(node.pendingCount) || 0;
}
return { ...card, value };
});
}, [nodeStatsApi]);
const projectSummary = useMemo(() => {

View File

@ -256,8 +256,9 @@
.todo-item p { margin: 0; color: #d8edff; font-size: 11px; white-space: normal; line-height: 1.1; overflow-wrap: anywhere; }
.todo-item b { color: #13c8ff; font-size: 15px; }
.period-tabs { position: absolute; left: 14px; top: 42px; display: flex; z-index: 2; }
.period-tabs span { width: 34px; height: 19px; display: grid; place-items: center; color: #d8f2ff; border: 1px solid rgba(46, 159, 232, .48); background: rgba(7, 37, 88, .6); font-size: 11px; }
.period-tabs span:first-child { background: rgba(5, 126, 176, .5); }
.period-tabs span { width: 34px; height: 19px; display: grid; place-items: center; color: #d8f2ff; border: 1px solid rgba(46, 159, 232, .48); background: rgba(7, 37, 88, .6); font-size: 11px; cursor: pointer; }
.period-tabs span + span { border-left: none; }
.period-tabs span.active { background: rgba(5, 126, 176, .5); color: #fff; }
.line-chart { height: 176px; }
.review-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 11px 14px; }

View File

@ -40,11 +40,17 @@ const iconMap = [
ContainerOutlined,
];
// 格式化当前时间为 yyyy年MM月dd日 HH:mm:ss
function formatNow(date = new Date()) {
const pad = n => String(n).padStart(2, '0');
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
// 将 mock 数据转为 API 结构,作为初始值
function buildInitialFromMock(m) {
return {
title: m.title,
currentTime: m.currentTime,
currentTime: formatNow(),
lifecycleStats: {
newFilingOrgCount: m.lifecycleStats[0]?.value || 0,
currentFilingOrgCount: m.lifecycleStats[1]?.value || 0,
@ -121,13 +127,14 @@ function buildInitialFromMock(m) {
// ==================== 子组件 ====================
function EChart({ option, className, events }) {
function EChart({ option, className, events, onInstance }) {
const chartRef = useRef(null);
const instanceRef = useRef(null);
useEffect(() => {
if (!chartRef.current) return undefined;
instanceRef.current = echarts.init(chartRef.current, null, { renderer: 'svg' });
if (onInstance) onInstance(instanceRef.current);
const resize = () => instanceRef.current && instanceRef.current.resize();
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
@ -139,6 +146,7 @@ function EChart({ option, className, events }) {
}
return () => {
if (onInstance) onInstance(null);
if (events && instanceRef.current) {
Object.entries(events).forEach(([eventName, handler]) => instanceRef.current.off(eventName, handler));
}
@ -289,6 +297,8 @@ function KpiCards({ data }) {
// ==================== 地图区域分布 ====================
function MapSection({ data }) {
const [selectedAreaName, setSelectedAreaName] = useState('重庆市');
const zoomRef = useRef(1.1);
const chartInstanceRef = useRef(null);
const { regionList, regionSummary, mapScatterPoints } = data;
const statMap = useMemo(() => {
@ -304,19 +314,16 @@ function MapSection({ data }) {
return map;
}, [regionList]);
// 使用后端真实数据;后端未返回的区县补 0不填充伪造数字
const mapData = useMemo(() => {
return (chongqingGeoJson.features || []).map((feature, index) => {
return (chongqingGeoJson.features || []).map((feature) => {
const name = feature.properties && feature.properties.name;
const stat = statMap.get(name) || {
name,
evalProjectCount: 18 + index * 7,
filingOrgCount: 0,
};
const stat = statMap.get(name);
return {
name,
districtCode: stat.districtCode || '',
evalProjectCount: stat.evalProjectCount,
filingOrgCount: stat.filingOrgCount,
districtCode: (stat && stat.districtCode) || '',
evalProjectCount: (stat && stat.evalProjectCount) || 0,
filingOrgCount: (stat && stat.filingOrgCount) || 0,
};
});
}, [statMap]);
@ -333,6 +340,7 @@ function MapSection({ data }) {
const mapOption = useMemo(() => ({
tooltip: {
trigger: 'item',
confine: false,
backgroundColor: 'rgba(2, 31, 82, .96)',
borderColor: '#1aa7ff',
borderWidth: 1,
@ -341,11 +349,15 @@ function MapSection({ data }) {
formatter: params => {
const item = params.data || statMap.get(params.name);
if (!item) return params.name;
return [
`<strong>${params.name}</strong>`,
`评价项目数:${item.evalProjectCount || 0}`,
`备案机构数:${item.filingOrgCount || 0}`,
].join('<br/>');
// 随地图缩放比例同步缩放悬浮框字体
const z = zoomRef.current || 1;
const fs = Math.max(10, Math.round(12 * z));
const title = Math.round(13 * z);
return `<div style="font-size:${fs}px;line-height:1.7">
<strong style="font-size:${title}px">${params.name}</strong><br/>
评价项目数${item.evalProjectCount || 0}<br/>
备案机构数${item.filingOrgCount || 0}
</div>`;
},
},
visualMap: {
@ -411,11 +423,26 @@ function MapSection({ data }) {
click: params => {
if (params && params.name) setSelectedAreaName(params.name);
},
}), []);
// 地图缩放/平移时更新缩放比例tooltip 同步跟随并按比例缩放
georoam: params => {
if (params && typeof params.zoom === 'number') {
zoomRef.current = params.zoom;
// 刷新已显示的 tooltip使其随缩放实时更新字体大小无 hover 时无副作用)
const chart = chartInstanceRef.current;
if (chart) {
chart.dispatchAction({ type: 'showTip' });
}
}
},
}), [selectedAreaName]);
const handleMapInstance = useMemo(() => (instance) => {
chartInstanceRef.current = instance;
}, []);
return (
<div className="map-shell">
<EChart className="map-chart" option={mapOption} events={mapEvents} />
<EChart className="map-chart" option={mapOption} events={mapEvents} onInstance={handleMapInstance} />
{mapScatterPoints.map((item, index) => <div className={`map-card map-card--${index + 1}`} key={item.name}><strong>{item.name}</strong><span>{item.value}</span></div>)}
<div className="map-summary">
<strong>{selectedArea.name}</strong>
@ -443,7 +470,13 @@ function TodoPanel({ data }) {
}
// ==================== 评价类型趋势 ====================
function ReviewLinePanel({ data }) {
const PERIOD_TABS = [
{ key: 'year', label: '年' },
{ key: 'quarter', label: '季' },
{ key: 'month', label: '月' },
];
function ReviewLinePanel({ data, period, onPeriodChange }) {
const { trendBuckets, trendSeries } = data;
const lineOption = useMemo(() => {
@ -468,7 +501,15 @@ function ReviewLinePanel({ data }) {
return (
<Panel title="评价类型趋势" className="line-panel">
<div className="period-tabs"><span></span><span></span><span></span></div>
<div className="period-tabs">
{PERIOD_TABS.map(tab => (
<span
key={tab.key}
className={period === tab.key ? 'active' : ''}
onClick={() => onPeriodChange && onPeriodChange(tab.key)}
>{tab.label}</span>
))}
</div>
<EChart className="line-chart" option={lineOption} />
</Panel>
);
@ -528,11 +569,22 @@ export default function SupervisionCockpit() {
// 取当前年份/月份
const currentYear = useMemo(() => new Date().getFullYear(), []);
// 右上角实时日期时间,每秒刷新
useEffect(() => {
const timer = setInterval(() => {
setData(prev => ({ ...prev, currentTime: formatNow() }));
}, 1000);
return () => clearInterval(timer);
}, []);
const currentMonth = useMemo(() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}, []);
// 评价类型趋势的时间粒度(年/季/月)
const [trendPeriod, setTrendPeriod] = useState('year');
// 标记是否使用 API 数据(仅当对应 API 成功时为 true
const [apiFlags, setApiFlags] = useState({
qualification: false,
@ -583,17 +635,6 @@ export default function SupervisionCockpit() {
})
.catch(() => {});
// 5. 评价类型趋势
fetchEvalTypeTrend('month', currentYear)
.then((apiData) => {
setData(prev => ({
...prev,
trendBuckets: apiData.buckets || prev.trendBuckets,
trendSeries: apiData.series || prev.trendSeries,
}));
})
.catch(() => {});
// 6. 复盘评估改进提效
fetchReviewSummary(currentYear)
.then((apiData) => {
@ -610,6 +651,19 @@ export default function SupervisionCockpit() {
.catch(() => {});
}, [currentYear, currentMonth]);
// 5. 评价类型趋势(随 年/季/月 tab 切换重新查询)
useEffect(() => {
fetchEvalTypeTrend(trendPeriod, currentYear)
.then((apiData) => {
setData(prev => ({
...prev,
trendBuckets: apiData.buckets || prev.trendBuckets,
trendSeries: apiData.series || prev.trendSeries,
}));
})
.catch(() => {});
}, [trendPeriod, currentYear]);
// 项目流程面板数据API 有值时用 processNodes否则用 mock processMockNodes
const processPanelNodes = useMemo(() => {
if (apiFlags.process && data.processNodes && data.processNodes.length > 0) {
@ -634,7 +688,11 @@ export default function SupervisionCockpit() {
</section>
<aside className="cockpit-right">
<TodoPanel data={data} />
<ReviewLinePanel data={data} />
<ReviewLinePanel
data={data}
period={trendPeriod}
onPeriodChange={setTrendPeriod}
/>
<ReviewProgressPanel data={data} />
</aside>
</main>