ProjectDetail.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. <template>
  2. <div class="page-container" v-loading="loading">
  3. <div class="page-header">
  4. <div>
  5. <el-button text @click="$router.push('/projects')" style="padding: 0; margin-bottom: 8px">
  6. <el-icon><ArrowLeft /></el-icon> 返回项目列表
  7. </el-button>
  8. <span class="page-title">{{ project?.name }}</span>
  9. <el-tag size="small" style="margin-left: 12px">{{ project?.topology }}</el-tag>
  10. <el-tag size="small" type="success" style="margin-left: 8px">{{ project?.status }}</el-tag>
  11. </div>
  12. <el-button type="primary" @click="showCreatePlan = true">
  13. <el-icon><Plus /></el-icon> 新建方案
  14. </el-button>
  15. </div>
  16. <el-row :gutter="16" v-if="project">
  17. <el-col :span="8">
  18. <div class="card">
  19. <h3 style="margin-bottom: 12px">项目信息</h3>
  20. <el-descriptions :column="1" border size="small">
  21. <el-descriptions-item label="ID">{{ project.id }}</el-descriptions-item>
  22. <el-descriptions-item label="拓扑结构">{{ project.topology }}</el-descriptions-item>
  23. <el-descriptions-item label="模型">{{ project.model_path || '-' }}</el-descriptions-item>
  24. <el-descriptions-item label="状态">{{ project.status }}</el-descriptions-item>
  25. <el-descriptions-item label="方案数">{{ project.plan_count }}</el-descriptions-item>
  26. <el-descriptions-item label="创建时间">{{ formatDate(project.created_at) }}</el-descriptions-item>
  27. <el-descriptions-item label="更新时间">{{ formatDate(project.updated_at) }}</el-descriptions-item>
  28. </el-descriptions>
  29. <div v-if="project.description" style="margin-top: 12px">
  30. <div style="color: #6b7280; font-size: 12px; margin-bottom: 4px">项目描述</div>
  31. <div style="font-size: 13px">{{ project.description }}</div>
  32. </div>
  33. </div>
  34. </el-col>
  35. <el-col :span="16">
  36. <div class="card">
  37. <h3 style="margin-bottom: 12px">边界条件</h3>
  38. <pre v-if="Object.keys(project.boundary_conditions).length" style="background: #f8fafc; padding: 12px; border-radius: 4px; font-size: 12px">{{ JSON.stringify(project.boundary_conditions, null, 2) }}</pre>
  39. <el-empty v-else description="未设置边界条件" :image-size="60" />
  40. </div>
  41. </el-col>
  42. </el-row>
  43. <div class="card">
  44. <h3 style="margin-bottom: 12px">仿真方案</h3>
  45. <el-table :data="plans" stripe @row-click="goToPlan">
  46. <el-table-column prop="id" label="ID" width="60" />
  47. <el-table-column prop="name" label="方案名称" min-width="200">
  48. <template #default="{ row }">
  49. <span style="color: #3b82f6; cursor: pointer">{{ row.name }}</span>
  50. </template>
  51. </el-table-column>
  52. <el-table-column prop="plan_id" label="方案UUID" width="180" />
  53. <el-table-column prop="status" label="状态" width="100">
  54. <template #default="{ row }">
  55. <el-tag size="small" :type="planStatusType(row.status)">{{ row.status }}</el-tag>
  56. </template>
  57. </el-table-column>
  58. <el-table-column prop="estimated_points" label="数据点" width="80" />
  59. <el-table-column prop="estimated_time_min" label="预估(分钟)" width="100" />
  60. <el-table-column prop="result_count" label="结果数" width="80" />
  61. <el-table-column prop="updated_at" label="更新时间" width="160">
  62. <template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
  63. </el-table-column>
  64. </el-table>
  65. </div>
  66. <!-- Create Plan Dialog with Visual Variable Editor -->
  67. <el-dialog v-model="showCreatePlan" title="新建仿真方案" width="900px" top="5vh">
  68. <el-form :model="planForm" label-width="100px">
  69. <el-row :gutter="12">
  70. <el-col :span="14">
  71. <el-form-item label="方案名称">
  72. <el-input v-model="planForm.name" placeholder="例如:气隙扫描 0.6-1.5mm" />
  73. </el-form-item>
  74. </el-col>
  75. <el-col :span="10">
  76. <el-form-item label="模型路径">
  77. <el-input v-model="planForm.model_path" :placeholder="project?.model_path || 'models/xxx.mot'" />
  78. </el-form-item>
  79. </el-col>
  80. </el-row>
  81. <el-form-item label="备注">
  82. <el-input v-model="planForm.notes" type="textarea" :rows="1" />
  83. </el-form-item>
  84. </el-form>
  85. <el-divider content-position="left">扫描变量</el-divider>
  86. <div class="plan-editor-toolbar">
  87. <el-button type="primary" size="small" @click="addVariable">
  88. <el-icon><Plus /></el-icon> 添加变量
  89. </el-button>
  90. <el-button type="success" size="small" @click="generateFromRules" :loading="generating">
  91. <el-icon><MagicStick /></el-icon> 规则生成
  92. </el-button>
  93. <el-button size="small" @click="clearVariables">清空全部</el-button>
  94. <div class="plan-estimate">
  95. <el-tag type="info">总数据点:<b>{{ estimatedPoints }}</b></el-tag>
  96. <el-tag type="warning" style="margin-left: 8px">预估时间:<b>{{ estimatedTimeMin }} 分钟</b></el-tag>
  97. </div>
  98. </div>
  99. <el-table :data="planVariables" size="small" border class="variable-table" max-height="320">
  100. <el-table-column label="#" type="index" width="40" align="center" />
  101. <el-table-column label="参数" width="200">
  102. <template #default="{ row }">
  103. <el-select v-model="row.name" size="small" filterable @change="onParamChange(row)" style="width: 100%">
  104. <el-option
  105. v-for="p in availableParams(row.name)"
  106. :key="p.name"
  107. :label="`${p.display_name} (${p.unit})`"
  108. :value="p.name"
  109. >
  110. <span style="float: left">{{ p.display_name }}</span>
  111. <span style="float: right; color: #8492a6; font-size: 12px">{{ p.category }} / {{ p.unit }}</span>
  112. </el-option>
  113. </el-select>
  114. </template>
  115. </el-table-column>
  116. <el-table-column label="模式" width="90" align="center">
  117. <template #default="{ row }">
  118. <el-radio-group v-model="row.mode" size="small" @change="onModeChange(row)">
  119. <el-radio-button label="range">范围</el-radio-button>
  120. <el-radio-button label="values">枚举</el-radio-button>
  121. </el-radio-group>
  122. </template>
  123. </el-table-column>
  124. <el-table-column label="起始" width="90">
  125. <template #default="{ row }">
  126. <el-input-number v-if="row.mode === 'range'" v-model="row.start" :step="row.step || 0.1" :precision="4" controls-position="right" size="small" style="width: 100%" @change="updateValues(row)" />
  127. <span v-else style="color: #c0c4cc">-</span>
  128. </template>
  129. </el-table-column>
  130. <el-table-column label="终止" width="90">
  131. <template #default="{ row }">
  132. <el-input-number v-if="row.mode === 'range'" v-model="row.stop" :step="row.step || 0.1" :precision="4" controls-position="right" size="small" style="width: 100%" @change="updateValues(row)" />
  133. <span v-else style="color: #c0c4cc">-</span>
  134. </template>
  135. </el-table-column>
  136. <el-table-column label="步长" width="80">
  137. <template #default="{ row }">
  138. <el-input-number v-if="row.mode === 'range'" v-model="row.step" :min="0.001" :precision="4" controls-position="right" size="small" style="width: 100%" @change="updateValues(row)" />
  139. <span v-else style="color: #c0c4cc">-</span>
  140. </template>
  141. </el-table-column>
  142. <el-table-column label="枚举值">
  143. <template #default="{ row }">
  144. <el-input
  145. v-if="row.mode === 'values'"
  146. v-model="row.values_text"
  147. size="small"
  148. placeholder="例如:0.6, 1.0, 1.5"
  149. @change="parseValues(row)"
  150. />
  151. <span v-else style="font-size: 12px; color: #606266">{{ row.values?.join(', ') || '' }}</span>
  152. </template>
  153. </el-table-column>
  154. <el-table-column label="数量" width="60" align="center">
  155. <template #default="{ row }">
  156. <el-tag size="small" :type="row.values?.length > 0 ? 'success' : 'info'">{{ row.values?.length || 0 }}</el-tag>
  157. </template>
  158. </el-table-column>
  159. <el-table-column label="操作" width="60" align="center" fixed="right">
  160. <template #default="{ $index }">
  161. <el-button size="small" text type="danger" @click="removeVariable($index)">
  162. <el-icon><Delete /></el-icon>
  163. </el-button>
  164. </template>
  165. </el-table-column>
  166. </el-table>
  167. <div v-if="planVariables.length === 0" style="text-align: center; padding: 24px; color: #909399">
  168. 暂无扫描变量。点击"添加变量"或"规则生成"开始配置。
  169. </div>
  170. <template #footer>
  171. <el-button @click="showCreatePlan = false">取消</el-button>
  172. <el-button type="primary" @click="createPlan" :loading="creating" :disabled="planVariables.length === 0">
  173. Create Plan ({{ estimatedPoints }} points)
  174. </el-button>
  175. </template>
  176. </el-dialog>
  177. </div>
  178. </template>
  179. <script setup lang="ts">
  180. import { ref, computed, onMounted } from 'vue'
  181. import { useRoute, useRouter } from 'vue-router'
  182. import { ElMessage } from 'element-plus'
  183. import { ArrowLeft, Plus, Delete, MagicStick } from '@element-plus/icons-vue'
  184. import { projectApi, planApi, generationApi } from '@/api'
  185. const route = useRoute()
  186. const router = useRouter()
  187. const loading = ref(false)
  188. const creating = ref(false)
  189. const generating = ref(false)
  190. const project = ref<any>(null)
  191. const plans = ref<any[]>([])
  192. const showCreatePlan = ref(false)
  193. // Plan form
  194. const planForm = ref({ name: '', model_path: '', notes: '' })
  195. // Scan parameter registry (loaded from API)
  196. const scanParams = ref<any[]>([])
  197. // Plan variables (editable table rows)
  198. interface PlanVariable {
  199. name: string
  200. display_name: string
  201. unit: string
  202. mode: 'range' | 'values'
  203. start: number
  204. stop: number
  205. step: number
  206. values: number[]
  207. values_text: string
  208. }
  209. const planVariables = ref<PlanVariable[]>([])
  210. // Load scan parameter registry
  211. async function loadScanParams() {
  212. try {
  213. const res = await generationApi.listScanParameters()
  214. scanParams.value = res.data.parameters
  215. } catch (e: any) {
  216. ElMessage.error('加载扫描参数失败:' + e.message)
  217. }
  218. }
  219. // Filter available params (exclude already used, except current row)
  220. function availableParams(currentName: string) {
  221. const used = new Set(planVariables.value.filter(v => v.name !== currentName).map(v => v.name))
  222. return scanParams.value.filter(p => !used.has(p.name))
  223. }
  224. // Add a new variable row
  225. function addVariable() {
  226. if (scanParams.value.length === 0) {
  227. ElMessage.warning('扫描参数尚未加载')
  228. return
  229. }
  230. const available = availableParams('')
  231. if (available.length === 0) {
  232. ElMessage.warning('所有参数已添加')
  233. return
  234. }
  235. const p = available[0]
  236. planVariables.value.push({
  237. name: p.name,
  238. display_name: p.display_name,
  239. unit: p.unit,
  240. mode: 'range',
  241. start: p.default_start,
  242. stop: p.default_stop,
  243. step: p.default_step,
  244. values: generateValues(p.default_start, p.default_stop, p.default_step),
  245. values_text: '',
  246. })
  247. }
  248. // Remove a variable
  249. function removeVariable(index: number) {
  250. planVariables.value.splice(index, 1)
  251. }
  252. // Clear all variables
  253. function clearVariables() {
  254. planVariables.value = []
  255. }
  256. // On parameter change: update metadata and defaults
  257. function onParamChange(row: PlanVariable) {
  258. const p = scanParams.value.find(sp => sp.name === row.name)
  259. if (p) {
  260. row.display_name = p.display_name
  261. row.unit = p.unit
  262. row.start = p.default_start
  263. row.stop = p.default_stop
  264. row.step = p.default_step
  265. if (row.mode === 'range') {
  266. row.values = generateValues(row.start, row.stop, row.step)
  267. }
  268. }
  269. }
  270. // On mode change
  271. function onModeChange(row: PlanVariable) {
  272. if (row.mode === 'range') {
  273. row.values = generateValues(row.start, row.stop, row.step)
  274. row.values_text = ''
  275. } else {
  276. row.values_text = row.values.join(', ')
  277. }
  278. }
  279. // Update values from range inputs
  280. function updateValues(row: PlanVariable) {
  281. if (row.mode === 'range') {
  282. row.values = generateValues(row.start, row.stop, row.step)
  283. }
  284. }
  285. // Parse explicit values from text
  286. function parseValues(row: PlanVariable) {
  287. if (row.mode !== 'values') return
  288. const parts = row.values_text.split(/[,;\s]+/).filter(s => s.trim() !== '')
  289. const vals: number[] = []
  290. for (const p of parts) {
  291. const n = parseFloat(p)
  292. if (!isNaN(n)) vals.push(n)
  293. }
  294. row.values = vals
  295. }
  296. // Generate evenly spaced values
  297. function generateValues(start: number, stop: number, step: number): number[] {
  298. if (!step || step <= 0 || start === undefined || stop === undefined) return []
  299. const count = Math.floor((stop - start) / step + 1e-9) + 1
  300. if (count <= 0 || count > 1000) return []
  301. const vals = []
  302. for (let i = 0; i < count; i++) {
  303. vals.push(parseFloat((start + i * step).toFixed(6)))
  304. }
  305. // Ensure stop is included
  306. if (vals.length > 0 && Math.abs(vals[vals.length - 1] - stop) > 1e-9) {
  307. vals.push(parseFloat(stop.toFixed(6)))
  308. }
  309. return vals
  310. }
  311. // Estimate total points (Cartesian product)
  312. const estimatedPoints = computed(() => {
  313. let total = 1
  314. for (const v of planVariables.value) {
  315. const count = v.values?.length || 0
  316. total *= count > 0 ? count : 1
  317. }
  318. return planVariables.value.length > 0 ? total : 0
  319. })
  320. const estimatedTimeMin = computed(() => estimatedPoints.value * 3)
  321. // Generate plan from rules (backend rule engine)
  322. async function generateFromRules() {
  323. if (!project.value) {
  324. ElMessage.warning('项目未加载')
  325. return
  326. }
  327. generating.value = true
  328. try {
  329. const res = await generationApi.generatePlanForProject(
  330. Number(route.params.id),
  331. { model_path: project.value.model_path || '' }
  332. )
  333. const generated = res.data
  334. planForm.value.name = generated.name
  335. planForm.value.model_path = generated.model_path || project.value.model_path || ''
  336. // Convert generated variables to editor format
  337. planVariables.value = (generated.variables || []).map((v: any) => ({
  338. name: v.name,
  339. display_name: v.display_name || v.name,
  340. unit: v.unit || '',
  341. mode: 'range',
  342. start: v.start ?? v.values?.[0] ?? 0,
  343. stop: v.stop ?? v.values?.[v.values.length - 1] ?? 0,
  344. step: v.step ?? 1,
  345. values: v.values || [],
  346. values_text: '',
  347. }))
  348. ElMessage.success(`已生成方案,共 ${generated.estimated_points} 个数据点`)
  349. } catch (e: any) {
  350. ElMessage.error('Failed to generate plan: ' + e.message)
  351. } finally {
  352. generating.value = false
  353. }
  354. }
  355. async function loadProject() {
  356. loading.value = true
  357. try {
  358. const id = Number(route.params.id)
  359. const res = await projectApi.get(id)
  360. project.value = res.data
  361. const plansRes = await planApi.list({ project_id: id, limit: 100 })
  362. plans.value = plansRes.data.items
  363. } catch (e: any) {
  364. ElMessage.error('加载项目失败:' + e.message)
  365. } finally {
  366. loading.value = false
  367. }
  368. }
  369. async function createPlan() {
  370. if (!planForm.value.name.trim()) {
  371. ElMessage.warning('请输入方案名称')
  372. return
  373. }
  374. if (planVariables.value.length === 0) {
  375. ElMessage.warning('请至少添加一个扫描变量')
  376. return
  377. }
  378. // Validate all variables have values
  379. for (const v of planVariables.value) {
  380. if (!v.values || v.values.length === 0) {
  381. ElMessage.warning(`变量 "${v.name}" 没有有效值`)
  382. return
  383. }
  384. }
  385. creating.value = true
  386. try {
  387. // Build plan_data compatible with src/plan_schema.py
  388. const variables = planVariables.value.map(v => ({
  389. name: v.name,
  390. display_name: v.display_name,
  391. unit: v.unit,
  392. values: v.values,
  393. }))
  394. const planData = {
  395. model_path: planForm.value.model_path || project.value?.model_path || '',
  396. topology: project.value?.topology || 'SSSR',
  397. variables,
  398. cases: [],
  399. }
  400. await planApi.create({
  401. project_id: Number(route.params.id),
  402. name: planForm.value.name,
  403. plan_data: planData,
  404. notes: planForm.value.notes,
  405. })
  406. ElMessage.success(`方案已创建,共 ${estimatedPoints.value} 个数据点`)
  407. showCreatePlan.value = false
  408. resetPlanForm()
  409. await loadProject()
  410. } catch (e: any) {
  411. ElMessage.error('创建方案失败:' + e.message)
  412. } finally {
  413. creating.value = false
  414. }
  415. }
  416. function resetPlanForm() {
  417. planForm.value = { name: '', model_path: '', notes: '' }
  418. planVariables.value = []
  419. }
  420. function goToPlan(row: any) {
  421. router.push(`/plans/${row.id}`)
  422. }
  423. function planStatusType(status: string) {
  424. const map: Record<string, string> = { draft: 'info', confirmed: 'warning', executing: 'primary', completed: 'success', failed: 'danger' }
  425. return map[status] || 'info'
  426. }
  427. function formatDate(d: string) {
  428. return new Date(d).toLocaleString()
  429. }
  430. onMounted(() => {
  431. loadProject()
  432. loadScanParams()
  433. })
  434. </script>
  435. <style scoped>
  436. .plan-editor-toolbar {
  437. display: flex;
  438. align-items: center;
  439. margin-bottom: 12px;
  440. gap: 8px;
  441. }
  442. .plan-estimate {
  443. margin-left: auto;
  444. }
  445. .variable-table {
  446. margin-bottom: 12px;
  447. }
  448. </style>