CrossingDetailPanel.vue 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178
  1. <template>
  2. <div class="crossing-detail-panel">
  3. <div class="detail-panel-left">
  4. <div class="intersection-video-wrap">
  5. <IntersectionMapVideos :mapData="intersectionData" :videoUrls="currentRoute.cornerVideos" />
  6. </div>
  7. <div class="signal-timing-wrap">
  8. <div class="header">
  9. <div class="title-area">
  10. <span class="main-title">方案状态</span>
  11. <span class="sub-info">(周期: {{ cycleLength }} 相位差: {{ phaseDiff }} 协调时间: {{ coordTime }})</span>
  12. </div>
  13. </div>
  14. <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec" :phaseData="mockPhaseData"
  15. :showScanLine="dataReady" :autoScan="dataReady" @scan-tick="onScanTick" />
  16. </div>
  17. </div>
  18. <div class="detail-panel-right">
  19. <form class="detail-right-form" @submit.prevent>
  20. <div class="form-group">
  21. <div class="control-method">
  22. <div class="control-label-wrap">
  23. <span class="control-label">控制方式</span>
  24. <div class="control-operation">
  25. <div class="operation-btn" :class="{ 'is-active': isManualMode }"
  26. @click="toggleManualMode">
  27. {{ isManualMode ? '退出手动控制' : '手动控制' }}
  28. </div>
  29. </div>
  30. </div>
  31. </div>
  32. <div class="form-interactive-area">
  33. <div class="form-editable-area" :class="{ 'is-disabled': !isManualMode }">
  34. <div class="control-method-content">
  35. <SegmentedRadio v-model="currentMethod" :options="controlMethodOptions" size="auto" />
  36. </div>
  37. <div class="control-scheme">
  38. <div class="control-label-wrap">
  39. <span class="control-label">控制方案</span>
  40. <DropdownSelect v-model="currentScheme" :options="schemeOptions" size="auto" :class="{'DropdownSelect-is-disabled': !isManualMode }"/>
  41. </div>
  42. <div class="time-form-bar" v-if="currentMethod === 'temp'">
  43. <el-date-picker v-model="startDate" type="date" placeholder="选择日期"
  44. value-format="yyyy-MM-dd" size="small" :append-to-body="true"
  45. :popper-options="{ boundariesPadding: 0, gpuAcceleration: false }">
  46. </el-date-picker>
  47. <el-time-picker v-model="startTime" placeholder="选择时间"
  48. value-format="HH:mm:ss" size="small" :append-to-body="true"
  49. :popper-options="{ boundariesPadding: 0, gpuAcceleration: false }">
  50. </el-time-picker>
  51. <el-date-picker v-model="endDate" type="date" placeholder="选择日期"
  52. value-format="yyyy-MM-dd" size="small" :append-to-body="true"
  53. :popper-options="{ boundariesPadding: 0, gpuAcceleration: false }">
  54. </el-date-picker>
  55. <el-time-picker v-model="endTime" placeholder="选择时间"
  56. value-format="HH:mm:ss" size="small" :append-to-body="true"
  57. :popper-options="{ boundariesPadding: 0, gpuAcceleration: false }">
  58. </el-time-picker>
  59. <div class="form-item-labeled">
  60. <span class="form-label">时长</span>
  61. <el-select v-model="duration" placeholder="请选择时长" size="small"
  62. :popper-append-to-body="true">
  63. <el-option v-for="d in durationOptions" :key="d" :label="d"
  64. :value="d"></el-option>
  65. </el-select>
  66. </div>
  67. <div class="form-item-labeled">
  68. <span class="form-label">周期</span>
  69. <el-select v-model="period" placeholder="请选择周期" size="small"
  70. :popper-append-to-body="true">
  71. <el-option v-for="p in 8" :key="p" :label="'周期' + p"
  72. :value="p"></el-option>
  73. </el-select>
  74. </div>
  75. </div>
  76. <div class="current-stage">
  77. <div class="current-stage-warp">
  78. <div class="current-stage-label">当前阶段:</div>
  79. <div v-for="(item, index) in currentStageList" :key="index"
  80. class="stage-item-wrapper">
  81. <div class="phase-box" :class="{ 'is-active': item.value === currentStage }"
  82. @click="onStageClick(item.value)">
  83. <img :src="item.img" alt="stage" class="phase-image" />
  84. </div>
  85. <div class="bottom-controls">
  86. <div class="input-unit-wrapper">
  87. <input type="number" v-model.number="item.time" class="stage-input"
  88. :disabled="!canEditStage"
  89. :title="canEditStage ? '修改阶段时间' : '当前控制方式不可修改'" />
  90. <span class="unit">s</span>
  91. </div>
  92. <span class="percent">{{ stagePercent(item.time) }}</span>
  93. </div>
  94. </div>
  95. </div>
  96. </div>
  97. </div>
  98. </div>
  99. <!-- 方案圆饼图 -->
  100. <div class="donut-row">
  101. <div class="donut-item">
  102. <div class="donut-title">实时方案(执行方案3)</div>
  103. <PlanDonutChart :chartData="realtimeDonutData"
  104. :centerValue="String(realtimeRemaining)" centerLabel="剩余时长" :showTotal="true"
  105. :totalValue="cycleLength" :scale="panelScale" />
  106. </div>
  107. <div class="donut-item">
  108. <div class="donut-title">下周期方案</div>
  109. <PlanDonutChart :chartData="nextCycleDonutData" :centerValue="String(cycleLength)"
  110. centerLabel="总时长" :showTotal="false" :scale="panelScale" />
  111. </div>
  112. </div>
  113. </div>
  114. <div class="button-group" v-show="isManualMode && currentMethod !== 'step'">
  115. <div>
  116. <button type="button" class="btn btn-cancel" @click="onCancel()">取消</button>
  117. <button type="button" class="btn btn-confirm" @click="onConfirm()">确认</button>
  118. </div>
  119. </div>
  120. </div>
  121. </form>
  122. </div>
  123. <!-- 步进锁定时间弹窗 -->
  124. <transition name="fade">
  125. <div class="lock-time-overlay" v-if="showLockTime" @click.self="showLockTime = false">
  126. <div class="lock-time-dialog">
  127. <div class="lock-time-header">
  128. <span class="lock-time-title">锁定时间</span>
  129. <span class="lock-time-close" @click="showLockTime = false">✕</span>
  130. </div>
  131. <div class="lock-time-divider"></div>
  132. <div class="lock-time-body">
  133. <div class="lock-time-options">
  134. <div class="lock-time-option">
  135. <label>
  136. <input type="radio" v-model="lockTimeType" value="continuous" /> 持续放行
  137. </label>
  138. </div>
  139. <div class="lock-time-option">
  140. <label>
  141. <input type="radio" v-model="lockTimeType" value="timer" /> 放行
  142. <DropdownSelect placeholder="锁定时间" v-model="currentLocktime"
  143. :options="locktimeOptions" size="auto" @click.native.prevent />
  144. 秒解锁
  145. </label>
  146. </div>
  147. </div>
  148. <div class="lock-time-actions">
  149. <button type="button" class="btn btn-cancel"
  150. @click="onLockTimeCancel">取消</button>
  151. <button type="button" class="btn btn-confirm"
  152. @click="onLockTimeConfirm">确认</button>
  153. </div>
  154. </div>
  155. </div>
  156. </div>
  157. </transition>
  158. </div>
  159. </template>
  160. <script>
  161. import SignalTimingChart from '@/components/ui/SignalTimingChart.vue';
  162. import IntersectionMapVideos from '@/components/ui/IntersectionMapVideos.vue';
  163. import SegmentedRadio from '@/components/ui/SegmentedRadio.vue';
  164. import DropdownSelect from '@/components/ui/DropdownSelect.vue';
  165. import PlanDonutChart from '@/components/ui/PlanDonutChart.vue';
  166. import { apiGetCrossingDetailData } from '@/api';
  167. export default {
  168. name: 'CrossingPanel',
  169. components: {
  170. SignalTimingChart,
  171. IntersectionMapVideos,
  172. SegmentedRadio,
  173. DropdownSelect,
  174. PlanDonutChart
  175. },
  176. props: {
  177. preloadedData: { type: Object, default: null },
  178. iconMode: { type: String, default: 'simple' } // 'default' | 'simple'
  179. },
  180. data() {
  181. return {
  182. startDate: '2026-04-13',
  183. startTime: '14:03:06',
  184. endDate: '2026-04-13',
  185. endTime: '15:03:06',
  186. duration: null,
  187. period: null,
  188. // 核心状态控制
  189. isManualMode: false, // 是否处于手动控制模式
  190. showLockTime: false, // 是否显示锁定时间弹窗
  191. lockTimeType: 'continuous', // 锁定时间类型
  192. dataReady: false,
  193. followPhase: false,
  194. intersectionData: {},
  195. currentRoute: {},
  196. cycleLength: 140,
  197. currentSec: 0,
  198. phaseDiff: 0,
  199. coordTime: 0,
  200. mockPhaseData: [],
  201. panelScale: 1,
  202. // 控制方式数据
  203. controlMethodOptions: [],
  204. currentMethod: 'temp',
  205. currentScheme: 'early_peak',
  206. schemeOptions: [],
  207. // 实时方案圆饼图数据(从 phaseData 动态生成)
  208. realtimeDonutData: [],
  209. // 下周期方案圆饼图数据
  210. nextCycleDonutData: [],
  211. // 实时方案剩余时长
  212. realtimeRemaining: 0,
  213. // 各阶段基础数据(从 phaseData 解析)
  214. phaseStages: [],
  215. currentLocktime: 50,
  216. locktimeOptions: [],
  217. currentStage: '1',
  218. // 补充了 time 属性,用于双向绑定输入框的时间
  219. currentStageList: []
  220. }
  221. },
  222. computed: {
  223. // 黄闪、关灯、全红时禁用控制方案
  224. isSchemeDisabled() {
  225. return ['yellow_flash', 'lights_off', 'all_red'].includes(this.currentMethod);
  226. },
  227. // 定周期、中心计划、感应控制、临时方案可编辑当前阶段
  228. canEditStage() {
  229. return ['fixed', 'system', 'sensor', 'temp'].includes(this.currentMethod);
  230. },
  231. // 时长选项:30, 60, 90 ... 300
  232. durationOptions() {
  233. const list = [];
  234. for (let i = 30; i <= 300; i += 30) list.push(i);
  235. return list;
  236. }
  237. },
  238. watch: {
  239. // 监听控制方式切换
  240. currentMethod(newVal) {
  241. // 切换到步进时不自动弹出锁定时间,等用户点击阶段再弹
  242. this.showLockTime = false;
  243. if (newVal === 'step') {
  244. this.syncLocktimeByStage();
  245. }
  246. // 模拟需求1:根据不同模式,切换对应的控制方案数据 (Mock 逻辑)
  247. this.updateSchemeDataByMethod(newVal);
  248. }
  249. },
  250. mounted() {
  251. this.initScaleObserver();
  252. if (this.preloadedData) {
  253. this.applyData(this.preloadedData);
  254. } else {
  255. this.loadData();
  256. }
  257. },
  258. beforeDestroy() {
  259. if (this._ro) this._ro.disconnect();
  260. },
  261. methods: {
  262. // 点击阶段:切换选中,步进模式下同时弹出锁定时间
  263. onStageClick(value) {
  264. this.currentStage = value;
  265. if (this.currentMethod === 'step') {
  266. this.showLockTime = true;
  267. this.syncLocktimeByStage();
  268. }
  269. },
  270. // 根据当前选中阶段同步锁定时间选项
  271. syncLocktimeByStage() {
  272. const stage = this.currentStageList.find(s => s.value === this.currentStage);
  273. if (stage && stage.locktimeOptions) {
  274. this.locktimeOptions = stage.locktimeOptions;
  275. // 如果当前值不在新选项中,重置为第一个
  276. const hasValue = this.locktimeOptions.some(o => o.value === this.currentLocktime);
  277. if (!hasValue) {
  278. this.currentLocktime = this.locktimeOptions[0]?.value || null;
  279. }
  280. }
  281. },
  282. stagePercent(time) {
  283. const total = this.currentStageList.reduce((s, item) => s + (item.time || 0), 0);
  284. if (!total) return '0%';
  285. return Math.round(time / total * 100) + '%';
  286. },
  287. onScanTick(activeTime) {
  288. if (!this.mockPhaseData || this.mockPhaseData.length === 0) return;
  289. // 只看第一轨道(trackIdx=0)的相位
  290. const phase = this.mockPhaseData.find(p => p[0] === 0 && activeTime >= p[1] && activeTime < p[2]);
  291. if (!phase) return;
  292. const type = phase[5]; // green/stripe/yellow/red
  293. const iconValue = phase[6]; // 如 "STRAIGHT_DOWN,STRAIGHT_UP"
  294. const direction = phase[7]; // ns/ew
  295. const phaseName = phase[3]; // P1/P2 等
  296. const endTime = phase[2];
  297. const remaining = Math.max(0, Math.round(endTime - activeTime));
  298. const nsGreen = (type === 'green' && direction === 'ns');
  299. const ewGreen = (type === 'green' && direction === 'ew');
  300. // 从图标值解析当前允许的行驶方向类型
  301. // STRAIGHT→S, TURN_*_LEFT→L, *_UTURN→U
  302. let activeArrowTypes = [];
  303. if ((nsGreen || ewGreen) && iconValue) {
  304. const icons = iconValue.split(',');
  305. icons.forEach(ic => {
  306. if (ic.includes('UTURN')) activeArrowTypes.push('U');
  307. if (ic.includes('TURN') && !ic.includes('UTURN')) activeArrowTypes.push('L');
  308. if (ic.includes('STRAIGHT')) activeArrowTypes.push('S');
  309. });
  310. // 去重
  311. activeArrowTypes = [...new Set(activeArrowTypes)];
  312. }
  313. // 人行道全红判断:只有 P1/P3 绿灯期间人行道才有绿灯,其余时段全红
  314. const pedAllRed = !(type === 'green' && (phaseName === 'P1' || phaseName === 'P3'));
  315. this.$set(this.intersectionData, 'signals', {
  316. pedAllRed,
  317. ns: {
  318. phaseName: nsGreen ? ({ P1: '南北直行', P2: '南北左转' }[phaseName] || '南北') : (this.intersectionData.signals?.ns?.phaseName || '南北'),
  319. time: remaining,
  320. isGreen: nsGreen,
  321. activeArrowTypes: nsGreen ? activeArrowTypes : []
  322. },
  323. ew: {
  324. phaseName: ewGreen ? ({ P3: '东西直行', P4: '东西左转' }[phaseName] || '东西') : (this.intersectionData.signals?.ew?.phaseName || '东西'),
  325. time: remaining,
  326. isGreen: ewGreen,
  327. activeArrowTypes: ewGreen ? activeArrowTypes : []
  328. }
  329. });
  330. // 更新实时方案圆饼图的已走时长
  331. this.updateRealtimeDonut(activeTime);
  332. },
  333. // 从 phaseData 解析4个阶段的绿灯时长,构建圆饼图数据
  334. buildDonutFromPhaseData() {
  335. const phaseData = this.mockPhaseData || [];
  336. const stageColors = ['#3b82f6', '#a855f7', '#14b8a6', '#f59e0b'];
  337. const stageLabels = ['1-南北直行', '2-南北左转', '3-东西直行', '4-东西左转'];
  338. // 提取 track 0 的绿灯相位(每阶段的第一个 green)
  339. const greenPhases = phaseData.filter(p => p[0] === 0 && p[5] === 'green');
  340. const stages = greenPhases.slice(0, 4).map((p, i) => {
  341. const total = p[8] || Math.floor(this.cycleLength / 4);
  342. return {
  343. label: stageLabels[i] || `阶段${i + 1}`,
  344. value: total, // 阶段总时长
  345. start: p[1], // 阶段开始时间(从绿灯起始)
  346. end: p[1] + total, // 阶段结束时间
  347. color: stageColors[i]
  348. };
  349. });
  350. this.phaseStages = stages;
  351. // 实时方案:已走时长 + 4个阶段
  352. this.realtimeDonutData = [
  353. { label: '已走时长', value: 0, color: '#8892a0' },
  354. ...stages.map(s => ({ label: s.label, value: s.value, color: s.color }))
  355. ];
  356. this.realtimeRemaining = this.cycleLength;
  357. // 下周期方案:4个阶段,初始数据与实时方案相同
  358. this.nextCycleDonutData = stages.map(s => ({
  359. label: s.label,
  360. value: s.value,
  361. color: s.color
  362. }));
  363. },
  364. // 根据扫描线时间更新实时方案圆饼图
  365. updateRealtimeDonut(activeTime) {
  366. if (this.phaseStages.length === 0) return;
  367. const elapsed = Math.round(activeTime);
  368. const remaining = Math.max(0, this.cycleLength - elapsed);
  369. this.realtimeRemaining = remaining;
  370. // 计算各阶段的剩余时长
  371. const stageValues = this.phaseStages.map(s => {
  372. if (activeTime >= s.end) return 0; // 阶段已完成
  373. if (activeTime < s.start) return s.value; // 阶段未开始
  374. return Math.max(0, Math.round(s.end - activeTime)); // 阶段进行中
  375. });
  376. this.realtimeDonutData = [
  377. { label: '已走时长', value: elapsed, color: '#8892a0' },
  378. ...this.phaseStages.map((s, i) => ({
  379. label: s.label,
  380. value: stageValues[i],
  381. color: s.color
  382. }))
  383. ];
  384. },
  385. initScaleObserver() {
  386. const ro = new ResizeObserver(entries => {
  387. const { width } = entries[0].contentRect;
  388. const s = Math.min(width / 1315, 1);
  389. this.$el.style.setProperty('--s', s);
  390. this.panelScale = s;
  391. });
  392. ro.observe(this.$el);
  393. this._ro = ro;
  394. },
  395. async loadData() {
  396. const nodeId = this.$attrs.id || this.id;
  397. const data = await apiGetCrossingDetailData(nodeId, { iconMode: this.iconMode });
  398. if (data) {
  399. this.applyData(data);
  400. }
  401. },
  402. applyData(data) {
  403. this.currentRoute = data.currentRoute || {};
  404. this.intersectionData = data.intersectionData || {};
  405. this.mockPhaseData = data.phaseData || [];
  406. this.cycleLength = data.cycleLength || 140;
  407. this.currentSec = data.currentTime || 0;
  408. this.phaseDiff = data.phaseDiff || 0;
  409. this.coordTime = data.coordTime || 0;
  410. this.currentStageList = data.stageList || [];
  411. this.buildDonutFromPhaseData();
  412. this.$nextTick(() => {
  413. this.dataReady = true;
  414. });
  415. this.schemeOptions = data.schemeOptions || [];
  416. if (data.currentScheme) this.currentScheme = data.currentScheme;
  417. if (data.controlMethodOptions) this.controlMethodOptions = data.controlMethodOptions;
  418. if (data.currentMethod) this.currentMethod = data.currentMethod;
  419. if (data.locktimeOptions) this.locktimeOptions = data.locktimeOptions;
  420. if (data.startDate) this.startDate = data.startDate;
  421. if (data.startTime) this.startTime = data.startTime;
  422. if (data.endDate) this.endDate = data.endDate;
  423. if (data.endTime) this.endTime = data.endTime;
  424. if (data.duration) this.duration = data.duration;
  425. if (data.period) this.period = data.period;
  426. },
  427. // 切换手动控制模式
  428. toggleManualMode() {
  429. this.isManualMode = !this.isManualMode;
  430. if (!this.isManualMode) {
  431. // 如果退出手动模式,可选择重置表单状态
  432. this.showLockTime = false;
  433. }
  434. },
  435. // 模拟:根据控制方式改变下拉方案的数据
  436. updateSchemeDataByMethod(method) {
  437. if (method === 'system') {
  438. this.schemeOptions = [
  439. { label: '系统优化方案A', value: 'sys_a' },
  440. { label: '系统优化方案B', value: 'sys_b' }
  441. ];
  442. this.currentScheme = 'sys_a';
  443. } else {
  444. this.schemeOptions = [
  445. { label: '早高峰', value: 'early_peak' },
  446. { label: '晚高峰', value: 'evening_peak' },
  447. { label: '平峰', value: 'normal' }
  448. ];
  449. this.currentScheme = 'early_peak';
  450. }
  451. },
  452. // 取消按钮(退出手动控制)
  453. onCancel() {
  454. this.isManualMode = false;
  455. this.showLockTime = false;
  456. },
  457. // 步进锁定时间弹窗:取消(不退出手动控制)
  458. onLockTimeCancel() {
  459. this.showLockTime = false;
  460. },
  461. // 步进锁定时间弹窗:确认(不退出手动控制)
  462. onLockTimeConfirm() {
  463. this.showLockTime = false;
  464. const submitData = {
  465. method: 'step',
  466. stage: this.currentStage,
  467. lockTimeType: this.lockTimeType,
  468. locktime: this.lockTimeType === 'timer' ? this.currentLocktime : null,
  469. };
  470. console.log('步进指令:', submitData);
  471. this.$emit('confirm', submitData);
  472. },
  473. // 需求5:点击确认按钮提交 + 表单验证
  474. onConfirm() {
  475. // 验证1:临时方案必须检查时间是否有效
  476. if (this.currentMethod === 'temp') {
  477. const isInvalid = this.currentStageList.some(item => !item.time || item.time <= 0);
  478. if (isInvalid) {
  479. alert('请输入有效的阶段时间 (必须大于0)!');
  480. return;
  481. }
  482. }
  483. // 验证2:步进方案必须选择锁定类型
  484. if (this.currentMethod === 'step') {
  485. if (this.lockTimeType === 'timer' && !this.currentLocktime) {
  486. alert('请选择解锁时间!');
  487. return;
  488. }
  489. }
  490. // 构造提交参数
  491. const submitData = {
  492. method: this.currentMethod,
  493. scheme: this.currentScheme,
  494. stages: this.currentMethod === 'temp' ? this.currentStageList : null,
  495. lockConfig: this.currentMethod === 'step' ? {
  496. stage: this.currentStage,
  497. type: this.lockTimeType,
  498. time: this.lockTimeType === 'timer' ? this.currentLocktime : null
  499. } : null
  500. };
  501. console.log('提交的数据:', submitData);
  502. // 提交完成后可根据业务决定是否退出手动模式
  503. // this.isManualMode = false;
  504. }
  505. }
  506. }
  507. </script>
  508. <style scoped>
  509. .crossing-detail-panel {
  510. --s: 1;
  511. position: relative;
  512. display: flex;
  513. flex-direction: row;
  514. gap: clamp(4px, calc(var(--s) * 12px), 12px);
  515. height: 100%;
  516. min-height: 0;
  517. overflow: hidden;
  518. }
  519. /* ===== 左侧:还原原始固定 55% 占比 ===== */
  520. .detail-panel-left {
  521. display: flex;
  522. flex-direction: column;
  523. flex: 0 0 55%;
  524. min-height: 0;
  525. min-width: 0;
  526. }
  527. /* ===== 右侧:flex 列容器 + 滚动兜底 ===== */
  528. .detail-panel-right {
  529. flex: 1;
  530. min-width: 0;
  531. min-height: 0;
  532. overflow-y: auto;
  533. overflow-x: hidden;
  534. display: flex;
  535. flex-direction: column;
  536. }
  537. .intersection-video-wrap {
  538. width: 100%;
  539. min-height: 0;
  540. flex: 2;
  541. }
  542. .signal-timing-wrap {
  543. flex: 0 0 auto;
  544. min-height: 0;
  545. height: clamp(95px, calc(var(--s) * 166px), 166px);
  546. width: 100%;
  547. min-width: 0;
  548. background-color: transparent;
  549. box-sizing: border-box;
  550. position: relative;
  551. display: flex;
  552. flex-direction: column;
  553. overflow: hidden;
  554. padding: clamp(3px, calc(var(--s) * 10px), 10px);
  555. }
  556. .header {
  557. display: flex;
  558. justify-content: space-between;
  559. align-items: center;
  560. margin-bottom: clamp(2px, calc(var(--s) * 6px), 15px);
  561. color: #e0e6f1;
  562. flex-shrink: 0;
  563. }
  564. .title-area {
  565. font-size: clamp(9px, calc(var(--s) * 16px), 16px);
  566. }
  567. .main-title {
  568. font-size: clamp(10px, calc(var(--s) * 18px), 18px);
  569. font-weight: bold;
  570. margin-right: clamp(4px, calc(var(--s) * 10px), 10px);
  571. }
  572. .sub-info {
  573. font-size: clamp(8px, calc(var(--s) * 12px), 12px);
  574. opacity: 0.8;
  575. }
  576. .checkbox-area {
  577. font-size: clamp(8px, calc(var(--s) * 12px), 12px);
  578. display: flex;
  579. align-items: center;
  580. cursor: pointer;
  581. opacity: 0.7;
  582. user-select: none;
  583. }
  584. .checkbox-area:hover {
  585. opacity: 1;
  586. }
  587. .checkbox-mock {
  588. width: clamp(10px, calc(var(--s) * 14px), 14px);
  589. height: clamp(10px, calc(var(--s) * 14px), 14px);
  590. border: 1px solid rgba(255, 255, 255, 0.5);
  591. margin-right: clamp(3px, calc(var(--s) * 6px), 6px);
  592. border-radius: 2px;
  593. display: flex;
  594. align-items: center;
  595. justify-content: center;
  596. }
  597. .checkbox-mock.is-checked {
  598. background-color: #4da8ff;
  599. border-color: #4da8ff;
  600. }
  601. .chart-container {
  602. width: 100%;
  603. min-width: 0;
  604. flex: 1;
  605. min-height: 50px;
  606. overflow: hidden;
  607. }
  608. .loading-overlay {
  609. flex: 1;
  610. display: flex;
  611. align-items: center;
  612. justify-content: center;
  613. color: #758599;
  614. font-size: 14px;
  615. }
  616. /* ===== 右侧表单内层容器 ===== */
  617. .detail-right-form {
  618. flex: 1;
  619. min-height: 0;
  620. display: flex;
  621. flex-direction: column;
  622. }
  623. .form-group {
  624. flex: 1;
  625. min-height: 0;
  626. display: flex;
  627. flex-direction: column;
  628. }
  629. /** 控制方法 */
  630. .control-method {
  631. color: #ffffff;
  632. flex-shrink: 0;
  633. }
  634. .control-label-wrap {
  635. display: flex;
  636. align-items: center;
  637. margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
  638. column-gap: clamp(4px, calc(var(--s) * 10px), 20px);
  639. margin-top: clamp(4px, calc(var(--s) * 20px), 20px);
  640. }
  641. .control-label {
  642. font-size: clamp(12px, calc(var(--s) * 22px), 28px);
  643. color: #ffffff;
  644. white-space: nowrap;
  645. }
  646. .control-label-wrap span {
  647. display: inline-block;
  648. }
  649. .operation-btn {
  650. font-size: clamp(10px, calc(var(--s) * 16px), 16px);
  651. cursor: pointer;
  652. user-select: none;
  653. }
  654. .operation-btn:hover {
  655. text-decoration: underline;
  656. }
  657. .operation-btn.is-active {
  658. text-decoration: underline;
  659. }
  660. .control-method .control-label-wrap {
  661. justify-content: space-between;
  662. }
  663. /* 控制方式按钮组:设置 font-size 供 SegmentedRadio size="auto" 继承 */
  664. .control-method-content {
  665. font-size: clamp(10px, calc(var(--s) * 15px), 15px);
  666. }
  667. .control-scheme {
  668. margin-top: clamp(4px, calc(var(--s) * 10px), 20px);
  669. /* 设置 font-size 供 DropdownSelect size="auto" 继承 */
  670. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  671. }
  672. .control-scheme.is-disabled {
  673. opacity: 0.4;
  674. pointer-events: none;
  675. }
  676. .lock-time {
  677. width: 80%;
  678. border-radius: 8px;
  679. box-shadow:
  680. inset 0px 0px 10px 0px rgba(88, 146, 255, 0.4),
  681. inset 20px 0px 30px -10px rgba(88, 146, 255, 0.15);
  682. }
  683. /* 步进锁定时间弹窗 - 遮罩 */
  684. .lock-time-overlay {
  685. position: absolute;
  686. inset: 0;
  687. display: flex;
  688. align-items: center;
  689. justify-content: center;
  690. background: rgba(0, 0, 0, 0.45);
  691. z-index: 200;
  692. border-radius: inherit;
  693. }
  694. /* 步进锁定时间弹窗 */
  695. .lock-time-dialog {
  696. background: linear-gradient(135deg, rgba(10, 25, 60, 0.97) 0%, rgba(20, 40, 90, 0.97) 100%);
  697. border: 1px solid rgba(161, 190, 255, 0.3);
  698. border-radius: 6px;
  699. min-width: 220px;
  700. max-width: 320px;
  701. width: 70%;
  702. box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
  703. }
  704. .lock-time-header {
  705. display: flex;
  706. align-items: center;
  707. justify-content: space-between;
  708. padding: clamp(4px, calc(var(--s) * 8px), 10px);
  709. border-radius: 6px 6px 0 0;
  710. color: #ffffff;
  711. background: linear-gradient(180deg,
  712. rgba(65, 115, 205, 0.6) 0%,
  713. rgba(40, 70, 130, 0.1) 100%);
  714. backdrop-filter: blur(10px);
  715. }
  716. .lock-time-title {
  717. font-size: clamp(10px, calc(var(--s) * 16px), 16px);
  718. color: #ffffff;
  719. }
  720. .lock-time-close {
  721. cursor: pointer;
  722. color: #ffffff;
  723. }
  724. .lock-time-body {
  725. padding: 0;
  726. }
  727. .lock-time-options {
  728. display: flex;
  729. flex-direction: column;
  730. row-gap: clamp(4px, calc(var(--s) * 10px), 10px);
  731. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  732. padding: clamp(4px, calc(var(--s) * 10px), 10px);
  733. color: #ffffff;
  734. }
  735. .lock-time-actions {
  736. display: flex;
  737. justify-content: flex-end;
  738. gap: 8px;
  739. padding: 8px 12px;
  740. border-top: 1px solid rgba(161, 190, 255, 0.15);
  741. }
  742. .lock-time-option {
  743. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  744. }
  745. /* 过渡动画 */
  746. .fade-enter-active,
  747. .fade-leave-active {
  748. transition: opacity 0.25s;
  749. }
  750. .fade-enter,
  751. .fade-leave-to {
  752. opacity: 0;
  753. }
  754. .current-stage {
  755. background-color: rgba(65, 115, 205, 0.2);
  756. border: 1px solid #3660a5;
  757. margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
  758. display: flex;
  759. justify-content: center;
  760. }
  761. .current-stage-warp {
  762. width: 100%;
  763. display: flex;
  764. align-items: center;
  765. flex-wrap: nowrap;
  766. gap: clamp(8px, calc(var(--s) * 18px), 24px);
  767. padding: clamp(4px, calc(var(--s) * 8px), 16px);
  768. color: #ffffff;
  769. }
  770. .current-stage-label {
  771. font-size: clamp(10px, calc(var(--s) * 16px), 16px);
  772. white-space: nowrap;
  773. flex-shrink: 0;
  774. }
  775. .stage-input {
  776. width: 100%;
  777. min-width: 0;
  778. border: 1px solid rgba(161, 190, 255, 0.7);
  779. background-color: transparent;
  780. padding: clamp(2px, calc(var(--s) * 5px), 5px);
  781. color: #ffffff;
  782. text-align: center;
  783. }
  784. .phase-box {
  785. position: relative;
  786. width: 100%;
  787. aspect-ratio: 1 / 1;
  788. background: #E6F0FF;
  789. border-radius: 4px;
  790. display: flex;
  791. align-items: center;
  792. justify-content: center;
  793. cursor: pointer;
  794. transition: all 0.3s ease;
  795. box-sizing: border-box;
  796. overflow: hidden;
  797. }
  798. .phase-image {
  799. width: 100%;
  800. height: 100%;
  801. object-fit: contain;
  802. display: block;
  803. }
  804. .phase-box::after {
  805. content: '';
  806. position: absolute;
  807. top: 0;
  808. left: 0;
  809. width: 100%;
  810. height: 100%;
  811. background: rgba(30, 106, 255, 0.5);
  812. opacity: 0;
  813. transition: opacity 0.3s ease;
  814. pointer-events: none;
  815. }
  816. .phase-box.is-active::after {
  817. opacity: 1;
  818. }
  819. /** 按钮 */
  820. .btn {
  821. display: inline-flex;
  822. justify-content: center;
  823. align-items: center;
  824. height: clamp(22px, calc(var(--s) * 36px), 36px);
  825. padding: 0 clamp(10px, calc(var(--s) * 32px), 32px);
  826. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  827. border-radius: 4px;
  828. cursor: pointer;
  829. user-select: none;
  830. transition: all 0.2s ease-in-out;
  831. box-sizing: border-box;
  832. }
  833. .btn-cancel {
  834. background-color: transparent;
  835. color: #d1d5db;
  836. border: 1px solid rgba(130, 150, 190, 0.4);
  837. }
  838. .btn-cancel:hover {
  839. color: #ffffff;
  840. border-color: rgba(130, 150, 190, 0.8);
  841. background-color: rgba(255, 255, 255, 0.05);
  842. }
  843. .btn-cancel:active {
  844. background-color: rgba(255, 255, 255, 0.1);
  845. }
  846. .btn-confirm {
  847. background-color: #3b74ff;
  848. color: #ffffff;
  849. border: 1px solid #3b74ff;
  850. }
  851. .btn-confirm:hover {
  852. background-color: #5a8bff;
  853. border-color: #5a8bff;
  854. box-shadow: 0 2px 8px rgba(59, 116, 255, 0.3);
  855. }
  856. .btn-confirm:active {
  857. background-color: #265bed;
  858. border-color: #265bed;
  859. box-shadow: none;
  860. }
  861. .button-group {
  862. display: flex;
  863. justify-content: flex-end;
  864. margin-top: clamp(4px, calc(var(--s) * 10px), 20px);
  865. flex-shrink: 0;
  866. }
  867. .button-group>div {
  868. display: flex;
  869. gap: clamp(4px, calc(var(--s) * 8px), 12px);
  870. }
  871. /* 禁用状态 */
  872. .form-interactive-area {
  873. transition: opacity 0.3s;
  874. flex: 1;
  875. min-height: 0;
  876. overflow-y: auto;
  877. overflow-x: hidden;
  878. }
  879. .form-editable-area.is-disabled {
  880. opacity: 0.6;
  881. pointer-events: none;
  882. }
  883. .DropdownSelect-is-disabled{
  884. opacity: 0.6;
  885. }
  886. /* 当前阶段输入框微调 */
  887. .stage-item-wrapper {
  888. flex: 1 1 0;
  889. min-width: 0;
  890. display: flex;
  891. flex-direction: column;
  892. align-items: stretch;
  893. gap: clamp(2px, calc(var(--s) * 4px), 6px);
  894. position: relative;
  895. }
  896. .bottom-controls {
  897. display: flex;
  898. align-items: center;
  899. justify-content: center;
  900. gap: clamp(4px, calc(var(--s) * 6px), 8px);
  901. /* 输入框和百分比的间距 */
  902. }
  903. /* 新增包裹层的相对定位 */
  904. .input-unit-wrapper {
  905. position: relative;
  906. display: inline-block;
  907. }
  908. .stage-input {
  909. width: 100%;
  910. min-width: 0;
  911. border: 1px solid rgba(161, 190, 255, 0.7);
  912. background-color: transparent;
  913. padding: clamp(2px, calc(var(--s) * 5px), 5px);
  914. /* 给右侧留出空间,防止数字过长被 s 挡住 */
  915. padding-right: clamp(10px, calc(var(--s) * 16px), 16px);
  916. color: #ffffff;
  917. text-align: center;
  918. border-radius: 4px;
  919. font-size: clamp(9px, calc(var(--s) * 13px), 13px);
  920. }
  921. /* 修改 s 单位的定位方式为垂直居中 */
  922. .input-unit-wrapper .unit {
  923. position: absolute;
  924. top: 50%;
  925. transform: translateY(-50%);
  926. right: clamp(3px, calc(var(--s) * 6px), 8px);
  927. color: #77A1FF;
  928. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  929. pointer-events: none;
  930. }
  931. /* 微调百分比的间距,让排版更紧凑 */
  932. .stage-item-wrapper .percent {
  933. color: rgba(255, 255, 255, 0.5);
  934. font-size: clamp(9px, calc(var(--s) * 13px), 13px);
  935. white-space: nowrap;
  936. }
  937. .stage-input {
  938. width: 100%;
  939. min-width: 0;
  940. border: 1px solid rgba(161, 190, 255, 0.7);
  941. background-color: transparent;
  942. padding: clamp(2px, calc(var(--s) * 5px), 5px);
  943. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  944. color: #ffffff;
  945. text-align: center;
  946. border-radius: 4px;
  947. -moz-appearance: textfield;
  948. }
  949. .stage-input::-webkit-outer-spin-button,
  950. .stage-input::-webkit-inner-spin-button {
  951. -webkit-appearance: none;
  952. margin: 0;
  953. }
  954. .stage-input:disabled {
  955. border-color: rgba(255, 255, 255, 0.2);
  956. color: rgba(255, 255, 255, 0.5);
  957. background-color: rgba(0, 0, 0, 0.2);
  958. }
  959. /* 弹窗过渡动画 */
  960. /* lock-time 弹窗补充样式 */
  961. .lock-time {
  962. margin-top: clamp(4px, calc(var(--s) * 10px), 15px);
  963. background-color: rgba(20, 30, 50, 0.9);
  964. }
  965. /* 单选框基础对齐 */
  966. .lock-time-option label {
  967. display: flex;
  968. align-items: center;
  969. gap: clamp(3px, calc(var(--s) * 6px), 8px);
  970. cursor: pointer;
  971. }
  972. /* ===== 方案圆饼图左右布局 ===== */
  973. .donut-row {
  974. display: flex;
  975. gap: clamp(4px, calc(var(--s) * 16px), 16px);
  976. width: 100%;
  977. margin-top: clamp(4px, calc(var(--s) * 20px), 20px);
  978. }
  979. .donut-item {
  980. flex: 1;
  981. min-width: 0;
  982. }
  983. .donut-title {
  984. font-size: clamp(12px, calc(var(--s) * 22px), 28px);
  985. color: #ffffff;
  986. margin-bottom: 4px;
  987. }
  988. /* ===== 时间表单栏布局 ===== */
  989. .time-form-bar {
  990. display: grid;
  991. grid-template-columns: repeat(4, 1fr);
  992. gap: clamp(3px, calc(var(--s) * 5px), 6px);
  993. margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
  994. }
  995. /* 第二行:带标签的项各占 2 列(共 4 列,两项铺满一行) */
  996. .time-form-bar .form-item-labeled {
  997. grid-column: span 2;
  998. display: flex;
  999. align-items: center;
  1000. gap: clamp(3px, calc(var(--s) * 6px), 8px);
  1001. }
  1002. .time-form-bar .form-label {
  1003. white-space: nowrap;
  1004. flex-shrink: 0;
  1005. color: rgba(200, 220, 255, 0.65);
  1006. font-size: clamp(9px, calc(var(--s) * 12px), 13px);
  1007. }
  1008. .time-form-bar .form-item-labeled .el-select {
  1009. flex: 1;
  1010. min-width: 0;
  1011. }
  1012. /* ===== ElementUI 深色主题适配 ===== */
  1013. /* 覆盖 el-date-editor 固定宽度,让其跟随 grid 布局 */
  1014. .time-form-bar>>>.el-date-editor.el-input,
  1015. .time-form-bar>>>.el-date-editor.el-input__inner,
  1016. .time-form-bar>>>.el-select {
  1017. width: 100%;
  1018. }
  1019. /* 输入框样式 */
  1020. .time-form-bar>>>.el-input__inner {
  1021. background-color: rgba(255, 255, 255, 0.06);
  1022. border: 1px solid rgba(161, 190, 255, 0.35);
  1023. color: #e0e6f1;
  1024. font-size: clamp(9px, calc(var(--s) * 11px), 11px);
  1025. height: clamp(22px, calc(var(--s) * 28px), 28px);
  1026. line-height: clamp(22px, calc(var(--s) * 28px), 28px);
  1027. padding-left: clamp(22px, calc(var(--s) * 26px), 26px);
  1028. padding-right: clamp(4px, calc(var(--s) * 6px), 6px);
  1029. }
  1030. .time-form-bar>>>.el-input__inner::placeholder {
  1031. color: rgba(255, 255, 255, 0.3);
  1032. }
  1033. .time-form-bar>>>.el-input__inner:hover {
  1034. border-color: rgba(161, 190, 255, 0.6);
  1035. }
  1036. .time-form-bar>>>.el-input__inner:focus {
  1037. border-color: #3b74ff;
  1038. }
  1039. /* 图标颜色、尺寸跟随缩放 */
  1040. .time-form-bar>>>.el-input__prefix,
  1041. .time-form-bar>>>.el-input__suffix {
  1042. color: rgba(255, 255, 255, 0.4);
  1043. }
  1044. .time-form-bar>>>.el-input__icon {
  1045. font-size: clamp(9px, calc(var(--s) * 12px), 12px);
  1046. line-height: clamp(22px, calc(var(--s) * 28px), 28px);
  1047. width: clamp(18px, calc(var(--s) * 22px), 22px);
  1048. }
  1049. </style>