CrossingDetailPanel.vue 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881
  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" :showScanLine="dataReady" :autoScan="dataReady" @scan-tick="onScanTick" />
  15. </div>
  16. </div>
  17. <div class="detail-panel-right">
  18. <form class="detail-right-form" @submit.prevent>
  19. <div class="form-group">
  20. <div class="control-method">
  21. <div class="control-label-wrap">
  22. <span class="control-label">控制方式</span>
  23. <div class="control-operation">
  24. <div class="operation-btn"
  25. :class="{ 'is-active': isManualMode }"
  26. @click="toggleManualMode">
  27. {{ isManualMode ? '退出手动控制' : '手动控制' }}
  28. </div>
  29. </div>
  30. </div>
  31. </div>
  32. <div class="form-interactive-area" :class="{ 'is-disabled': !isManualMode }">
  33. <div class="control-method-content">
  34. <SegmentedRadio v-model="currentMethod" :options="controlMethodOptions" size="auto" />
  35. </div>
  36. <div class="control-scheme">
  37. <div class="control-label-wrap">
  38. <span class="control-label">控制方案</span>
  39. <DropdownSelect v-model="currentScheme" :options="schemeOptions" size="auto" />
  40. </div>
  41. <div class="current-stage">
  42. <div class="current-stage-warp">
  43. <div class="current-stage-label">当前阶段:</div>
  44. <div v-for="(item, index) in currentStageList" :key="index" class="stage-item-wrapper">
  45. <div
  46. class="phase-box"
  47. :class="{ 'is-active': item.value === currentStage }"
  48. @click="currentStage = item.value"
  49. >
  50. <img :src="item.img" alt="stage" class="phase-image" />
  51. </div>
  52. <input
  53. type="number"
  54. v-model.number="item.time"
  55. class="stage-input"
  56. :disabled="currentMethod !== 'temp'"
  57. :title="currentMethod !== 'temp' ? '仅临时方案可修改' : '修改阶段时间'"
  58. />
  59. <span class="unit">s</span>
  60. </div>
  61. </div>
  62. </div>
  63. <!-- 方案圆饼图 -->
  64. <div class="donut-row" v-if="!showLockTime">
  65. <div class="donut-item">
  66. <div class="donut-title">实时方案(执行方案3)</div>
  67. <PlanDonutChart
  68. :chartData="realtimeDonutData"
  69. :centerValue="String(realtimeRemaining)"
  70. centerLabel="剩余时长"
  71. :showTotal="true"
  72. :totalValue="cycleLength"
  73. :scale="panelScale"
  74. />
  75. </div>
  76. <div class="donut-item">
  77. <div class="donut-title">下周期方案</div>
  78. <PlanDonutChart
  79. :chartData="nextCycleDonutData"
  80. :centerValue="String(cycleLength)"
  81. centerLabel="总时长"
  82. :showTotal="false"
  83. :scale="panelScale"
  84. />
  85. </div>
  86. </div>
  87. <transition name="fade">
  88. <div class="lock-time" v-if="showLockTime">
  89. <div class="lock-time-label-wrap glow-header">
  90. <div class="lock-time-label">锁定时间</div>
  91. <div class="lock-time-close" @click="showLockTime = false">✕</div>
  92. </div>
  93. <div class="lock-time-options">
  94. <div class="lock-time-option">
  95. <label>
  96. <input type="radio" v-model="lockTimeType" value="continuous" /> 持续放行
  97. </label>
  98. </div>
  99. <div class="lock-time-option">
  100. <label>
  101. <input type="radio" v-model="lockTimeType" value="timer" /> 放行
  102. <DropdownSelect placeholder="锁定时间" v-model="currentLocktime" :options="locktimeOptions" size="auto"
  103. @click.native.prevent />
  104. 秒解锁
  105. </label>
  106. </div>
  107. </div>
  108. </div>
  109. </transition>
  110. </div>
  111. </div>
  112. <div class="button-group" v-show="isManualMode">
  113. <div>
  114. <button type="button" class="btn btn-cancel" @click="onCancel()">取消</button>
  115. <button type="button" class="btn btn-confirm" @click="onConfirm()">确认</button>
  116. </div>
  117. </div>
  118. </div>
  119. </form>
  120. </div>
  121. </div>
  122. </template>
  123. <script>
  124. import SignalTimingChart from '@/components/ui/SignalTimingChart.vue';
  125. import IntersectionMapVideos from '@/components/ui/IntersectionMapVideos.vue';
  126. import SegmentedRadio from '@/components/ui/SegmentedRadio.vue';
  127. import DropdownSelect from '@/components/ui/DropdownSelect.vue';
  128. import PlanDonutChart from '@/components/ui/PlanDonutChart.vue';
  129. import { apiGetCrossingDetailData } from '@/api';
  130. export default {
  131. name: 'CrossingPanel',
  132. components: {
  133. SignalTimingChart,
  134. IntersectionMapVideos,
  135. SegmentedRadio,
  136. DropdownSelect,
  137. PlanDonutChart
  138. },
  139. props: {
  140. preloadedData: { type: Object, default: null },
  141. iconMode: { type: String, default: 'simple' } // 'default' | 'simple'
  142. },
  143. data() {
  144. return {
  145. // 核心状态控制
  146. isManualMode: false, // 是否处于手动控制模式
  147. showLockTime: false, // 是否显示锁定时间弹窗
  148. lockTimeType: 'continuous', // 锁定时间类型
  149. dataReady: false,
  150. followPhase: false,
  151. intersectionData: {},
  152. currentRoute: {},
  153. cycleLength: 140,
  154. currentSec: 0,
  155. phaseDiff: 0,
  156. coordTime: 0,
  157. mockPhaseData: [],
  158. panelScale: 1,
  159. // 控制方式数据
  160. controlMethodOptions: [],
  161. currentMethod: 'temp',
  162. currentScheme: 'early_peak',
  163. schemeOptions: [],
  164. // 实时方案圆饼图数据(从 phaseData 动态生成)
  165. realtimeDonutData: [],
  166. // 下周期方案圆饼图数据
  167. nextCycleDonutData: [],
  168. // 实时方案剩余时长
  169. realtimeRemaining: 0,
  170. // 各阶段基础数据(从 phaseData 解析)
  171. phaseStages: [],
  172. currentLocktime: 50,
  173. locktimeOptions: [],
  174. currentStage: '1',
  175. // 补充了 time 属性,用于双向绑定输入框的时间
  176. currentStageList: []
  177. }
  178. },
  179. watch: {
  180. // 监听控制方式切换
  181. currentMethod(newVal) {
  182. // 需求4:切换步进方案策略时,出现锁定时间弹窗
  183. if (newVal === 'step') {
  184. this.showLockTime = true;
  185. } else {
  186. this.showLockTime = false;
  187. }
  188. // 模拟需求1:根据不同模式,切换对应的控制方案数据 (Mock 逻辑)
  189. this.updateSchemeDataByMethod(newVal);
  190. }
  191. },
  192. mounted() {
  193. this.initScaleObserver();
  194. if (this.preloadedData) {
  195. this.applyData(this.preloadedData);
  196. } else {
  197. this.loadData();
  198. }
  199. },
  200. beforeDestroy() {
  201. if (this._ro) this._ro.disconnect();
  202. },
  203. methods: {
  204. onScanTick(activeTime) {
  205. if (!this.mockPhaseData || this.mockPhaseData.length === 0) return;
  206. // 只看第一轨道(trackIdx=0)的相位
  207. const phase = this.mockPhaseData.find(p => p[0] === 0 && activeTime >= p[1] && activeTime < p[2]);
  208. if (!phase) return;
  209. const type = phase[5]; // green/stripe/yellow/red
  210. const iconValue = phase[6]; // 如 "STRAIGHT_DOWN,STRAIGHT_UP"
  211. const direction = phase[7]; // ns/ew
  212. const phaseName = phase[3]; // P1/P2 等
  213. const endTime = phase[2];
  214. const remaining = Math.max(0, Math.round(endTime - activeTime));
  215. const nsGreen = (type === 'green' && direction === 'ns');
  216. const ewGreen = (type === 'green' && direction === 'ew');
  217. // 从图标值解析当前允许的行驶方向类型
  218. // STRAIGHT→S, TURN_*_LEFT→L, *_UTURN→U
  219. let activeArrowTypes = [];
  220. if ((nsGreen || ewGreen) && iconValue) {
  221. const icons = iconValue.split(',');
  222. icons.forEach(ic => {
  223. if (ic.includes('UTURN')) activeArrowTypes.push('U');
  224. if (ic.includes('TURN') && !ic.includes('UTURN')) activeArrowTypes.push('L');
  225. if (ic.includes('STRAIGHT')) activeArrowTypes.push('S');
  226. });
  227. // 去重
  228. activeArrowTypes = [...new Set(activeArrowTypes)];
  229. }
  230. this.$set(this.intersectionData, 'signals', {
  231. ns: {
  232. phaseName: nsGreen ? (phaseName ? `相位${phaseName.replace('P', '')}` : '南北') : (this.intersectionData.signals?.ns?.phaseName || '南北'),
  233. time: remaining,
  234. isGreen: nsGreen,
  235. activeArrowTypes: nsGreen ? activeArrowTypes : []
  236. },
  237. ew: {
  238. phaseName: ewGreen ? (phaseName ? `相位${phaseName.replace('P', '')}` : '东西') : (this.intersectionData.signals?.ew?.phaseName || '东西'),
  239. time: remaining,
  240. isGreen: ewGreen,
  241. activeArrowTypes: ewGreen ? activeArrowTypes : []
  242. }
  243. });
  244. // 更新实时方案圆饼图的已走时长
  245. this.updateRealtimeDonut(activeTime);
  246. },
  247. // 从 phaseData 解析4个阶段的绿灯时长,构建圆饼图数据
  248. buildDonutFromPhaseData() {
  249. const phaseData = this.mockPhaseData || [];
  250. const stageColors = ['#3b82f6', '#a855f7', '#14b8a6', '#f59e0b'];
  251. const stageLabels = ['1-南北直行', '2-南北左转', '3-东西直行', '4-东西左转'];
  252. // 提取 track 0 的绿灯相位(每阶段的第一个 green)
  253. const greenPhases = phaseData.filter(p => p[0] === 0 && p[5] === 'green');
  254. const stages = greenPhases.slice(0, 4).map((p, i) => {
  255. const total = p[8] || Math.floor(this.cycleLength / 4);
  256. return {
  257. label: stageLabels[i] || `阶段${i + 1}`,
  258. value: total, // 阶段总时长
  259. start: p[1], // 阶段开始时间(从绿灯起始)
  260. end: p[1] + total, // 阶段结束时间
  261. color: stageColors[i]
  262. };
  263. });
  264. this.phaseStages = stages;
  265. // 实时方案:已走时长 + 4个阶段
  266. this.realtimeDonutData = [
  267. { label: '已走时长', value: 0, color: '#8892a0' },
  268. ...stages.map(s => ({ label: s.label, value: s.value, color: s.color }))
  269. ];
  270. this.realtimeRemaining = this.cycleLength;
  271. // 下周期方案:4个阶段,初始数据与实时方案相同
  272. this.nextCycleDonutData = stages.map(s => ({
  273. label: s.label,
  274. value: s.value,
  275. color: s.color
  276. }));
  277. },
  278. // 根据扫描线时间更新实时方案圆饼图
  279. updateRealtimeDonut(activeTime) {
  280. if (this.phaseStages.length === 0) return;
  281. const elapsed = Math.round(activeTime);
  282. const remaining = Math.max(0, this.cycleLength - elapsed);
  283. this.realtimeRemaining = remaining;
  284. // 计算各阶段的剩余时长
  285. const stageValues = this.phaseStages.map(s => {
  286. if (activeTime >= s.end) return 0; // 阶段已完成
  287. if (activeTime < s.start) return s.value; // 阶段未开始
  288. return Math.max(0, Math.round(s.end - activeTime)); // 阶段进行中
  289. });
  290. this.realtimeDonutData = [
  291. { label: '已走时长', value: elapsed, color: '#8892a0' },
  292. ...this.phaseStages.map((s, i) => ({
  293. label: s.label,
  294. value: stageValues[i],
  295. color: s.color
  296. }))
  297. ];
  298. },
  299. initScaleObserver() {
  300. const ro = new ResizeObserver(entries => {
  301. const { width } = entries[0].contentRect;
  302. const s = Math.min(width / 1315, 1);
  303. this.$el.style.setProperty('--s', s);
  304. this.panelScale = s;
  305. });
  306. ro.observe(this.$el);
  307. this._ro = ro;
  308. },
  309. async loadData() {
  310. const nodeId = this.$attrs.id || this.id;
  311. const data = await apiGetCrossingDetailData(nodeId, { iconMode: this.iconMode });
  312. if (data) {
  313. this.applyData(data);
  314. }
  315. },
  316. applyData(data) {
  317. this.currentRoute = data.currentRoute || {};
  318. this.intersectionData = data.intersectionData || {};
  319. this.mockPhaseData = data.phaseData || [];
  320. this.cycleLength = data.cycleLength || 140;
  321. this.currentSec = data.currentTime || 0;
  322. this.phaseDiff = data.phaseDiff || 0;
  323. this.coordTime = data.coordTime || 0;
  324. this.currentStageList = data.stageList || [];
  325. this.buildDonutFromPhaseData();
  326. this.$nextTick(() => {
  327. this.dataReady = true;
  328. });
  329. this.schemeOptions = data.schemeOptions || [];
  330. if (data.currentScheme) this.currentScheme = data.currentScheme;
  331. if (data.controlMethodOptions) this.controlMethodOptions = data.controlMethodOptions;
  332. if (data.currentMethod) this.currentMethod = data.currentMethod;
  333. if (data.locktimeOptions) this.locktimeOptions = data.locktimeOptions;
  334. },
  335. // 切换手动控制模式
  336. toggleManualMode() {
  337. this.isManualMode = !this.isManualMode;
  338. if (!this.isManualMode) {
  339. // 如果退出手动模式,可选择重置表单状态
  340. this.showLockTime = false;
  341. }
  342. },
  343. // 模拟:根据控制方式改变下拉方案的数据
  344. updateSchemeDataByMethod(method) {
  345. if (method === 'system') {
  346. this.schemeOptions = [
  347. { label: '系统优化方案A', value: 'sys_a' },
  348. { label: '系统优化方案B', value: 'sys_b' }
  349. ];
  350. this.currentScheme = 'sys_a';
  351. } else {
  352. this.schemeOptions = [
  353. { label: '早高峰', value: 'early_peak' },
  354. { label: '晚高峰', value: 'evening_peak' },
  355. { label: '平峰', value: 'normal' }
  356. ];
  357. this.currentScheme = 'early_peak';
  358. }
  359. },
  360. // 取消按钮
  361. onCancel() {
  362. this.isManualMode = false;
  363. this.showLockTime = false;
  364. // 可以在此添加回滚初始数据的逻辑
  365. },
  366. // 需求5:点击确认按钮提交 + 表单验证
  367. onConfirm() {
  368. // 验证1:临时方案必须检查时间是否有效
  369. if (this.currentMethod === 'temp') {
  370. const isInvalid = this.currentStageList.some(item => !item.time || item.time <= 0);
  371. if (isInvalid) {
  372. alert('请输入有效的阶段时间 (必须大于0)!');
  373. return;
  374. }
  375. }
  376. // 验证2:步进方案必须选择锁定类型
  377. if (this.currentMethod === 'step') {
  378. if (this.lockTimeType === 'timer' && !this.currentLocktime) {
  379. alert('请选择解锁时间!');
  380. return;
  381. }
  382. }
  383. // 构造提交参数
  384. const submitData = {
  385. method: this.currentMethod,
  386. scheme: this.currentScheme,
  387. stages: this.currentMethod === 'temp' ? this.currentStageList : null,
  388. lockConfig: this.currentMethod === 'step' ? {
  389. type: this.lockTimeType,
  390. time: this.lockTimeType === 'timer' ? this.currentLocktime : null
  391. } : null
  392. };
  393. console.log('提交的数据:', submitData);
  394. // 提交完成后可根据业务决定是否退出手动模式
  395. // this.isManualMode = false;
  396. }
  397. }
  398. }
  399. </script>
  400. <style scoped>
  401. .crossing-detail-panel {
  402. --s: 1;
  403. display: flex;
  404. flex-direction: row;
  405. gap: clamp(4px, calc(var(--s) * 12px), 12px);
  406. height: 100%;
  407. min-height: 0;
  408. overflow: hidden;
  409. }
  410. /* ===== 左侧:还原原始固定 55% 占比 ===== */
  411. .detail-panel-left {
  412. display: flex;
  413. flex-direction: column;
  414. flex: 0 0 55%;
  415. min-height: 0;
  416. min-width: 0;
  417. }
  418. /* ===== 右侧:flex 列容器 + 滚动兜底 ===== */
  419. .detail-panel-right {
  420. flex: 1;
  421. min-width: 0;
  422. min-height: 0;
  423. overflow-y: auto;
  424. overflow-x: hidden;
  425. display: flex;
  426. flex-direction: column;
  427. }
  428. .intersection-video-wrap {
  429. width: 100%;
  430. min-height: 0;
  431. flex: 2;
  432. }
  433. .signal-timing-wrap {
  434. flex: 0 0 auto;
  435. min-height: 0;
  436. height: clamp(110px, calc(var(--s) * 200px), 200px);
  437. width: 100%;
  438. min-width: 0;
  439. background-color: transparent;
  440. box-sizing: border-box;
  441. position: relative;
  442. display: flex;
  443. flex-direction: column;
  444. overflow: hidden;
  445. padding: clamp(3px, calc(var(--s) * 10px), 10px);
  446. }
  447. .header {
  448. display: flex;
  449. justify-content: space-between;
  450. align-items: center;
  451. margin-bottom: clamp(2px, calc(var(--s) * 6px), 15px);
  452. color: #e0e6f1;
  453. flex-shrink: 0;
  454. }
  455. .title-area {
  456. font-size: clamp(9px, calc(var(--s) * 16px), 16px);
  457. }
  458. .main-title {
  459. font-size: clamp(10px, calc(var(--s) * 18px), 18px);
  460. font-weight: bold;
  461. margin-right: clamp(4px, calc(var(--s) * 10px), 10px);
  462. }
  463. .sub-info {
  464. font-size: clamp(8px, calc(var(--s) * 12px), 12px);
  465. opacity: 0.8;
  466. }
  467. .checkbox-area {
  468. font-size: clamp(8px, calc(var(--s) * 12px), 12px);
  469. display: flex;
  470. align-items: center;
  471. cursor: pointer;
  472. opacity: 0.7;
  473. user-select: none;
  474. }
  475. .checkbox-area:hover {
  476. opacity: 1;
  477. }
  478. .checkbox-mock {
  479. width: clamp(10px, calc(var(--s) * 14px), 14px);
  480. height: clamp(10px, calc(var(--s) * 14px), 14px);
  481. border: 1px solid rgba(255, 255, 255, 0.5);
  482. margin-right: clamp(3px, calc(var(--s) * 6px), 6px);
  483. border-radius: 2px;
  484. display: flex;
  485. align-items: center;
  486. justify-content: center;
  487. }
  488. .checkbox-mock.is-checked {
  489. background-color: #4da8ff;
  490. border-color: #4da8ff;
  491. }
  492. .chart-container {
  493. width: 100%;
  494. min-width: 0;
  495. flex: 1;
  496. min-height: 50px;
  497. overflow: hidden;
  498. }
  499. .loading-overlay {
  500. flex: 1;
  501. display: flex;
  502. align-items: center;
  503. justify-content: center;
  504. color: #758599;
  505. font-size: 14px;
  506. }
  507. /* ===== 右侧表单内层容器 ===== */
  508. .detail-right-form {
  509. flex: 1;
  510. min-height: 0;
  511. display: flex;
  512. flex-direction: column;
  513. }
  514. .form-group {
  515. flex: 1;
  516. min-height: 0;
  517. display: flex;
  518. flex-direction: column;
  519. }
  520. /** 控制方法 */
  521. .control-method {
  522. color: #ffffff;
  523. flex-shrink: 0;
  524. }
  525. .control-label-wrap {
  526. display: flex;
  527. align-items: center;
  528. margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
  529. column-gap: clamp(4px, calc(var(--s) * 10px), 20px);
  530. }
  531. .control-label {
  532. font-size: clamp(12px, calc(var(--s) * 22px), 28px);
  533. color: #ffffff;
  534. white-space: nowrap;
  535. }
  536. .control-label-wrap span {
  537. display: inline-block;
  538. }
  539. .operation-btn {
  540. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  541. cursor: pointer;
  542. user-select: none;
  543. }
  544. .operation-btn:hover {
  545. text-decoration: underline;
  546. }
  547. .operation-btn.is-active {
  548. text-decoration: underline;
  549. }
  550. .control-method .control-label-wrap {
  551. justify-content: space-between;
  552. }
  553. /* 控制方式按钮组:设置 font-size 供 SegmentedRadio size="auto" 继承 */
  554. .control-method-content {
  555. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  556. }
  557. .control-scheme {
  558. margin-top: clamp(4px, calc(var(--s) * 10px), 20px);
  559. /* 设置 font-size 供 DropdownSelect size="auto" 继承 */
  560. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  561. }
  562. .lock-time {
  563. width: 80%;
  564. border-radius: 8px;
  565. box-shadow:
  566. inset 0px 0px 10px 0px rgba(88, 146, 255, 0.4),
  567. inset 20px 0px 30px -10px rgba(88, 146, 255, 0.15);
  568. }
  569. .lock-time-label-wrap {
  570. display: flex;
  571. align-items: center;
  572. justify-content: space-between;
  573. padding: clamp(4px, calc(var(--s) * 8px), 10px);
  574. border-radius: 8px 8px 0 0;
  575. color: #ffffff;
  576. }
  577. .lock-time-label {
  578. font-size: clamp(10px, calc(var(--s) * 16px), 16px);
  579. color: #ffffff;
  580. }
  581. .lock-time-options {
  582. display: flex;
  583. flex-direction: column;
  584. row-gap: clamp(4px, calc(var(--s) * 10px), 10px);
  585. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  586. padding: clamp(4px, calc(var(--s) * 10px), 10px);
  587. color: #ffffff;
  588. }
  589. .lock-time-option {
  590. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  591. }
  592. .lock-time-close {
  593. cursor: pointer;
  594. }
  595. .glow-header {
  596. background: linear-gradient(180deg,
  597. rgba(65, 115, 205, 0.6) 0%,
  598. rgba(40, 70, 130, 0.1) 100%);
  599. backdrop-filter: blur(10px);
  600. }
  601. .current-stage {
  602. background-color: rgba(65, 115, 205, 0.2);
  603. border: 1px solid #3660a5;
  604. margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
  605. display: flex;
  606. justify-content: center;
  607. }
  608. .current-stage-warp {
  609. display: flex;
  610. align-items: center;
  611. justify-content: center;
  612. flex-wrap: wrap;
  613. gap: clamp(4px, calc(var(--s) * 8px), 10px);
  614. padding: clamp(6px, calc(var(--s) * 12px), 32px);
  615. color: #ffffff;
  616. }
  617. .current-stage-label {
  618. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  619. width: auto;
  620. white-space: nowrap;
  621. }
  622. .stage-input {
  623. width: clamp(32px, calc(var(--s) * 65px), 65px);
  624. border: 1px solid rgba(161,190,255,0.7);
  625. background-color: transparent;
  626. padding: clamp(2px, calc(var(--s) * 5px), 5px);
  627. color: #ffffff;
  628. text-align: center;
  629. }
  630. .phase-box {
  631. position: relative;
  632. width: clamp(30px, calc(var(--s) * 65px), 65px);
  633. height: clamp(30px, calc(var(--s) * 65px), 65px);
  634. background: #E6F0FF;
  635. border-radius: 4px;
  636. display: flex;
  637. align-items: center;
  638. justify-content: center;
  639. cursor: pointer;
  640. transition: all 0.3s ease;
  641. box-sizing: border-box;
  642. overflow: hidden;
  643. }
  644. .phase-image {
  645. width: 100%;
  646. height: 100%;
  647. object-fit: contain;
  648. display: block;
  649. }
  650. .phase-box::after {
  651. content: '';
  652. position: absolute;
  653. top: 0;
  654. left: 0;
  655. width: 100%;
  656. height: 100%;
  657. background: rgba(30, 106, 255, 0.5);
  658. opacity: 0;
  659. transition: opacity 0.3s ease;
  660. pointer-events: none;
  661. }
  662. .phase-box.is-active::after {
  663. opacity: 1;
  664. }
  665. /** 按钮 */
  666. .btn {
  667. display: inline-flex;
  668. justify-content: center;
  669. align-items: center;
  670. height: clamp(22px, calc(var(--s) * 36px), 36px);
  671. padding: 0 clamp(10px, calc(var(--s) * 32px), 32px);
  672. font-size: clamp(9px, calc(var(--s) * 14px), 14px);
  673. border-radius: 4px;
  674. cursor: pointer;
  675. user-select: none;
  676. transition: all 0.2s ease-in-out;
  677. box-sizing: border-box;
  678. }
  679. .btn-cancel {
  680. background-color: transparent;
  681. color: #d1d5db;
  682. border: 1px solid rgba(130, 150, 190, 0.4);
  683. }
  684. .btn-cancel:hover {
  685. color: #ffffff;
  686. border-color: rgba(130, 150, 190, 0.8);
  687. background-color: rgba(255, 255, 255, 0.05);
  688. }
  689. .btn-cancel:active {
  690. background-color: rgba(255, 255, 255, 0.1);
  691. }
  692. .btn-confirm {
  693. background-color: #3b74ff;
  694. color: #ffffff;
  695. border: 1px solid #3b74ff;
  696. }
  697. .btn-confirm:hover {
  698. background-color: #5a8bff;
  699. border-color: #5a8bff;
  700. box-shadow: 0 2px 8px rgba(59, 116, 255, 0.3);
  701. }
  702. .btn-confirm:active {
  703. background-color: #265bed;
  704. border-color: #265bed;
  705. box-shadow: none;
  706. }
  707. .button-group {
  708. display: flex;
  709. justify-content: flex-end;
  710. margin-top: clamp(4px, calc(var(--s) * 10px), 20px);
  711. flex-shrink: 0;
  712. }
  713. .button-group>div {
  714. display: flex;
  715. gap: clamp(4px, calc(var(--s) * 8px), 12px);
  716. }
  717. /* 禁用状态 */
  718. .form-interactive-area {
  719. transition: opacity 0.3s;
  720. flex: 1;
  721. min-height: 0;
  722. overflow-y: auto;
  723. overflow-x: hidden;
  724. }
  725. .form-interactive-area.is-disabled {
  726. opacity: 0.6;
  727. pointer-events: none;
  728. }
  729. /* 当前阶段输入框微调 */
  730. .stage-item-wrapper {
  731. display: flex;
  732. flex-direction: column;
  733. align-items: center;
  734. gap: clamp(2px, calc(var(--s) * 4px), 6px);
  735. position: relative;
  736. }
  737. .stage-input {
  738. width: clamp(32px, calc(var(--s) * 65px), 65px);
  739. border: 1px solid rgba(161,190,255,0.7);
  740. background-color: transparent;
  741. padding: clamp(2px, calc(var(--s) * 5px), 5px);
  742. color: #ffffff;
  743. text-align: center;
  744. border-radius: 4px;
  745. }
  746. .stage-input:disabled {
  747. border-color: rgba(255, 255, 255, 0.2);
  748. color: rgba(255, 255, 255, 0.5);
  749. background-color: rgba(0, 0, 0, 0.2);
  750. }
  751. .stage-item-wrapper .unit {
  752. position: absolute;
  753. bottom: clamp(2px, calc(var(--s) * 6px), 6px);
  754. right: clamp(3px, calc(var(--s) * 6px), 8px);
  755. color: #77A1FF;
  756. font-size: clamp(8px, calc(var(--s) * 12px), 12px);
  757. pointer-events: none;
  758. }
  759. /* 弹窗过渡动画 */
  760. .fade-enter-active, .fade-leave-active {
  761. transition: opacity 0.3s, transform 0.3s;
  762. }
  763. .fade-enter, .fade-leave-to {
  764. opacity: 0;
  765. transform: translateY(-10px);
  766. }
  767. /* lock-time 弹窗补充样式 */
  768. .lock-time {
  769. margin-top: clamp(4px, calc(var(--s) * 10px), 15px);
  770. background-color: rgba(20, 30, 50, 0.9);
  771. }
  772. /* 单选框基础对齐 */
  773. .lock-time-option label {
  774. display: flex;
  775. align-items: center;
  776. gap: clamp(3px, calc(var(--s) * 6px), 8px);
  777. cursor: pointer;
  778. }
  779. /* ===== 方案圆饼图左右布局 ===== */
  780. .donut-row {
  781. display: flex;
  782. gap: clamp(4px, calc(var(--s) * 16px), 16px);
  783. width: 100%;
  784. }
  785. .donut-item {
  786. flex: 1;
  787. min-width: 0;
  788. }
  789. .donut-title {
  790. font-size: clamp(11px, calc(var(--s) * 13px), 14px);
  791. color: #a0aec0;
  792. margin-bottom: 4px;
  793. }
  794. </style>