App.vue 8.3 KB

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