Kaynağa Gözat

Merge remote-tracking branch 'origin/master'

# Conflicts:
#	src/views/decision/testBuild/index.vue
zhaoen 3 ay önce
ebeveyn
işleme
8e34c24e29

+ 6 - 0
src/router/index.js

@@ -153,6 +153,12 @@ export const constantRoutes = [
     component: () => import("@/views/Deduction/stratDeduction/index.vue"),
     hidden: true,
   },
+  {
+    path: "/Deduction/test",
+    name: "stratDeduction2",
+    component: () => import("@/views/Deduction/stratDeduction/test.vue"),
+    hidden: true,
+  },
   {
     path: "/importTask/configForPlan",
     name: "configForPlan",

+ 329 - 0
src/views/Deduction/stratDeduction/components/UnityController.vue

@@ -0,0 +1,329 @@
+<template>
+  <div class="main-container">
+    <!-- Unity Canvas 容器 -->
+    <div id="unity-container" class="unity-desktop">
+      <canvas id="unity-canvas" ref="unityCanvas"></canvas>
+      <div id="unity-loading-bar">
+        <div id="unity-logo"></div>
+        <div id="unity-progress-bar-empty">
+          <div id="unity-progress-bar-full" :style="{ width: progress + '%' }"></div>
+        </div>
+      </div>
+      <div id="unity-warning"></div>
+      <div id="unity-footer">
+        <div id="unity-webgl-logo"></div>
+        <div id="unity-fullscreen-button" @click="setFullscreen">⛶</div>
+      </div>
+    </div>
+
+    <!-- 控制面板 -->
+    <div class="controls">
+      <h2>模型控制器</h2>
+
+      <!-- 创建 / 销毁模块 -->
+      <div class="control-group">
+        <h3>创建 / 销毁</h3>
+        <label>模型ID:
+          <input v-model="create.modelId" type="text" />
+        </label>
+        <label>模型类型:
+          <select v-model="create.modelType">
+            <option value="red">red</option>
+            <option value="green">green</option>
+            <option value="blue">blue</option>
+          </select>
+        </label>
+        <label>位置 (x,y,z):
+          <input v-model="create.position" type="text" />
+        </label>
+        <label>旋转 (x,y,z):
+          <input v-model="create.rotation" type="text" />
+        </label>
+        <button @click="createModel">创建模型</button>
+        <button @click="destroyModel">销毁模型</button>
+      </div>
+
+      <!-- 控制模块 -->
+      <div class="control-group">
+        <h3>控制指定ID的模型</h3>
+        <label>目标ID:
+          <input v-model="control.modelId" type="text" />
+        </label>
+        <hr />
+        <label>新位置:
+          <input v-model="control.position" type="text" />
+        </label>
+        <label>新旋转:
+          <input v-model="control.rotation" type="text" />
+        </label>
+        <button @click="setModelTransform">设置变换</button>
+        <hr />
+        <label>颜色:
+          <input v-model="control.color" type="color" />
+        </label>
+        <button @click="changeModelColor">改变颜色</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'UnityController',
+
+  data() {
+    return {
+      unityInstance: null, // 存储 Unity 实例
+      progress: 0,         // 加载进度
+
+      // 创建表单数据
+      create: {
+        modelId: 'my_web_cube_1',
+        modelType: 'red',
+        position: '0,0.5,0',
+        rotation: '0,0,0'
+      },
+
+      // 控制表单数据
+      control: {
+        modelId: 'my_web_cube_1',
+        position: '2,1,0',
+        rotation: '0,90,0',
+        color: '#ff0000'
+      }
+    }
+  },
+
+  mounted() {
+    this.loadUnity()
+  },
+
+  methods: {
+    loadUnity() {
+      const container = document.getElementById('unity-container')
+      const canvas = this.$refs.unityCanvas
+      const loadingBar = document.getElementById('unity-loading-bar')
+      const progressBarFull = document.getElementById('unity-progress-bar-full')
+      const fullscreenButton = document.getElementById('unity-fullscreen-button')
+      const warningBanner = document.getElementById('unity-warning')
+
+      function unityShowBanner(msg, type) {
+        const div = document.createElement('div')
+        div.innerHTML = msg
+        warningBanner.appendChild(div)
+        if (type === 'error') {
+          div.style = 'background: red; padding: 10px; color: white;'
+        } else {
+          if (type === 'warning') div.style = 'background: yellow; padding: 10px;'
+          setTimeout(() => {
+            warningBanner.removeChild(div)
+          }, 5000)
+        }
+      }
+
+      // 假设 Unity 文件放在 public/unity 下
+      const buildUrl = '/3DModel/Build'
+      const config = {
+        dataUrl: `${buildUrl}/Build.data`,
+        frameworkUrl: `${buildUrl}/Build.framework.js`,
+        codeUrl: `${buildUrl}/Build.wasm`,
+        streamingAssetsUrl: 'StreamingAssets',
+        companyName: 'DefaultCompany',
+        productName: 'My project',
+        productVersion: '0.1',
+        showBanner: unityShowBanner
+      }
+
+      // 移动端适配
+      if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
+        container.className = 'unity-mobile'
+        canvas.className = 'unity-mobile'
+      } else {
+        canvas.style.width = '960px'
+        canvas.style.height = '600px'
+      }
+
+      loadingBar.style.display = 'block'
+
+      const script = document.createElement('script')
+      script.src = '/3DModel/Build/Build.loader.js' // 路径需匹配
+
+      script.onload = () => {
+        createUnityInstance(canvas, config, (progress) => {
+          this.progress = 100 * progress
+          progressBarFull.style.width = this.progress + '%'
+        })
+          .then((instance) => {
+            this.unityInstance = instance
+            loadingBar.style.display = 'none'
+          })
+          .catch((message) => {
+            alert(message)
+          })
+      }
+
+      document.body.appendChild(script)
+    },
+
+    // 发送消息到 Unity 的通用方法
+    sendMessage(methodName, data) {
+      if (!this.unityInstance) {
+        alert('Unity 实例尚未加载完成!')
+        return
+      }
+      this.unityInstance.SendMessage('WebGLBridgeManager', methodName, JSON.stringify(data))
+    },
+
+    createModel() {
+      this.sendMessage('CreateModel', {
+        modelId: this.create.modelId,
+        modelType: this.create.modelType,
+        position: this.create.position,
+        rotation: this.create.rotation
+      })
+    },
+
+    destroyModel() {
+      this.sendMessage('DestroyModel', {
+        modelId: this.create.modelId
+      })
+    },
+
+    setModelTransform() {
+      this.sendMessage('SetModelTransform', {
+        modelId: this.control.modelId,
+        position: this.control.position,
+        rotation: this.control.rotation
+      })
+    },
+
+    changeModelColor() {
+      const hex = this.control.color
+      const r = parseInt(hex.slice(1, 3), 16) / 255
+      const g = parseInt(hex.slice(3, 5), 16) / 255
+      const b = parseInt(hex.slice(5, 7), 16) / 255
+
+      this.sendMessage('ChangeModelColor', {
+        modelId: this.control.modelId,
+        r, g, b
+      })
+    },
+
+    setFullscreen() {
+      if (this.unityInstance) {
+        this.unityInstance.SetFullscreen(1)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.main-container {
+  display: flex;
+  align-items: flex-start;
+  gap: 20px;
+  padding: 20px;
+  font-family: Arial, sans-serif;
+}
+
+/* Unity 容器样式 */
+#unity-container {
+  flex: 1;
+  min-width: 900px;
+  border: 1px solid #ccc;
+  border-radius: 8px;
+  background: #000;
+  position: relative;
+}
+
+#unity-canvas {
+  width: 100%;
+  height: 700px;
+  display: block;
+}
+
+#unity-loading-bar {
+  display: none;
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: 200px;
+  height: 20px;
+  background: rgba(0, 0, 0, 0.8);
+  border-radius: 10px;
+  padding: 4px;
+}
+
+#unity-progress-bar-empty {
+  width: 100%;
+  height: 100%;
+  background: #444;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+#unity-progress-bar-full {
+  height: 100%;
+  background: #0078d4;
+  width: 0;
+  transition: width 0.2s;
+}
+
+/* 控制面板样式 */
+.controls {
+  width: 300px;
+}
+
+.control-group {
+  border: 1px solid #ccc;
+  padding: 15px;
+  border-radius: 8px;
+  background-color: #f9f9f9;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+h3 {
+  margin-top: 0;
+  color: #333;
+}
+
+label {
+  display: block;
+  margin-bottom: 5px;
+  font-size: 14px;
+  color: #555;
+}
+
+input[type="text"], select, input[type="color"] {
+  width: 100%;
+  padding: 8px;
+  margin-bottom: 10px;
+  border: 1px solid #ccc;
+  border-radius: 4px;
+  box-sizing: border-box;
+}
+
+button {
+  width: 100%;
+  padding: 10px;
+  background-color: #0078d4;
+  color: white;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  font-weight: bold;
+  margin-bottom: 10px;
+}
+
+button:hover {
+  background-color: #005a9e;
+}
+
+hr {
+  border: 0;
+  border-top: 1px solid #eee;
+  margin: 15px 0;
+}
+</style>

+ 16 - 0
src/views/Deduction/stratDeduction/test.vue

@@ -0,0 +1,16 @@
+<script>
+import UnityController from "./components/UnityController.vue";
+export default {
+  components:{
+    UnityController
+  }
+}
+</script>
+
+<template>
+  <UnityController />
+</template>
+
+<style scoped>
+
+</style>

