stats-dashboard.vue 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. <template>
  2. <div class="center-shell">
  3. <header>
  4. <div class="center-head-row">
  5. <n-button text @click="router.push(route.query.suiteCode ? `/app-center/suite/${route.query.suiteCode}` : '/app-center')">
  6. 返回应用中心
  7. </n-button>
  8. <n-button secondary :disabled="!selectedConfig" @click="loadData">
  9. 刷新
  10. </n-button>
  11. </div>
  12. <h1>业务报表看板</h1>
  13. <p>按业务单元展示数量、阶段、金额、流程结果和新增趋势。</p>
  14. </header>
  15. <section class="stats-toolbar">
  16. <n-space :wrap="true" align="center">
  17. <n-select
  18. v-model:value="selectedConfig"
  19. style="width: 280px"
  20. placeholder="选择业务应用"
  21. :options="appOptions"
  22. filterable
  23. @update:value="handleConfigChange"
  24. />
  25. <n-select
  26. v-model:value="trendPeriod"
  27. style="width: 120px"
  28. :options="periodOptions"
  29. @update:value="loadData"
  30. />
  31. <n-tag v-if="selectedObject" :bordered="false">
  32. {{ selectedObject.objectName || selectedObject.objectCode }}
  33. </n-tag>
  34. </n-space>
  35. </section>
  36. <n-spin :show="loading">
  37. <BusinessMetricPanel v-if="selectedConfig" :metrics="metrics" />
  38. <n-empty v-else description="请选择一个业务应用查看统计" />
  39. </n-spin>
  40. </div>
  41. </template>
  42. <script setup>
  43. import { computed, onMounted, ref, watch } from 'vue'
  44. import { useRoute, useRouter } from 'vue-router'
  45. import {
  46. businessAppList,
  47. businessObjectFields,
  48. businessObjectList,
  49. businessStatsMetrics,
  50. } from '@/api/business-app'
  51. import BusinessMetricPanel from './components/BusinessMetricPanel.vue'
  52. const router = useRouter()
  53. const route = useRoute()
  54. const loading = ref(false)
  55. const apps = ref([])
  56. const objects = ref([])
  57. const fields = ref([])
  58. const metrics = ref([])
  59. const selectedConfig = ref(route.query.configKey || null)
  60. const selectedObjectCode = ref(route.query.objectCode || null)
  61. const trendPeriod = ref('day')
  62. const periodOptions = [
  63. { label: '按天', value: 'day' },
  64. { label: '按周', value: 'week' },
  65. { label: '按月', value: 'month' },
  66. ]
  67. const appOptions = computed(() => apps.value
  68. .filter(app => app.configKey)
  69. .map(app => ({
  70. label: `${app.appName || app.objectName || app.configKey}`,
  71. value: app.configKey,
  72. objectCode: app.objectCode,
  73. })))
  74. const selectedApp = computed(() => apps.value.find(app => app.configKey === selectedConfig.value) || null)
  75. const selectedObject = computed(() => {
  76. const objectCode = selectedObjectCode.value || selectedApp.value?.objectCode
  77. return objects.value.find(item => item.objectCode === objectCode) || null
  78. })
  79. onMounted(loadInitialData)
  80. watch(() => route.query.configKey, (value) => {
  81. if (value && value !== selectedConfig.value) {
  82. selectedConfig.value = value
  83. handleConfigChange(value)
  84. }
  85. })
  86. async function loadInitialData() {
  87. await Promise.all([loadApps(), loadObjects()])
  88. resolveInitialSelection()
  89. await loadData()
  90. }
  91. async function loadApps() {
  92. const res = await businessAppList({
  93. status: 1,
  94. suiteCode: route.query.suiteCode || undefined,
  95. })
  96. apps.value = res.data || []
  97. }
  98. async function loadObjects() {
  99. const res = await businessObjectList({
  100. suiteCode: route.query.suiteCode || undefined,
  101. objectCode: route.query.objectCode || undefined,
  102. })
  103. objects.value = res.data || []
  104. }
  105. function resolveInitialSelection() {
  106. if (selectedConfig.value)
  107. return
  108. if (route.query.objectCode) {
  109. const app = apps.value.find(item => item.objectCode === route.query.objectCode && item.configKey)
  110. selectedConfig.value = app?.configKey || null
  111. selectedObjectCode.value = route.query.objectCode
  112. return
  113. }
  114. selectedConfig.value = appOptions.value[0]?.value || null
  115. selectedObjectCode.value = appOptions.value[0]?.objectCode || null
  116. }
  117. async function handleConfigChange(configKey) {
  118. selectedObjectCode.value = apps.value.find(app => app.configKey === configKey)?.objectCode || null
  119. await loadData()
  120. }
  121. async function loadData() {
  122. if (!selectedConfig.value) {
  123. metrics.value = []
  124. return
  125. }
  126. loading.value = true
  127. try {
  128. await loadSelectedFields()
  129. const res = await businessStatsMetrics(selectedConfig.value, buildMetricQuery())
  130. metrics.value = res.data || []
  131. }
  132. catch {
  133. metrics.value = []
  134. }
  135. finally {
  136. loading.value = false
  137. }
  138. }
  139. async function loadSelectedFields() {
  140. if (!selectedObject.value?.id) {
  141. fields.value = []
  142. return
  143. }
  144. try {
  145. const res = await businessObjectFields(selectedObject.value.id)
  146. fields.value = res.data || []
  147. }
  148. catch (error) {
  149. console.warn('[stats-dashboard] 加载业务字段失败,使用默认指标', error)
  150. fields.value = []
  151. }
  152. }
  153. function buildMetricQuery() {
  154. const statusField = pickField(['documentStatus', 'approvalStatus', 'status', 'state'], ['状态'])
  155. const stageField = pickField(['stage', 'opportunityStage', 'salesStage'], ['阶段'])
  156. const amountField = pickField(['expectedAmount', 'amount', 'totalAmount', 'contractAmount'], ['金额', '费用', '价格'])
  157. return {
  158. metricTypes: ['OVERVIEW', 'TREND', 'FLOW_RESULT', ...(amountField ? ['SUM'] : [])],
  159. statusField,
  160. stageField,
  161. amountField,
  162. includeFlowResult: true,
  163. period: trendPeriod.value,
  164. days: 30,
  165. }
  166. }
  167. function pickField(codeCandidates = [], labelCandidates = []) {
  168. const list = fields.value || []
  169. const byCode = list.find(field => codeCandidates.some(code => normalize(field.fieldCode || field.field) === normalize(code)))
  170. if (byCode)
  171. return byCode.fieldCode || byCode.field
  172. const byLabel = list.find(field => labelCandidates.some(label => String(field.fieldName || field.label || '').includes(label)))
  173. return byLabel ? byLabel.fieldCode || byLabel.field : undefined
  174. }
  175. function normalize(value) {
  176. return String(value || '').replace(/_/g, '').toLowerCase()
  177. }
  178. </script>
  179. <style scoped>
  180. @import './shared-center.css';
  181. .stats-toolbar {
  182. margin-bottom: 16px;
  183. border: 1px solid #e5e7eb;
  184. border-radius: 8px;
  185. background: #fff;
  186. padding: 12px;
  187. }
  188. </style>