CrossingDetailPanel.vue 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564
  1. <template>
  2. <div class="crossing-detail-panel" :class="{ 'is-dual': isDual }">
  3. <!-- content-row:单图模式 display:contents 透明(保持原 row 布局),双图模式显形为 row 容器 -->
  4. <div class="content-row">
  5. <div class="detail-panel-left">
  6. <div class="intersection-video-wrap">
  7. <IntersectionMapVideos :mapData="intersectionData" />
  8. </div>
  9. <!-- 单图模式:相位图保持在左侧栏内 -->
  10. <div v-if="!isDual" class="signal-timing-wrap">
  11. <div class="header">
  12. <div class="title-area">
  13. <span class="main-title">方案状态</span>
  14. <span class="sub-info">(周期: {{ cycleLength }} 相位差: {{ phaseDiff }} 协调时间: {{ coordTime }})</span>
  15. </div>
  16. </div>
  17. <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec"
  18. :phaseData="mockPhaseData" :showScanLine="dataReady" :autoScan="dataReady"
  19. @scan-tick="onScanTick" />
  20. </div>
  21. </div>
  22. <div class="detail-panel-right">
  23. <form class="detail-right-form" @submit.prevent>
  24. <div class="form-group">
  25. <!-- 新版控制方式按钮组:手动开关 / 紧急模式 / 配时动作 -->
  26. <div class="control-method">
  27. <div class="control-label-wrap control-chips-row">
  28. <div class="control-chips">
  29. <!-- 手动开关 -->
  30. <button type="button" class="chip chip-manual"
  31. :class="{ 'is-active': isManualMode }" @click="toggleManualMode">
  32. {{ isManualMode ? '解除手动' : '手动控制' }}
  33. </button>
  34. <span class="chip-divider"></span>
  35. <!-- 紧急模式:关灯 / 黄闪 / 全红 -->
  36. <button type="button" class="chip chip-mode-off"
  37. :class="{ 'is-active': currentMethod === 'lights_off' }"
  38. :disabled="!isManualMode" @click="setControlMethod('lights_off')">关灯</button>
  39. <button type="button" class="chip chip-mode-yellow"
  40. :class="{ 'is-active': currentMethod === 'yellow_flash' }"
  41. :disabled="!isManualMode" @click="setControlMethod('yellow_flash')">黄闪</button>
  42. <button type="button" class="chip chip-mode-red"
  43. :class="{ 'is-active': currentMethod === 'all_red' }"
  44. :disabled="!isManualMode" @click="setControlMethod('all_red')">全红</button>
  45. <span class="chip-divider"></span>
  46. <!-- 配时动作:弹窗 -->
  47. <button type="button" class="chip chip-action"
  48. :disabled="!isManualMode" @click="openTempSchemeDialog">临时修改</button>
  49. <button type="button" class="chip chip-action"
  50. :disabled="!isManualMode" @click="openSchemeDialog">修改方案</button>
  51. </div>
  52. </div>
  53. </div>
  54. <div class="form-interactive-area">
  55. <div class="form-editable-area" :class="{ 'is-disabled': !isManualMode }">
  56. <div class="control-scheme">
  57. <div class="current-stage" ref="stageGrid">
  58. <div v-for="(item, index) in currentStageList" :key="index"
  59. class="stage-item-wrapper">
  60. <div class="phase-box" :class="{ 'is-active': item.value === currentStage }"
  61. @click="onStageClick(item.value)">
  62. <PhaseDiagram
  63. v-if="item.icons && item.icons.length"
  64. :icons="item.icons"
  65. :no="item.no || item.value"
  66. :arrow-color="item.arrowColor"
  67. :arrow-colors="item.arrowColors || {}"
  68. :bg-color="item.bgColor"
  69. :number-color="item.numberColor"
  70. />
  71. <img v-else :src="item.img" alt="stage" class="phase-image" />
  72. </div>
  73. <div class="bottom-controls">
  74. <div class="input-unit-wrapper">
  75. <input type="number" v-model.number="item.time" class="stage-input"
  76. :disabled="!canEditStage"
  77. :title="canEditStage ? '修改阶段时间' : '当前控制方式不可修改'" />
  78. <span class="unit">s</span>
  79. </div>
  80. <span class="percent">{{ stagePercent(item.time) }}</span>
  81. </div>
  82. </div>
  83. </div>
  84. </div>
  85. </div>
  86. </div>
  87. <!-- 方案圆饼图: 从 .form-interactive-area 抽出, 作为 .form-group 直接子节点,
  88. 脱离 current-stage 的滚动上下文, 保证 4 阶段时圆饼图始终可见 -->
  89. <div class="donut-row">
  90. <div class="donut-item">
  91. <div class="donut-title">实时方案(执行方案3)</div>
  92. <PlanDonutChart :chartData="realtimeDonutData"
  93. :centerValue="String(realtimeRemaining)" centerLabel="剩余时长" :showTotal="true"
  94. :totalValue="cycleLength" :scale="panelScale" />
  95. </div>
  96. <div class="donut-item">
  97. <div class="donut-title">下周期方案</div>
  98. <PlanDonutChart :chartData="nextCycleDonutData" :centerValue="String(cycleLength)"
  99. centerLabel="总时长" :showTotal="false" :scale="panelScale" />
  100. </div>
  101. </div>
  102. <div class="button-group" v-show="isManualMode && currentMethod !== 'step'">
  103. <div>
  104. <button type="button" class="btn btn-cancel" @click="onCancel()">取消</button>
  105. <button type="button" class="btn btn-confirm" @click="onConfirm()">确认</button>
  106. </div>
  107. </div>
  108. </div>
  109. </form>
  110. </div>
  111. </div><!-- /.content-row -->
  112. <!-- 双图模式:相位图作为整宽底部行(跨左右两栏) -->
  113. <div v-if="isDual" class="signal-timing-wrap is-dual">
  114. <div class="header">
  115. <div class="title-area">
  116. <span class="main-title">方案状态</span>
  117. <span class="sub-info">(周期: {{ cycleLength }} 相位差: {{ phaseDiff }} 协调时间: {{ coordTime }})</span>
  118. </div>
  119. </div>
  120. <div class="timing-row timing-row-live">
  121. <div class="row-label">
  122. 本周期 实时<span v-if="thisCycle"> · {{ thisCycle.schemeName }}</span>
  123. </div>
  124. <div class="row-chart">
  125. <SignalTimingChart :cycleLength="thisCycle.cycleLength" :currentTime="currentSec"
  126. :phaseData="thisCycle.phaseData" :showScanLine="dataReady" :showScanLineLabel="dataReady"
  127. :clipToActive="true" :compactScanLine="true" :autoScan="dataReady"
  128. @scan-tick="onScanTick" />
  129. </div>
  130. </div>
  131. <div class="timing-row timing-row-last">
  132. <div class="row-label">
  133. 上周期 方案
  134. <span v-if="lastCycle"> · 实际 {{ lastCycle.actualDuration }}s / 计划 {{ lastCycle.cycleLength }}s</span>
  135. </div>
  136. <div class="row-chart">
  137. <SignalTimingChart v-if="lastCycle" :cycleLength="lastCycle.cycleLength" :currentTime="0"
  138. :phaseData="lastCycle.phaseData" :showScanLine="false" :showScanLineLabel="false" />
  139. <div v-else class="empty-placeholder">暂无上周期数据</div>
  140. </div>
  141. </div>
  142. </div>
  143. <!-- 步进锁定时间弹窗 -->
  144. <transition name="fade">
  145. <div class="lock-time-overlay" v-if="showLockTime" @click.self="showLockTime = false">
  146. <div class="lock-time-dialog">
  147. <div class="lock-time-header">
  148. <span class="lock-time-title">锁定时间</span>
  149. <span class="lock-time-close" @click="showLockTime = false">✕</span>
  150. </div>
  151. <div class="lock-time-divider"></div>
  152. <div class="lock-time-body">
  153. <div class="lock-time-options">
  154. <div class="lock-time-option">
  155. <label>
  156. <input type="radio" v-model="lockTimeType" value="continuous" /> 持续放行
  157. </label>
  158. </div>
  159. <div class="lock-time-option">
  160. <label>
  161. <input type="radio" v-model="lockTimeType" value="timer" /> 放行
  162. <DropdownSelect placeholder="锁定时间" v-model="currentLocktime"
  163. :options="locktimeOptions" size="auto" @click.native.prevent />
  164. 秒解锁
  165. </label>
  166. </div>
  167. </div>
  168. <div class="lock-time-actions">
  169. <button type="button" class="btn btn-cancel"
  170. @click="onLockTimeCancel">取消</button>
  171. <button type="button" class="btn btn-confirm"
  172. @click="onLockTimeConfirm">确认</button>
  173. </div>
  174. </div>
  175. </div>
  176. </div>
  177. </transition>
  178. </div>
  179. </template>
  180. <script>
  181. import SignalTimingChart from '@/components/ui/SignalTimingChart.vue';
  182. import IntersectionMapVideos from '@/components/ui/IntersectionMapVideos.vue';
  183. import DropdownSelect from '@/components/ui/DropdownSelect.vue';
  184. import PlanDonutChart from '@/components/ui/PlanDonutChart.vue';
  185. import PhaseDiagram from '@/components/ui/PhaseDiagram.vue';
  186. import { apiGetCrossingDetailData, apiSaveCrossingTempScheme, apiSaveCrossingScheme } from '@/api';
  187. export default {
  188. name: 'CrossingPanel',
  189. components: {
  190. SignalTimingChart,
  191. IntersectionMapVideos,
  192. DropdownSelect,
  193. PlanDonutChart,
  194. PhaseDiagram,
  195. },
  196. props: {
  197. preloadedData: { type: Object, default: null },
  198. iconMode: { type: String, default: 'simple' } // 'default' | 'simple'
  199. },
  200. // dialogManager 由 DashboardLayout 顶层 provide;用于"临时修改 / 修改方案"弹窗的打开与关闭
  201. inject: {
  202. dialogManager: { default: null },
  203. },
  204. data() {
  205. return {
  206. startDate: '2026-04-13',
  207. startTime: '14:03:06',
  208. endDate: '2026-04-13',
  209. endTime: '15:03:06',
  210. duration: null,
  211. period: null,
  212. // 核心状态控制
  213. isManualMode: false, // 是否处于手动控制模式
  214. showLockTime: false, // 是否显示锁定时间弹窗
  215. lockTimeType: 'continuous', // 锁定时间类型
  216. dataReady: false,
  217. followPhase: false,
  218. intersectionData: {},
  219. currentRoute: {},
  220. cycleLength: 140,
  221. currentSec: 0,
  222. phaseDiff: 0,
  223. coordTime: 0,
  224. mockPhaseData: [],
  225. panelScale: 1,
  226. // 控制方式数据
  227. controlMethodOptions: [],
  228. currentMethod: 'temp',
  229. currentScheme: 'early_peak',
  230. schemeOptions: [],
  231. // 实时方案圆饼图数据(从 phaseData 动态生成)
  232. realtimeDonutData: [],
  233. // 下周期方案圆饼图数据
  234. nextCycleDonutData: [],
  235. // 实时方案剩余时长
  236. realtimeRemaining: 0,
  237. // 各阶段基础数据(从 phaseData 解析)
  238. phaseStages: [],
  239. currentLocktime: 50,
  240. locktimeOptions: [],
  241. currentStage: '1',
  242. // 补充了 time 属性,用于双向绑定输入框的时间
  243. currentStageList: [],
  244. // 双相位图模式数据(仅当后端返回 thisCycle/lastCycle 时启用)
  245. thisCycle: null,
  246. lastCycle: null,
  247. }
  248. },
  249. computed: {
  250. // 双相位图模式:后端提供了 thisCycle 时启用,否则保持单图行为
  251. isDual() {
  252. return !!(this.thisCycle && this.thisCycle.phaseData && this.thisCycle.phaseData.length);
  253. },
  254. // 黄闪、关灯、全红时禁用控制方案
  255. isSchemeDisabled() {
  256. return ['yellow_flash', 'lights_off', 'all_red'].includes(this.currentMethod);
  257. },
  258. // 定周期、中心计划、感应控制、临时方案可编辑当前阶段
  259. canEditStage() {
  260. return ['fixed', 'system', 'sensor', 'temp'].includes(this.currentMethod);
  261. },
  262. // 时长选项:30, 60, 90 ... 300
  263. durationOptions() {
  264. const list = [];
  265. for (let i = 30; i <= 300; i += 30) list.push(i);
  266. return list;
  267. }
  268. },
  269. watch: {
  270. // 监听控制方式切换
  271. currentMethod(newVal) {
  272. // 切换到步进时不自动弹出锁定时间,等用户点击阶段再弹
  273. this.showLockTime = false;
  274. if (newVal === 'step') {
  275. this.syncLocktimeByStage();
  276. }
  277. // 模拟需求1:根据不同模式,切换对应的控制方案数据 (Mock 逻辑)
  278. this.updateSchemeDataByMethod(newVal);
  279. },
  280. // 阶段列表变化时重挂 ResizeObserver 到新的首个元素 (max-height 跟着重算)
  281. currentStageList() {
  282. this.$nextTick(() => this._observeFirstStageItem());
  283. },
  284. // 当前阶段切到第 5+ 个时, 自动滚到可见
  285. currentStage() {
  286. this.$nextTick(() => this._scrollCurrentStageIntoView());
  287. },
  288. },
  289. mounted() {
  290. this.initScaleObserver();
  291. if (this.preloadedData) {
  292. this.applyData(this.preloadedData);
  293. } else {
  294. this.loadData();
  295. }
  296. },
  297. beforeDestroy() {
  298. if (this._ro) this._ro.disconnect();
  299. if (this._stageRO) this._stageRO.disconnect();
  300. if (this._stageRaf) cancelAnimationFrame(this._stageRaf);
  301. },
  302. methods: {
  303. // 点击阶段:切换选中,步进模式下同时弹出锁定时间
  304. onStageClick(value) {
  305. this.currentStage = value;
  306. if (this.currentMethod === 'step') {
  307. this.showLockTime = true;
  308. this.syncLocktimeByStage();
  309. }
  310. },
  311. // 根据当前选中阶段同步锁定时间选项
  312. syncLocktimeByStage() {
  313. const stage = this.currentStageList.find(s => s.value === this.currentStage);
  314. if (stage && stage.locktimeOptions) {
  315. this.locktimeOptions = stage.locktimeOptions;
  316. // 如果当前值不在新选项中,重置为第一个
  317. const hasValue = this.locktimeOptions.some(o => o.value === this.currentLocktime);
  318. if (!hasValue) {
  319. this.currentLocktime = this.locktimeOptions[0]?.value || null;
  320. }
  321. }
  322. },
  323. stagePercent(time) {
  324. const total = this.currentStageList.reduce((s, item) => s + (item.time || 0), 0);
  325. if (!total) return '0%';
  326. return Math.round(time / total * 100) + '%';
  327. },
  328. onScanTick(activeTime) {
  329. if (!this.mockPhaseData || this.mockPhaseData.length === 0) return;
  330. // 只看第一轨道(trackIdx=0)的相位
  331. const phase = this.mockPhaseData.find(p => p[0] === 0 && activeTime >= p[1] && activeTime < p[2]);
  332. if (!phase) return;
  333. const type = phase[5]; // green/stripe/yellow/red
  334. const iconValue = phase[6]; // 如 "STRAIGHT_DOWN,STRAIGHT_UP"
  335. const direction = phase[7]; // ns/ew
  336. const phaseName = phase[3]; // P1/P2 等
  337. const endTime = phase[2];
  338. const remaining = Math.max(0, Math.round(endTime - activeTime));
  339. const nsGreen = (type === 'green' && direction === 'ns');
  340. const ewGreen = (type === 'green' && direction === 'ew');
  341. // 从图标值解析当前允许的行驶方向类型
  342. // STRAIGHT→S, TURN_*_LEFT→L, *_UTURN→U
  343. let activeArrowTypes = [];
  344. if ((nsGreen || ewGreen) && iconValue) {
  345. const icons = iconValue.split(',');
  346. icons.forEach(ic => {
  347. if (ic.includes('UTURN')) activeArrowTypes.push('U');
  348. if (ic.includes('TURN') && !ic.includes('UTURN')) activeArrowTypes.push('L');
  349. if (ic.includes('STRAIGHT')) activeArrowTypes.push('S');
  350. });
  351. // 去重
  352. activeArrowTypes = [...new Set(activeArrowTypes)];
  353. }
  354. // 人行道全红判断:只有 P1/P3 绿灯期间人行道才有绿灯,其余时段全红
  355. const pedAllRed = !(type === 'green' && (phaseName === 'P1' || phaseName === 'P3'));
  356. this.$set(this.intersectionData, 'signals', {
  357. pedAllRed,
  358. ns: {
  359. phaseName: nsGreen ? ({ P1: '南北直行', P2: '南北左转' }[phaseName] || '南北') : (this.intersectionData.signals?.ns?.phaseName || '南北'),
  360. time: remaining,
  361. isGreen: nsGreen,
  362. activeArrowTypes: nsGreen ? activeArrowTypes : []
  363. },
  364. ew: {
  365. phaseName: ewGreen ? ({ P3: '东西直行', P4: '东西左转' }[phaseName] || '东西') : (this.intersectionData.signals?.ew?.phaseName || '东西'),
  366. time: remaining,
  367. isGreen: ewGreen,
  368. activeArrowTypes: ewGreen ? activeArrowTypes : []
  369. }
  370. });
  371. // 更新实时方案圆饼图的已走时长
  372. this.updateRealtimeDonut(activeTime);
  373. // 双相位图模式:检测周期 wrap-around,触发 refetch 让后端把刚结束的周期作为 lastCycle 返回
  374. if (this.isDual) {
  375. if (this._prevTick != null && activeTime < this._prevTick - 1) {
  376. this.refetchDetail();
  377. }
  378. this._prevTick = activeTime;
  379. }
  380. },
  381. async refetchDetail() {
  382. if (this._refetching) return;
  383. this._refetching = true;
  384. try {
  385. const nodeId = this.$attrs.id || this.id;
  386. const data = await apiGetCrossingDetailData(nodeId, { iconMode: this.iconMode });
  387. if (data) this.applyData(data);
  388. } finally {
  389. this._refetching = false;
  390. }
  391. },
  392. // 从 phaseData 解析4个阶段的绿灯时长,构建圆饼图数据
  393. buildDonutFromPhaseData() {
  394. const phaseData = this.mockPhaseData || [];
  395. const stageColors = ['#3b82f6', '#a855f7', '#14b8a6', '#f59e0b'];
  396. const stageLabels = ['1-南北直行', '2-南北左转', '3-东西直行', '4-东西左转'];
  397. // 提取 track 0 的绿灯相位(每阶段的第一个 green)
  398. const greenPhases = phaseData.filter(p => p[0] === 0 && p[5] === 'green');
  399. const stages = greenPhases.slice(0, 4).map((p, i) => {
  400. const total = p[8] || Math.floor(this.cycleLength / 4);
  401. return {
  402. label: stageLabels[i] || `阶段${i + 1}`,
  403. value: total, // 阶段总时长
  404. start: p[1], // 阶段开始时间(从绿灯起始)
  405. end: p[1] + total, // 阶段结束时间
  406. color: stageColors[i]
  407. };
  408. });
  409. this.phaseStages = stages;
  410. // 实时方案:已走时长 + 4个阶段
  411. this.realtimeDonutData = [
  412. { label: '已走时长', value: 0, color: '#8892a0' },
  413. ...stages.map(s => ({ label: s.label, value: s.value, color: s.color }))
  414. ];
  415. this.realtimeRemaining = this.cycleLength;
  416. // 下周期方案:4个阶段,初始数据与实时方案相同
  417. this.nextCycleDonutData = stages.map(s => ({
  418. label: s.label,
  419. value: s.value,
  420. color: s.color
  421. }));
  422. },
  423. // 根据扫描线时间更新实时方案圆饼图
  424. updateRealtimeDonut(activeTime) {
  425. if (this.phaseStages.length === 0) return;
  426. const elapsed = Math.round(activeTime);
  427. const remaining = Math.max(0, this.cycleLength - elapsed);
  428. this.realtimeRemaining = remaining;
  429. // 计算各阶段的剩余时长
  430. const stageValues = this.phaseStages.map(s => {
  431. if (activeTime >= s.end) return 0; // 阶段已完成
  432. if (activeTime < s.start) return s.value; // 阶段未开始
  433. return Math.max(0, Math.round(s.end - activeTime)); // 阶段进行中
  434. });
  435. this.realtimeDonutData = [
  436. { label: '已走时长', value: elapsed, color: '#8892a0' },
  437. ...this.phaseStages.map((s, i) => ({
  438. label: s.label,
  439. value: stageValues[i],
  440. color: s.color
  441. }))
  442. ];
  443. },
  444. // 观察首个 stage-item-wrapper, 算出 2 行高度(含行间 gap) 写到 CSS 变量。
  445. // grid 的 height 锁在 2 行高度: 不足 8 个时下排留白; 超 8 个时按行滚动。
  446. // 防 ResizeObserver loop 警告: rAF 推到下一帧 + 值比较切断"测量→撑大→再测"循环。
  447. _observeFirstStageItem() {
  448. const grid = this.$refs.stageGrid;
  449. if (!grid) return;
  450. if (this._stageRO) {
  451. this._stageRO.disconnect();
  452. this._stageRO = null;
  453. }
  454. const first = grid.firstElementChild;
  455. if (!first) return;
  456. const schedule = () => {
  457. if (this._stageRaf) return;
  458. this._stageRaf = requestAnimationFrame(() => {
  459. this._stageRaf = 0;
  460. const f = grid.firstElementChild;
  461. if (!f) return;
  462. const rowH = f.offsetHeight;
  463. if (rowH <= 0) return;
  464. const cs = window.getComputedStyle(grid);
  465. const gap = parseFloat(cs.rowGap || cs.gap || '0') || 0;
  466. const reservedH = rowH * 2 + gap;
  467. if (this._lastRowH === rowH && this._lastReservedH === reservedH) return;
  468. this._lastRowH = rowH;
  469. this._lastReservedH = reservedH;
  470. grid.style.setProperty('--stage-row-h', rowH + 'px');
  471. grid.style.setProperty('--stage-reserved-h', reservedH + 'px');
  472. });
  473. };
  474. this._stageRO = new ResizeObserver(schedule);
  475. this._stageRO.observe(first);
  476. schedule();
  477. },
  478. // 当前阶段切换后, 若它在第二行/之后, 自动滚到可见
  479. _scrollCurrentStageIntoView() {
  480. const grid = this.$refs.stageGrid;
  481. if (!grid) return;
  482. const idx = this.currentStageList.findIndex(s => s.value === this.currentStage);
  483. const el = idx >= 0 ? grid.children[idx] : null;
  484. if (el && typeof el.scrollIntoView === 'function') {
  485. el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
  486. }
  487. },
  488. initScaleObserver() {
  489. const ro = new ResizeObserver(entries => {
  490. const { width } = entries[0].contentRect;
  491. const s = Math.min(width / 1315, 1);
  492. this.$el.style.setProperty('--s', s);
  493. this.panelScale = s;
  494. });
  495. ro.observe(this.$el);
  496. this._ro = ro;
  497. },
  498. async loadData() {
  499. const nodeId = this.$attrs.id || this.id;
  500. const data = await apiGetCrossingDetailData(nodeId, { iconMode: this.iconMode });
  501. if (data) {
  502. this.applyData(data);
  503. }
  504. },
  505. // 给每个 stage 派生 icons (PhaseDiagram 渲染用):
  506. // 优先用后端直接给的 icons[], 否则从 direction 字符串拆 token。
  507. // 缺失时 PhaseDiagram 不渲染, 模板内 fallback 到旧的 <img :src="item.img">。
  508. _withIcons(stages) {
  509. return (stages || []).map(item => ({
  510. ...item,
  511. icons: item.icons || (item.direction || '').split(',').filter(Boolean),
  512. }));
  513. },
  514. applyData(data) {
  515. this.currentRoute = data.currentRoute || {};
  516. this.intersectionData = data.intersectionData || {};
  517. this.mockPhaseData = data.phaseData || [];
  518. this.cycleLength = data.cycleLength || 140;
  519. this.currentSec = data.currentTime || 0;
  520. this.phaseDiff = data.phaseDiff || 0;
  521. this.coordTime = data.coordTime || 0;
  522. this.currentStageList = this._withIcons(data.stageList);
  523. // 双相位图字段:后端没返回时为 null,UI 自动退化为单图模式
  524. this.thisCycle = data.thisCycle || null;
  525. this.lastCycle = data.lastCycle || null;
  526. this.buildDonutFromPhaseData();
  527. this.$nextTick(() => {
  528. this.dataReady = true;
  529. });
  530. this.schemeOptions = data.schemeOptions || [];
  531. if (data.currentScheme) this.currentScheme = data.currentScheme;
  532. if (data.controlMethodOptions) this.controlMethodOptions = data.controlMethodOptions;
  533. if (data.currentMethod) this.currentMethod = data.currentMethod;
  534. if (data.locktimeOptions) this.locktimeOptions = data.locktimeOptions;
  535. if (data.startDate) this.startDate = data.startDate;
  536. if (data.startTime) this.startTime = data.startTime;
  537. if (data.endDate) this.endDate = data.endDate;
  538. if (data.endTime) this.endTime = data.endTime;
  539. if (data.duration) this.duration = data.duration;
  540. if (data.period) this.period = data.period;
  541. },
  542. // 切换手动控制模式
  543. toggleManualMode() {
  544. this.isManualMode = !this.isManualMode;
  545. if (!this.isManualMode) {
  546. // 如果退出手动模式,可选择重置表单状态
  547. this.showLockTime = false;
  548. }
  549. },
  550. // 控制方式 chip 按钮点击:切 currentMethod 触发现有 watch(line ~272)逻辑
  551. setControlMethod(value) {
  552. if (!this.isManualMode) return;
  553. this.currentMethod = value;
  554. },
  555. // ====== 配时方案编辑弹窗 ======
  556. // 当前路口在弹窗里用的稳定 ID(不同路口独立弹窗,同一路口同一类型互斥)
  557. _crossingId() {
  558. return this.currentRoute?.id || this.$attrs.id || this.id || 'unknown';
  559. },
  560. _tempDialogId() { return `scheme-edit-temp-${this._crossingId()}`; },
  561. _schemeDialogId() { return `scheme-edit-permanent-${this._crossingId()}`; },
  562. // "临时修改"按钮:打开带时间区段的弹窗(同时关掉"修改方案"避免互相覆盖)
  563. openTempSchemeDialog() {
  564. if (!this.isManualMode || !this.dialogManager) return;
  565. // 互斥:先关掉对面弹窗(DashboardLayout provide 的方法名是 closeDialog,不是 handleDialogClose)
  566. this.dialogManager.closeDialog(this._schemeDialogId());
  567. const id = this._tempDialogId();
  568. this.dialogManager.openDialog({
  569. id,
  570. title: '修改临时配时方案',
  571. component: 'SchemeStageEditDialog',
  572. width: 720,
  573. height: 480,
  574. center: true,
  575. draggable: true,
  576. resizable: true,
  577. noPadding: true, // 内容组件自管 padding
  578. showClose: true,
  579. data: {
  580. dialogId: id,
  581. showTimeRange: true,
  582. payload: {
  583. stages: JSON.parse(JSON.stringify(this.currentStageList || [])),
  584. timeRange: {
  585. startDate: this.startDate, startTime: this.startTime,
  586. endDate: this.endDate, endTime: this.endTime,
  587. duration: this.duration, period: this.period,
  588. },
  589. isFixedCycle: false,
  590. },
  591. onSave: (updated) => this.onTempSchemeSave(updated),
  592. onCancel: null,
  593. },
  594. });
  595. },
  596. // "修改方案"按钮:打开不带时间区段的弹窗
  597. openSchemeDialog() {
  598. if (!this.isManualMode || !this.dialogManager) return;
  599. // 互斥:先关掉对面弹窗
  600. this.dialogManager.closeDialog(this._tempDialogId());
  601. const id = this._schemeDialogId();
  602. this.dialogManager.openDialog({
  603. id,
  604. title: '修改配时方案',
  605. component: 'SchemeStageEditDialog',
  606. width: 660,
  607. height: 420,
  608. center: true,
  609. draggable: true,
  610. resizable: true,
  611. noPadding: true,
  612. showClose: true,
  613. data: {
  614. dialogId: id,
  615. showTimeRange: false,
  616. payload: {
  617. stages: JSON.parse(JSON.stringify(this.currentStageList || [])),
  618. isFixedCycle: false,
  619. },
  620. onSave: (updated) => this.onSchemeSave(updated),
  621. onCancel: null,
  622. },
  623. });
  624. },
  625. async onTempSchemeSave(payload) {
  626. const id = this._crossingId();
  627. // 调后端保存(mock 当前直接 200,真实后端可能返回 schemeId / appliedAt 等)
  628. try {
  629. await apiSaveCrossingTempScheme(id, payload);
  630. } catch (e) {
  631. console.warn('[CrossingDetailPanel] saveTempScheme failed:', e);
  632. }
  633. // 本地写回(不阻塞 UI)
  634. if (Array.isArray(payload.stages)) this.currentStageList = this._withIcons(payload.stages);
  635. const tr = payload.timeRange || {};
  636. if (tr.startDate !== undefined) this.startDate = tr.startDate;
  637. if (tr.startTime !== undefined) this.startTime = tr.startTime;
  638. if (tr.endDate !== undefined) this.endDate = tr.endDate;
  639. if (tr.endTime !== undefined) this.endTime = tr.endTime;
  640. if (tr.duration !== undefined) this.duration = tr.duration;
  641. if (tr.period !== undefined) this.period = tr.period;
  642. this.buildDonutFromPhaseData();
  643. this.$emit('confirm', { method: 'temp', scheme: this.currentScheme, stages: this.currentStageList });
  644. },
  645. async onSchemeSave(payload) {
  646. const id = this._crossingId();
  647. try {
  648. await apiSaveCrossingScheme(id, { ...payload, schemeId: this.currentScheme });
  649. } catch (e) {
  650. console.warn('[CrossingDetailPanel] saveScheme failed:', e);
  651. }
  652. if (Array.isArray(payload.stages)) this.currentStageList = this._withIcons(payload.stages);
  653. this.buildDonutFromPhaseData();
  654. this.$emit('confirm', { method: this.currentMethod, scheme: this.currentScheme, stages: this.currentStageList });
  655. },
  656. // 模拟:根据控制方式改变下拉方案的数据
  657. updateSchemeDataByMethod(method) {
  658. if (method === 'system') {
  659. this.schemeOptions = [
  660. { label: '系统优化方案A', value: 'sys_a' },
  661. { label: '系统优化方案B', value: 'sys_b' }
  662. ];
  663. this.currentScheme = 'sys_a';
  664. } else {
  665. this.schemeOptions = [
  666. { label: '早高峰', value: 'early_peak' },
  667. { label: '晚高峰', value: 'evening_peak' },
  668. { label: '平峰', value: 'normal' }
  669. ];
  670. this.currentScheme = 'early_peak';
  671. }
  672. },
  673. // 取消按钮(退出手动控制)
  674. onCancel() {
  675. this.isManualMode = false;
  676. this.showLockTime = false;
  677. },
  678. // 步进锁定时间弹窗:取消(不退出手动控制)
  679. onLockTimeCancel() {
  680. this.showLockTime = false;
  681. },
  682. // 步进锁定时间弹窗:确认(不退出手动控制)
  683. onLockTimeConfirm() {
  684. this.showLockTime = false;
  685. const submitData = {
  686. method: 'step',
  687. stage: this.currentStage,
  688. lockTimeType: this.lockTimeType,
  689. locktime: this.lockTimeType === 'timer' ? this.currentLocktime : null,
  690. };
  691. console.log('步进指令:', submitData);
  692. this.$emit('confirm', submitData);
  693. },
  694. // 需求5:点击确认按钮提交 + 表单验证
  695. onConfirm() {
  696. // 验证1:临时方案必须检查时间是否有效
  697. if (this.currentMethod === 'temp') {
  698. const isInvalid = this.currentStageList.some(item => !item.time || item.time <= 0);
  699. if (isInvalid) {
  700. alert('请输入有效的阶段时间 (必须大于0)!');
  701. return;
  702. }
  703. }
  704. // 验证2:步进方案必须选择锁定类型
  705. if (this.currentMethod === 'step') {
  706. if (this.lockTimeType === 'timer' && !this.currentLocktime) {
  707. alert('请选择解锁时间!');
  708. return;
  709. }
  710. }
  711. // 构造提交参数
  712. const submitData = {
  713. method: this.currentMethod,
  714. scheme: this.currentScheme,
  715. stages: this.currentMethod === 'temp' ? this.currentStageList : null,
  716. lockConfig: this.currentMethod === 'step' ? {
  717. stage: this.currentStage,
  718. type: this.lockTimeType,
  719. time: this.lockTimeType === 'timer' ? this.currentLocktime : null
  720. } : null
  721. };
  722. console.log('提交的数据:', submitData);
  723. // 提交完成后可根据业务决定是否退出手动模式
  724. // this.isManualMode = false;
  725. }
  726. }
  727. }
  728. </script>
  729. <style scoped>
  730. .crossing-detail-panel {
  731. --s: 1;
  732. position: relative;
  733. display: flex;
  734. flex-direction: row;
  735. gap: clamp(4px, calc(var(--s) * 12px), 12px);
  736. height: 100%;
  737. min-height: 0;
  738. overflow: hidden;
  739. }
  740. /* 双图模式:根节点变 column,content-row 变 row 容器,相位图作为整宽底部行 */
  741. .crossing-detail-panel.is-dual {
  742. flex-direction: column;
  743. gap: clamp(4px, calc(var(--s) * 8px), 8px);
  744. }
  745. /* 单图模式:content-row 透明,子节点直接挂到根的 row 布局上(保持原行为) */
  746. .content-row {
  747. display: contents;
  748. }
  749. /* 双图模式:content-row 显形为 row,承载视频+表单 */
  750. .crossing-detail-panel.is-dual .content-row {
  751. display: flex;
  752. flex-direction: row;
  753. gap: clamp(4px, calc(var(--s) * 12px), 12px);
  754. flex: 1 1 0;
  755. min-width: 0;
  756. min-height: 0;
  757. width: 100%;
  758. }
  759. /* ===== 左侧:还原原始固定 55% 占比 ===== */
  760. .detail-panel-left {
  761. display: flex;
  762. flex-direction: column;
  763. flex: 0 0 55%;
  764. min-height: 0;
  765. min-width: 0;
  766. }
  767. /* ===== 右侧:flex 列容器 + 滚动兜底 ===== */
  768. .detail-panel-right {
  769. flex: 1;
  770. min-width: 0;
  771. min-height: 0;
  772. overflow-y: auto;
  773. overflow-x: hidden;
  774. display: flex;
  775. flex-direction: column;
  776. }
  777. .intersection-video-wrap {
  778. width: 100%;
  779. min-height: 0;
  780. flex: 2;
  781. }
  782. .signal-timing-wrap {
  783. flex: 0 0 auto;
  784. min-height: 0;
  785. height: clamp(95px, calc(var(--s) * 166px), 166px);
  786. width: 100%;
  787. min-width: 0;
  788. background-color: transparent;
  789. box-sizing: border-box;
  790. position: relative;
  791. display: flex;
  792. flex-direction: column;
  793. overflow: hidden;
  794. padding: clamp(3px, calc(var(--s) * 10px), 10px);
  795. }
  796. /* 双相位图模式:整宽底部行 */
  797. .signal-timing-wrap.is-dual {
  798. height: clamp(100px, calc(var(--s) * 170px), 170px);
  799. padding: 0 clamp(3px, calc(var(--s) * 10px), 10px);
  800. }
  801. /* 双图模式下 header 紧贴下方相位图,避免无意义空白 */
  802. .signal-timing-wrap.is-dual .header {
  803. margin-bottom: 0;
  804. line-height: 1;
  805. padding: 2px 0 0;
  806. }
  807. .signal-timing-wrap.is-dual .main-title {
  808. font-size: clamp(10px, calc(var(--s) * 14px), 14px);
  809. }
  810. .signal-timing-wrap.is-dual .sub-info {
  811. font-size: clamp(9px, calc(var(--s) * 12px), 12px);
  812. }
  813. .signal-timing-wrap.is-dual .timing-row {
  814. flex: 1 1 0;
  815. min-height: 0;
  816. display: flex;
  817. flex-direction: column;
  818. }
  819. .signal-timing-wrap.is-dual .row-label {
  820. flex: 0 0 auto;
  821. font-size: clamp(8px, calc(var(--s) * 11px), 11px);
  822. color: #9ca3af;
  823. padding: 0 4px;
  824. line-height: 1;
  825. margin-bottom: 2px;
  826. /* 强制单行 + 省略号:多窗口窄屏下"上周期 实际 / 计划" 这类长文本曾换成 2 行,
  827. 挤压自己 row 内 chart 高度并视觉撑出上一条 chart 的 canvas 边界 */
  828. white-space: nowrap;
  829. overflow: hidden;
  830. text-overflow: ellipsis;
  831. }
  832. .signal-timing-wrap.is-dual .row-chart {
  833. flex: 1 1 0;
  834. min-height: 0;
  835. display: flex;
  836. position: relative;
  837. /* 防御性:ECharts 极端情况下 label/markLine 可能溢出 canvas 边界,加裁切兜底 */
  838. overflow: hidden;
  839. }
  840. .signal-timing-wrap.is-dual .empty-placeholder {
  841. flex: 1;
  842. display: flex;
  843. align-items: center;
  844. justify-content: center;
  845. color: #6b7280;
  846. font-size: clamp(10px, calc(var(--s) * 12px), 12px);
  847. }
  848. .header {
  849. display: flex;
  850. justify-content: space-between;
  851. align-items: center;
  852. margin-bottom: clamp(2px, calc(var(--s) * 6px), 15px);
  853. color: #e0e6f1;
  854. flex-shrink: 0;
  855. }
  856. .title-area {
  857. font-size: clamp(9px, calc(var(--s) * 16px), 16px);
  858. }
  859. .main-title {
  860. font-size: clamp(10px, calc(var(--s) * 18px), 18px);
  861. font-weight: bold;
  862. margin-right: clamp(4px, calc(var(--s) * 10px), 10px);
  863. }
  864. .sub-info {
  865. font-size: clamp(8px, calc(var(--s) * 12px), 12px);
  866. opacity: 0.8;
  867. }
  868. .checkbox-area {
  869. font-size: clamp(8px, calc(var(--s) * 12px), 12px);
  870. display: flex;
  871. align-items: center;
  872. cursor: pointer;
  873. opacity: 0.7;
  874. user-select: none;
  875. }
  876. .checkbox-area:hover {
  877. opacity: 1;
  878. }
  879. .checkbox-mock {
  880. width: clamp(10px, calc(var(--s) * 14px), 14px);
  881. height: clamp(10px, calc(var(--s) * 14px), 14px);
  882. border: 1px solid rgba(255, 255, 255, 0.5);
  883. margin-right: clamp(3px, calc(var(--s) * 6px), 6px);
  884. border-radius: 2px;
  885. display: flex;
  886. align-items: center;
  887. justify-content: center;
  888. }
  889. .checkbox-mock.is-checked {
  890. background-color: #4da8ff;
  891. border-color: #4da8ff;
  892. }
  893. .chart-container {
  894. width: 100%;
  895. min-width: 0;
  896. flex: 1;
  897. min-height: 50px;
  898. overflow: hidden;
  899. }
  900. .loading-overlay {
  901. flex: 1;
  902. display: flex;
  903. align-items: center;
  904. justify-content: center;
  905. color: #758599;
  906. font-size: 14px;
  907. }
  908. /* ===== 右侧表单内层容器 ===== */
  909. .detail-right-form {
  910. flex: 1;
  911. min-height: 0;
  912. display: flex;
  913. flex-direction: column;
  914. }
  915. .form-group {
  916. flex: 1;
  917. min-height: 0;
  918. display: flex;
  919. flex-direction: column;
  920. }
  921. /** 控制方法 */
  922. .control-method {
  923. color: #ffffff;
  924. flex-shrink: 0;
  925. }
  926. .control-label-wrap {
  927. display: flex;
  928. align-items: center;
  929. margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
  930. column-gap: clamp(4px, calc(var(--s) * 10px), 20px);
  931. margin-top: clamp(4px, calc(var(--s) * 20px), 20px);
  932. }
  933. .control-label {
  934. font-size: clamp(12px, calc(var(--s) * 22px), 28px);
  935. color: #ffffff;
  936. white-space: nowrap;
  937. }
  938. .control-label-wrap span {
  939. display: inline-block;
  940. }
  941. .operation-btn {
  942. font-size: clamp(10px, calc(var(--s) * 16px), 16px);
  943. cursor: pointer;
  944. user-select: none;
  945. }
  946. .operation-btn:hover {
  947. text-decoration: underline;
  948. }
  949. .operation-btn.is-active {
  950. text-decoration: underline;
  951. }
  952. .control-method .control-label-wrap {
  953. justify-content: flex-start;
  954. }
  955. .control-chips-row {
  956. flex-wrap: wrap;
  957. gap: clamp(4px, calc(var(--s) * 8px), 10px);
  958. }
  959. /* ===== 新版控制方式 chip 按钮组 ===== */
  960. .control-chips {
  961. display: flex;
  962. align-items: center;
  963. flex-wrap: wrap;
  964. gap: clamp(4px, calc(var(--s) * 6px), 8px);
  965. }
  966. .chip {
  967. display: inline-flex;
  968. align-items: center;
  969. justify-content: center;
  970. border: none;
  971. outline: none;
  972. cursor: pointer;
  973. user-select: none;
  974. color: #ffffff;
  975. font-size: clamp(10px, calc(var(--s) * 14px), 14px);
  976. line-height: 1;
  977. padding: clamp(4px, calc(var(--s) * 6px), 8px) clamp(8px, calc(var(--s) * 14px), 16px);
  978. border-radius: 4px;
  979. background: rgba(255, 255, 255, 0.06);
  980. box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
  981. transition: filter 0.15s, opacity 0.15s, transform 0.05s, box-shadow 0.15s;
  982. white-space: nowrap;
  983. }
  984. .chip:hover:not(:disabled) {
  985. filter: brightness(1.15);
  986. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.32);
  987. }
  988. .chip:active:not(:disabled) {
  989. transform: translateY(1px);
  990. box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
  991. }
  992. .chip:focus-visible:not(:disabled) {
  993. box-shadow: 0 0 0 2px #ffffff, 0 1px 2px rgba(0, 0, 0, 0.2);
  994. }
  995. .chip:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
  996. .chip.is-active {
  997. box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.55) inset, 0 1px 2px rgba(0, 0, 0, 0.2);
  998. }
  999. /* 颜色 token(参考图) */
  1000. .chip-manual { background: #ff6b6b; } /* 解除手动 / 手动控制 */
  1001. .chip-mode-off { background: #4dd4ac; } /* 关灯 */
  1002. .chip-mode-yellow { background: #fbbf24; color: #1f2937; } /* 黄闪:背景亮,文字反深色 */
  1003. .chip-mode-red { background: #ef4444; } /* 全红 */
  1004. .chip-action { background: #3b82f6; } /* 临时修改 / 修改方案 */
  1005. .chip-action:hover:not(:disabled) { background: #2563eb; }
  1006. .chip-divider {
  1007. width: 1px;
  1008. height: clamp(14px, calc(var(--s) * 20px), 22px);
  1009. background: rgba(127, 182, 255, 0.25);
  1010. margin: 0 clamp(2px, calc(var(--s) * 4px), 6px);
  1011. }
  1012. .control-scheme {
  1013. margin-top: clamp(4px, calc(var(--s) * 10px), 20px);
  1014. /* 设置 font-size 供 DropdownSelect size="auto" 继承 */
  1015. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  1016. }
  1017. .control-scheme.is-disabled {
  1018. opacity: 0.4;
  1019. pointer-events: none;
  1020. }
  1021. .lock-time {
  1022. width: 80%;
  1023. border-radius: 8px;
  1024. box-shadow:
  1025. inset 0px 0px 10px 0px rgba(88, 146, 255, 0.4),
  1026. inset 20px 0px 30px -10px rgba(88, 146, 255, 0.15);
  1027. }
  1028. /* 步进锁定时间弹窗 - 遮罩 */
  1029. .lock-time-overlay {
  1030. position: absolute;
  1031. inset: 0;
  1032. display: flex;
  1033. align-items: center;
  1034. justify-content: center;
  1035. background: rgba(0, 0, 0, 0.45);
  1036. z-index: 200;
  1037. border-radius: inherit;
  1038. }
  1039. /* 步进锁定时间弹窗 */
  1040. .lock-time-dialog {
  1041. background: linear-gradient(135deg, rgba(10, 25, 60, 0.97) 0%, rgba(20, 40, 90, 0.97) 100%);
  1042. border: 1px solid rgba(161, 190, 255, 0.3);
  1043. border-radius: 6px;
  1044. min-width: 220px;
  1045. max-width: 320px;
  1046. width: 70%;
  1047. box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
  1048. }
  1049. .lock-time-header {
  1050. display: flex;
  1051. align-items: center;
  1052. justify-content: space-between;
  1053. padding: clamp(4px, calc(var(--s) * 8px), 10px);
  1054. border-radius: 6px 6px 0 0;
  1055. color: #ffffff;
  1056. background: linear-gradient(180deg,
  1057. rgba(65, 115, 205, 0.6) 0%,
  1058. rgba(40, 70, 130, 0.1) 100%);
  1059. backdrop-filter: blur(10px);
  1060. }
  1061. .lock-time-title {
  1062. font-size: clamp(10px, calc(var(--s) * 16px), 16px);
  1063. color: #ffffff;
  1064. }
  1065. .lock-time-close {
  1066. cursor: pointer;
  1067. color: #ffffff;
  1068. }
  1069. .lock-time-body {
  1070. padding: 0;
  1071. }
  1072. .lock-time-options {
  1073. display: flex;
  1074. flex-direction: column;
  1075. row-gap: clamp(4px, calc(var(--s) * 10px), 10px);
  1076. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  1077. padding: clamp(4px, calc(var(--s) * 10px), 10px);
  1078. color: #ffffff;
  1079. }
  1080. .lock-time-actions {
  1081. display: flex;
  1082. justify-content: flex-end;
  1083. gap: 8px;
  1084. padding: 8px 12px;
  1085. border-top: 1px solid rgba(161, 190, 255, 0.15);
  1086. }
  1087. .lock-time-option {
  1088. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  1089. }
  1090. /* 过渡动画 */
  1091. .fade-enter-active,
  1092. .fade-leave-active {
  1093. transition: opacity 0.25s;
  1094. }
  1095. .fade-enter,
  1096. .fade-leave-to {
  1097. opacity: 0;
  1098. }
  1099. /* 4 列固定网格; 始终锁 2 行 + 行间 gap (不足 8 阶段时第 2 行留白); 超 8 滚动 */
  1100. .current-stage {
  1101. margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
  1102. display: grid;
  1103. grid-template-columns: repeat(4, 1fr);
  1104. /* 关键: grid 默认 align-content/items: stretch 会把内容拉伸填满 min-height,
  1105. 与 _observeFirstStageItem 测量形成"测量→撑大→再测→更撑大"循环。
  1106. 锁住行集合贴顶 + 行高按内容定, 切断循环。 */
  1107. grid-auto-rows: max-content;
  1108. align-content: start;
  1109. align-items: start;
  1110. gap: clamp(8px, calc(var(--s) * 18px), 24px);
  1111. color: #ffffff;
  1112. /* --stage-reserved-h 由 JS 测量首个 stage 后写入 (2 行高 + 行间 gap)。
  1113. 不给 fallback: 未写入前 var() 整体无效, min/max-height 退到 initial(0/none) */
  1114. min-height: var(--stage-reserved-h);
  1115. max-height: var(--stage-reserved-h);
  1116. overflow-y: auto;
  1117. overflow-x: hidden;
  1118. /* Firefox 滚动条 */
  1119. scrollbar-width: thin;
  1120. scrollbar-color: rgba(161, 190, 255, 0.45) rgba(255, 255, 255, 0.04);
  1121. }
  1122. /* WebKit / Blink (Chrome / Edge / Electron) 滚动条样式 */
  1123. .current-stage::-webkit-scrollbar {
  1124. width: 6px;
  1125. }
  1126. .current-stage::-webkit-scrollbar-track {
  1127. background: rgba(255, 255, 255, 0.04);
  1128. border-radius: 3px;
  1129. }
  1130. .current-stage::-webkit-scrollbar-thumb {
  1131. background: rgba(161, 190, 255, 0.45);
  1132. border-radius: 3px;
  1133. }
  1134. .current-stage::-webkit-scrollbar-thumb:hover {
  1135. background: rgba(161, 190, 255, 0.75);
  1136. }
  1137. .stage-input {
  1138. width: 100%;
  1139. min-width: 0;
  1140. border: 1px solid rgba(161, 190, 255, 0.7);
  1141. background-color: transparent;
  1142. padding: clamp(2px, calc(var(--s) * 5px), 5px);
  1143. color: #ffffff;
  1144. text-align: center;
  1145. }
  1146. .phase-box {
  1147. position: relative;
  1148. width: 100%;
  1149. /* 尺寸上限由 .stage-item-wrapper 的 --item-max-w 控制, 与 bottom-controls 同宽 */
  1150. max-width: var(--item-max-w);
  1151. margin: 0 auto;
  1152. aspect-ratio: 1 / 1;
  1153. background: #E6F0FF;
  1154. border-radius: 4px;
  1155. display: flex;
  1156. align-items: center;
  1157. justify-content: center;
  1158. cursor: pointer;
  1159. transition: all 0.3s ease;
  1160. box-sizing: border-box;
  1161. overflow: hidden;
  1162. }
  1163. .phase-image {
  1164. width: 100%;
  1165. height: 100%;
  1166. object-fit: contain;
  1167. display: block;
  1168. }
  1169. .phase-box::after {
  1170. content: '';
  1171. position: absolute;
  1172. top: 0;
  1173. left: 0;
  1174. width: 100%;
  1175. height: 100%;
  1176. background: rgba(30, 106, 255, 0.5);
  1177. opacity: 0;
  1178. transition: opacity 0.3s ease;
  1179. pointer-events: none;
  1180. }
  1181. .phase-box.is-active::after {
  1182. opacity: 1;
  1183. }
  1184. /** 按钮 */
  1185. .btn {
  1186. display: inline-flex;
  1187. justify-content: center;
  1188. align-items: center;
  1189. height: clamp(22px, calc(var(--s) * 36px), 36px);
  1190. padding: 0 clamp(10px, calc(var(--s) * 32px), 32px);
  1191. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  1192. border-radius: 4px;
  1193. cursor: pointer;
  1194. user-select: none;
  1195. transition: all 0.2s ease-in-out;
  1196. box-sizing: border-box;
  1197. }
  1198. .btn-cancel {
  1199. background-color: transparent;
  1200. color: #d1d5db;
  1201. border: 1px solid rgba(130, 150, 190, 0.4);
  1202. }
  1203. .btn-cancel:hover {
  1204. color: #ffffff;
  1205. border-color: rgba(130, 150, 190, 0.8);
  1206. background-color: rgba(255, 255, 255, 0.05);
  1207. }
  1208. .btn-cancel:active {
  1209. background-color: rgba(255, 255, 255, 0.1);
  1210. }
  1211. .btn-confirm {
  1212. background-color: #3b74ff;
  1213. color: #ffffff;
  1214. border: 1px solid #3b74ff;
  1215. }
  1216. .btn-confirm:hover {
  1217. background-color: #5a8bff;
  1218. border-color: #5a8bff;
  1219. box-shadow: 0 2px 8px rgba(59, 116, 255, 0.3);
  1220. }
  1221. .btn-confirm:active {
  1222. background-color: #265bed;
  1223. border-color: #265bed;
  1224. box-shadow: none;
  1225. }
  1226. .button-group {
  1227. display: flex;
  1228. justify-content: flex-end;
  1229. flex-shrink: 0;
  1230. /* margin-top: auto: 在 .form-group(flex column) 里把按钮推到底, 即使 form 内容不满也贴底 */
  1231. margin-top: auto;
  1232. /* sticky 兜底: 内容溢出 .detail-panel-right 时(16/32 阶段且面板较矮), 滚动也能见 */
  1233. position: sticky;
  1234. bottom: 0;
  1235. z-index: 5;
  1236. padding-top: clamp(4px, calc(var(--s) * 10px), 20px);
  1237. }
  1238. .button-group>div {
  1239. display: flex;
  1240. gap: clamp(4px, calc(var(--s) * 8px), 12px);
  1241. }
  1242. /* 禁用状态 */
  1243. .form-interactive-area {
  1244. transition: opacity 0.3s;
  1245. /* donut-row 抽出去后, 内部只剩 current-stage (本身已锁 2 行高 + 内部滚动),
  1246. 不再需要 flex:1 撑高; auto 高度让 donut 紧贴 current-stage 下方, 不留空隙 */
  1247. flex: 0 0 auto;
  1248. min-height: 0;
  1249. /* current-stage 内部已经有 overflow-y: auto, 这里不再二次滚动 */
  1250. overflow: visible;
  1251. }
  1252. .form-editable-area.is-disabled {
  1253. opacity: 0.6;
  1254. pointer-events: none;
  1255. }
  1256. .DropdownSelect-is-disabled{
  1257. opacity: 0.6;
  1258. }
  1259. /* 当前阶段输入框微调 */
  1260. .stage-item-wrapper {
  1261. flex: 1 1 0;
  1262. min-width: 0;
  1263. display: flex;
  1264. flex-direction: column;
  1265. align-items: stretch;
  1266. gap: clamp(2px, calc(var(--s) * 4px), 6px);
  1267. position: relative;
  1268. /* phase-box 和 bottom-controls 共用同一份上限宽, 保证 svg 框与下面的输入框等宽对齐 */
  1269. --item-max-w: clamp(60px, calc(var(--s) * 100px), 110px);
  1270. }
  1271. .bottom-controls {
  1272. display: flex;
  1273. align-items: center;
  1274. justify-content: center;
  1275. gap: clamp(4px, calc(var(--s) * 6px), 8px);
  1276. /* 输入框和百分比的间距 */
  1277. /* 与 phase-box 同宽: 共用 --item-max-w + 居中 */
  1278. width: 100%;
  1279. max-width: var(--item-max-w);
  1280. margin: 0 auto;
  1281. box-sizing: border-box;
  1282. }
  1283. /* 新增包裹层的相对定位 */
  1284. .input-unit-wrapper {
  1285. position: relative;
  1286. display: inline-block;
  1287. }
  1288. .stage-input {
  1289. width: 100%;
  1290. min-width: 0;
  1291. border: 1px solid rgba(161, 190, 255, 0.7);
  1292. background-color: transparent;
  1293. padding: clamp(2px, calc(var(--s) * 5px), 5px);
  1294. /* 给右侧留出空间,防止数字过长被 s 挡住 */
  1295. padding-right: clamp(10px, calc(var(--s) * 16px), 16px);
  1296. color: #ffffff;
  1297. text-align: center;
  1298. border-radius: 4px;
  1299. font-size: clamp(9px, calc(var(--s) * 13px), 13px);
  1300. }
  1301. /* 修改 s 单位的定位方式为垂直居中 */
  1302. .input-unit-wrapper .unit {
  1303. position: absolute;
  1304. top: 50%;
  1305. transform: translateY(-50%);
  1306. right: clamp(3px, calc(var(--s) * 6px), 8px);
  1307. color: #77A1FF;
  1308. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  1309. pointer-events: none;
  1310. }
  1311. /* 微调百分比的间距,让排版更紧凑 */
  1312. .stage-item-wrapper .percent {
  1313. color: rgba(255, 255, 255, 0.5);
  1314. font-size: clamp(9px, calc(var(--s) * 13px), 13px);
  1315. white-space: nowrap;
  1316. }
  1317. .stage-input {
  1318. width: 100%;
  1319. min-width: 0;
  1320. border: 1px solid rgba(161, 190, 255, 0.7);
  1321. background-color: transparent;
  1322. padding: clamp(2px, calc(var(--s) * 5px), 5px);
  1323. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  1324. color: #ffffff;
  1325. text-align: center;
  1326. border-radius: 4px;
  1327. -moz-appearance: textfield;
  1328. }
  1329. .stage-input::-webkit-outer-spin-button,
  1330. .stage-input::-webkit-inner-spin-button {
  1331. -webkit-appearance: none;
  1332. margin: 0;
  1333. }
  1334. .stage-input:disabled {
  1335. border-color: rgba(255, 255, 255, 0.2);
  1336. color: rgba(255, 255, 255, 0.5);
  1337. background-color: rgba(0, 0, 0, 0.2);
  1338. }
  1339. /* 弹窗过渡动画 */
  1340. /* lock-time 弹窗补充样式 */
  1341. .lock-time {
  1342. margin-top: clamp(4px, calc(var(--s) * 10px), 15px);
  1343. background-color: rgba(20, 30, 50, 0.9);
  1344. }
  1345. /* 单选框基础对齐 */
  1346. .lock-time-option label {
  1347. display: flex;
  1348. align-items: center;
  1349. gap: clamp(3px, calc(var(--s) * 6px), 8px);
  1350. cursor: pointer;
  1351. }
  1352. /* ===== 方案圆饼图左右布局 ===== */
  1353. .donut-row {
  1354. display: flex;
  1355. gap: clamp(4px, calc(var(--s) * 16px), 16px);
  1356. width: 100%;
  1357. margin-top: clamp(4px, calc(var(--s) * 20px), 20px);
  1358. }
  1359. .donut-item {
  1360. flex: 1;
  1361. min-width: 0;
  1362. }
  1363. .donut-title {
  1364. font-size: clamp(12px, calc(var(--s) * 22px), 28px);
  1365. color: #ffffff;
  1366. margin-bottom: 4px;
  1367. }
  1368. /* ===== 时间表单栏布局 ===== */
  1369. .time-form-bar {
  1370. display: grid;
  1371. grid-template-columns: repeat(4, 1fr);
  1372. gap: clamp(3px, calc(var(--s) * 5px), 6px);
  1373. margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
  1374. }
  1375. /* 第二行:带标签的项各占 2 列(共 4 列,两项铺满一行) */
  1376. .time-form-bar .form-item-labeled {
  1377. grid-column: span 2;
  1378. display: flex;
  1379. align-items: center;
  1380. gap: clamp(3px, calc(var(--s) * 6px), 8px);
  1381. }
  1382. .time-form-bar .form-label {
  1383. white-space: nowrap;
  1384. flex-shrink: 0;
  1385. color: rgba(200, 220, 255, 0.65);
  1386. font-size: clamp(9px, calc(var(--s) * 12px), 13px);
  1387. }
  1388. .time-form-bar .form-item-labeled .el-select {
  1389. flex: 1;
  1390. min-width: 0;
  1391. }
  1392. /* ===== ElementUI 深色主题适配 ===== */
  1393. /* 覆盖 el-date-editor 固定宽度,让其跟随 grid 布局 */
  1394. .time-form-bar>>>.el-date-editor.el-input,
  1395. .time-form-bar>>>.el-date-editor.el-input__inner,
  1396. .time-form-bar>>>.el-select {
  1397. width: 100%;
  1398. }
  1399. /* 输入框样式 */
  1400. .time-form-bar>>>.el-input__inner {
  1401. background-color: rgba(255, 255, 255, 0.06);
  1402. border: 1px solid rgba(161, 190, 255, 0.35);
  1403. color: #e0e6f1;
  1404. font-size: clamp(9px, calc(var(--s) * 11px), 11px);
  1405. height: clamp(22px, calc(var(--s) * 28px), 28px);
  1406. line-height: clamp(22px, calc(var(--s) * 28px), 28px);
  1407. padding-left: clamp(22px, calc(var(--s) * 26px), 26px);
  1408. padding-right: clamp(4px, calc(var(--s) * 6px), 6px);
  1409. }
  1410. .time-form-bar>>>.el-input__inner::placeholder {
  1411. color: rgba(255, 255, 255, 0.3);
  1412. }
  1413. .time-form-bar>>>.el-input__inner:hover {
  1414. border-color: rgba(161, 190, 255, 0.6);
  1415. }
  1416. .time-form-bar>>>.el-input__inner:focus {
  1417. border-color: #3b74ff;
  1418. }
  1419. /* 图标颜色、尺寸跟随缩放 */
  1420. .time-form-bar>>>.el-input__prefix,
  1421. .time-form-bar>>>.el-input__suffix {
  1422. color: rgba(255, 255, 255, 0.4);
  1423. }
  1424. .time-form-bar>>>.el-input__icon {
  1425. font-size: clamp(9px, calc(var(--s) * 12px), 12px);
  1426. line-height: clamp(22px, calc(var(--s) * 28px), 28px);
  1427. width: clamp(18px, calc(var(--s) * 22px), 22px);
  1428. }
  1429. </style>