| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564 |
- <template>
- <div class="crossing-detail-panel" :class="{ 'is-dual': isDual }">
- <!-- content-row:单图模式 display:contents 透明(保持原 row 布局),双图模式显形为 row 容器 -->
- <div class="content-row">
- <div class="detail-panel-left">
- <div class="intersection-video-wrap">
- <IntersectionMapVideos :mapData="intersectionData" />
- </div>
- <!-- 单图模式:相位图保持在左侧栏内 -->
- <div v-if="!isDual" class="signal-timing-wrap">
- <div class="header">
- <div class="title-area">
- <span class="main-title">方案状态</span>
- <span class="sub-info">(周期: {{ cycleLength }} 相位差: {{ phaseDiff }} 协调时间: {{ coordTime }})</span>
- </div>
- </div>
- <SignalTimingChart :cycleLength="cycleLength" :currentTime="currentSec"
- :phaseData="mockPhaseData" :showScanLine="dataReady" :autoScan="dataReady"
- @scan-tick="onScanTick" />
- </div>
- </div>
- <div class="detail-panel-right">
- <form class="detail-right-form" @submit.prevent>
- <div class="form-group">
- <!-- 新版控制方式按钮组:手动开关 / 紧急模式 / 配时动作 -->
- <div class="control-method">
- <div class="control-label-wrap control-chips-row">
- <div class="control-chips">
- <!-- 手动开关 -->
- <button type="button" class="chip chip-manual"
- :class="{ 'is-active': isManualMode }" @click="toggleManualMode">
- {{ isManualMode ? '解除手动' : '手动控制' }}
- </button>
- <span class="chip-divider"></span>
- <!-- 紧急模式:关灯 / 黄闪 / 全红 -->
- <button type="button" class="chip chip-mode-off"
- :class="{ 'is-active': currentMethod === 'lights_off' }"
- :disabled="!isManualMode" @click="setControlMethod('lights_off')">关灯</button>
- <button type="button" class="chip chip-mode-yellow"
- :class="{ 'is-active': currentMethod === 'yellow_flash' }"
- :disabled="!isManualMode" @click="setControlMethod('yellow_flash')">黄闪</button>
- <button type="button" class="chip chip-mode-red"
- :class="{ 'is-active': currentMethod === 'all_red' }"
- :disabled="!isManualMode" @click="setControlMethod('all_red')">全红</button>
- <span class="chip-divider"></span>
- <!-- 配时动作:弹窗 -->
- <button type="button" class="chip chip-action"
- :disabled="!isManualMode" @click="openTempSchemeDialog">临时修改</button>
- <button type="button" class="chip chip-action"
- :disabled="!isManualMode" @click="openSchemeDialog">修改方案</button>
- </div>
- </div>
- </div>
- <div class="form-interactive-area">
- <div class="form-editable-area" :class="{ 'is-disabled': !isManualMode }">
- <div class="control-scheme">
- <div class="current-stage" ref="stageGrid">
- <div v-for="(item, index) in currentStageList" :key="index"
- class="stage-item-wrapper">
- <div class="phase-box" :class="{ 'is-active': item.value === currentStage }"
- @click="onStageClick(item.value)">
- <PhaseDiagram
- v-if="item.icons && item.icons.length"
- :icons="item.icons"
- :no="item.no || item.value"
- :arrow-color="item.arrowColor"
- :arrow-colors="item.arrowColors || {}"
- :bg-color="item.bgColor"
- :number-color="item.numberColor"
- />
- <img v-else :src="item.img" alt="stage" class="phase-image" />
- </div>
- <div class="bottom-controls">
- <div class="input-unit-wrapper">
- <input type="number" v-model.number="item.time" class="stage-input"
- :disabled="!canEditStage"
- :title="canEditStage ? '修改阶段时间' : '当前控制方式不可修改'" />
- <span class="unit">s</span>
- </div>
- <span class="percent">{{ stagePercent(item.time) }}</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 方案圆饼图: 从 .form-interactive-area 抽出, 作为 .form-group 直接子节点,
- 脱离 current-stage 的滚动上下文, 保证 4 阶段时圆饼图始终可见 -->
- <div class="donut-row">
- <div class="donut-item">
- <div class="donut-title">实时方案(执行方案3)</div>
- <PlanDonutChart :chartData="realtimeDonutData"
- :centerValue="String(realtimeRemaining)" centerLabel="剩余时长" :showTotal="true"
- :totalValue="cycleLength" :scale="panelScale" />
- </div>
- <div class="donut-item">
- <div class="donut-title">下周期方案</div>
- <PlanDonutChart :chartData="nextCycleDonutData" :centerValue="String(cycleLength)"
- centerLabel="总时长" :showTotal="false" :scale="panelScale" />
- </div>
- </div>
- <div class="button-group" v-show="isManualMode && currentMethod !== 'step'">
- <div>
- <button type="button" class="btn btn-cancel" @click="onCancel()">取消</button>
- <button type="button" class="btn btn-confirm" @click="onConfirm()">确认</button>
- </div>
- </div>
- </div>
- </form>
- </div>
- </div><!-- /.content-row -->
- <!-- 双图模式:相位图作为整宽底部行(跨左右两栏) -->
- <div v-if="isDual" class="signal-timing-wrap is-dual">
- <div class="header">
- <div class="title-area">
- <span class="main-title">方案状态</span>
- <span class="sub-info">(周期: {{ cycleLength }} 相位差: {{ phaseDiff }} 协调时间: {{ coordTime }})</span>
- </div>
- </div>
- <div class="timing-row timing-row-live">
- <div class="row-label">
- 本周期 实时<span v-if="thisCycle"> · {{ thisCycle.schemeName }}</span>
- </div>
- <div class="row-chart">
- <SignalTimingChart :cycleLength="thisCycle.cycleLength" :currentTime="currentSec"
- :phaseData="thisCycle.phaseData" :showScanLine="dataReady" :showScanLineLabel="dataReady"
- :clipToActive="true" :compactScanLine="true" :autoScan="dataReady"
- @scan-tick="onScanTick" />
- </div>
- </div>
- <div class="timing-row timing-row-last">
- <div class="row-label">
- 上周期 方案
- <span v-if="lastCycle"> · 实际 {{ lastCycle.actualDuration }}s / 计划 {{ lastCycle.cycleLength }}s</span>
- </div>
- <div class="row-chart">
- <SignalTimingChart v-if="lastCycle" :cycleLength="lastCycle.cycleLength" :currentTime="0"
- :phaseData="lastCycle.phaseData" :showScanLine="false" :showScanLineLabel="false" />
- <div v-else class="empty-placeholder">暂无上周期数据</div>
- </div>
- </div>
- </div>
- <!-- 步进锁定时间弹窗 -->
- <transition name="fade">
- <div class="lock-time-overlay" v-if="showLockTime" @click.self="showLockTime = false">
- <div class="lock-time-dialog">
- <div class="lock-time-header">
- <span class="lock-time-title">锁定时间</span>
- <span class="lock-time-close" @click="showLockTime = false">✕</span>
- </div>
- <div class="lock-time-divider"></div>
- <div class="lock-time-body">
- <div class="lock-time-options">
- <div class="lock-time-option">
- <label>
- <input type="radio" v-model="lockTimeType" value="continuous" /> 持续放行
- </label>
- </div>
- <div class="lock-time-option">
- <label>
- <input type="radio" v-model="lockTimeType" value="timer" /> 放行
- <DropdownSelect placeholder="锁定时间" v-model="currentLocktime"
- :options="locktimeOptions" size="auto" @click.native.prevent />
- 秒解锁
- </label>
- </div>
- </div>
- <div class="lock-time-actions">
- <button type="button" class="btn btn-cancel"
- @click="onLockTimeCancel">取消</button>
- <button type="button" class="btn btn-confirm"
- @click="onLockTimeConfirm">确认</button>
- </div>
- </div>
- </div>
- </div>
- </transition>
- </div>
- </template>
- <script>
- import SignalTimingChart from '@/components/ui/SignalTimingChart.vue';
- import IntersectionMapVideos from '@/components/ui/IntersectionMapVideos.vue';
- import DropdownSelect from '@/components/ui/DropdownSelect.vue';
- import PlanDonutChart from '@/components/ui/PlanDonutChart.vue';
- import PhaseDiagram from '@/components/ui/PhaseDiagram.vue';
- import { apiGetCrossingDetailData, apiSaveCrossingTempScheme, apiSaveCrossingScheme } from '@/api';
- export default {
- name: 'CrossingPanel',
- components: {
- SignalTimingChart,
- IntersectionMapVideos,
- DropdownSelect,
- PlanDonutChart,
- PhaseDiagram,
- },
- props: {
- preloadedData: { type: Object, default: null },
- iconMode: { type: String, default: 'simple' } // 'default' | 'simple'
- },
- // dialogManager 由 DashboardLayout 顶层 provide;用于"临时修改 / 修改方案"弹窗的打开与关闭
- inject: {
- dialogManager: { default: null },
- },
- data() {
- return {
- startDate: '2026-04-13',
- startTime: '14:03:06',
- endDate: '2026-04-13',
- endTime: '15:03:06',
- duration: null,
- period: null,
- // 核心状态控制
- isManualMode: false, // 是否处于手动控制模式
- showLockTime: false, // 是否显示锁定时间弹窗
- lockTimeType: 'continuous', // 锁定时间类型
- dataReady: false,
- followPhase: false,
- intersectionData: {},
- currentRoute: {},
- cycleLength: 140,
- currentSec: 0,
- phaseDiff: 0,
- coordTime: 0,
- mockPhaseData: [],
- panelScale: 1,
- // 控制方式数据
- controlMethodOptions: [],
- currentMethod: 'temp',
- currentScheme: 'early_peak',
- schemeOptions: [],
- // 实时方案圆饼图数据(从 phaseData 动态生成)
- realtimeDonutData: [],
- // 下周期方案圆饼图数据
- nextCycleDonutData: [],
- // 实时方案剩余时长
- realtimeRemaining: 0,
- // 各阶段基础数据(从 phaseData 解析)
- phaseStages: [],
- currentLocktime: 50,
- locktimeOptions: [],
- currentStage: '1',
- // 补充了 time 属性,用于双向绑定输入框的时间
- currentStageList: [],
- // 双相位图模式数据(仅当后端返回 thisCycle/lastCycle 时启用)
- thisCycle: null,
- lastCycle: null,
- }
- },
- computed: {
- // 双相位图模式:后端提供了 thisCycle 时启用,否则保持单图行为
- isDual() {
- return !!(this.thisCycle && this.thisCycle.phaseData && this.thisCycle.phaseData.length);
- },
- // 黄闪、关灯、全红时禁用控制方案
- isSchemeDisabled() {
- return ['yellow_flash', 'lights_off', 'all_red'].includes(this.currentMethod);
- },
- // 定周期、中心计划、感应控制、临时方案可编辑当前阶段
- canEditStage() {
- return ['fixed', 'system', 'sensor', 'temp'].includes(this.currentMethod);
- },
- // 时长选项:30, 60, 90 ... 300
- durationOptions() {
- const list = [];
- for (let i = 30; i <= 300; i += 30) list.push(i);
- return list;
- }
- },
- watch: {
- // 监听控制方式切换
- currentMethod(newVal) {
- // 切换到步进时不自动弹出锁定时间,等用户点击阶段再弹
- this.showLockTime = false;
- if (newVal === 'step') {
- this.syncLocktimeByStage();
- }
- // 模拟需求1:根据不同模式,切换对应的控制方案数据 (Mock 逻辑)
- this.updateSchemeDataByMethod(newVal);
- },
- // 阶段列表变化时重挂 ResizeObserver 到新的首个元素 (max-height 跟着重算)
- currentStageList() {
- this.$nextTick(() => this._observeFirstStageItem());
- },
- // 当前阶段切到第 5+ 个时, 自动滚到可见
- currentStage() {
- this.$nextTick(() => this._scrollCurrentStageIntoView());
- },
- },
- mounted() {
- this.initScaleObserver();
- if (this.preloadedData) {
- this.applyData(this.preloadedData);
- } else {
- this.loadData();
- }
- },
- beforeDestroy() {
- if (this._ro) this._ro.disconnect();
- if (this._stageRO) this._stageRO.disconnect();
- if (this._stageRaf) cancelAnimationFrame(this._stageRaf);
- },
- methods: {
- // 点击阶段:切换选中,步进模式下同时弹出锁定时间
- onStageClick(value) {
- this.currentStage = value;
- if (this.currentMethod === 'step') {
- this.showLockTime = true;
- this.syncLocktimeByStage();
- }
- },
- // 根据当前选中阶段同步锁定时间选项
- syncLocktimeByStage() {
- const stage = this.currentStageList.find(s => s.value === this.currentStage);
- if (stage && stage.locktimeOptions) {
- this.locktimeOptions = stage.locktimeOptions;
- // 如果当前值不在新选项中,重置为第一个
- const hasValue = this.locktimeOptions.some(o => o.value === this.currentLocktime);
- if (!hasValue) {
- this.currentLocktime = this.locktimeOptions[0]?.value || null;
- }
- }
- },
- stagePercent(time) {
- const total = this.currentStageList.reduce((s, item) => s + (item.time || 0), 0);
- if (!total) return '0%';
- return Math.round(time / total * 100) + '%';
- },
- onScanTick(activeTime) {
- if (!this.mockPhaseData || this.mockPhaseData.length === 0) return;
- // 只看第一轨道(trackIdx=0)的相位
- const phase = this.mockPhaseData.find(p => p[0] === 0 && activeTime >= p[1] && activeTime < p[2]);
- if (!phase) return;
- const type = phase[5]; // green/stripe/yellow/red
- const iconValue = phase[6]; // 如 "STRAIGHT_DOWN,STRAIGHT_UP"
- const direction = phase[7]; // ns/ew
- const phaseName = phase[3]; // P1/P2 等
- const endTime = phase[2];
- const remaining = Math.max(0, Math.round(endTime - activeTime));
- const nsGreen = (type === 'green' && direction === 'ns');
- const ewGreen = (type === 'green' && direction === 'ew');
- // 从图标值解析当前允许的行驶方向类型
- // STRAIGHT→S, TURN_*_LEFT→L, *_UTURN→U
- let activeArrowTypes = [];
- if ((nsGreen || ewGreen) && iconValue) {
- const icons = iconValue.split(',');
- icons.forEach(ic => {
- if (ic.includes('UTURN')) activeArrowTypes.push('U');
- if (ic.includes('TURN') && !ic.includes('UTURN')) activeArrowTypes.push('L');
- if (ic.includes('STRAIGHT')) activeArrowTypes.push('S');
- });
- // 去重
- activeArrowTypes = [...new Set(activeArrowTypes)];
- }
- // 人行道全红判断:只有 P1/P3 绿灯期间人行道才有绿灯,其余时段全红
- const pedAllRed = !(type === 'green' && (phaseName === 'P1' || phaseName === 'P3'));
- this.$set(this.intersectionData, 'signals', {
- pedAllRed,
- ns: {
- phaseName: nsGreen ? ({ P1: '南北直行', P2: '南北左转' }[phaseName] || '南北') : (this.intersectionData.signals?.ns?.phaseName || '南北'),
- time: remaining,
- isGreen: nsGreen,
- activeArrowTypes: nsGreen ? activeArrowTypes : []
- },
- ew: {
- phaseName: ewGreen ? ({ P3: '东西直行', P4: '东西左转' }[phaseName] || '东西') : (this.intersectionData.signals?.ew?.phaseName || '东西'),
- time: remaining,
- isGreen: ewGreen,
- activeArrowTypes: ewGreen ? activeArrowTypes : []
- }
- });
- // 更新实时方案圆饼图的已走时长
- this.updateRealtimeDonut(activeTime);
- // 双相位图模式:检测周期 wrap-around,触发 refetch 让后端把刚结束的周期作为 lastCycle 返回
- if (this.isDual) {
- if (this._prevTick != null && activeTime < this._prevTick - 1) {
- this.refetchDetail();
- }
- this._prevTick = activeTime;
- }
- },
- async refetchDetail() {
- if (this._refetching) return;
- this._refetching = true;
- try {
- const nodeId = this.$attrs.id || this.id;
- const data = await apiGetCrossingDetailData(nodeId, { iconMode: this.iconMode });
- if (data) this.applyData(data);
- } finally {
- this._refetching = false;
- }
- },
- // 从 phaseData 解析4个阶段的绿灯时长,构建圆饼图数据
- buildDonutFromPhaseData() {
- const phaseData = this.mockPhaseData || [];
- const stageColors = ['#3b82f6', '#a855f7', '#14b8a6', '#f59e0b'];
- const stageLabels = ['1-南北直行', '2-南北左转', '3-东西直行', '4-东西左转'];
- // 提取 track 0 的绿灯相位(每阶段的第一个 green)
- const greenPhases = phaseData.filter(p => p[0] === 0 && p[5] === 'green');
- const stages = greenPhases.slice(0, 4).map((p, i) => {
- const total = p[8] || Math.floor(this.cycleLength / 4);
- return {
- label: stageLabels[i] || `阶段${i + 1}`,
- value: total, // 阶段总时长
- start: p[1], // 阶段开始时间(从绿灯起始)
- end: p[1] + total, // 阶段结束时间
- color: stageColors[i]
- };
- });
- this.phaseStages = stages;
- // 实时方案:已走时长 + 4个阶段
- this.realtimeDonutData = [
- { label: '已走时长', value: 0, color: '#8892a0' },
- ...stages.map(s => ({ label: s.label, value: s.value, color: s.color }))
- ];
- this.realtimeRemaining = this.cycleLength;
- // 下周期方案:4个阶段,初始数据与实时方案相同
- this.nextCycleDonutData = stages.map(s => ({
- label: s.label,
- value: s.value,
- color: s.color
- }));
- },
- // 根据扫描线时间更新实时方案圆饼图
- updateRealtimeDonut(activeTime) {
- if (this.phaseStages.length === 0) return;
- const elapsed = Math.round(activeTime);
- const remaining = Math.max(0, this.cycleLength - elapsed);
- this.realtimeRemaining = remaining;
- // 计算各阶段的剩余时长
- const stageValues = this.phaseStages.map(s => {
- if (activeTime >= s.end) return 0; // 阶段已完成
- if (activeTime < s.start) return s.value; // 阶段未开始
- return Math.max(0, Math.round(s.end - activeTime)); // 阶段进行中
- });
- this.realtimeDonutData = [
- { label: '已走时长', value: elapsed, color: '#8892a0' },
- ...this.phaseStages.map((s, i) => ({
- label: s.label,
- value: stageValues[i],
- color: s.color
- }))
- ];
- },
- // 观察首个 stage-item-wrapper, 算出 2 行高度(含行间 gap) 写到 CSS 变量。
- // grid 的 height 锁在 2 行高度: 不足 8 个时下排留白; 超 8 个时按行滚动。
- // 防 ResizeObserver loop 警告: rAF 推到下一帧 + 值比较切断"测量→撑大→再测"循环。
- _observeFirstStageItem() {
- const grid = this.$refs.stageGrid;
- if (!grid) return;
- if (this._stageRO) {
- this._stageRO.disconnect();
- this._stageRO = null;
- }
- const first = grid.firstElementChild;
- if (!first) return;
- const schedule = () => {
- if (this._stageRaf) return;
- this._stageRaf = requestAnimationFrame(() => {
- this._stageRaf = 0;
- const f = grid.firstElementChild;
- if (!f) return;
- const rowH = f.offsetHeight;
- if (rowH <= 0) return;
- const cs = window.getComputedStyle(grid);
- const gap = parseFloat(cs.rowGap || cs.gap || '0') || 0;
- const reservedH = rowH * 2 + gap;
- if (this._lastRowH === rowH && this._lastReservedH === reservedH) return;
- this._lastRowH = rowH;
- this._lastReservedH = reservedH;
- grid.style.setProperty('--stage-row-h', rowH + 'px');
- grid.style.setProperty('--stage-reserved-h', reservedH + 'px');
- });
- };
- this._stageRO = new ResizeObserver(schedule);
- this._stageRO.observe(first);
- schedule();
- },
- // 当前阶段切换后, 若它在第二行/之后, 自动滚到可见
- _scrollCurrentStageIntoView() {
- const grid = this.$refs.stageGrid;
- if (!grid) return;
- const idx = this.currentStageList.findIndex(s => s.value === this.currentStage);
- const el = idx >= 0 ? grid.children[idx] : null;
- if (el && typeof el.scrollIntoView === 'function') {
- el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
- }
- },
- initScaleObserver() {
- const ro = new ResizeObserver(entries => {
- const { width } = entries[0].contentRect;
- const s = Math.min(width / 1315, 1);
- this.$el.style.setProperty('--s', s);
- this.panelScale = s;
- });
- ro.observe(this.$el);
- this._ro = ro;
- },
- async loadData() {
- const nodeId = this.$attrs.id || this.id;
- const data = await apiGetCrossingDetailData(nodeId, { iconMode: this.iconMode });
- if (data) {
- this.applyData(data);
- }
- },
- // 给每个 stage 派生 icons (PhaseDiagram 渲染用):
- // 优先用后端直接给的 icons[], 否则从 direction 字符串拆 token。
- // 缺失时 PhaseDiagram 不渲染, 模板内 fallback 到旧的 <img :src="item.img">。
- _withIcons(stages) {
- return (stages || []).map(item => ({
- ...item,
- icons: item.icons || (item.direction || '').split(',').filter(Boolean),
- }));
- },
- applyData(data) {
- this.currentRoute = data.currentRoute || {};
- this.intersectionData = data.intersectionData || {};
- this.mockPhaseData = data.phaseData || [];
- this.cycleLength = data.cycleLength || 140;
- this.currentSec = data.currentTime || 0;
- this.phaseDiff = data.phaseDiff || 0;
- this.coordTime = data.coordTime || 0;
- this.currentStageList = this._withIcons(data.stageList);
- // 双相位图字段:后端没返回时为 null,UI 自动退化为单图模式
- this.thisCycle = data.thisCycle || null;
- this.lastCycle = data.lastCycle || null;
- this.buildDonutFromPhaseData();
- this.$nextTick(() => {
- this.dataReady = true;
- });
- this.schemeOptions = data.schemeOptions || [];
- if (data.currentScheme) this.currentScheme = data.currentScheme;
- if (data.controlMethodOptions) this.controlMethodOptions = data.controlMethodOptions;
- if (data.currentMethod) this.currentMethod = data.currentMethod;
- if (data.locktimeOptions) this.locktimeOptions = data.locktimeOptions;
- if (data.startDate) this.startDate = data.startDate;
- if (data.startTime) this.startTime = data.startTime;
- if (data.endDate) this.endDate = data.endDate;
- if (data.endTime) this.endTime = data.endTime;
- if (data.duration) this.duration = data.duration;
- if (data.period) this.period = data.period;
- },
- // 切换手动控制模式
- toggleManualMode() {
- this.isManualMode = !this.isManualMode;
- if (!this.isManualMode) {
- // 如果退出手动模式,可选择重置表单状态
- this.showLockTime = false;
- }
- },
- // 控制方式 chip 按钮点击:切 currentMethod 触发现有 watch(line ~272)逻辑
- setControlMethod(value) {
- if (!this.isManualMode) return;
- this.currentMethod = value;
- },
- // ====== 配时方案编辑弹窗 ======
- // 当前路口在弹窗里用的稳定 ID(不同路口独立弹窗,同一路口同一类型互斥)
- _crossingId() {
- return this.currentRoute?.id || this.$attrs.id || this.id || 'unknown';
- },
- _tempDialogId() { return `scheme-edit-temp-${this._crossingId()}`; },
- _schemeDialogId() { return `scheme-edit-permanent-${this._crossingId()}`; },
- // "临时修改"按钮:打开带时间区段的弹窗(同时关掉"修改方案"避免互相覆盖)
- openTempSchemeDialog() {
- if (!this.isManualMode || !this.dialogManager) return;
- // 互斥:先关掉对面弹窗(DashboardLayout provide 的方法名是 closeDialog,不是 handleDialogClose)
- this.dialogManager.closeDialog(this._schemeDialogId());
- const id = this._tempDialogId();
- this.dialogManager.openDialog({
- id,
- title: '修改临时配时方案',
- component: 'SchemeStageEditDialog',
- width: 720,
- height: 480,
- center: true,
- draggable: true,
- resizable: true,
- noPadding: true, // 内容组件自管 padding
- showClose: true,
- data: {
- dialogId: id,
- showTimeRange: true,
- payload: {
- stages: JSON.parse(JSON.stringify(this.currentStageList || [])),
- timeRange: {
- startDate: this.startDate, startTime: this.startTime,
- endDate: this.endDate, endTime: this.endTime,
- duration: this.duration, period: this.period,
- },
- isFixedCycle: false,
- },
- onSave: (updated) => this.onTempSchemeSave(updated),
- onCancel: null,
- },
- });
- },
- // "修改方案"按钮:打开不带时间区段的弹窗
- openSchemeDialog() {
- if (!this.isManualMode || !this.dialogManager) return;
- // 互斥:先关掉对面弹窗
- this.dialogManager.closeDialog(this._tempDialogId());
- const id = this._schemeDialogId();
- this.dialogManager.openDialog({
- id,
- title: '修改配时方案',
- component: 'SchemeStageEditDialog',
- width: 660,
- height: 420,
- center: true,
- draggable: true,
- resizable: true,
- noPadding: true,
- showClose: true,
- data: {
- dialogId: id,
- showTimeRange: false,
- payload: {
- stages: JSON.parse(JSON.stringify(this.currentStageList || [])),
- isFixedCycle: false,
- },
- onSave: (updated) => this.onSchemeSave(updated),
- onCancel: null,
- },
- });
- },
- async onTempSchemeSave(payload) {
- const id = this._crossingId();
- // 调后端保存(mock 当前直接 200,真实后端可能返回 schemeId / appliedAt 等)
- try {
- await apiSaveCrossingTempScheme(id, payload);
- } catch (e) {
- console.warn('[CrossingDetailPanel] saveTempScheme failed:', e);
- }
- // 本地写回(不阻塞 UI)
- if (Array.isArray(payload.stages)) this.currentStageList = this._withIcons(payload.stages);
- const tr = payload.timeRange || {};
- if (tr.startDate !== undefined) this.startDate = tr.startDate;
- if (tr.startTime !== undefined) this.startTime = tr.startTime;
- if (tr.endDate !== undefined) this.endDate = tr.endDate;
- if (tr.endTime !== undefined) this.endTime = tr.endTime;
- if (tr.duration !== undefined) this.duration = tr.duration;
- if (tr.period !== undefined) this.period = tr.period;
- this.buildDonutFromPhaseData();
- this.$emit('confirm', { method: 'temp', scheme: this.currentScheme, stages: this.currentStageList });
- },
- async onSchemeSave(payload) {
- const id = this._crossingId();
- try {
- await apiSaveCrossingScheme(id, { ...payload, schemeId: this.currentScheme });
- } catch (e) {
- console.warn('[CrossingDetailPanel] saveScheme failed:', e);
- }
- if (Array.isArray(payload.stages)) this.currentStageList = this._withIcons(payload.stages);
- this.buildDonutFromPhaseData();
- this.$emit('confirm', { method: this.currentMethod, scheme: this.currentScheme, stages: this.currentStageList });
- },
- // 模拟:根据控制方式改变下拉方案的数据
- updateSchemeDataByMethod(method) {
- if (method === 'system') {
- this.schemeOptions = [
- { label: '系统优化方案A', value: 'sys_a' },
- { label: '系统优化方案B', value: 'sys_b' }
- ];
- this.currentScheme = 'sys_a';
- } else {
- this.schemeOptions = [
- { label: '早高峰', value: 'early_peak' },
- { label: '晚高峰', value: 'evening_peak' },
- { label: '平峰', value: 'normal' }
- ];
- this.currentScheme = 'early_peak';
- }
- },
- // 取消按钮(退出手动控制)
- onCancel() {
- this.isManualMode = false;
- this.showLockTime = false;
- },
- // 步进锁定时间弹窗:取消(不退出手动控制)
- onLockTimeCancel() {
- this.showLockTime = false;
- },
- // 步进锁定时间弹窗:确认(不退出手动控制)
- onLockTimeConfirm() {
- this.showLockTime = false;
- const submitData = {
- method: 'step',
- stage: this.currentStage,
- lockTimeType: this.lockTimeType,
- locktime: this.lockTimeType === 'timer' ? this.currentLocktime : null,
- };
- console.log('步进指令:', submitData);
- this.$emit('confirm', submitData);
- },
- // 需求5:点击确认按钮提交 + 表单验证
- onConfirm() {
- // 验证1:临时方案必须检查时间是否有效
- if (this.currentMethod === 'temp') {
- const isInvalid = this.currentStageList.some(item => !item.time || item.time <= 0);
- if (isInvalid) {
- alert('请输入有效的阶段时间 (必须大于0)!');
- return;
- }
- }
- // 验证2:步进方案必须选择锁定类型
- if (this.currentMethod === 'step') {
- if (this.lockTimeType === 'timer' && !this.currentLocktime) {
- alert('请选择解锁时间!');
- return;
- }
- }
- // 构造提交参数
- const submitData = {
- method: this.currentMethod,
- scheme: this.currentScheme,
- stages: this.currentMethod === 'temp' ? this.currentStageList : null,
- lockConfig: this.currentMethod === 'step' ? {
- stage: this.currentStage,
- type: this.lockTimeType,
- time: this.lockTimeType === 'timer' ? this.currentLocktime : null
- } : null
- };
- console.log('提交的数据:', submitData);
- // 提交完成后可根据业务决定是否退出手动模式
- // this.isManualMode = false;
- }
- }
- }
- </script>
- <style scoped>
- .crossing-detail-panel {
- --s: 1;
- position: relative;
- display: flex;
- flex-direction: row;
- gap: clamp(4px, calc(var(--s) * 12px), 12px);
- height: 100%;
- min-height: 0;
- overflow: hidden;
- }
- /* 双图模式:根节点变 column,content-row 变 row 容器,相位图作为整宽底部行 */
- .crossing-detail-panel.is-dual {
- flex-direction: column;
- gap: clamp(4px, calc(var(--s) * 8px), 8px);
- }
- /* 单图模式:content-row 透明,子节点直接挂到根的 row 布局上(保持原行为) */
- .content-row {
- display: contents;
- }
- /* 双图模式:content-row 显形为 row,承载视频+表单 */
- .crossing-detail-panel.is-dual .content-row {
- display: flex;
- flex-direction: row;
- gap: clamp(4px, calc(var(--s) * 12px), 12px);
- flex: 1 1 0;
- min-width: 0;
- min-height: 0;
- width: 100%;
- }
- /* ===== 左侧:还原原始固定 55% 占比 ===== */
- .detail-panel-left {
- display: flex;
- flex-direction: column;
- flex: 0 0 55%;
- min-height: 0;
- min-width: 0;
- }
- /* ===== 右侧:flex 列容器 + 滚动兜底 ===== */
- .detail-panel-right {
- flex: 1;
- min-width: 0;
- min-height: 0;
- overflow-y: auto;
- overflow-x: hidden;
- display: flex;
- flex-direction: column;
- }
- .intersection-video-wrap {
- width: 100%;
- min-height: 0;
- flex: 2;
- }
- .signal-timing-wrap {
- flex: 0 0 auto;
- min-height: 0;
- height: clamp(95px, calc(var(--s) * 166px), 166px);
- width: 100%;
- min-width: 0;
- background-color: transparent;
- box-sizing: border-box;
- position: relative;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- padding: clamp(3px, calc(var(--s) * 10px), 10px);
- }
- /* 双相位图模式:整宽底部行 */
- .signal-timing-wrap.is-dual {
- height: clamp(100px, calc(var(--s) * 170px), 170px);
- padding: 0 clamp(3px, calc(var(--s) * 10px), 10px);
- }
- /* 双图模式下 header 紧贴下方相位图,避免无意义空白 */
- .signal-timing-wrap.is-dual .header {
- margin-bottom: 0;
- line-height: 1;
- padding: 2px 0 0;
- }
- .signal-timing-wrap.is-dual .main-title {
- font-size: clamp(10px, calc(var(--s) * 14px), 14px);
- }
- .signal-timing-wrap.is-dual .sub-info {
- font-size: clamp(9px, calc(var(--s) * 12px), 12px);
- }
- .signal-timing-wrap.is-dual .timing-row {
- flex: 1 1 0;
- min-height: 0;
- display: flex;
- flex-direction: column;
- }
- .signal-timing-wrap.is-dual .row-label {
- flex: 0 0 auto;
- font-size: clamp(8px, calc(var(--s) * 11px), 11px);
- color: #9ca3af;
- padding: 0 4px;
- line-height: 1;
- margin-bottom: 2px;
- /* 强制单行 + 省略号:多窗口窄屏下"上周期 实际 / 计划" 这类长文本曾换成 2 行,
- 挤压自己 row 内 chart 高度并视觉撑出上一条 chart 的 canvas 边界 */
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .signal-timing-wrap.is-dual .row-chart {
- flex: 1 1 0;
- min-height: 0;
- display: flex;
- position: relative;
- /* 防御性:ECharts 极端情况下 label/markLine 可能溢出 canvas 边界,加裁切兜底 */
- overflow: hidden;
- }
- .signal-timing-wrap.is-dual .empty-placeholder {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #6b7280;
- font-size: clamp(10px, calc(var(--s) * 12px), 12px);
- }
- .header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: clamp(2px, calc(var(--s) * 6px), 15px);
- color: #e0e6f1;
- flex-shrink: 0;
- }
- .title-area {
- font-size: clamp(9px, calc(var(--s) * 16px), 16px);
- }
- .main-title {
- font-size: clamp(10px, calc(var(--s) * 18px), 18px);
- font-weight: bold;
- margin-right: clamp(4px, calc(var(--s) * 10px), 10px);
- }
- .sub-info {
- font-size: clamp(8px, calc(var(--s) * 12px), 12px);
- opacity: 0.8;
- }
- .checkbox-area {
- font-size: clamp(8px, calc(var(--s) * 12px), 12px);
- display: flex;
- align-items: center;
- cursor: pointer;
- opacity: 0.7;
- user-select: none;
- }
- .checkbox-area:hover {
- opacity: 1;
- }
- .checkbox-mock {
- width: clamp(10px, calc(var(--s) * 14px), 14px);
- height: clamp(10px, calc(var(--s) * 14px), 14px);
- border: 1px solid rgba(255, 255, 255, 0.5);
- margin-right: clamp(3px, calc(var(--s) * 6px), 6px);
- border-radius: 2px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .checkbox-mock.is-checked {
- background-color: #4da8ff;
- border-color: #4da8ff;
- }
- .chart-container {
- width: 100%;
- min-width: 0;
- flex: 1;
- min-height: 50px;
- overflow: hidden;
- }
- .loading-overlay {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #758599;
- font-size: 14px;
- }
- /* ===== 右侧表单内层容器 ===== */
- .detail-right-form {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- }
- .form-group {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- }
- /** 控制方法 */
- .control-method {
- color: #ffffff;
- flex-shrink: 0;
- }
- .control-label-wrap {
- display: flex;
- align-items: center;
- margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
- column-gap: clamp(4px, calc(var(--s) * 10px), 20px);
- margin-top: clamp(4px, calc(var(--s) * 20px), 20px);
- }
- .control-label {
- font-size: clamp(12px, calc(var(--s) * 22px), 28px);
- color: #ffffff;
- white-space: nowrap;
- }
- .control-label-wrap span {
- display: inline-block;
- }
- .operation-btn {
- font-size: clamp(10px, calc(var(--s) * 16px), 16px);
- cursor: pointer;
- user-select: none;
- }
- .operation-btn:hover {
- text-decoration: underline;
- }
- .operation-btn.is-active {
- text-decoration: underline;
- }
- .control-method .control-label-wrap {
- justify-content: flex-start;
- }
- .control-chips-row {
- flex-wrap: wrap;
- gap: clamp(4px, calc(var(--s) * 8px), 10px);
- }
- /* ===== 新版控制方式 chip 按钮组 ===== */
- .control-chips {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: clamp(4px, calc(var(--s) * 6px), 8px);
- }
- .chip {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border: none;
- outline: none;
- cursor: pointer;
- user-select: none;
- color: #ffffff;
- font-size: clamp(10px, calc(var(--s) * 14px), 14px);
- line-height: 1;
- padding: clamp(4px, calc(var(--s) * 6px), 8px) clamp(8px, calc(var(--s) * 14px), 16px);
- border-radius: 4px;
- background: rgba(255, 255, 255, 0.06);
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
- transition: filter 0.15s, opacity 0.15s, transform 0.05s, box-shadow 0.15s;
- white-space: nowrap;
- }
- .chip:hover:not(:disabled) {
- filter: brightness(1.15);
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.32);
- }
- .chip:active:not(:disabled) {
- transform: translateY(1px);
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
- }
- .chip:focus-visible:not(:disabled) {
- box-shadow: 0 0 0 2px #ffffff, 0 1px 2px rgba(0, 0, 0, 0.2);
- }
- .chip:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
- .chip.is-active {
- box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.55) inset, 0 1px 2px rgba(0, 0, 0, 0.2);
- }
- /* 颜色 token(参考图) */
- .chip-manual { background: #ff6b6b; } /* 解除手动 / 手动控制 */
- .chip-mode-off { background: #4dd4ac; } /* 关灯 */
- .chip-mode-yellow { background: #fbbf24; color: #1f2937; } /* 黄闪:背景亮,文字反深色 */
- .chip-mode-red { background: #ef4444; } /* 全红 */
- .chip-action { background: #3b82f6; } /* 临时修改 / 修改方案 */
- .chip-action:hover:not(:disabled) { background: #2563eb; }
- .chip-divider {
- width: 1px;
- height: clamp(14px, calc(var(--s) * 20px), 22px);
- background: rgba(127, 182, 255, 0.25);
- margin: 0 clamp(2px, calc(var(--s) * 4px), 6px);
- }
- .control-scheme {
- margin-top: clamp(4px, calc(var(--s) * 10px), 20px);
- /* 设置 font-size 供 DropdownSelect size="auto" 继承 */
- font-size: clamp(9px, calc(var(--s) * 14px), 14px);
- }
- .control-scheme.is-disabled {
- opacity: 0.4;
- pointer-events: none;
- }
- .lock-time {
- width: 80%;
- border-radius: 8px;
- box-shadow:
- inset 0px 0px 10px 0px rgba(88, 146, 255, 0.4),
- inset 20px 0px 30px -10px rgba(88, 146, 255, 0.15);
- }
- /* 步进锁定时间弹窗 - 遮罩 */
- .lock-time-overlay {
- position: absolute;
- inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(0, 0, 0, 0.45);
- z-index: 200;
- border-radius: inherit;
- }
- /* 步进锁定时间弹窗 */
- .lock-time-dialog {
- background: linear-gradient(135deg, rgba(10, 25, 60, 0.97) 0%, rgba(20, 40, 90, 0.97) 100%);
- border: 1px solid rgba(161, 190, 255, 0.3);
- border-radius: 6px;
- min-width: 220px;
- max-width: 320px;
- width: 70%;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
- }
- .lock-time-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: clamp(4px, calc(var(--s) * 8px), 10px);
- border-radius: 6px 6px 0 0;
- color: #ffffff;
- background: linear-gradient(180deg,
- rgba(65, 115, 205, 0.6) 0%,
- rgba(40, 70, 130, 0.1) 100%);
- backdrop-filter: blur(10px);
- }
- .lock-time-title {
- font-size: clamp(10px, calc(var(--s) * 16px), 16px);
- color: #ffffff;
- }
- .lock-time-close {
- cursor: pointer;
- color: #ffffff;
- }
- .lock-time-body {
- padding: 0;
- }
- .lock-time-options {
- display: flex;
- flex-direction: column;
- row-gap: clamp(4px, calc(var(--s) * 10px), 10px);
- font-size: clamp(9px, calc(var(--s) * 14px), 14px);
- padding: clamp(4px, calc(var(--s) * 10px), 10px);
- color: #ffffff;
- }
- .lock-time-actions {
- display: flex;
- justify-content: flex-end;
- gap: 8px;
- padding: 8px 12px;
- border-top: 1px solid rgba(161, 190, 255, 0.15);
- }
- .lock-time-option {
- font-size: clamp(9px, calc(var(--s) * 14px), 14px);
- }
- /* 过渡动画 */
- .fade-enter-active,
- .fade-leave-active {
- transition: opacity 0.25s;
- }
- .fade-enter,
- .fade-leave-to {
- opacity: 0;
- }
- /* 4 列固定网格; 始终锁 2 行 + 行间 gap (不足 8 阶段时第 2 行留白); 超 8 滚动 */
- .current-stage {
- margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- /* 关键: grid 默认 align-content/items: stretch 会把内容拉伸填满 min-height,
- 与 _observeFirstStageItem 测量形成"测量→撑大→再测→更撑大"循环。
- 锁住行集合贴顶 + 行高按内容定, 切断循环。 */
- grid-auto-rows: max-content;
- align-content: start;
- align-items: start;
- gap: clamp(8px, calc(var(--s) * 18px), 24px);
- color: #ffffff;
- /* --stage-reserved-h 由 JS 测量首个 stage 后写入 (2 行高 + 行间 gap)。
- 不给 fallback: 未写入前 var() 整体无效, min/max-height 退到 initial(0/none) */
- min-height: var(--stage-reserved-h);
- max-height: var(--stage-reserved-h);
- overflow-y: auto;
- overflow-x: hidden;
- /* Firefox 滚动条 */
- scrollbar-width: thin;
- scrollbar-color: rgba(161, 190, 255, 0.45) rgba(255, 255, 255, 0.04);
- }
- /* WebKit / Blink (Chrome / Edge / Electron) 滚动条样式 */
- .current-stage::-webkit-scrollbar {
- width: 6px;
- }
- .current-stage::-webkit-scrollbar-track {
- background: rgba(255, 255, 255, 0.04);
- border-radius: 3px;
- }
- .current-stage::-webkit-scrollbar-thumb {
- background: rgba(161, 190, 255, 0.45);
- border-radius: 3px;
- }
- .current-stage::-webkit-scrollbar-thumb:hover {
- background: rgba(161, 190, 255, 0.75);
- }
- .stage-input {
- width: 100%;
- min-width: 0;
- border: 1px solid rgba(161, 190, 255, 0.7);
- background-color: transparent;
- padding: clamp(2px, calc(var(--s) * 5px), 5px);
- color: #ffffff;
- text-align: center;
- }
- .phase-box {
- position: relative;
- width: 100%;
- /* 尺寸上限由 .stage-item-wrapper 的 --item-max-w 控制, 与 bottom-controls 同宽 */
- max-width: var(--item-max-w);
- margin: 0 auto;
- aspect-ratio: 1 / 1;
- background: #E6F0FF;
- border-radius: 4px;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- transition: all 0.3s ease;
- box-sizing: border-box;
- overflow: hidden;
- }
- .phase-image {
- width: 100%;
- height: 100%;
- object-fit: contain;
- display: block;
- }
- .phase-box::after {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(30, 106, 255, 0.5);
- opacity: 0;
- transition: opacity 0.3s ease;
- pointer-events: none;
- }
- .phase-box.is-active::after {
- opacity: 1;
- }
- /** 按钮 */
- .btn {
- display: inline-flex;
- justify-content: center;
- align-items: center;
- height: clamp(22px, calc(var(--s) * 36px), 36px);
- padding: 0 clamp(10px, calc(var(--s) * 32px), 32px);
- font-size: clamp(9px, calc(var(--s) * 14px), 14px);
- border-radius: 4px;
- cursor: pointer;
- user-select: none;
- transition: all 0.2s ease-in-out;
- box-sizing: border-box;
- }
- .btn-cancel {
- background-color: transparent;
- color: #d1d5db;
- border: 1px solid rgba(130, 150, 190, 0.4);
- }
- .btn-cancel:hover {
- color: #ffffff;
- border-color: rgba(130, 150, 190, 0.8);
- background-color: rgba(255, 255, 255, 0.05);
- }
- .btn-cancel:active {
- background-color: rgba(255, 255, 255, 0.1);
- }
- .btn-confirm {
- background-color: #3b74ff;
- color: #ffffff;
- border: 1px solid #3b74ff;
- }
- .btn-confirm:hover {
- background-color: #5a8bff;
- border-color: #5a8bff;
- box-shadow: 0 2px 8px rgba(59, 116, 255, 0.3);
- }
- .btn-confirm:active {
- background-color: #265bed;
- border-color: #265bed;
- box-shadow: none;
- }
- .button-group {
- display: flex;
- justify-content: flex-end;
- flex-shrink: 0;
- /* margin-top: auto: 在 .form-group(flex column) 里把按钮推到底, 即使 form 内容不满也贴底 */
- margin-top: auto;
- /* sticky 兜底: 内容溢出 .detail-panel-right 时(16/32 阶段且面板较矮), 滚动也能见 */
- position: sticky;
- bottom: 0;
- z-index: 5;
- padding-top: clamp(4px, calc(var(--s) * 10px), 20px);
- }
- .button-group>div {
- display: flex;
- gap: clamp(4px, calc(var(--s) * 8px), 12px);
- }
- /* 禁用状态 */
- .form-interactive-area {
- transition: opacity 0.3s;
- /* donut-row 抽出去后, 内部只剩 current-stage (本身已锁 2 行高 + 内部滚动),
- 不再需要 flex:1 撑高; auto 高度让 donut 紧贴 current-stage 下方, 不留空隙 */
- flex: 0 0 auto;
- min-height: 0;
- /* current-stage 内部已经有 overflow-y: auto, 这里不再二次滚动 */
- overflow: visible;
- }
- .form-editable-area.is-disabled {
- opacity: 0.6;
- pointer-events: none;
- }
- .DropdownSelect-is-disabled{
- opacity: 0.6;
- }
- /* 当前阶段输入框微调 */
- .stage-item-wrapper {
- flex: 1 1 0;
- min-width: 0;
- display: flex;
- flex-direction: column;
- align-items: stretch;
- gap: clamp(2px, calc(var(--s) * 4px), 6px);
- position: relative;
- /* phase-box 和 bottom-controls 共用同一份上限宽, 保证 svg 框与下面的输入框等宽对齐 */
- --item-max-w: clamp(60px, calc(var(--s) * 100px), 110px);
- }
- .bottom-controls {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: clamp(4px, calc(var(--s) * 6px), 8px);
- /* 输入框和百分比的间距 */
- /* 与 phase-box 同宽: 共用 --item-max-w + 居中 */
- width: 100%;
- max-width: var(--item-max-w);
- margin: 0 auto;
- box-sizing: border-box;
- }
- /* 新增包裹层的相对定位 */
- .input-unit-wrapper {
- position: relative;
- display: inline-block;
- }
- .stage-input {
- width: 100%;
- min-width: 0;
- border: 1px solid rgba(161, 190, 255, 0.7);
- background-color: transparent;
- padding: clamp(2px, calc(var(--s) * 5px), 5px);
- /* 给右侧留出空间,防止数字过长被 s 挡住 */
- padding-right: clamp(10px, calc(var(--s) * 16px), 16px);
- color: #ffffff;
- text-align: center;
- border-radius: 4px;
- font-size: clamp(9px, calc(var(--s) * 13px), 13px);
- }
- /* 修改 s 单位的定位方式为垂直居中 */
- .input-unit-wrapper .unit {
- position: absolute;
- top: 50%;
- transform: translateY(-50%);
- right: clamp(3px, calc(var(--s) * 6px), 8px);
- color: #77A1FF;
- font-size: clamp(9px, calc(var(--s) * 14px), 14px);
- pointer-events: none;
- }
- /* 微调百分比的间距,让排版更紧凑 */
- .stage-item-wrapper .percent {
- color: rgba(255, 255, 255, 0.5);
- font-size: clamp(9px, calc(var(--s) * 13px), 13px);
- white-space: nowrap;
- }
- .stage-input {
- width: 100%;
- min-width: 0;
- border: 1px solid rgba(161, 190, 255, 0.7);
- background-color: transparent;
- padding: clamp(2px, calc(var(--s) * 5px), 5px);
- font-size: clamp(9px, calc(var(--s) * 14px), 14px);
- color: #ffffff;
- text-align: center;
- border-radius: 4px;
- -moz-appearance: textfield;
- }
- .stage-input::-webkit-outer-spin-button,
- .stage-input::-webkit-inner-spin-button {
- -webkit-appearance: none;
- margin: 0;
- }
- .stage-input:disabled {
- border-color: rgba(255, 255, 255, 0.2);
- color: rgba(255, 255, 255, 0.5);
- background-color: rgba(0, 0, 0, 0.2);
- }
- /* 弹窗过渡动画 */
- /* lock-time 弹窗补充样式 */
- .lock-time {
- margin-top: clamp(4px, calc(var(--s) * 10px), 15px);
- background-color: rgba(20, 30, 50, 0.9);
- }
- /* 单选框基础对齐 */
- .lock-time-option label {
- display: flex;
- align-items: center;
- gap: clamp(3px, calc(var(--s) * 6px), 8px);
- cursor: pointer;
- }
- /* ===== 方案圆饼图左右布局 ===== */
- .donut-row {
- display: flex;
- gap: clamp(4px, calc(var(--s) * 16px), 16px);
- width: 100%;
- margin-top: clamp(4px, calc(var(--s) * 20px), 20px);
- }
- .donut-item {
- flex: 1;
- min-width: 0;
- }
- .donut-title {
- font-size: clamp(12px, calc(var(--s) * 22px), 28px);
- color: #ffffff;
- margin-bottom: 4px;
- }
- /* ===== 时间表单栏布局 ===== */
- .time-form-bar {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: clamp(3px, calc(var(--s) * 5px), 6px);
- margin-bottom: clamp(4px, calc(var(--s) * 10px), 20px);
- }
- /* 第二行:带标签的项各占 2 列(共 4 列,两项铺满一行) */
- .time-form-bar .form-item-labeled {
- grid-column: span 2;
- display: flex;
- align-items: center;
- gap: clamp(3px, calc(var(--s) * 6px), 8px);
- }
- .time-form-bar .form-label {
- white-space: nowrap;
- flex-shrink: 0;
- color: rgba(200, 220, 255, 0.65);
- font-size: clamp(9px, calc(var(--s) * 12px), 13px);
- }
- .time-form-bar .form-item-labeled .el-select {
- flex: 1;
- min-width: 0;
- }
- /* ===== ElementUI 深色主题适配 ===== */
- /* 覆盖 el-date-editor 固定宽度,让其跟随 grid 布局 */
- .time-form-bar>>>.el-date-editor.el-input,
- .time-form-bar>>>.el-date-editor.el-input__inner,
- .time-form-bar>>>.el-select {
- width: 100%;
- }
- /* 输入框样式 */
- .time-form-bar>>>.el-input__inner {
- background-color: rgba(255, 255, 255, 0.06);
- border: 1px solid rgba(161, 190, 255, 0.35);
- color: #e0e6f1;
- font-size: clamp(9px, calc(var(--s) * 11px), 11px);
- height: clamp(22px, calc(var(--s) * 28px), 28px);
- line-height: clamp(22px, calc(var(--s) * 28px), 28px);
- padding-left: clamp(22px, calc(var(--s) * 26px), 26px);
- padding-right: clamp(4px, calc(var(--s) * 6px), 6px);
- }
- .time-form-bar>>>.el-input__inner::placeholder {
- color: rgba(255, 255, 255, 0.3);
- }
- .time-form-bar>>>.el-input__inner:hover {
- border-color: rgba(161, 190, 255, 0.6);
- }
- .time-form-bar>>>.el-input__inner:focus {
- border-color: #3b74ff;
- }
- /* 图标颜色、尺寸跟随缩放 */
- .time-form-bar>>>.el-input__prefix,
- .time-form-bar>>>.el-input__suffix {
- color: rgba(255, 255, 255, 0.4);
- }
- .time-form-bar>>>.el-input__icon {
- font-size: clamp(9px, calc(var(--s) * 12px), 12px);
- line-height: clamp(22px, calc(var(--s) * 28px), 28px);
- width: clamp(18px, calc(var(--s) * 22px), 22px);
- }
- </style>
|