| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166 |
- <script setup lang="ts">
- /**
- * 观澜前端外壳(P11)—— Vue 3 + Vite + TypeScript。
- *
- * 两种模式:
- * · 工作台:与重构前 `/detail/v2` 同构(页签清单一致),数据取 `/api/fleet?win=`(与 app.js 的 load() 同一个接口);
- * · 契约自检:把冻契约里的关键接口各取一次,显示 状态/耗时/顶层键。
- *
- * 迁移纪律(用户令 2026-09-29):页签逐个搬,**与重构前页面对拍通过后**才删对应 Python 前端源码。
- */
- import { computed, onMounted, ref, watch } from 'vue'
- import ContractSmoke from './views/ContractSmoke.vue'
- import Workbench from './views/Workbench.vue'
- import { TABS, DONE } from './lib/tabs'
- import { t } from './i18n/zh'
- import PortalView from './views/PortalView.vue'
- import { callApi } from './api/contract'
- /** ★P28 用户令:根路径 `/` = **门户**(与 v2.9.2 一致);工作台走 `#workbench`。 */
- type Mode = 'portal' | 'workbench' | 'smoke' | 'about'
- /** 入口哈希:`#workbench` 或 `#tab=<页签>` ⇒ 工作台(后者直达该页签);否则门户。 */
- const _h = location.hash.replace('#', '')
- const _tabInHash = (_h.match(/^tab=([a-z_]+)/) || [])[1] || ''
- /** 旧 SPA 地址 `/detail/v2` 一律进工作台(用户令:登录后要进工作台) */
- const _isDetail = /\/detail\/v2\/?$/.test(location.pathname)
- const mode = ref<Mode>((_isDetail || _h === 'workbench' || _tabInHash) ? 'workbench' : 'portal')
- function go(m: Mode) { mode.value = m; try { location.hash = m === 'portal' ? '' : m } catch { /* ignore */ } }
- const tab = ref<string>('overview')
- /** ★P53:`#tab=<id>` 直达页签(必须放在 tab 声明之后,否则 TDZ 报错导致整页空白) */
- if (_tabInHash) tab.value = _tabInHash
- const sub = ref<string | null>(null)
- const d = ref<any>(null)
- const win = ref<string>('')
- const WIN_PRESETS: [string, string][] = [['近30日', 'win.30d'], ['近90日', 'win.90d'], ['2026年', 'win.2026'], ['2026H1', 'win.2026H1'], ['2025H2', 'win.2025H2'], ['全程', 'win.all']]
- const WIN_SLUG: Record<string, string> = {'近30日': '30d', '近90日': '90d', '2026年': '2026', '2026H1': '2026H1', '2025H2': '2025H2', '全程': 'all'}
- const winSel = computed<string>(() => win.value || '')
- const isCustomWin = computed<boolean>(() => /^\d{4}-\d{2}-\d{2}~\d{4}-\d{2}-\d{2}$/.test(winSel.value))
- const mon = computed<string[]>(() => (((d.value || {}).all_months) || []).slice().reverse())
- const wa = ref<string>('')
- const wb2 = ref<string>('')
- function pickWin(ev: Event) { const v = (ev.target as HTMLSelectElement).value; win.value = WIN_SLUG[v] || v }
- function applyRange() { if (wa.value && wb2.value) win.value = wa.value + '~' + wb2.value }
- const loading = ref(false)
- const err = ref('')
- /** ★P70 各页签需要、但 `/api/fleet` 之外的接口(旧页也是按页签按需取)。
- * 实逮:此前应用**只取 /api/fleet** ⇒ 「系统维护」的巡检表、决策/汇报的模型清单、发电性能的曲线
- * 在真实页面上永远为空(对拍工具喂了数据,所以判据是绿的却与实际不符)。 */
- const TAB_APIS: Record<string, string[]> = {
- system: ['/api/maint_survey'],
- decision: ['/api/ask_models'],
- report: ['/api/ask_models'],
- generation: ['/api/curves'],
- }
- const apis = ref<Record<string, any>>({})
- const curves = ref<any>(null)
- async function ensureTabApis(id: string): Promise<void> {
- const need = TAB_APIS[id] || []
- for (const p of need) {
- if (p === '/api/curves') {
- if (curves.value) continue
- } else if (apis.value[p] !== undefined) continue
- try {
- const r = await fetch(p)
- const j = await r.json()
- if (p === '/api/curves') curves.value = j
- else apis.value = { ...apis.value, [p]: j }
- } catch (e) {
- apis.value = { ...apis.value, [p]: { error: String((e as any)?.message || e) } }
- }
- }
- }
- watch(tab, (id) => { if (mode.value === 'workbench') void ensureTabApis(id) }, { immediate: true })
- async function load() {
- loading.value = true
- err.value = ''
- const q = win.value ? `?win=${encodeURIComponent(win.value)}` : ''
- const r = await callApi<any>('fleet', { method: 'GET' })
- // callApi 走的是契约里的固定路径;窗参数在这里补上(契约只约定路径与方法)
- if (q) {
- try {
- const resp = await fetch('/api/fleet' + q)
- d.value = await resp.json()
- } catch (e) {
- err.value = e instanceof Error ? e.message : String(e)
- }
- } else if (r.ok) {
- d.value = r.data
- if (d.value?.win) win.value = d.value.win
- } else {
- err.value = r.error || `HTTP ${r.status}`
- }
- loading.value = false
- }
- watch(win, (v) => { if (v && v !== d.value?.win) void load() })
- function reselect() { tab.value = 'overview'; sub.value = null; void load() }
- onMounted(() => {
- load()
- /** ★P54:运行时也认 hash(外层「工作台」按钮/深链都能切) */
- window.addEventListener('hashchange', () => {
- const h = location.hash.replace('#', '')
- const tid = (h.match(/^tab=([a-z_]+)/) || [])[1] || ''
- if (h === 'workbench' || tid) { mode.value = 'workbench'; if (tid) tab.value = tid }
- else if (!h) { mode.value = 'portal' }
- })
- })
- </script>
- <template>
- <!-- 门户模式:只渲染门户(与 v2.9.2 的 `/` 一致),不显示工作台外壳 -->
- <PortalView v-if="mode === 'portal'" />
- <div v-else class="wrap">
- <header class="top">
- <span class="mark">≋</span>
- <span class="brand">观澜<small>WIND ASSET INTELLIGENCE</small></span>
- <div class="right">
- <a class="back-portal" href="#" :title="t('ui.back_portal')" @click.prevent="go('portal')">{{ t('ui.back_portal') }}</a>
- <button type="button" class="reselect" :title="t('ui.reselect')" @click="reselect">{{ t('ui.reselect') }}</button>
- <label class="small muted" for="win">{{ t('win.label') }}</label>
- <select id="win" :aria-label="t('win.label')" :value="winSel" @change="pickWin">
- <optgroup :label="t('win.presets')">
- <option v-for="[w2, k] in WIN_PRESETS" :key="w2" :value="w2">{{ t(k) }}</option>
- </optgroup>
- <optgroup v-if="isCustomWin" :label="t('win.custom')">
- <option :value="winSel">{{ t('win.custom_sel', { a: winSel.split('~')[0], b: winSel.split('~')[1] }) }}</option>
- </optgroup>
- <optgroup v-if="mon.length" :label="t('win.months')">
- <option v-for="m in mon" :key="m" :value="m">{{ m }}</option>
- </optgroup>
- </select>
- <span class="winrng" :title="t('win.custom_hint')">
- <input type="date" :aria-label="t('win.from')" v-model="wa">
- <span class="muted">~</span>
- <input type="date" :aria-label="t('win.to')" v-model="wb2">
- <button type="button" @click="applyRange">{{ t('win.apply') }}</button>
- </span>
- </div>
- </header>
- <nav class="tabs" id="tabs">
- <button v-for="id in TABS" :key="id" :class="{ on: id === tab }" @click="tab = id">
- {{ t('tab.' + id) }}<sup v-if="!DONE.includes(id)" class="todo">迁移中</sup>
- </button>
- </nav>
- <main id="root" v-if="mode !== 'portal'">
- <template v-if="mode === 'workbench'">
- <p v-if="loading" class="hint" style="padding:0 18px">取数中(/api/fleet)…</p>
- <p v-else-if="err" class="hint" style="padding:0 18px;color:var(--bad)">取数失败:{{ err }}</p>
- <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" />
- </template>
- <ContractSmoke v-else-if="mode === 'smoke'" />
- <section v-else class="card">
- <h2>关于本前端</h2>
- <ul>
- <li>技术栈:<b>Vue 3 + Vite + TypeScript + Node.js</b>(用户令 2026-09-29)</li>
- <li>与后端交互:只走网关一个源站,路径与形状以 <code>app_backEnd/app_backEnd_guanlan/contract/http_api_v1.json</code> 冻契约为准</li>
- <li>数据来源:与重构前 <code>/detail/v2</code> 同一个接口 <code>/api/fleet?win=</code></li>
- <li>迁移纪律:页签逐个搬,与重构前页面对拍通过后才删对应 Python 源码</li>
- </ul>
- </section>
- </main>
- <footer id="foot" class="small muted" style="margin-top:36px"></footer>
- </div>
- </template>
|