App.vue 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. <script setup lang="ts">
  2. /**
  3. * 观澜前端外壳(P11)—— Vue 3 + Vite + TypeScript。
  4. *
  5. * 两种模式:
  6. * · 工作台:与重构前 `/detail/v2` 同构(页签清单一致),数据取 `/api/fleet?win=`(与 app.js 的 load() 同一个接口);
  7. * · 契约自检:把冻契约里的关键接口各取一次,显示 状态/耗时/顶层键。
  8. *
  9. * 迁移纪律(用户令 2026-09-29):页签逐个搬,**与重构前页面对拍通过后**才删对应 Python 前端源码。
  10. */
  11. import { computed, onMounted, ref, watch } from 'vue'
  12. import ContractSmoke from './views/ContractSmoke.vue'
  13. import Workbench from './views/Workbench.vue'
  14. import { TABS, DONE } from './lib/tabs'
  15. import { t } from './i18n/zh'
  16. import PortalView from './views/PortalView.vue'
  17. import { callApi } from './api/contract'
  18. /** ★P28 用户令:根路径 `/` = **门户**(与 v2.9.2 一致);工作台走 `#workbench`。 */
  19. type Mode = 'portal' | 'workbench' | 'smoke' | 'about'
  20. /** 入口哈希:`#workbench` 或 `#tab=<页签>` ⇒ 工作台(后者直达该页签);否则门户。 */
  21. const _h = location.hash.replace('#', '')
  22. const _tabInHash = (_h.match(/^tab=([a-z_]+)/) || [])[1] || ''
  23. /** ★P72 子视图深链:`#tab=vibration:portal_cms` ⇒ 直接进该子页(也便于真机验证) */
  24. const _subInHash = (_h.match(/^tab=[a-z_]+:([a-z_]+)/) || [])[1] || ''
  25. /** 旧 SPA 地址 `/detail/v2` 一律进工作台(用户令:登录后要进工作台) */
  26. const _isDetail = /\/detail\/v2\/?$/.test(location.pathname)
  27. const mode = ref<Mode>((_isDetail || _h === 'workbench' || _tabInHash) ? 'workbench' : 'portal')
  28. function go(m: Mode) { mode.value = m; try { location.hash = m === 'portal' ? '' : m } catch { /* ignore */ } }
  29. const tab = ref<string>('overview')
  30. /** ★P53:`#tab=<id>` 直达页签(必须放在 tab 声明之后,否则 TDZ 报错导致整页空白) */
  31. if (_tabInHash) tab.value = _tabInHash
  32. const sub = ref<string | null>(null)
  33. /** ★P72 子视图深链(必须放在 `sub` 声明之后,否则 TDZ 报错导致整页空白) */
  34. if (_subInHash) sub.value = _subInHash
  35. const d = ref<any>(null)
  36. const win = ref<string>('')
  37. const WIN_PRESETS: [string, string][] = [['近30日', 'win.30d'], ['近90日', 'win.90d'], ['2026年', 'win.2026'], ['2026H1', 'win.2026H1'], ['2025H2', 'win.2025H2'], ['全程', 'win.all']]
  38. const WIN_SLUG: Record<string, string> = {'近30日': '30d', '近90日': '90d', '2026年': '2026', '2026H1': '2026H1', '2025H2': '2025H2', '全程': 'all'}
  39. const winSel = computed<string>(() => win.value || '')
  40. const isCustomWin = computed<boolean>(() => /^\d{4}-\d{2}-\d{2}~\d{4}-\d{2}-\d{2}$/.test(winSel.value))
  41. const mon = computed<string[]>(() => (((d.value || {}).all_months) || []).slice().reverse())
  42. const wa = ref<string>('')
  43. const wb2 = ref<string>('')
  44. function pickWin(ev: Event) { const v = (ev.target as HTMLSelectElement).value; win.value = WIN_SLUG[v] || v }
  45. function applyRange() { if (wa.value && wb2.value) win.value = wa.value + '~' + wb2.value }
  46. const loading = ref(false)
  47. const err = ref('')
  48. /** ★P70 各页签需要、但 `/api/fleet` 之外的接口(旧页也是按页签按需取)。
  49. * 实逮:此前应用**只取 /api/fleet** ⇒ 「系统维护」的巡检表、决策/汇报的模型清单、发电性能的曲线
  50. * 在真实页面上永远为空(对拍工具喂了数据,所以判据是绿的却与实际不符)。 */
  51. const TAB_APIS: Record<string, string[]> = {
  52. system: ['/api/maint_survey'],
  53. decision: ['/api/ask_models'],
  54. report: ['/api/ask_models'],
  55. generation: ['/api/curves'],
  56. }
  57. const apis = ref<Record<string, any>>({})
  58. const curves = ref<any>(null)
  59. async function ensureTabApis(id: string): Promise<void> {
  60. const need = TAB_APIS[id] || []
  61. for (const p of need) {
  62. if (p === '/api/curves') {
  63. if (curves.value) continue
  64. } else if (apis.value[p] !== undefined) continue
  65. try {
  66. const r = await fetch(p)
  67. const j = await r.json()
  68. if (p === '/api/curves') curves.value = j
  69. else apis.value = { ...apis.value, [p]: j }
  70. } catch (e) {
  71. apis.value = { ...apis.value, [p]: { error: String((e as any)?.message || e) } }
  72. }
  73. }
  74. }
  75. watch(tab, (id) => { if (mode.value === 'workbench') void ensureTabApis(id) }, { immediate: true })
  76. async function load() {
  77. loading.value = true
  78. err.value = ''
  79. const q = win.value ? `?win=${encodeURIComponent(win.value)}` : ''
  80. const r = await callApi<any>('fleet', { method: 'GET' })
  81. // callApi 走的是契约里的固定路径;窗参数在这里补上(契约只约定路径与方法)
  82. if (q) {
  83. try {
  84. const resp = await fetch('/api/fleet' + q)
  85. d.value = await resp.json()
  86. } catch (e) {
  87. err.value = e instanceof Error ? e.message : String(e)
  88. }
  89. } else if (r.ok) {
  90. d.value = r.data
  91. if (d.value?.win) win.value = d.value.win
  92. } else {
  93. err.value = r.error || `HTTP ${r.status}`
  94. }
  95. loading.value = false
  96. }
  97. watch(win, (v) => { if (v && v !== d.value?.win) void load() })
  98. function reselect() { tab.value = 'overview'; sub.value = null; void load() }
  99. onMounted(() => {
  100. load()
  101. /** ★P54:运行时也认 hash(外层「工作台」按钮/深链都能切) */
  102. window.addEventListener('hashchange', () => {
  103. const h = location.hash.replace('#', '')
  104. const tid = (h.match(/^tab=([a-z_]+)/) || [])[1] || ''
  105. if (h === 'workbench' || tid) { mode.value = 'workbench'; if (tid) tab.value = tid }
  106. else if (!h) { mode.value = 'portal' }
  107. })
  108. })
  109. </script>
  110. <template>
  111. <!-- 门户模式:只渲染门户(与 v2.9.2 的 `/` 一致),不显示工作台外壳 -->
  112. <PortalView v-if="mode === 'portal'" />
  113. <div v-else class="wrap">
  114. <header class="top">
  115. <span class="mark">≋</span>
  116. <span class="brand">观澜<small>WIND ASSET INTELLIGENCE</small></span>
  117. <div class="right">
  118. <a class="back-portal" href="#" :title="t('ui.back_portal')" @click.prevent="go('portal')">{{ t('ui.back_portal') }}</a>
  119. <button type="button" class="reselect" :title="t('ui.reselect')" @click="reselect">{{ t('ui.reselect') }}</button>
  120. <label class="small muted" for="win">{{ t('win.label') }}</label>
  121. <select id="win" :aria-label="t('win.label')" :value="winSel" @change="pickWin">
  122. <optgroup :label="t('win.presets')">
  123. <option v-for="[w2, k] in WIN_PRESETS" :key="w2" :value="w2">{{ t(k) }}</option>
  124. </optgroup>
  125. <optgroup v-if="isCustomWin" :label="t('win.custom')">
  126. <option :value="winSel">{{ t('win.custom_sel', { a: winSel.split('~')[0], b: winSel.split('~')[1] }) }}</option>
  127. </optgroup>
  128. <optgroup v-if="mon.length" :label="t('win.months')">
  129. <option v-for="m in mon" :key="m" :value="m">{{ m }}</option>
  130. </optgroup>
  131. </select>
  132. <span class="winrng" :title="t('win.custom_hint')">
  133. <input type="date" :aria-label="t('win.from')" v-model="wa">
  134. <span class="muted">~</span>
  135. <input type="date" :aria-label="t('win.to')" v-model="wb2">
  136. <button type="button" @click="applyRange">{{ t('win.apply') }}</button>
  137. </span>
  138. </div>
  139. </header>
  140. <nav class="tabs" id="tabs">
  141. <button v-for="id in TABS" :key="id" :class="{ on: id === tab }" @click="tab = id">
  142. {{ t('tab.' + id) }}<sup v-if="!DONE.includes(id)" class="todo">迁移中</sup>
  143. </button>
  144. </nav>
  145. <main id="root" v-if="mode !== 'portal'">
  146. <template v-if="mode === 'workbench'">
  147. <p v-if="loading" class="hint" style="padding:0 18px">取数中(/api/fleet)…</p>
  148. <p v-else-if="err" class="hint" style="padding:0 18px;color:var(--bad)">取数失败:{{ err }}</p>
  149. <Workbench v-else-if="d" :d="d" :tab="tab" :sub="sub" @tab="tab = $event" @sub="sub = $event" :win="win" @win="(w) => { win = w }" @reselect="reselect" :apis="apis" :curves="curves" />
  150. </template>
  151. <ContractSmoke v-else-if="mode === 'smoke'" />
  152. <section v-else class="card">
  153. <h2>关于本前端</h2>
  154. <ul>
  155. <li>技术栈:<b>Vue 3 + Vite + TypeScript + Node.js</b>(用户令 2026-09-29)</li>
  156. <li>与后端交互:只走网关一个源站,路径与形状以 <code>app_backEnd/app_backEnd_guanlan/contract/http_api_v1.json</code> 冻契约为准</li>
  157. <li>数据来源:与重构前 <code>/detail/v2</code> 同一个接口 <code>/api/fleet?win=</code></li>
  158. <li>迁移纪律:页签逐个搬,与重构前页面对拍通过后才删对应 Python 源码</li>
  159. </ul>
  160. </section>
  161. </main>
  162. <footer id="foot" class="small muted" style="margin-top:36px"></footer>
  163. </div>
  164. </template>