Topic1Page.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. <template>
  2. <PageHeader />
  3. <div class="main">
  4. <TopicPageHeader :topic-id="1" />
  5. <QueryFilter />
  6. <div class="topic1-lower">
  7. <div class="topic1-charts">
  8. <div class="pie-chart-group">
  9. <div
  10. v-for="item in topPieChartsList"
  11. :key="item.id"
  12. class="pie-chart-card"
  13. :class="{
  14. 'pie-chart-card--usage': item.id === '2',
  15. 'pie-chart-card--building': item.id === '1',
  16. 'pie-chart-card--age': item.id === '4',
  17. }"
  18. >
  19. <SectionHeader :title="item.text" />
  20. <PieChart
  21. variant="topic1"
  22. clickable
  23. :data="item.data"
  24. :active-slice="item.activeSlice"
  25. :value-unit="item.valueUnit"
  26. @slice-click="item.onSliceClick"
  27. />
  28. </div>
  29. </div>
  30. <div class="bar-chart-group">
  31. <div class="land-purpose-pie">
  32. <SectionHeader title="土地类型面积" />
  33. <div class="chart-card__body chart-card__body--pie">
  34. <PieChart
  35. variant="topic1"
  36. size="lg"
  37. clickable
  38. :data="purposeList"
  39. :active-slice="listFilters.landUsage"
  40. value-unit="万m²"
  41. @slice-click="onLandPurposeSliceClick"
  42. />
  43. </div>
  44. </div>
  45. <div class="asset-structure">
  46. <SectionHeader title="各集团建筑面积与已使用面积对比" />
  47. <div class="chart-card__body">
  48. <GroupAreaCompareBarChart
  49. clickable
  50. :height="300"
  51. :active-group="activeGroupBar"
  52. @group-click="onGroupBarClick"
  53. />
  54. </div>
  55. </div>
  56. </div>
  57. </div>
  58. <FullDataList
  59. ref="fullListRef"
  60. :filters="listFilters"
  61. :rows="fullRows"
  62. :fw-usage-options="fwUsageFilterOptions"
  63. :land-type-options="landTypeFilterOptions"
  64. @update:filters="listFilters = $event"
  65. @reset="resetListFilters"
  66. @row-action="openAssetDetail"
  67. />
  68. </div>
  69. </div>
  70. </template>
  71. <script setup>
  72. import { showWarning } from '@/utils/message';
  73. import { ref, computed, watch } from 'vue';
  74. import { useRouter } from 'vue-router';
  75. import PageHeader from '@/components/layout/PageHeader.vue';
  76. import TopicPageHeader from '@/components/stats/TopicPageHeader.vue';
  77. import QueryFilter from '@/components/stats/QueryFilter.vue';
  78. import SectionHeader from '@/components/base/SectionHeader.vue';
  79. import PieChart from '@/components/chart/PieChart.vue';
  80. import GroupAreaCompareBarChart from '@/components/chart/GroupAreaCompareBarChart.vue';
  81. import FullDataList from '@/components/stats/FullDataList.vue';
  82. import { useEnterpriseStore } from '@/store/enterprise';
  83. import { useFilterStore } from '@/store/filter';
  84. import { toWanSquareMeters, sumFieldToWan } from '@/utils/areaMath';
  85. import {
  86. buildLandTypeFilterOptions,
  87. buildFwUsageFilterOptions,
  88. filterTopic1Assets,
  89. mapTopic1Row,
  90. } from '@/utils/topic1AssetList';
  91. import { GROUP_DISPLAY_NAMES } from '@/utils/aggregateReportRecords';
  92. const enterpriseStore = useEnterpriseStore();
  93. const filterStore = useFilterStore();
  94. const router = useRouter();
  95. const fullListRef = ref(null);
  96. const DISPLAY_TO_GROUP = Object.fromEntries(
  97. Object.entries(GROUP_DISPLAY_NAMES).map(([group, short]) => [short, group])
  98. );
  99. const AGE_SLICE_TO_KEY = {
  100. '0-10年': '0-10',
  101. '10-20年': '10-20',
  102. '20年以上': '20+',
  103. };
  104. const USAGE_PIE_CONFIG = [
  105. { name: '出租', field: 'c_czmj', color: 'rgba(1, 118, 255, 1)' },
  106. { name: '自用', field: 'c_zymj', color: 'rgba(255, 121, 98, 1)' },
  107. { name: '出借', field: 'c_cjmj', color: 'rgba(104, 221, 169, 1)' },
  108. { name: '闲置', field: 'c_xzmj', color: 'rgba(255, 232, 119, 1)' },
  109. ];
  110. const createDefaultFilters = () => ({
  111. keyword: '',
  112. purpose: '全部',
  113. group: filterStore.selectGroup,
  114. age: '全部',
  115. util: '全部',
  116. fwUsage: '',
  117. landType: '',
  118. landUsage: '',
  119. });
  120. const listFilters = ref(createDefaultFilters());
  121. const filteredList = computed(() =>
  122. enterpriseStore.filteredByGroup(filterStore.selectGroup, filterStore.selectDate)
  123. );
  124. const OTHER_LABEL = '其他';
  125. const EMPTY_LABEL = '未知';
  126. const hashString = (str) => {
  127. let hash = 0;
  128. for (let i = 0; i < str.length; i++) {
  129. hash = (hash * 31 + str.charCodeAt(i)) >>> 0;
  130. }
  131. return hash;
  132. };
  133. const getColorByName = (name) => {
  134. const hash = hashString(name);
  135. const hue = (hash * 137.508) % 360;
  136. const saturation = 58 + (hash % 4) * 9;
  137. const lightness = 44 + ((hash >> 3) % 5) * 5;
  138. return `hsla(${Math.round(hue)}, ${saturation}%, ${lightness}%, 1)`;
  139. };
  140. const buildAllSlices = (entries, toValue = (v) => v) =>
  141. entries
  142. .map(([name, raw]) => ({
  143. name,
  144. value: toValue(raw),
  145. color: getColorByName(name),
  146. }))
  147. .filter((item) => item.value > 0)
  148. .sort((a, b) => b.value - a.value);
  149. const buildingAreaChart = computed(() => {
  150. const purposeMap = (filteredList.value ?? []).reduce((acc, item) => {
  151. const name = (item.c_fwyt_yt1 ?? OTHER_LABEL).trim() || OTHER_LABEL;
  152. acc[name] = (acc[name] ?? 0) + Number(item.c_sjjzmj || 0);
  153. return acc;
  154. }, {});
  155. const slices = buildAllSlices(Object.entries(purposeMap), (sumSqm) =>
  156. toWanSquareMeters(sumSqm)
  157. );
  158. return { slices };
  159. });
  160. const buildingAreaStructureList = computed(() => buildingAreaChart.value.slices);
  161. const usageStatusList = computed(() =>
  162. USAGE_PIE_CONFIG.map(({ name, field, color }) => ({
  163. name,
  164. color,
  165. value: sumFieldToWan(filteredList.value, field),
  166. }))
  167. );
  168. const landPurposeChart = computed(() => {
  169. const purposeMap = (filteredList.value ?? []).reduce((acc, item) => {
  170. const name = (item.c_tdqk_yt1 ?? EMPTY_LABEL).trim() || EMPTY_LABEL;
  171. acc[name] = (acc[name] ?? 0) + Number(item.c_zdmj || 0);
  172. return acc;
  173. }, {});
  174. const slices = buildAllSlices(Object.entries(purposeMap), (sumSqm) =>
  175. toWanSquareMeters(sumSqm)
  176. );
  177. return { slices };
  178. });
  179. const purposeList = computed(() => landPurposeChart.value.slices);
  180. const landTypeFilterOptions = computed(() =>
  181. buildLandTypeFilterOptions(filteredList.value)
  182. );
  183. const fwUsageFilterOptions = computed(() =>
  184. buildFwUsageFilterOptions(filteredList.value)
  185. );
  186. const ageDistributionList = computed(() => {
  187. const origin = filteredList.value || [];
  188. const groupConfig = [
  189. { name: '0-10年', color: 'rgba(1, 118, 255, 1)', filter: (val) => Number(val) <= 10 },
  190. {
  191. name: '10-20年',
  192. color: 'rgba(255, 121, 98, 1)',
  193. filter: (val) => Number(val) > 10 && Number(val) <= 20,
  194. },
  195. {
  196. name: '20年以上',
  197. color: 'rgba(104, 221, 169, 1)',
  198. filter: (val) => Number(val) > 20 || val === '30年以上',
  199. },
  200. {
  201. name: '不确定',
  202. color: 'rgba(255, 232, 119, 1)',
  203. filter: () => true,
  204. },
  205. ];
  206. const result = groupConfig.map((item) => ({
  207. name: item.name,
  208. value: 0,
  209. color: item.color,
  210. }));
  211. origin.forEach((item) => {
  212. const fieldVal = item.c_fl;
  213. const target = result.find((_, idx) => groupConfig[idx].filter(fieldVal));
  214. if (target) {
  215. target.value += Number(item.c_sjjzmj || 0);
  216. }
  217. });
  218. return result
  219. .map((item) => ({
  220. ...item,
  221. value: toWanSquareMeters(item.value),
  222. }))
  223. .filter((item) => item.value > 0);
  224. });
  225. const listFilterContext = computed(() => ({
  226. ...listFilters.value,
  227. }));
  228. const fullRows = computed(() =>
  229. filterTopic1Assets(filteredList.value, listFilterContext.value).map(mapTopic1Row)
  230. );
  231. const scrollToFullList = () => {
  232. fullListRef.value?.scrollIntoView();
  233. };
  234. const applyListFilters = (patch) => {
  235. listFilters.value = { ...listFilters.value, ...patch };
  236. scrollToFullList();
  237. };
  238. const resetListFilters = () => {
  239. listFilters.value = createDefaultFilters();
  240. };
  241. watch(() => filterStore.selectGroup, resetListFilters);
  242. watch(
  243. () => listFilters.value.group,
  244. (group) => {
  245. if (filterStore.selectGroup !== group) {
  246. filterStore.setGroup(group);
  247. }
  248. }
  249. );
  250. const onBuildingSliceClick = (name) => {
  251. const next = listFilters.value.fwUsage === name ? '' : name;
  252. applyListFilters({
  253. ...createDefaultFilters(),
  254. fwUsage: next,
  255. });
  256. };
  257. const onUsageSliceClick = (name) => {
  258. const next = listFilters.value.util === name ? '全部' : name;
  259. applyListFilters({
  260. ...createDefaultFilters(),
  261. util: next,
  262. });
  263. };
  264. const onLandPurposeSliceClick = (name) => {
  265. const next = listFilters.value.landUsage === name ? '' : name;
  266. applyListFilters({
  267. ...createDefaultFilters(),
  268. landUsage: next,
  269. });
  270. };
  271. const onAgeSliceClick = (name) => {
  272. const key = AGE_SLICE_TO_KEY[name];
  273. if (!key) return;
  274. const next = listFilters.value.age === key ? '全部' : key;
  275. applyListFilters({
  276. ...createDefaultFilters(),
  277. age: next,
  278. });
  279. };
  280. const onGroupBarClick = (shortName) => {
  281. const group = DISPLAY_TO_GROUP[shortName] || shortName;
  282. const next = listFilters.value.group === group ? '全部' : group;
  283. applyListFilters({
  284. ...createDefaultFilters(),
  285. group: next,
  286. });
  287. };
  288. const openAssetDetail = (row) => {
  289. const id = row.id ?? row.code;
  290. if (!id || id === '—') {
  291. showWarning('无法打开详情:缺少资产标识');
  292. return;
  293. }
  294. router.push({
  295. path: `/manage/asset/${encodeURIComponent(String(id))}`,
  296. query: { from: 'stats' },
  297. });
  298. };
  299. const activeGroupBar = computed(() =>
  300. listFilters.value.group === '全部'
  301. ? ''
  302. : (GROUP_DISPLAY_NAMES[listFilters.value.group] ?? listFilters.value.group)
  303. );
  304. const activeAgeSlice = computed(() => {
  305. if (listFilters.value.age === '全部') return '';
  306. const map = { '0-10': '0-10年', '10-20': '10-20年', '20+': '20年以上' };
  307. return map[listFilters.value.age] ?? '';
  308. });
  309. const activeUsageSlice = computed(() =>
  310. listFilters.value.util === '全部' ? '' : listFilters.value.util
  311. );
  312. const topPieChartsList = computed(() => [
  313. {
  314. id: '2',
  315. text: '房屋使用状态',
  316. data: usageStatusList.value,
  317. activeSlice: activeUsageSlice.value,
  318. onSliceClick: onUsageSliceClick,
  319. valueUnit: '万m²',
  320. },
  321. {
  322. id: '1',
  323. text: '房屋用途',
  324. data: buildingAreaStructureList.value,
  325. activeSlice: listFilters.value.fwUsage,
  326. onSliceClick: onBuildingSliceClick,
  327. valueUnit: '万m²',
  328. },
  329. {
  330. id: '4',
  331. text: '房龄分布',
  332. data: ageDistributionList.value,
  333. activeSlice: activeAgeSlice.value,
  334. onSliceClick: onAgeSliceClick,
  335. valueUnit: '万m²',
  336. },
  337. ]);
  338. </script>
  339. <style scoped>
  340. .main {
  341. width: 100%;
  342. max-width: var(--page-max-width);
  343. padding: 24px;
  344. padding-bottom: 32px;
  345. display: flex;
  346. flex-direction: column;
  347. gap: 12px;
  348. position: relative;
  349. }
  350. .pie-chart-group {
  351. width: 100%;
  352. max-width: var(--content-max-width);
  353. min-height: 280px;
  354. display: flex;
  355. align-items: stretch;
  356. gap: 14px;
  357. }
  358. .pie-chart-card {
  359. flex: 1;
  360. min-width: 0;
  361. min-height: 280px;
  362. display: flex;
  363. flex-direction: column;
  364. gap: 24px;
  365. border-radius: var(--radius-card);
  366. padding: var(--card-padding);
  367. background: var(--color-surface);
  368. border: 1px solid var(--color-accent-border);
  369. }
  370. .pie-chart-card :deep(.pie-chart-main--topic1) {
  371. margin-top: 4px;
  372. width: 100%;
  373. justify-content: center;
  374. }
  375. .topic1-lower {
  376. width: 100%;
  377. display: flex;
  378. flex-direction: column;
  379. gap: 4px;
  380. }
  381. .topic1-charts {
  382. width: 100%;
  383. display: flex;
  384. flex-direction: column;
  385. gap: 14px;
  386. }
  387. .bar-chart-group {
  388. width: 100%;
  389. display: flex;
  390. gap: 14px;
  391. align-items: stretch;
  392. }
  393. .land-purpose-pie {
  394. width: 613px;
  395. display: flex;
  396. flex-direction: column;
  397. gap: 8px;
  398. border-radius: var(--radius-card);
  399. padding: var(--card-padding);
  400. background: var(--color-surface);
  401. border: 1px solid var(--color-accent-border);
  402. }
  403. .land-purpose-pie :deep(.pie-chart-main--topic1-lg) {
  404. width: 100%;
  405. justify-content: center;
  406. }
  407. .asset-structure {
  408. flex: 1;
  409. min-width: 0;
  410. display: flex;
  411. flex-direction: column;
  412. gap: 8px;
  413. border-radius: var(--radius-card);
  414. padding: var(--card-padding);
  415. background: var(--color-surface);
  416. border: 1px solid var(--color-accent-border);
  417. }
  418. .chart-card__body {
  419. flex: 1;
  420. height: 300px;
  421. min-height: 300px;
  422. }
  423. .chart-card__body--pie {
  424. display: flex;
  425. align-items: center;
  426. justify-content: center;
  427. }
  428. @media screen and (max-width: 1440px) {
  429. .main {
  430. width: 100%;
  431. max-width: 1440px;
  432. }
  433. .pie-chart-group,
  434. .bar-chart-group,
  435. .topic1-lower,
  436. .topic1-charts {
  437. width: 100%;
  438. }
  439. .topic1-charts {
  440. display: grid;
  441. grid-template-columns: repeat(2, minmax(0, 1fr));
  442. grid-template-areas:
  443. 'usage building'
  444. 'age land'
  445. 'bar bar';
  446. gap: 14px;
  447. align-items: stretch;
  448. }
  449. .pie-chart-group,
  450. .bar-chart-group {
  451. display: contents;
  452. }
  453. .pie-chart-card--usage {
  454. grid-area: usage;
  455. }
  456. .pie-chart-card--building {
  457. grid-area: building;
  458. }
  459. .pie-chart-card--age {
  460. grid-area: age;
  461. }
  462. .land-purpose-pie {
  463. grid-area: land;
  464. width: 100%;
  465. min-width: 0;
  466. }
  467. .land-purpose-pie .chart-card__body--pie {
  468. height: auto;
  469. min-height: 240px;
  470. }
  471. .asset-structure {
  472. grid-area: bar;
  473. width: 100%;
  474. }
  475. .pie-chart-card,
  476. .land-purpose-pie {
  477. overflow: visible;
  478. min-width: 0;
  479. }
  480. .topic1-charts .pie-chart-card :deep(.pie-chart-main--topic1),
  481. .land-purpose-pie :deep(.pie-chart-main--topic1-lg) {
  482. flex-wrap: wrap;
  483. justify-content: center;
  484. align-items: flex-start;
  485. height: auto;
  486. min-height: 196px;
  487. max-width: 100%;
  488. gap: 12px;
  489. }
  490. .topic1-charts :deep(.donut-chart) {
  491. width: 120px;
  492. height: 120px;
  493. }
  494. .pie-chart-card :deep(.legend-group--topic1-grid),
  495. .pie-chart-card--age :deep(.legend-group--topic1-grid),
  496. .land-purpose-pie :deep(.legend-group--topic1-grid) {
  497. flex: 1;
  498. width: auto;
  499. min-width: 0;
  500. max-width: 100%;
  501. height: auto;
  502. padding: 0;
  503. row-gap: 6px;
  504. column-gap: 12px;
  505. }
  506. .pie-chart-card--usage :deep(.legend-group--topic1-grid),
  507. .pie-chart-card--building :deep(.legend-group--topic1-grid) {
  508. min-height: 88px;
  509. grid-template-rows: repeat(6, auto);
  510. }
  511. .pie-chart-card--age :deep(.legend-group--topic1-grid) {
  512. min-height: 88px;
  513. grid-template-rows: repeat(2, auto);
  514. row-gap: 8px;
  515. }
  516. .land-purpose-pie :deep(.legend-group--topic1-grid) {
  517. min-height: 220px;
  518. grid-template-rows: repeat(6, auto);
  519. }
  520. .pie-chart-card :deep(.legend-item__text),
  521. .pie-chart-card--age :deep(.legend-item__text),
  522. .land-purpose-pie :deep(.legend-item__text) {
  523. font-size: 12px;
  524. line-height: 14px;
  525. }
  526. .pie-chart-card :deep(.legend-item__percent),
  527. .pie-chart-card--age :deep(.legend-item__percent),
  528. .land-purpose-pie :deep(.legend-item__percent) {
  529. font-size: 12px;
  530. line-height: 14px;
  531. margin-left: 4px;
  532. }
  533. .pie-chart-card :deep(.legend-item__text span:last-child),
  534. .pie-chart-card--age :deep(.legend-item__text span:last-child),
  535. .land-purpose-pie :deep(.legend-item__text span:last-child) {
  536. white-space: normal;
  537. overflow: visible;
  538. text-overflow: unset;
  539. }
  540. }
  541. </style>