+ 134 - 0
src/views/decision/testBuild/components/BasicInfo.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="section-card mb-6">
+    <div class="section-header cursor-pointer" @click="toggleCollapse">
+      <h4 class="section-title flex items-center justify-between">
+        <span class="flex items-center">
+          <i class="el-icon-info mr-2"></i>
+          基本信息
+        </span>
+        <i
+          class="el-icon-arrow-right transition-transform duration-300"
+          :class="{ 'rotate-90': !isCollapsed, '-rotate-90': isCollapsed }"
+        ></i>
+      </h4>
+    </div>
+    <el-form
+      :model="taskForm"
+      label-width="100px"
+      size="small"
+      class="p-4"
+      v-show="!isCollapsed"
+    >
+      <el-form-item label="任务名称" class="mb-3">
+        <el-input v-model="taskForm.name" placeholder="请输入任务名称" :disabled="true" />
+      </el-form-item>
+      <el-form-item label="任务类型" class="mb-3">
+        <el-select v-model="taskForm.category" placeholder="请选择任务类别" :disabled="true">
+          <el-option label="保障任务" value="support" />
+          <el-option label="实战任务" value="combat" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="打击纬度" class="mb-3">
+        <el-select v-model="taskForm.type" placeholder="请选择任务类型" :disabled="true">
+          <el-option label="防空作战" value="air-defense" />
+          <el-option label="对海作战" value="sea-combat" />
+          <el-option label="对地打击" value="ground-strike" />
+          <el-option label="电子对抗" value="electronic-warfare" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="导弹类型" class="mb-3">
+        <el-select
+          v-model="taskForm.missileType"
+          placeholder="请选择导弹类型"
+          @change="$emit('update-targets-by-missile-type', $event)"
+          :disabled="true"
+        >
+          <el-option label="XXX子母弹" value="surface-to-air" />
+          <el-option label="空地导弹" value="air-to-surface" />
+          <el-option label="反舰导弹" value="anti-ship" />
+        </el-select>
+      </el-form-item>
+
+
+      <el-form-item :disabled="true" label="零点时刻" class="mb-3">
+        <el-date-picker
+          v-model="taskForm.TO"
+          type="datetime"
+          placeholder="请选择零点时刻"
+          format="yyyy-MM-dd HH:mm:ss"
+          value-format="yyyy-MM-dd HH:mm:ss"
+        >
+        </el-date-picker>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'BasicInfo',
+  props: {
+    taskForm: {
+      type: Object,
+      required: true,
+      default: () => ({
+        TO:'',
+        name: '',
+        category: '',
+        type: '',
+        missileType: '',
+        missileCount: 0,
+        executeTime: '',
+        description: ''
+      })
+    }
+  },
+  data() {
+    return {
+      isCollapsed: false
+    }
+  },
+  methods: {
+    toggleCollapse() {
+      this.isCollapsed = !this.isCollapsed;
+    }
+  }
+}
+</script>
+
+<style scoped>
+.section-card {
+  background-color: rgba(15, 23, 42, 0.7);
+  border: 1px solid rgba(14, 165, 233, 0.2);
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.section-header {
+  background-color: rgba(30, 58, 138, 0.3);
+  padding: 8px 16px;
+  border-bottom: 1px solid rgba(14, 165, 233, 0.2);
+}
+
+.section-title {
+  color: #bae6fd;
+  font-size: 14px;
+  margin: 0;
+}
+
+.cursor-pointer {
+  cursor: pointer;
+}
+
+.rotate-90 {
+  transform: rotate(90deg);
+}
+
+.-rotate-90 {
+  transform: rotate(-90deg);
+}
+
+.el-icon-arrow-right {
+  color: #94a3b8;
+}
+</style>

+ 233 - 0
src/views/decision/testBuild/components/ExcelImportDialog.vue

@@ -0,0 +1,233 @@
+<template>
+  <el-dialog
+    :title="title"
+    :visible.sync="innerVisible"
+    :close-on-click-modal="false"
+    :append-to-body="true"
+    width="820px"
+    class="excel-import-dialog"
+    @close="handleClose"
+  >
+    <!-- 上传区:支持多文件 -->
+    <el-upload
+      ref="upload"
+      drag
+      :action="'#'"
+      accept=".xls,.xlsx"
+      :multiple="true"
+      :limit="maxFiles"
+      :show-file-list="true"
+      :before-upload="beforeUpload"
+      :http-request="handleImport"
+      :on-change="onChange"
+      :on-remove="onRemove"
+      style="width: 100%"
+    >
+      <i class="el-icon-upload"></i>
+      <div class="el-upload__text">将文件拖到此处,或 <em>点击导入</em></div>
+      <div slot="tip" class="el-upload__tip">
+        仅支持 .xls / .xlsx,单个不超过 {{ maxSizeMB }}MB,最多 {{ maxFiles }} 个文件
+      </div>
+    </el-upload>
+
+    <!-- 映射表:每个文件填写一个靶标编号 -->
+    <div v-if="fileList.length" class="map-wrap">
+      <div class="map-title">为每个文件填写对应的靶标编号</div>
+      <el-table :data="fileList" size="small" border>
+        <el-table-column prop="name" label="文件名" min-width="260" />
+        <el-table-column label="上传状态" width="120">
+          <template slot-scope="{ row }">
+            <el-tag v-if="getStatus(row.uid) === 'success'" type="success" size="small">已上传</el-tag>
+            <el-tag v-else-if="getStatus(row.uid) === 'uploading'" type="info" size="small">上传中</el-tag>
+            <el-tag v-else-if="getStatus(row.uid) === 'fail'" type="danger" size="small">失败</el-tag>
+            <el-tag v-else size="small">待上传</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="靶标编号" min-width="220">
+          <template slot-scope="{ row }">
+            <el-input
+              :value="getTargetCode(row.uid)"
+              placeholder="必填:输入该文件对应的靶标编号"
+              size="small"
+              :class="{ 'is-error': !!errorUids[row.uid] }"
+              @input="val => setTargetCode(row.uid, val)"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="100">
+          <template slot-scope="{ row }">
+            <el-button type="text" size="small" @click="softRemove(row)">移除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div class="hint">提示:必须为每个文件填写靶标编号,并且文件需要上传成功。</div>
+    </div>
+
+    <div slot="footer" class="dialog-footer">
+      <el-button type="primary" @click="onConfirm">确 定</el-button>
+      <el-button @click="handleClose">关 闭</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { doImportExcel } from '@/api/gentest/user/TestUserManagement';
+
+export default {
+  name: 'ExcelImportDialog',
+  props: {
+    visible:   { type: Boolean, default: false },
+    title:     { type: String,  default: '导入 Excel' },
+    maxSizeMB: { type: Number,  default: 5 },
+    maxFiles:  { type: Number,  default: 10 }
+  },
+  data() {
+    return {
+      innerVisible: this.visible,
+      fileList: [],
+      // rows[uid] = { targetCode: '', status: 'waiting'|'uploading'|'success'|'fail', upload: any }
+      rows: {},
+      // 用对象而不是 Set,保证 Vue 2 可响应
+      errorUids: {}
+    };
+  },
+  watch: {
+    visible(v) { this.innerVisible = v; },
+    innerVisible(v) { this.$emit('update:visible', v); }
+  },
+  methods: {
+    /* 工具:安全取值,避免 ?. 语法 */
+    ensureRow(uid) {
+      if (!this.rows[uid]) this.$set(this.rows, uid, { targetCode: '', status: 'waiting', upload: null });
+      return this.rows[uid];
+    },
+    getStatus(uid) {
+      const r = this.rows[uid];
+      return r ? r.status : 'waiting';
+    },
+    getTargetCode(uid) {
+      const r = this.rows[uid];
+      return r ? (r.targetCode || '') : '';
+    },
+    setTargetCode(uid, val) {
+      this.ensureRow(uid).targetCode = val;
+      if (this.errorUids[uid]) this.$delete(this.errorUids, uid);
+    },
+
+    /* 生命周期/显隐 */
+    handleClose() {
+      this.$refs.upload && this.$refs.upload.clearFiles();
+      this.fileList = [];
+      this.rows = {};
+      this.errorUids = {};
+      this.innerVisible = false;
+      this.$emit('close');
+    },
+
+    /* 上传前校验 */
+    beforeUpload(file) {
+      const name = file.name || '';
+      const ext = name.slice(name.lastIndexOf('.')).toLowerCase();
+      if (ext !== '.xls' && ext !== '.xlsx') {
+        this.$message.warning('仅支持 .xls / .xlsx');
+        return false;
+      }
+      if (file.size / 1024 / 1024 >= this.maxSizeMB) {
+        this.$message.warning(`文件不能超过 ${this.maxSizeMB}MB`);
+        return false;
+      }
+      return true;
+    },
+
+    /* 选择/移除文件时同步行数据 */
+    onChange(file, fileList) {
+      this.fileList = fileList.slice();
+      this.fileList.forEach(f => this.ensureRow(f.uid));
+      const keep = new Set(this.fileList.map(f => f.uid));
+      Object.keys(this.rows).forEach(uid => { if (!keep.has(uid)) this.$delete(this.rows, uid); });
+      Object.keys(this.errorUids).forEach(uid => { if (!keep.has(uid)) this.$delete(this.errorUids, uid); });
+    },
+    onRemove(file, fileList) {
+      this.fileList = fileList.slice();
+      if (this.rows[file.uid]) this.$delete(this.rows, file.uid);
+      if (this.errorUids[file.uid]) this.$delete(this.errorUids, file.uid);
+    },
+
+    /* 自定义上传:逐个文件直传接口 */
+    handleImport({ file, onSuccess, onError }) {
+      if (!file) return onError && onError();
+      this.ensureRow(file.uid).status = 'uploading';
+
+      const formData = new FormData();
+      formData.append('file', file, file.name);
+
+      doImportExcel(formData)
+        .then(res => {
+          const r = this.ensureRow(file.uid);
+          r.status = 'success';
+          r.upload = res;
+          this.$message.success(`${file.name} 上传成功`);
+          onSuccess && onSuccess(res);
+          this.$emit('success', { file, upload: res });
+        })
+        .catch(err => {
+          this.ensureRow(file.uid).status = 'fail';
+          this.$message.error(`${file.name} 上传失败`);
+          onError && onError(err);
+          this.$emit('error', { file, err });
+        });
+    },
+
+    softRemove(row) {
+      // 还是建议用户用上面的文件列表删除按钮
+      this.$message.info('请使用上方文件列表的删除按钮移除文件');
+    },
+
+    onConfirm() {
+      if (!this.fileList.length) {
+        this.$message.warning('请先选择要导入的文件');
+        return;
+      }
+      const missingCode = [];
+      const notUploaded = [];
+      this.fileList.forEach(f => {
+        const r = this.ensureRow(f.uid);
+        if (!r.targetCode || !String(r.targetCode).trim()) {
+          missingCode.push(f.name);
+          this.$set(this.errorUids, f.uid, true);
+        }
+        if (r.status !== 'success') notUploaded.push(f.name);
+      });
+
+      if (missingCode.length) {
+        this.$message.warning(`有 ${missingCode.length} 个文件未填写靶标编号`);
+        return;
+      }
+      if (notUploaded.length) {
+        this.$message.warning(`有 ${notUploaded.length} 个文件尚未上传成功`);
+        return;
+      }
+
+      const mappings = this.fileList.map(f => {
+        const r = this.ensureRow(f.uid);
+        return {
+          fileName: f.name,
+          targetCode: String(r.targetCode || '').trim(),
+          upload: r.upload
+        };
+      });
+      this.$emit('confirm', mappings);
+      this.handleClose();
+    }
+  }
+};
+</script>
+
+<style scoped>
+.excel-import-dialog ::v-deep .el-upload { width: 100%; }
+.map-wrap { margin-top: 14px; }
+.map-title { color:#93c5fd; margin-bottom:8px; }
+.hint { color:#94a3b8; font-size:12px; margin-top:6px; }
+.is-error ::v-deep(.el-input__inner),
+.is-error .el-input__inner { border-color: #f56c6c !important; }
+</style>

+ 367 - 0
src/views/decision/testBuild/components/GanttChart.vue

@@ -0,0 +1,367 @@
+<template>
+  <div class="gantt-container">
+    <!-- 图例 -->
+    <div class="legend-container">
+      <div class="legend-items">
+        <div
+          class="legend-item"
+          v-for="(item, index) in legendData"
+          :key="index"
+          @click="handleLegendClick(item.name)"
+          :style="{ cursor: 'pointer', opacity: isLegendActive(item.name) ? 1 : 0.45 }"
+        >
+          <div class="legend-marker" :style="{ backgroundColor: item.color }"></div>
+          <div class="legend-text">{{ item.name }}</div>
+        </div>
+      </div>
+    </div>
+
+    <div class="chart-wrapper">
+      <div ref="ganttChart" class="gantt-chart"></div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import 'echarts/theme/macarons'
+
+export default {
+  name: 'GanttChart',
+  props: {
+    /**
+     * timelineData 数组:父组件必须传入已归一化数据
+     * 每项需要字段:
+     * - name: string(y 轴类别)
+     * - rawTime: 'T0+HH:MM:SS'(x 轴起点)
+     * - duration: number(秒)
+     * - title?: string
+     * - desc?: string
+     * - kindText: string(用于查 typeColorMap)
+     * - triggerTypeText?: string
+     */
+    timelineData: { type: Array, required: true, default: () => [] },
+
+    /**
+     * 类型→颜色映射,用于图例与条带颜色:
+     * 例:{ '测量装备':'#5470c6', '靶标装备':'#91cc75' }
+     */
+    typeColorMap: {
+      type: Object,
+      default: () => ({
+        '测量装备': '#5470c6',
+        '靶标装备': '#91cc75',
+        '干扰装备': '#fac858'
+      })
+    },
+
+    // 后端不给 duration 时兜底
+    defaultDuration: {type: Number},
+    // x 轴左右留白秒数
+    timeMargin: {type: Number, default: 300}
+  },
+  data() {
+    return {
+      chart: null,
+      activeLegends: {},
+      _ro: null
+    }
+  },
+  computed: {
+    legendData() {
+      const legends = Object.keys(this.typeColorMap).map(type => ({
+        name: type, color: this.typeColorMap[type]
+      }))
+      this.initActiveLegends(legends)
+      return legends
+    },
+
+    timeRange() {
+      if (this.timelineData.length === 0) return {min: 0, max: 3600}
+
+      const toSec = t => {
+        const s = t.startsWith('T0+') ? t.slice(3) : t
+        const [h, m, sec] = s.split(':').map(Number)
+        return h * 3600 + m * 60 + sec
+      }
+
+      const times = this.timelineData.flatMap(item => {
+        const start = toSec(item.rawTime)
+        const end = start + (item.duration ?? this.defaultDuration ?? Math.max(1, 86399 - start))
+        return [start, Math.min(end, 86399)]
+      })
+
+      const min = Math.max(0, Math.min(...times) - this.timeMargin)
+      const max = Math.min(86399, Math.max(...times) + this.timeMargin)
+      return {min, max}
+    },
+
+    processedData() {
+      const names = [...new Set(this.timelineData.map(i => i.name))]
+      const toSec = t => {
+        const s = t.startsWith('T0+') ? t.slice(3) : t
+        const [h, m, sec] = s.split(':').map(Number)
+        return h * 3600 + m * 60 + sec
+      }
+
+      const raw = this.timelineData.map(item => {
+        const start = toSec(item.rawTime)
+        const dur = item.duration ?? this.defaultDuration ?? Math.max(1, 86399 - start)
+        const end = Math.min(start + dur, 86399)
+        const labelText = `${item.title || '无标题'} `
+
+        return {
+          name: item.name,
+          kindText: item.kindText,
+          start,
+          end,
+          value: [names.indexOf(item.name), start, end, end - start, item.title],
+          itemStyle: {color: this.typeColorMap[item.kindText] || '#bbb'},
+          label: {
+            text: labelText,
+            color: this.getTextColor(this.typeColorMap[item.kindText] || '#bbb')
+          }
+        }
+      })
+
+      // 图例过滤
+      const filtered = raw.filter(d => {
+        if (!Object.keys(this.activeLegends).length) return true
+        return this.activeLegends[d.kindText] === true
+      })
+
+      // 按开始时间升序
+      return filtered.sort((a, b) => a.start - b.start)
+    }
+  },
+  watch: {
+    timelineData: {deep: true, handler: 'updateChart'},
+    typeColorMap: {deep: true, handler: 'updateChart'},
+    defaultDuration: {handler: 'updateChart'},
+    timeMargin: {handler: 'updateChart'},
+    activeLegends: {deep: true, handler: 'updateChart'}
+  },
+  mounted() {
+    this.tryInitChart()
+    if (window.ResizeObserver) {
+      this._ro = new ResizeObserver(() => this.handleResize())
+      this._ro.observe(this.$refs.ganttChart)
+    } else {
+      window.addEventListener('resize', this.handleResize)
+    }
+  },
+  beforeDestroy() {
+    if (this._ro) {
+      this._ro.disconnect();
+      this._ro = null
+    } else {
+      window.removeEventListener('resize', this.handleResize)
+    }
+    if (this.chart) {
+      this.chart.dispose();
+      this.chart = null
+    }
+  },
+  methods: {
+    initActiveLegends(legends) {
+      const next = {}
+      legends.forEach(it => {
+        next[it.name] = this.activeLegends[it.name] ?? true
+      })
+      this.activeLegends = next
+    },
+    isLegendActive(name) {
+      return !Object.keys(this.activeLegends).length || this.activeLegends[name] === true
+    },
+    handleLegendClick(name) {
+      this.activeLegends = {...this.activeLegends, [name]: !this.activeLegends[name]}
+    },
+
+    getTextColor(bg) {
+      if (!bg) return '#000'
+      let hex = bg.replace('#', '')
+      if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
+      const r = parseInt(hex.slice(0, 2), 16), g = parseInt(hex.slice(2, 4), 16), b = parseInt(hex.slice(4, 6), 16)
+      const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
+      return lum > 0.5 ? '#000' : '#fff'
+    },
+
+    tryInitChart(attempt = 0) {
+      const el = this.$refs.ganttChart
+      if (!el) return
+      const hasSize = el.clientWidth > 0 && el.clientHeight > 0
+      if (!hasSize) {
+        if (attempt < 10) setTimeout(() => this.tryInitChart(attempt + 1), 50)
+        return
+      }
+      this.initChart()
+    },
+
+    initChart() {
+      if (this.chart) this.chart.dispose()
+      this.chart = echarts.init(this.$refs.ganttChart, 'macarons')
+      this.setChartOption()
+    },
+
+    setChartOption() {
+      if (!this.chart) return
+      const names = [...new Set(this.timelineData.map(i => i.name))]
+
+      const formatTime = (sec) => {
+        const h = String(Math.floor(sec / 3600)).padStart(2, '0')
+        const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0')
+        const s = String(sec % 60).padStart(2, '0')
+        return `T0+${h}:${m}:${s}`
+      }
+
+      const option = {
+        animation: false,
+        tooltip: {
+          trigger: 'item',
+          confine: true,
+          formatter: (p) => {
+            const d = p.data
+            const start = d ? d.value[1] : 0
+            const end = d ? d.value[2] : 0
+            const dur = Math.max(0, end - start)
+            return `
+              <div style="white-space:pre-line">
+              <b>${d?.value?.[4] || ''}</b>
+              ${formatTime(start)} ~ ${formatTime(end)}
+              时长:${dur}s
+              </div>
+            `
+          }
+        },
+        grid: {left: '5%', right: '4%', top: '10%', bottom: '18%', containLabel: false},
+        xAxis: {
+          type: 'value',
+          min: this.timeRange.min,
+          max: this.timeRange.max,
+          splitNumber: 8,
+          axisLabel: {formatter: v => formatTime(v)},
+          axisPointer: {show: true, type: 'line', snap: true, label: {formatter: v => formatTime(v.value)}},
+          minorTick: {show: true},
+          minorSplitLine: {show: true},
+          splitLine: {lineStyle: {type: 'dashed', opacity: 0.5}}
+        },
+        yAxis: {
+          type: 'category',
+          data: names,
+          axisLabel: {show: true},
+          axisTick: {show: false},
+          splitLine: {show: false}
+        },
+        series: [{
+          type: 'custom',
+          data: this.processedData,
+          renderItem: (params, api) => {
+            const catIdx = api.value(0)
+            const startCoord = api.coord([api.value(1), catIdx])
+            const endCoord = api.coord([api.value(2), catIdx])
+
+            const bandSize = api.size([0, 1])[1]
+            const height = Math.max(16, Math.min(bandSize * 0.7, 48))
+
+            const minSec = 60
+            const minPix = api.coord([api.value(1) + minSec, catIdx])[0] - startCoord[0]
+            const widthPix = Math.max(endCoord[0] - startCoord[0], Math.max(0, minPix))
+
+            const d = this.processedData[params.dataIndex]
+
+            const rect = {
+              type: 'rect',
+              shape: {x: startCoord[0], y: startCoord[1] - height / 2, width: widthPix, height: height, r: 4},
+              style: api.style(),
+              z2: 0
+            }
+
+            const text = {
+              type: 'text',
+              x: startCoord[0] + 6,
+              y: startCoord[1],
+              style: {
+                text: d.label.text,
+                fill: d.label.color,
+                textAlign: 'left',
+                textVerticalAlign: 'middle',
+                font: '12px sans-serif',
+                lineHeight: 18
+              },
+              z: 1
+            }
+
+            return {type: 'group', children: [rect, text]}
+          },
+          encode: {x: [1, 2], y: 0}
+        }]
+      }
+
+      this.chart.setOption(option)
+    },
+
+    updateChart() {
+      if (this.chart) this.setChartOption()
+      else this.tryInitChart()
+    },
+
+    handleResize() {
+      if (this.chart) this.chart.resize()
+      else this.tryInitChart()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.gantt-container {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  padding: 1rem 1.25rem 0 1.25rem;
+}
+
+.chart-wrapper {
+  flex: 1 1 auto;
+  min-height: 0;
+  display: flex;
+}
+
+.gantt-chart {
+  width: 100%;
+  height: 100%;
+}
+
+.legend-container {
+  flex: 0 0 auto;
+  margin-top: 0.5rem;
+}
+
+.legend-items {
+  display: flex;
+  justify-content: center;
+  gap: 1rem;
+  flex-wrap: wrap;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  transition: opacity 0.2s ease;
+  user-select: none;
+}
+
+.legend-marker {
+  width: 1rem;
+  height: 1rem;
+  border-radius: 0.25rem;
+  margin-right: 0.4rem;
+}
+
+.legend-text {
+  color: #ffffff;
+  font-size: 0.875rem;
+}
+</style>

+ 1 - 0
src/views/decision/testBuild/components/ImportTaskCard.vue

@@ -174,6 +174,7 @@ export default {
     },
   },
   mounted() {
+    console.log(this.plan,"d")
     this.checkOverflows(); // 初始化时检查文本溢出
   },
   updated() {

+ 367 - 0
src/views/decision/testBuild/components/RunwayDropSetting.vue

@@ -0,0 +1,367 @@
+<template>
+  <div class="gantt-container">
+    <!-- 图例 -->
+    <div class="legend-container">
+      <div class="legend-items">
+        <div
+          class="legend-item"
+          v-for="(item, index) in legendData"
+          :key="index"
+          @click="handleLegendClick(item.name)"
+          :style="{ cursor: 'pointer', opacity: isLegendActive(item.name) ? 1 : 0.45 }"
+        >
+          <div class="legend-marker" :style="{ backgroundColor: item.color }"></div>
+          <div class="legend-text">{{ item.name }}</div>
+        </div>
+      </div>
+    </div>
+
+    <div class="chart-wrapper">
+      <div ref="ganttChart" class="gantt-chart"></div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import 'echarts/theme/macarons'
+
+export default {
+  name: 'GanttChart',
+  props: {
+    /**
+     * timelineData 数组:父组件必须传入已归一化数据
+     * 每项需要字段:
+     * - name: string(y 轴类别)
+     * - rawTime: 'T0+HH:MM:SS'(x 轴起点)
+     * - duration: number(秒)
+     * - title?: string
+     * - desc?: string
+     * - kindText: string(用于查 typeColorMap)
+     * - triggerTypeText?: string
+     */
+    timelineData: { type: Array, required: true, default: () => [] },
+
+    /**
+     * 类型→颜色映射,用于图例与条带颜色:
+     * 例:{ '测量装备':'#5470c6', '靶标装备':'#91cc75' }
+     */
+    typeColorMap: {
+      type: Object,
+      default: () => ({
+        '测量装备': '#5470c6',
+        '靶标装备': '#91cc75',
+        '干扰装备': '#fac858'
+      })
+    },
+
+    // 后端不给 duration 时兜底
+    defaultDuration: {type: Number},
+    // x 轴左右留白秒数
+    timeMargin: {type: Number, default: 300}
+  },
+  data() {
+    return {
+      chart: null,
+      activeLegends: {},
+      _ro: null
+    }
+  },
+  computed: {
+    legendData() {
+      const legends = Object.keys(this.typeColorMap).map(type => ({
+        name: type, color: this.typeColorMap[type]
+      }))
+      this.initActiveLegends(legends)
+      return legends
+    },
+
+    timeRange() {
+      if (this.timelineData.length === 0) return {min: 0, max: 3600}
+
+      const toSec = t => {
+        const s = t.startsWith('T0+') ? t.slice(3) : t
+        const [h, m, sec] = s.split(':').map(Number)
+        return h * 3600 + m * 60 + sec
+      }
+
+      const times = this.timelineData.flatMap(item => {
+        const start = toSec(item.rawTime)
+        const end = start + (item.duration ?? this.defaultDuration ?? Math.max(1, 86399 - start))
+        return [start, Math.min(end, 86399)]
+      })
+
+      const min = Math.max(0, Math.min(...times) - this.timeMargin)
+      const max = Math.min(86399, Math.max(...times) + this.timeMargin)
+      return {min, max}
+    },
+
+    processedData() {
+      const names = [...new Set(this.timelineData.map(i => i.name))]
+      const toSec = t => {
+        const s = t.startsWith('T0+') ? t.slice(3) : t
+        const [h, m, sec] = s.split(':').map(Number)
+        return h * 3600 + m * 60 + sec
+      }
+
+      const raw = this.timelineData.map(item => {
+        const start = toSec(item.rawTime)
+        const dur = item.duration ?? this.defaultDuration ?? Math.max(1, 86399 - start)
+        const end = Math.min(start + dur, 86399)
+        const labelText = `${item.title || '无标题'} `
+
+        return {
+          name: item.name,
+          kindText: item.kindText,
+          start,
+          end,
+          value: [names.indexOf(item.name), start, end, end - start, item.title],
+          itemStyle: {color: this.typeColorMap[item.kindText] || '#bbb'},
+          label: {
+            text: labelText,
+            color: this.getTextColor(this.typeColorMap[item.kindText] || '#bbb')
+          }
+        }
+      })
+
+      // 图例过滤
+      const filtered = raw.filter(d => {
+        if (!Object.keys(this.activeLegends).length) return true
+        return this.activeLegends[d.kindText] === true
+      })
+
+      // 按开始时间升序
+      return filtered.sort((a, b) => a.start - b.start)
+    }
+  },
+  watch: {
+    timelineData: {deep: true, handler: 'updateChart'},
+    typeColorMap: {deep: true, handler: 'updateChart'},
+    defaultDuration: {handler: 'updateChart'},
+    timeMargin: {handler: 'updateChart'},
+    activeLegends: {deep: true, handler: 'updateChart'}
+  },
+  mounted() {
+    this.tryInitChart()
+    if (window.ResizeObserver) {
+      this._ro = new ResizeObserver(() => this.handleResize())
+      this._ro.observe(this.$refs.ganttChart)
+    } else {
+      window.addEventListener('resize', this.handleResize)
+    }
+  },
+  beforeDestroy() {
+    if (this._ro) {
+      this._ro.disconnect();
+      this._ro = null
+    } else {
+      window.removeEventListener('resize', this.handleResize)
+    }
+    if (this.chart) {
+      this.chart.dispose();
+      this.chart = null
+    }
+  },
+  methods: {
+    initActiveLegends(legends) {
+      const next = {}
+      legends.forEach(it => {
+        next[it.name] = this.activeLegends[it.name] ?? true
+      })
+      this.activeLegends = next
+    },
+    isLegendActive(name) {
+      return !Object.keys(this.activeLegends).length || this.activeLegends[name] === true
+    },
+    handleLegendClick(name) {
+      this.activeLegends = {...this.activeLegends, [name]: !this.activeLegends[name]}
+    },
+
+    getTextColor(bg) {
+      if (!bg) return '#000'
+      let hex = bg.replace('#', '')
+      if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
+      const r = parseInt(hex.slice(0, 2), 16), g = parseInt(hex.slice(2, 4), 16), b = parseInt(hex.slice(4, 6), 16)
+      const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
+      return lum > 0.5 ? '#000' : '#fff'
+    },
+
+    tryInitChart(attempt = 0) {
+      const el = this.$refs.ganttChart
+      if (!el) return
+      const hasSize = el.clientWidth > 0 && el.clientHeight > 0
+      if (!hasSize) {
+        if (attempt < 10) setTimeout(() => this.tryInitChart(attempt + 1), 50)
+        return
+      }
+      this.initChart()
+    },
+
+    initChart() {
+      if (this.chart) this.chart.dispose()
+      this.chart = echarts.init(this.$refs.ganttChart, 'macarons')
+      this.setChartOption()
+    },
+
+    setChartOption() {
+      if (!this.chart) return
+      const names = [...new Set(this.timelineData.map(i => i.name))]
+
+      const formatTime = (sec) => {
+        const h = String(Math.floor(sec / 3600)).padStart(2, '0')
+        const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0')
+        const s = String(sec % 60).padStart(2, '0')
+        return `T0+${h}:${m}:${s}`
+      }
+
+      const option = {
+        animation: false,
+        tooltip: {
+          trigger: 'item',
+          confine: true,
+          formatter: (p) => {
+            const d = p.data
+            const start = d ? d.value[1] : 0
+            const end = d ? d.value[2] : 0
+            const dur = Math.max(0, end - start)
+            return `
+              <div style="white-space:pre-line">
+              <b>${d?.value?.[4] || ''}</b>
+              ${formatTime(start)} ~ ${formatTime(end)}
+              时长:${dur}s
+              </div>
+            `
+          }
+        },
+        grid: {left: '5%', right: '4%', top: '10%', bottom: '18%', containLabel: false},
+        xAxis: {
+          type: 'value',
+          min: this.timeRange.min,
+          max: this.timeRange.max,
+          splitNumber: 8,
+          axisLabel: {formatter: v => formatTime(v)},
+          axisPointer: {show: true, type: 'line', snap: true, label: {formatter: v => formatTime(v.value)}},
+          minorTick: {show: true},
+          minorSplitLine: {show: true},
+          splitLine: {lineStyle: {type: 'dashed', opacity: 0.5}}
+        },
+        yAxis: {
+          type: 'category',
+          data: names,
+          axisLabel: {show: true},
+          axisTick: {show: false},
+          splitLine: {show: false}
+        },
+        series: [{
+          type: 'custom',
+          data: this.processedData,
+          renderItem: (params, api) => {
+            const catIdx = api.value(0)
+            const startCoord = api.coord([api.value(1), catIdx])
+            const endCoord = api.coord([api.value(2), catIdx])
+
+            const bandSize = api.size([0, 1])[1]
+            const height = Math.max(16, Math.min(bandSize * 0.7, 48))
+
+            const minSec = 60
+            const minPix = api.coord([api.value(1) + minSec, catIdx])[0] - startCoord[0]
+            const widthPix = Math.max(endCoord[0] - startCoord[0], Math.max(0, minPix))
+
+            const d = this.processedData[params.dataIndex]
+
+            const rect = {
+              type: 'rect',
+              shape: {x: startCoord[0], y: startCoord[1] - height / 2, width: widthPix, height: height, r: 4},
+              style: api.style(),
+              z2: 0
+            }
+
+            const text = {
+              type: 'text',
+              x: startCoord[0] + 6,
+              y: startCoord[1],
+              style: {
+                text: d.label.text,
+                fill: d.label.color,
+                textAlign: 'left',
+                textVerticalAlign: 'middle',
+                font: '12px sans-serif',
+                lineHeight: 18
+              },
+              z: 1
+            }
+
+            return {type: 'group', children: [rect, text]}
+          },
+          encode: {x: [1, 2], y: 0}
+        }]
+      }
+
+      this.chart.setOption(option)
+    },
+
+    updateChart() {
+      if (this.chart) this.setChartOption()
+      else this.tryInitChart()
+    },
+
+    handleResize() {
+      if (this.chart) this.chart.resize()
+      else this.tryInitChart()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.gantt-container {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  padding: 1rem 1.25rem 0 1.25rem;
+}
+
+.chart-wrapper {
+  flex: 1 1 auto;
+  min-height: 0;
+  display: flex;
+}
+
+.gantt-chart {
+  width: 100%;
+  height: 100%;
+}
+
+.legend-container {
+  flex: 0 0 auto;
+  margin-top: 0.5rem;
+}
+
+.legend-items {
+  display: flex;
+  justify-content: center;
+  gap: 1rem;
+  flex-wrap: wrap;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  transition: opacity 0.2s ease;
+  user-select: none;
+}
+
+.legend-marker {
+  width: 1rem;
+  height: 1rem;
+  border-radius: 0.25rem;
+  margin-right: 0.4rem;
+}
+
+.legend-text {
+  color: #ffffff;
+  font-size: 0.875rem;
+}
+</style>

+ 601 - 49
src/views/decision/testBuild/components/TargetEquipmentMap.vue

@@ -1,47 +1,253 @@
 <template>
   <div class="section-card">
+    <!-- 头部 -->
     <div class="section-header cursor-pointer" @click="toggleCollapse">
       <h4 class="section-title flex items-center justify-between">
         <span class="flex items-center">
           <i class="el-icon-link mr-2"></i>
-          靶标-装备分配关系
+          时序计划
         </span>
         <i
           class="el-icon-arrow-right transition-transform duration-300"
           :class="{ 'rotate-90': !isCollapsed, '-rotate-90': isCollapsed }"
-        ></i>
+        />
       </h4>
     </div>
-    <div class="target-equipment-map p-4" v-show="!isCollapsed">
+
+    <!-- 主体:检索 + 表格 -->
+    <div class="body p-4" v-show="!isCollapsed" ref="bodyRef">
+      <!-- 检索条 -->
+      <div class="toolbar" ref="toolbarRef">
+        <div class="filters">
+          <el-select
+            v-model="filterDomain"
+            clearable
+            placeholder="打击域"
+            size="small"
+            class="mr8"
+            @change="onDomainChange"
+          >
+            <el-option label="对陆" value="对陆" />
+            <el-option label="对海" value="对海" />
+          </el-select>
+
+          <el-input
+            v-model="filterKeyword"
+            clearable
+            placeholder="关键事件"
+            size="small"
+            class="mr8 w220"
+            @change="onKeywordChange"
+          >
+            <i slot="prefix" class="el-icon-search" />
+          </el-input>
+
+          <el-button size="small" @click="resetFilters">重置</el-button>
+        </div>
+
+        <div class="ops">
+          <el-button
+            type="primary"
+            size="small"
+            icon="el-icon-plus"
+            @click="openAdd"
+          >新增</el-button>
+        </div>
+      </div>
+
+      <!-- 表格 -->
       <el-table
-        :data="mapData"
+        :data="filteredData"
+        :height="tableHeight"
         border
         size="small"
+        :empty-text="emptyText"
         :header-cell-style="{background: 'rgba(30, 58, 138, 0.3)', color: '#bae6fd', borderColor: 'rgba(14, 165, 233, 0.2)'}"
         :row-style="{background: 'rgba(15, 23, 42, 0.5)', color: '#e0f2fe', borderColor: 'rgba(14, 165, 233, 0.1)'}"
       >
-        <el-table-column prop="targetName" label="靶标名称"></el-table-column>
-        <el-table-column prop="targetType" label="靶标类型"></el-table-column>
-        <el-table-column prop="threatLevel" label="威胁等级">
-          <template slot-scope="scope">
-            <div class="threat-bars flex gap-1 justify-center">
-              <div
-                class="threat-bar"
-                v-for="n in 5"
-                :key="n"
-                :class="{ 'active': n <= scope.row.threatLevel }"
-              ></div>
-            </div>
-          </template>
-        </el-table-column>
-        <el-table-column prop="equipmentName" label="负责装备" width="180"></el-table-column>
-        <el-table-column prop="equipmentStatus" label="装备状态" width="100">
+        <el-table-column type="index" label="序号" align="center" width="80"/>
+        <el-table-column prop="strikeDomain" label="打击域"  align="center" min-width="100"/>
+        <el-table-column prop="timing" label="时序"  align="center" min-width="160"/>
+        <el-table-column prop="keyEvent" label="关键事件"  align="center" min-width="220" show-overflow-tooltip/>
+        <el-table-column label="操作" width="180" align="center" fixed="right">
           <template slot-scope="scope">
-            <span :class="statusLabelClass(scope.row.equipmentStatus)">{{ equipmentStatusText(scope.row.equipmentStatus) }}</span>
+            <el-tooltip content="查看" placement="top" :open-delay="200">
+              <el-button
+                type="text"
+                icon="el-icon-view"
+                class="p-1 text-blue-400 action-btn"
+                @click="openForm('view', scope.row)"
+              />
+            </el-tooltip>
+            <el-tooltip content="编辑" placement="top" :open-delay="200">
+              <el-button
+                type="text"
+                icon="el-icon-edit"
+                class="p-1 text-blue-400 action-btn"
+                @click="openForm('edit', scope.row)"
+              />
+            </el-tooltip>
+            <el-tooltip content="删除" placement="top" :open-delay="200">
+              <el-button
+                type="text"
+                icon="el-icon-delete"
+                class="p-1 text-blue-400 action-btn"
+                @click="confirmDelete(scope.row)"
+              />
+            </el-tooltip>
           </template>
         </el-table-column>
       </el-table>
+
+      <!-- 完全无数据空态 -->
+      <el-empty
+        v-if="!hasAnyData"
+        description="暂无时序计划"
+        :image-size="80"
+        class="abs-empty"
+      />
     </div>
+
+    <!-- 新增抽屉:多选关键事件 -->
+    <el-drawer
+      title="新增时序(多选)"
+      :visible.sync="dlgAddVisible"
+      size="60%"
+      :close-on-click-modal="false"
+      :append-to-body="true"
+      destroy-on-close
+    >
+      <div class="drawer-body">
+        <div class="drawer-toolbar">
+          <el-input
+            v-model.trim="addSearch"
+            size="small"
+            clearable
+            placeholder="搜索关键事件/时序/打击域"
+            class="w260 mr8"
+          >
+            <i slot="prefix" class="el-icon-search" />
+          </el-input>
+          <el-select v-model="addDomain" size="small" clearable placeholder="按打击域筛选" class="mr8">
+            <el-option label="对陆" value="对陆" />
+            <el-option label="对海" value="对海" />
+          </el-select>
+          <el-button size="small" @click="resetAddFilters">重置</el-button>
+        </div>
+
+        <el-table
+          ref="addTableRef"
+          :data="addFiltered"
+          height="420"
+          border
+          size="small"
+          @selection-change="onAddSelectionChange"
+          :header-cell-style="{background: 'rgba(30, 58, 138, 0.3)', color: '#bae6fd', borderColor: 'rgba(14, 165, 233, 0.2)'}"
+          :row-style="{background: 'rgba(15, 23, 42, 0.5)', color: '#e0f2fe', borderColor: 'rgba(14, 165, 233, 0.1)'}"
+        >
+          <el-table-column type="selection" width="50" />
+          <el-table-column prop="strikeDomain" label="打击域" width="80" align="center" />
+          <el-table-column prop="timing" label="时序" width="130" align="center" />
+          <el-table-column prop="keyEvent" label="关键事件" min-width="280" show-overflow-tooltip />
+        </el-table>
+
+        <div class="drawer-footer">
+          <span class="hint">已选择:{{ addSelection.length }} 条</span>
+          <div>
+            <el-button @click="dlgAddVisible=false">取 消</el-button>
+            <el-button type="primary" @click="submitAddBatch">确 定</el-button>
+          </div>
+        </div>
+      </div>
+    </el-drawer>
+
+    <!-- 统一表单弹窗(查看 / 编辑共用) -->
+    <el-dialog
+      :title="isViewMode ? '查看时序' : '编辑时序'"
+      :visible.sync="dlgFormVisible"
+      width="520px"
+      :close-on-click-modal="false"
+      :append-to-body="true"
+      destroy-on-close
+    >
+      <el-form
+        :model="form"
+        :rules="rules"
+        ref="formRef"
+        label-width="96px"
+        size="small"
+      >
+        <el-form-item label="打击域" prop="strikeDomain">
+          <el-select v-model="form.strikeDomain" :disabled="isViewMode" placeholder="选择打击域">
+            <el-option label="对陆" value="对陆" />
+            <el-option label="对海" value="对海" />
+          </el-select>
+        </el-form-item>
+
+        <!-- 时序:单位秒,不做格式化。构造为 T、T+10、或 T-10~T+20 -->
+        <el-form-item label="时序" prop="timing">
+          <div class="timing-input-group">
+            <!-- 起始 -->
+            <div class="timing-input-item">
+              <span>T</span>
+              <el-select v-model="timingStart.operator" :disabled="isViewMode" size="mini" style="width: 60px;">
+                <el-option label="+" value="+" />
+                <el-option label="-" value="-" />
+              </el-select>
+              <el-input-number
+                v-model="timingStart.value"
+                :disabled="isViewMode"
+                :min="0"
+                :max="999999"
+                controls-position="right"
+                size="mini"
+                style="width: 100px;"
+                placeholder="秒"
+              />
+            </div>
+
+            <span class="tilde">~</span>
+
+            <!-- 结束 -->
+            <div class="timing-input-item">
+              <span>T</span>
+              <el-select v-model="timingEnd.operator" :disabled="isViewMode" size="mini" style="width: 60px;">
+                <el-option label="+" value="+" />
+                <el-option label="-" value="-" />
+              </el-select>
+              <el-input-number
+                v-model="timingEnd.value"
+                :disabled="isViewMode"
+                :min="0"
+                :max="999999"
+                controls-position="right"
+                size="mini"
+                style="width: 100px;"
+                placeholder="秒"
+              />
+            </div>
+          </div>
+
+          <!-- 预览(实时展示最终会提交/展示的时序字符串) -->
+          <div class="timing-preview">结果:{{ timingPreview }}</div>
+        </el-form-item>
+
+        <el-form-item label="关键事件" prop="keyEvent">
+          <el-input
+            type="textarea"
+            :rows="3"
+            v-model.trim="form.keyEvent"
+            :disabled="isViewMode"
+            placeholder="填写关键事件描述"
+          />
+        </el-form-item>
+      </el-form>
+
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="dlgFormVisible=false">{{ isViewMode ? '关 闭' : '取 消' }}</el-button>
+        <el-button v-if="!isViewMode" type="primary" @click="submitForm">保 存</el-button>
+      </span>
+    </el-dialog>
   </div>
 </template>
 
@@ -49,39 +255,306 @@
 export default {
   name: 'TargetEquipmentMap',
   props: {
-    mapData: {
+    // 表格数据由父组件持有
+    mapData: { type: Array, default: () => [] },
+    // 关键事件总表(新增抽屉多选)
+    // timing 建议也用本规则:T / T+10 / T-10~T+20
+    eventCatalog: {
       type: Array,
-      required: true,
-      default: () => []
+      default: () => ([
+        { id: 'E1', strikeDomain: '对陆', timing: 'T+30',  keyEvent: '测量系统上电' },
+        { id: 'E2', strikeDomain: '对陆', timing: 'T+60',  keyEvent: '目标进入监视区' },
+        { id: 'E3', strikeDomain: '对海', timing: 'T+90',  keyEvent: '干扰装备启动' },
+        { id: 'E4', strikeDomain: '对海', timing: 'T+120', keyEvent: '目标锁定' },
+        { id: 'E5', strikeDomain: '对陆', timing: 'T+150', keyEvent: '导弹准备' },
+        { id: 'E6', strikeDomain: '对海', timing: 'T+180', keyEvent: '导弹发射' },
+        { id: 'E7', strikeDomain: '对陆', timing: 'T+210', keyEvent: '数据记录开始' },
+        { id: 'E8', strikeDomain: '对海', timing: 'T+240', keyEvent: '拦截评估' }
+      ])
     }
   },
   data() {
     return {
-      isCollapsed: false
+      isCollapsed: false,
+
+      // 检索条件
+      filterDomain: '',
+      filterKeyword: '',
+
+      // 表格高度
+      tableHeight: 320,
+
+      // 新增抽屉
+      dlgAddVisible: false,
+      addSearch: '',
+      addDomain: '',
+      addSelection: [],
+
+      // 统一弹窗
+      dlgFormVisible: false,
+      dlgMode: 'view', // 'view' | 'edit'
+      form: this.initForm(),
+
+      // 时序控件:value=null 表示未填
+      timingStart: {operator: '+', value: null},
+      timingEnd: {operator: '+', value: null},
+
+      // 校验
+      rules: {
+        strikeDomain: [{required: true, message: '请选择打击域', trigger: 'change'}],
+        keyEvent: [{required: true, message: '请输入关键事件', trigger: 'blur'}],
+        // timing 允许:T、T+10、T-10、T-10~T+20
+        timing: [{
+          validator: (rule, v, cb) => {
+            const ok = /^T(?:[+-]\d+)?(?:~T[+-]\d+)?$/.test(String(v || ''));
+            ok ? cb() : cb(new Error('时序格式不合法(示例:T、T+10、T-10~T+20)'));
+          },
+          trigger: 'change'
+        }]
+      },
+
+      // 尺寸观察
+      _ro: null
+    };
+  },
+  computed: {
+    isViewMode() {
+      return this.dlgMode === 'view';
+    },
+    hasAnyData() {
+      return Array.isArray(this.mapData) && this.mapData.length > 0;
+    },
+    filteredData() {
+      if (!this.hasAnyData) return [];
+      const kw = (this.filterKeyword || '').trim();
+      return this.mapData.filter(row => {
+        const okDomain = this.filterDomain ? String(row.strikeDomain) === this.filterDomain : true;
+        const okKey = kw ? String(row.keyEvent || '').includes(kw) : true;
+        return okDomain && okKey;
+      });
+    },
+    emptyText() {
+      if (!this.hasAnyData) return '暂无数据';
+      if (this.filterDomain || (this.filterKeyword || '').trim()) return '无匹配数据';
+      return '暂无数据';
+    },
+    /** 抽屉内:筛选关键事件总表 */
+    addFiltered() {
+      let list = Array.isArray(this.eventCatalog) ? this.eventCatalog.slice() : [];
+      const kw = (this.addSearch || '').trim();
+      if (this.addDomain) list = list.filter(x => String(x.strikeDomain) === this.addDomain);
+      if (kw) {
+        list = list.filter(x =>
+          String(x.keyEvent || '').includes(kw) ||
+          String(x.timing || '').includes(kw) ||
+          String(x.strikeDomain || '').includes(kw)
+        );
+      }
+      return list;
+    },
+    /** 预览当前时序字符串(不格式化秒) */
+    timingPreview() {
+      return this.buildTimingString(
+        this.timingStart.operator, this.timingStart.value,
+        this.timingEnd.operator, this.timingEnd.value
+      );
+    }
+  },
+  watch: {
+    isCollapsed(nv) {
+      if (!nv) this.$nextTick(this.calcTableHeight);
     }
   },
+  mounted() {
+    console.log("eventCatalog",this.eventCatalog)
+    this.$nextTick(this.calcTableHeight);
+    if (window.ResizeObserver) {
+      this._ro = new ResizeObserver(() => this.calcTableHeight());
+      const body = this.$refs.bodyRef;
+      if (body) this._ro.observe(body);
+    } else {
+      window.addEventListener('resize', this.calcTableHeight);
+    }
+  },
+  beforeDestroy() {
+    if (this._ro) {
+      this._ro.disconnect();
+      this._ro = null;
+    }
+    window.removeEventListener('resize', this.calcTableHeight);
+  },
   methods: {
+    /* ========== 基础 ========== */
+    initForm() {
+      return {id: '', strikeDomain: '', timing: 'T', keyEvent: ''};
+    },
     toggleCollapse() {
       this.isCollapsed = !this.isCollapsed;
     },
-    equipmentStatusText(status) {
-      const statusMap = {
-        standby: '待命',
-        ready: '就绪',
-        maintenance: '维护中'
-      };
-      return statusMap[status] || '未知';
-    },
-    statusLabelClass(status) {
-      const classMap = {
-        standby: 'text-yellow-400',
-        ready: 'text-green-400',
-        maintenance: 'text-gray-400'
-      };
-      return classMap[status] || 'text-gray-300';
+
+    /* ========== 表格检索 ========== */
+    onDomainChange(val) {
+      this.filterDomain = val || '';
+      this.$emit('search-change', {strikeDomain: this.filterDomain, keyEvent: (this.filterKeyword || '').trim()});
+      this.$nextTick(this.calcTableHeight);
+    },
+    onKeywordChange(val) {
+      this.filterKeyword = (val || '').trim();
+      this.$emit('search-change', {strikeDomain: this.filterDomain, keyEvent: this.filterKeyword});
+      this.$nextTick(this.calcTableHeight);
+    },
+    resetFilters() {
+      this.filterDomain = '';
+      this.filterKeyword = '';
+      this.$emit('search-change', {strikeDomain: '', keyEvent: ''});
+      this.$nextTick(this.calcTableHeight);
+    },
+
+    /* ========== 表格高度计算 ========== */
+    calcTableHeight() {
+      const bodyEl = this.$refs.bodyRef;
+      if (!bodyEl || this.isCollapsed) return;
+      const bodyH = bodyEl.clientHeight || 0;
+      const toolbarH = this.$refs.toolbarRef ? this.$refs.toolbarRef.offsetHeight : 0;
+      const usable = Math.max(0, bodyH - toolbarH);
+      const h = Math.floor(usable * 0.6);
+      this.tableHeight = Math.max(h, 240);
+    },
+
+    /* ========== 新增(抽屉) ========== */
+    openAdd() {
+      this.addSearch = '';
+      this.addDomain = '';
+      this.addSelection = [];
+      this.dlgAddVisible = true;
+      console.log(this.addFiltered,"sss===")
+      this.$nextTick(() => {
+        if (this.$refs.addTableRef) this.$refs.addTableRef.clearSelection();
+      });
+    },
+    onAddSelectionChange(rows) {
+      this.addSelection = Array.isArray(rows) ? rows : [];
+    },
+    resetAddFilters() {
+      this.addSearch = '';
+      this.addDomain = '';
+      this.$nextTick(() => {
+        if (this.$refs.addTableRef) this.$refs.addTableRef.clearSelection();
+      });
+    },
+    submitAddBatch() {
+      if (!this.addSelection.length) return this.$message.warning('请至少选择一条关键事件');
+      const items = this.addSelection.map(x => ({
+        id: this.genId(),
+        strikeDomain: x.strikeDomain,
+        timing: x.timing,     // 直接使用目录中的 timing(T / T±n / T±n~T±m)
+        keyEvent: x.keyEvent
+      }));
+      this.$emit('create-batch', items);
+      items.forEach(it => this.$emit('create', it)); // 兼容:逐条
+      this.dlgAddVisible = false;
+    },
+
+    /* ========== 查看 / 编辑(统一弹窗) ========== */
+    openForm(mode, row) {
+      this.dlgMode = mode === 'edit' ? 'edit' : 'view';
+      this.form = {...(row || this.initForm())};
+      if (!this.form.id) this.form.id = this.genId();
+
+      // 解析 timing -> 控件
+      this.applyTimingToControls(this.form.timing);
+      this.dlgFormVisible = true;
+
+      this.$nextTick(() => this.$refs.formRef && this.$refs.formRef.clearValidate());
+    },
+    submitForm() {
+      // 仅编辑模式提交
+      if (this.isViewMode) return (this.dlgFormVisible = false);
+
+      // 用控件值构造 timing 字符串
+      this.form.timing = this.buildTimingString(
+        this.timingStart.operator, this.timingStart.value,
+        this.timingEnd.operator, this.timingEnd.value
+      );
+
+      this.$refs.formRef.validate(valid => {
+        if (!valid) return;
+        const payload = {...this.form};
+        this.$emit('update', payload);
+        this.dlgFormVisible = false;
+      });
+    },
+
+    /* ========== 时序字符串 <-> 控件 ========== */
+    /**
+     * 构造时序字符串(不格式化秒)
+     * 规则:
+     * - 两端都未填 -> "T"
+     * - 单端:T±n;若 n 为 0 -> "T"
+     * - 双端:T±a~T±b;若 a=0 且 b=0 -> "T"
+     * - 若一端为 0,另一端非 0 -> 返回单端形式(只保留非 0 的那端)
+     */
+    buildTimingString(op1, val1, op2, val2) {
+      const has1 = Number.isFinite(val1);
+      const has2 = Number.isFinite(val2);
+      const n1 = has1 ? Number(val1) : null;
+      const n2 = has2 ? Number(val2) : null;
+
+      if (!has1 && !has2) return 'T';
+
+      // 单端
+      if (has1 && !has2) return n1 === 0 ? 'T' : `T${op1}${n1}`;
+      if (!has1 && has2) return n2 === 0 ? 'T' : `T${op2}${n2}`;
+
+      // 双端
+      if (n1 === 0 && n2 === 0) return 'T';
+      if (n1 === 0 && n2 !== 0) return `T${op2}${n2}`;
+      if (n1 !== 0 && n2 === 0) return `T${op1}${n1}`;
+      return `T${op1}${n1}~T${op2}${n2}`;
+    },
+
+    /** 解析字符串到控件,支持:T / T±n / T±a~T±b */
+    applyTimingToControls(str) {
+      const s = String(str || '').trim();
+
+      // 默认值
+      this.timingStart = {operator: '+', value: null};
+      this.timingEnd = {operator: '+', value: null};
+
+      if (!s || s === 'T') return;
+
+      // 范围:T±a~T±b
+      let m = /^T([+-])(\d+)~T([+-])(\d+)$/.exec(s);
+      if (m) {
+        this.timingStart.operator = m[1];
+        this.timingStart.value = Number(m[2]);
+        this.timingEnd.operator = m[3];
+        this.timingEnd.value = Number(m[4]);
+        return;
+      }
+
+      // 单端:T±n
+      m = /^T([+-])(\d+)$/.exec(s);
+      if (m) {
+        this.timingStart.operator = m[1];
+        this.timingStart.value = Number(m[2]);
+      }
+    },
+
+    /* ========== 删除 ========== */
+    confirmDelete(row) {
+      this.$confirm('确定删除该条时序吗?', '提示', {type: 'warning'})
+        .then(() => this.$emit('delete', row))
+        .catch(() => {
+        });
+    },
+
+    /* ========== 工具 ========== */
+    genId() {
+      return 'S' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
     }
   }
-}
+};
 </script>
 
 <style scoped>
@@ -120,18 +593,97 @@ export default {
   color: #94a3b8;
 }
 
-.threat-bars {
+.body {
+  position: relative;
+  min-height: 200px;
+}
+
+/* 检索条 */
+.toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10px;
+}
+
+.filters {
+  display: flex;
   align-items: center;
+  flex-wrap: wrap;
+}
+
+.mr8 {
+  margin-right: 8px;
+}
+
+.w220 {
+  width: 220px;
+}
+
+/* 空态覆盖整个主体 */
+.abs-empty {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  pointer-events: none;
+}
+
+/* 抽屉 */
+.drawer-body {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.drawer-toolbar {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.w260 {
+  width: 260px;
+}
+
+.drawer-footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 10px;
+}
+
+.hint {
+  font-size: 12px;
+  color: #9aa0a6;
+}
+
+/* 统一表单:时序控件 */
+.timing-input-group {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.timing-input-item {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.tilde {
+  opacity: 0.7;
 }
 
-.threat-bar {
-  width: 6px;
-  height: 6px;
-  background-color: rgba(148, 163, 184, 0.3);
-  border-radius: 2px;
+.timing-preview {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #9aa0a6;
 }
 
-.threat-bar.active {
-  background-color: #f97316;
+/* 操作按钮 */
+.action-btn:hover {
+  filter: brightness(1.15);
 }
 </style>

+ 67 - 0
src/views/decision/testBuild/components/Trajectory.vue

@@ -0,0 +1,67 @@
+<template>
+  <div class="section-card mb-6">
+    <!-- 头部,仅折叠 -->
+    <div class="section-header cursor-pointer" @click="toggleCollapse">
+      <h4 class="section-title">
+        <span class="title-left">
+          <i class="el-icon-sunny" style="margin-right:6px;"></i>
+          理论数据导入
+        </span>
+        <i
+          class="el-icon-arrow-right transition-transform duration-300"
+          :class="{ 'rotate-90': !isCollapsed, '-rotate-90': isCollapsed }"
+        />
+      </h4>
+    </div>
+
+    <!-- 主体 -->
+    <div class="target-list p-4" v-show="!isCollapsed">
+      <el-button type="primary" @click="doImport">导入Excel</el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import ExcelImportDialog from './ExcelImportDialog.vue';
+
+export default {
+  name: 'Trajectory',
+  components: { ExcelImportDialog },
+  data() {
+    return {
+      visible: false,
+      isCollapsed: false
+    };
+  },
+  methods: {
+    toggleCollapse() { this.isCollapsed = !this.isCollapsed; },
+    doImport() {
+      // 这句就能把对话框打开
+      this.visible = false;
+
+      this.$emit('do-import')
+    },
+  }
+};
+</script>
+
+<style scoped>
+.section-card {
+  background-color: rgba(15, 23, 42, 0.7);
+  border: 1px solid rgba(14, 165, 233, 0.2);
+  border-radius: 4px;
+  overflow: hidden;
+}
+.section-header {
+  background-color: rgba(30, 58, 138, 0.3);
+  padding: 8px 16px;
+  border-bottom: 1px solid rgba(14, 165, 233, 0.2);
+}
+.section-title { color: #bae6fd; font-size: 14px; margin: 0; display:flex; justify-content:space-between; align-items:center; }
+.title-left { display:flex; align-items:center; }
+.cursor-pointer { cursor: pointer; }
+.rotate-90 { transform: rotate(90deg); }
+.-rotate-90 { transform: rotate(-90deg); }
+.el-icon-arrow-right { color: #94a3b8; margin-left: 8px; }
+.target-list { padding: 12px; }
+</style>

+ 297 - 0
src/views/decision/testBuild/components/WeatherEnvForm.vue

@@ -0,0 +1,297 @@
+<template>
+  <div class="section-card mb-6">
+    <!-- 头部,仅折叠 -->
+    <div class="section-header cursor-pointer" @click="toggleCollapse">
+      <h4 class="section-title">
+        <span class="title-left">
+          <i class="el-icon-sunny" style="margin-right:6px;"></i>
+          气象环境配置
+        </span>
+        <i class="el-icon-arrow-right transition-transform duration-300"
+           :class="{ 'rotate-90': !isCollapsed, '-rotate-90': isCollapsed }"/>
+      </h4>
+    </div>
+
+    <!-- 表单主体 -->
+    <div class="target-list p-4" v-show="!isCollapsed">
+      <el-form
+        ref="formRef"
+        :model="form"
+        :rules="rules"
+        class="dense-form"
+        label-width="150px"
+        size="small"
+      >
+        <el-row :gutter="12">
+          <el-col :span="24">
+            <el-form-item label="气温(°C)" prop="temp">
+              <el-input
+                v-model.number="form.temp"
+                type="number"
+                step="0.5"
+                placeholder="例如 20"
+                @input="emitChange"
+              />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24">
+            <el-form-item label="湿度(%RH)" prop="rh">
+              <el-input
+                v-model.number="form.rh"
+                type="number"
+                min="0"
+                max="100"
+                step="1"
+                placeholder="0~100"
+                @input="emitChange"
+              />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24">
+            <el-form-item label="气压(hPa)" prop="pressure">
+              <el-input
+                v-model.number="form.pressure"
+                type="number"
+                min="300"
+                max="1100"
+                step="1"
+                placeholder="例如 1013.25"
+                @input="emitChange"
+              />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24">
+            <el-form-item label="风速(m/s)" prop="wind">
+              <el-input
+                v-model.number="form.wind"
+                type="number"
+                min="0"
+                step="0.1"
+                placeholder="例如 5.5"
+                @input="emitChange"
+              />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24">
+            <el-form-item label="风向(°)" prop="windDir">
+              <el-input
+                v-model.number="form.windDir"
+                type="number"
+                min="0"
+                max="360"
+                step="1"
+                placeholder="0~360"
+                @input="emitChange"
+              />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24">
+            <el-form-item label="降速(m/s)" prop="fallSpeed">
+              <el-input
+                v-model.number="form.fallSpeed"
+                type="number"
+                min="0"
+                step="0.1"
+                placeholder="目标/介质下落速度"
+                @input="emitChange"
+              />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24">
+            <el-form-item label="大气密度(g/m³)" prop="airDensity">
+              <el-input
+                v-model.number="form.airDensity"
+                type="number"
+                min="0"
+                step="0.1"
+                placeholder="例如 1200(约等于1.2 kg/m³)"
+                @input="emitChange"
+              />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24">
+            <el-form-item label="水平能见度(m)" prop="visibility">
+              <el-input
+                v-model.number="form.visibility"
+                type="number"
+                min="0"
+                step="10"
+                placeholder="例如 5000"
+                @input="emitChange"
+              />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24">
+            <el-form-item label="降雨量(mm)" prop="rain">
+              <el-input
+                v-model.number="form.rain"
+                type="number"
+                min="0"
+                step="0.1"
+                placeholder="例如 12.3"
+                @input="emitChange"
+              />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24">
+            <el-form-item label="天气现象" prop="weather">
+              <el-select v-model="form.weather" placeholder="请选择" @change="emitChange" style="width:100%;">
+                <el-option label="晴" value="sunny"/>
+                <el-option label="多云" value="cloudy"/>
+                <el-option label="阴" value="overcast"/>
+                <el-option label="小雨" value="light_rain"/>
+                <el-option label="中雨" value="moderate_rain"/>
+                <el-option label="大雨" value="heavy_rain"/>
+                <el-option label="雷阵雨" value="thunderstorm"/>
+                <el-option label="小雪" value="light_snow"/>
+                <el-option label="雾" value="fog"/>
+                <el-option label="霾" value="haze"/>
+                <el-option label="沙尘" value="dust"/>
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+    </div>
+  </div>
+</template>
+
+<script>
+/**
+ * WeatherEnvForm
+ * v-model:prop 'value' / event 'input'
+ * 数据结构:
+ * {
+ *   temp, rh, pressure, wind, windDir, fallSpeed,
+ *   airDensity, visibility, rain, weather
+ * }
+ * 提供 validate(cb) 供父组件触发校验
+ */
+export default {
+  name: 'WeatherEnvForm',
+  model: { prop: 'value', event: 'input' },
+  props: {
+    value: {
+      type: Object,
+      default: () => ({
+        temp: null,          // °C
+        rh: null,            // %RH
+        pressure: null,      // hPa
+        wind: null,          // m/s
+        windDir: null,       // °
+        fallSpeed: null,     // m/s
+        airDensity: null,    // g/m³
+        visibility: null,    // m
+        rain: null,          // mm
+        weather: ''          // 枚举字符串
+      })
+    }
+  },
+  data() {
+    return {
+      isCollapsed: false,
+      form: this.normalize(this.value),
+      rules: this.buildRules()
+    };
+  },
+  watch: {
+    value: { deep: true, handler(nv) { this.form = this.normalize(nv); } }
+  },
+  methods: {
+    toggleCollapse() { this.isCollapsed = !this.isCollapsed; },
+
+    numOrNull(v) {
+      const n = Number(v);
+      return Number.isFinite(n) ? n : null;
+    },
+
+    normalize(x = {}) {
+      return {
+        temp: this.numOrNull(x.temp),
+        rh: this.numOrNull(x.rh),
+        pressure: this.numOrNull(x.pressure),
+        wind: this.numOrNull(x.wind),
+        windDir: this.numOrNull(x.windDir),
+        fallSpeed: this.numOrNull(x.fallSpeed),
+        airDensity: this.numOrNull(x.airDensity),
+        visibility: this.numOrNull(x.visibility),
+        rain: this.numOrNull(x.rain),
+        weather: typeof x.weather === 'string' ? x.weather : ''
+      };
+    },
+
+    emitChange() {
+      const payload = this.normalize(this.form);
+      this.$emit('input', payload);
+      this.$emit('change', payload);
+    },
+
+    buildRules() {
+      const reqNum = (msg) => ({
+        validator: (_, v, cb) => Number.isFinite(Number(v)) ? cb() : cb(new Error(msg || '请输入数值')),
+        trigger: ['blur', 'change']
+      });
+      const inRange = (min, max, msg) => ({
+        validator: (_, v, cb) => {
+          const n = Number(v);
+          if (!Number.isFinite(n)) return cb(new Error('请输入数值'));
+          if (n < min || n > max) return cb(new Error(msg));
+          cb();
+        },
+        trigger: ['blur', 'change']
+      });
+
+      return {
+        temp: [reqNum('请输入气温'), inRange(-80, 60, '气温范围 -80 ~ 60℃')],
+        rh: [reqNum('请输入湿度'), inRange(0, 100, '湿度范围 0 ~ 100%')],
+        pressure: [reqNum('请输入气压'), inRange(300, 1100, '气压范围 300 ~ 1100 hPa')],
+        wind: [reqNum('请输入风速'), inRange(0, 100, '风速范围 0 ~ 100 m/s')],
+        windDir: [reqNum('请输入风向'), inRange(0, 360, '风向范围 0 ~ 360°')],
+        fallSpeed: [reqNum('请输入降速'), inRange(0, 200, '降速范围 0 ~ 200 m/s')],
+        airDensity: [reqNum('请输入大气密度'), inRange(0, 2000, '密度范围 0 ~ 2000 g/m³')],
+        visibility: [reqNum('请输入能见度'), inRange(0, 200000, '能见度范围 0 ~ 200000 m')],
+        rain: [reqNum('请输入降雨量'), inRange(0, 1000, '降雨量范围 0 ~ 1000 mm')],
+        weather: [{ required: true, message: '请选择天气现象', trigger: ['change', 'blur'] }]
+      };
+    },
+
+    // 暴露校验方法
+    validate(cb) {
+      this.$refs.formRef && this.$refs.formRef.validate(cb);
+    }
+  }
+};
+</script>
+
+<style scoped>
+.section-card {
+  background-color: rgba(15, 23, 42, 0.7);
+  border: 1px solid rgba(14, 165, 233, 0.2);
+  border-radius: 4px;
+  overflow: hidden;
+}
+.section-header {
+  background-color: rgba(30, 58, 138, 0.3);
+  padding: 8px 16px;
+  border-bottom: 1px solid rgba(14, 165, 233, 0.2);
+}
+.section-title { color: #bae6fd; font-size: 14px; margin: 0; display:flex; justify-content:space-between; align-items:center; }
+.title-left { display:flex; align-items:center; }
+.cursor-pointer { cursor: pointer; }
+.rotate-90 { transform: rotate(90deg); }
+.-rotate-90 { transform: rotate(-90deg); }
+.el-icon-arrow-right { color: #94a3b8; margin-left: 8px; }
+
+.target-list { padding: 12px; }
+.block-title { color:#93c5fd; font-weight:600; margin: 6px 0 12px; }
+.dense-form ::v-deep .el-form-item { margin-bottom: 10px; }
+</style>

Dosya farkı çok büyük olduğundan ihmal edildi
+ 382 - 454
src/views/decision/testBuild/index.vue


Dosya farkı çok büyük olduğundan ihmal edildi
+ 554 - 828
src/views/decision/testBuild/orderEdit.vue


Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor