This commit is contained in:
che
2026-07-11 16:13:07 +05:00
parent 111cadd184
commit b815cde04c
24 changed files with 2858 additions and 76 deletions
+28 -14
View File
@@ -1,19 +1,33 @@
<script setup lang="ts">
import { paramsApi } from "@/api.ts";
import { onMounted, onUnmounted, ref } from "vue";
const res = ref({});
onMounted(async () => {
res.value = await paramsApi.list();
});
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<h1>You did it!</h1>
<p>
Visit {{ res }}
<a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to
read the documentation
</p>
</template>
<div class="min-vh-100 bg-light">
<nav class="navbar navbar-dark navbar-expand-lg bg-dark">
<div class="container-fluid">
<RouterLink class="navbar-brand fw-semibold" to="/control-cases">
Control Cases
</RouterLink>
<div class="navbar-nav">
<RouterLink class="nav-link" active-class="active" to="/control-cases">
Список
</RouterLink>
<RouterLink class="nav-link" active-class="active" to="/params">
Params
</RouterLink>
<RouterLink class="nav-link" active-class="active" to="/analize">
Analize
</RouterLink>
<RouterLink class="nav-link" active-class="active" to="/analize/chart">
Analize graph
</RouterLink>
</div>
</div>
</nav>
<style scoped></style>
<main class="container py-4">
<RouterView />
</main>
</div>
</template>
+87 -3
View File
@@ -1,5 +1,89 @@
import { createModelApi } from "@/api_client.ts";
import type { Params, ControlCase } from "@/models.ts";
import { createModelApi, apiClient } from "@/api_client.ts";
import type { Params, ControlCase, Analize } from "@/models.ts";
export const paramsApi = createModelApi<Params>("params");
export const controlCaseApi = createModelApi<ControlCase>("control-case");
export const controlCaseApi = createModelApi<ControlCase>("control_case");
export const analizeApi = createModelApi<Analize>("analize");
export interface CsvChartResponse {
columns: string[];
rows: Array<Array<string | number>>;
}
export interface FieldMapResponse {
requested_t: number;
stage_t: number;
rows: number;
cols: number;
fields: Record<string, number[][]>;
}
export async function getControlCaseChartData(id: number) {
const response = await apiClient.get<CsvChartResponse>(
`/control_case/${id}/chart-data`,
);
return response.data;
}
export async function getControlCaseFieldMap(id: number, time: number) {
const response = await apiClient.get<FieldMapResponse>(
`/control_case/${id}/field-map`,
{
params: { time },
},
);
return response.data;
}
export interface LaunchControlCasePayload {
params_id: number;
name?: string;
}
export async function launchControlCase(payload: LaunchControlCasePayload) {
const response = await apiClient.post<ControlCase>(
"/control_case/launch",
payload,
);
return response.data;
}
export async function recalculateControlCaseAnalysis(id: number) {
const response = await apiClient.post<Analize>(
`/control_case/${id}/recalculate-analysis`,
);
return response.data;
}
export async function recalculateAnalize(id: number) {
const response = await apiClient.post<Analize>(`/analize/${id}/recalculate`);
return response.data;
}
export interface AnalizeSeriesPoint {
case_id: number;
case_name: string;
x: number;
omega: number;
psi_max: number;
}
export interface AnalizeSeriesResponse {
parameter: string;
label: string;
points: AnalizeSeriesPoint[];
}
export interface AnalizeSeriesFilters {
filter_parameter?: string;
filter_min?: number;
filter_max?: number;
}
export async function getAnalizeSeries(parameter: string, filters?: AnalizeSeriesFilters) {
const response = await apiClient.get<AnalizeSeriesResponse>("/analize/series", {
params: { parameter, ...(filters ?? {}) },
});
return response.data;
}
+2 -3
View File
@@ -1,12 +1,12 @@
import axios from "axios";
export interface BaseEntity {
id: number;
id?: number;
ID?: number;
}
export interface ListParams {
page?: number;
page_size?: number;
search?: string;
ordering?: string;
[key: string]: string | number | boolean | undefined;
@@ -121,7 +121,6 @@ export function createModelApi<
params: {
...(params ?? {}),
page: 1,
page_size: params?.page_size ?? 100,
},
},
);
+1
View File
@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'bootstrap/dist/css/bootstrap.min.css'
import App from './App.vue'
import router from './router'
+33 -12
View File
@@ -1,23 +1,44 @@
export interface GormDeletedAt {
Time: string;
Valid: boolean;
}
export interface Params {
id: number;
created_at: string;
updated_at: string;
deleted_at: GormDeletedAt | null;
rel: number;
relC: number;
rel_c: number;
le: number;
pr: number;
pe: number;
initialCondition: string;
ma: number;
initial_condition: string;
time: number;
folderPath: string;
folder_path: string;
}
export interface ControlCase {
id: number;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
name: string;
paramsId: number;
params: Params;
status: string;
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt: GormDeletedAt | null;
Name: string;
ParamsID: number;
Params: Params;
Status: string;
}
export interface Analize {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt: GormDeletedAt | null;
Name: string;
CaseID: number;
CaseName: string;
PsiMax: number;
PsiLMax: number;
Omega: number;
}
+38 -1
View File
@@ -2,7 +2,44 @@ import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [],
routes: [
{
path: '/',
redirect: '/control-cases',
},
{
path: '/control-cases',
name: 'control-cases',
component: () => import('@/views/ControlCaseListView.vue'),
},
{
path: '/control-cases/:id',
name: 'control-case-detail',
component: () => import('@/views/ControlCaseDetailView.vue'),
props: true,
},
{
path: '/control-cases/:id/chart',
name: 'control-case-chart',
component: () => import('@/views/ControlCaseChartView.vue'),
props: true,
},
{
path: '/params',
name: 'params-list',
component: () => import('@/views/ParamsListView.vue'),
},
{
path: '/analize',
name: 'analize-list',
component: () => import('@/views/AnalizeListView.vue'),
},
{
path: '/analize/chart',
name: 'analize-chart',
component: () => import('@/views/AnalizeChartView.vue'),
},
],
})
export default router
+538
View File
@@ -0,0 +1,538 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { controlCaseApi, getAnalizeSeries, getControlCaseChartData, getControlCaseFieldMap } from '@/api.ts'
import { formatApiError } from '@/api_client.ts'
import type { ControlCase } from '@/models.ts'
const fieldOptions = [
{ value: 'psi', label: 'psi' },
{ value: 'phi', label: 'phi' },
{ value: 'T', label: 'T' },
{ value: 'C', label: 'C' },
] as const
const parameterOptions = [
{ value: 'Rel', label: 'Rel' },
{ value: 'RelC', label: 'RelC' },
{ value: 'Le', label: 'Le' },
{ value: 'Pr', label: 'Pr' },
{ value: 'Pe', label: 'Pe' },
{ value: 'Ma', label: 'Ma' },
{ value: 'Time', label: 'Time' },
] as const
const selectedParameter = ref<(typeof parameterOptions)[number]['value']>('Rel')
const filterParameter = ref('')
const filterMin = ref('')
const filterMax = ref('')
const loading = ref(false)
const error = ref('')
const containerRef = ref<HTMLDivElement | null>(null)
const caseChartRef = ref<HTMLDivElement | null>(null)
const chartTitle = ref('')
const seriesData = ref<{ case_id: number; x: number; omega: number; psi_max: number; case_name: string }[]>([])
const selectedCaseId = ref<number | null>(null)
const selectedCase = ref<ControlCase | null>(null)
const selectedCaseLoading = ref(false)
const selectedCaseError = ref('')
const selectedCasePoints = ref<{ t: number; psi_m: number }[]>([])
const selectedFieldMap = ref<{
requested_t: number
stage_t: number
rows: number
cols: number
fields: Record<string, number[][]>
} | null>(null)
const selectedFieldMapLoading = ref(false)
const selectedFieldMapError = ref('')
const selectedField = ref<(typeof fieldOptions)[number]['value']>('psi')
const fieldMapRef = ref<HTMLDivElement | null>(null)
let chart: echarts.ECharts | null = null
let caseChart: echarts.ECharts | null = null
let fieldMapChart: echarts.ECharts | null = null
const selectedLabel = computed(
() => parameterOptions.find((item) => item.value === selectedParameter.value)?.label ?? selectedParameter.value,
)
function buildOption() {
return {
title: { text: `Omega / PsiMax vs ${chartTitle.value || selectedLabel.value}` },
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const p = params.data
const valueLabel = params.seriesName === 'Omega' ? 'Omega' : 'PsiMax'
const value = params.seriesName === 'Omega' ? p.omega : p.psi_max
return [
`<strong>${p.case_name}</strong>`,
`${selectedLabel.value}: ${p.x}`,
`${valueLabel}: ${value}`,
].join('<br/>')
},
},
legend: { top: 28 },
grid: { left: 56, right: 32, top: 80, bottom: 56, containLabel: true },
xAxis: { type: 'value', name: selectedLabel.value },
yAxis: [
{ type: 'value', name: 'Omega', position: 'left' },
{ type: 'value', name: 'PsiMax', position: 'right' },
],
series: [
{
name: 'Omega',
type: 'scatter',
symbolSize: 10,
yAxisIndex: 0,
data: seriesData.value.map((point) => ({
case_id: point.case_id,
name: point.case_name,
value: [point.x, point.omega],
case_name: point.case_name,
x: point.x,
omega: point.omega,
psi_max: point.psi_max,
})),
},
{
name: 'PsiMax',
type: 'scatter',
symbolSize: 10,
yAxisIndex: 1,
data: seriesData.value.map((point) => ({
case_id: point.case_id,
name: point.case_name,
value: [point.x, point.psi_max],
case_name: point.case_name,
x: point.x,
omega: point.omega,
psi_max: point.psi_max,
})),
},
],
}
}
function renderChart() {
if (!chart) return
chart.setOption(buildOption(), true)
}
function buildCaseOption() {
return {
title: { text: 'psi_m / time' },
tooltip: { trigger: 'axis' },
grid: { left: 56, right: 24, top: 48, bottom: 48, containLabel: true },
xAxis: { type: 'value', name: 't' },
yAxis: { type: 'value', name: 'psi_m' },
series: [
{
type: 'line',
smooth: true,
showSymbol: false,
data: selectedCasePoints.value.map((point) => [point.t, point.psi_m]),
},
],
}
}
function renderCaseChart() {
if (!caseChart) return
caseChart.setOption(buildCaseOption(), true)
}
function buildFieldMapOption() {
const payload = selectedFieldMap.value
if (!payload) return null
const matrix = payload.fields[selectedField.value] ?? []
const rows = matrix.length
const cols = matrix[0]?.length ?? 0
const seriesData: Array<[number, number, number]> = []
let min = Number.POSITIVE_INFINITY
let max = Number.NEGATIVE_INFINITY
matrix.forEach((row, y) => {
row.forEach((value, x) => {
seriesData.push([x, y, value])
if (Number.isFinite(value)) {
min = Math.min(min, value)
max = Math.max(max, value)
}
})
})
if (!Number.isFinite(min) || !Number.isFinite(max)) {
min = 0
max = 1
}
return {
title: {
text: `Field map: ${selectedField.value}`,
subtext: `requested t=${payload.requested_t}, stage t=${payload.stage_t}`,
},
tooltip: {
position: 'top',
formatter: (params: any) => {
const [x, y, value] = params.data as [number, number, number]
return [`<strong>${selectedField.value}</strong>`, `x: ${x}`, `y: ${y}`, `value: ${value}`].join('<br/>')
},
},
grid: { left: 56, right: 32, top: 64, bottom: 48, containLabel: true },
xAxis: { type: 'category', name: 'x', data: Array.from({ length: cols }, (_, index) => index) },
yAxis: { type: 'category', name: 'y', data: Array.from({ length: rows }, (_, index) => index), inverse: true },
visualMap: {
min,
max,
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: 0,
inRange: {
color: ['#0000ff', '#00ffff', '#00ff00', '#ffff00', '#ff7f00', '#ff0000'],
},
},
series: [
{
type: 'heatmap',
data: seriesData,
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.35)' } },
},
],
}
}
function renderFieldMap() {
if (!fieldMapChart || !selectedFieldMap.value) return
const option = buildFieldMapOption()
if (option) {
fieldMapChart.setOption(option, true)
}
}
function formatValue(value: unknown) {
if (value == null || value === '') return '—'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
function caseObject(value: ControlCase | null) {
return (value ?? {}) as Record<string, unknown>
}
function paramsObject(value: ControlCase | null) {
const record = caseObject(value)
return (record.Params ?? record.params ?? {}) as Record<string, unknown>
}
async function loadData() {
loading.value = true
error.value = ''
try {
const response = await getAnalizeSeries(selectedParameter.value, {
filter_parameter: filterParameter.value || undefined,
filter_min: filterMin.value === '' ? undefined : Number(filterMin.value),
filter_max: filterMax.value === '' ? undefined : Number(filterMax.value),
})
chartTitle.value = response.label
seriesData.value = response.points.map((point) => ({
case_id: point.case_id,
x: point.x,
omega: point.omega,
psi_max: point.psi_max,
case_name: point.case_name,
}))
renderChart()
} catch (err) {
error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
seriesData.value = []
} finally {
loading.value = false
}
}
async function loadSelectedCase(caseId: number) {
selectedCaseId.value = caseId
selectedCaseLoading.value = true
selectedCaseError.value = ''
selectedCase.value = null
selectedCasePoints.value = []
selectedFieldMap.value = null
selectedFieldMapError.value = ''
selectedFieldMapLoading.value = false
fieldMapChart?.clear()
try {
const [caseData, csvData] = await Promise.all([
controlCaseApi.retrieve(caseId),
getControlCaseChartData(caseId),
])
selectedCase.value = caseData
const tIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 't')
const psiMIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 'psi_m')
if (tIndex < 0 || psiMIndex < 0) {
throw new Error('CSV does not contain t/psi_m columns')
}
selectedCasePoints.value = csvData.rows
.map((row) => ({
t: Number(row[tIndex]),
psi_m: Number(row[psiMIndex]),
}))
.filter((point) => Number.isFinite(point.t) && Number.isFinite(point.psi_m))
renderCaseChart()
} catch (err) {
selectedCaseError.value = formatApiError(err, 'Не удалось загрузить расчетный случай.')
} finally {
selectedCaseLoading.value = false
}
}
async function loadFieldMap(time: number) {
if (!selectedCaseId.value) return
selectedFieldMapLoading.value = true
selectedFieldMapError.value = ''
try {
selectedFieldMap.value = await getControlCaseFieldMap(selectedCaseId.value, time)
renderFieldMap()
} catch (err) {
selectedFieldMapError.value = formatApiError(err, 'Не удалось загрузить карту полей.')
selectedFieldMap.value = null
fieldMapChart?.clear()
} finally {
selectedFieldMapLoading.value = false
}
}
function handleResize() {
chart?.resize()
caseChart?.resize()
fieldMapChart?.resize()
}
function resetFilters() {
filterParameter.value = ''
filterMin.value = ''
filterMax.value = ''
void loadData()
}
onMounted(async () => {
if (containerRef.value) {
chart = echarts.init(containerRef.value)
chart.on('click', (params: any) => {
const caseId = Number(params?.data?.case_id)
if (Number.isFinite(caseId) && caseId > 0) {
void loadSelectedCase(caseId)
}
})
}
if (caseChartRef.value) {
caseChart = echarts.init(caseChartRef.value)
caseChart.on('click', (params: any) => {
const t = Number(params?.data?.[0])
if (Number.isFinite(t)) {
void loadFieldMap(t)
}
})
}
if (fieldMapRef.value) {
fieldMapChart = echarts.init(fieldMapRef.value)
window.addEventListener('resize', handleResize)
}
await loadData()
})
watch(selectedParameter, loadData)
watch(selectedField, renderFieldMap)
watch(selectedFieldMap, renderFieldMap)
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
chart?.dispose()
caseChart?.dispose()
fieldMapChart?.dispose()
chart = null
caseChart = null
fieldMapChart = null
})
</script>
<template>
<section class="d-flex flex-column gap-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<h1 class="h3 mb-1">Analize graph</h1>
<p class="text-muted mb-0">Omega и PsiMax по выбранному параметру</p>
</div>
<div class="d-flex gap-2 align-items-center">
<select v-model="selectedParameter" class="form-select">
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<button class="btn btn-outline-secondary" type="button" @click="loadData" :disabled="loading">
Обновить
</button>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-4">
<label class="form-label">Фильтр по параметру</label>
<select v-model="filterParameter" class="form-select">
<option value="">Без фильтра</option>
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="col-6 col-lg-2">
<label class="form-label">Min</label>
<input v-model="filterMin" type="number" step="any" class="form-control" />
</div>
<div class="col-6 col-lg-2">
<label class="form-label">Max</label>
<input v-model="filterMax" type="number" step="any" class="form-control" />
</div>
<div class="col-12 col-lg-4 d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary" type="button" @click="resetFilters" :disabled="loading">
Сбросить фильтр
</button>
<button class="btn btn-primary" type="button" @click="loadData" :disabled="loading">
Применить фильтр
</button>
</div>
</div>
</div>
</div>
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="position-relative" style="min-height: 560px;">
<div ref="containerRef" style="height: 560px; width: 100%;"></div>
<div
v-if="loading"
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-white bg-opacity-75"
>
<div class="text-muted">Загрузка...</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="fw-semibold">Выбранный расчетный случай</div>
<div v-if="selectedCaseId" class="text-muted small">ID {{ selectedCaseId }}</div>
</div>
<div class="card-body">
<div v-if="selectedCaseLoading" class="text-center text-muted py-4">Загрузка...</div>
<div v-else-if="selectedCaseError" class="alert alert-danger mb-0" role="alert">
{{ selectedCaseError }}
</div>
<template v-else-if="selectedCase">
<div class="row g-3 mb-3">
<div class="col-12 col-lg-4">
<div class="text-muted small">Название</div>
<div class="fw-semibold">{{ selectedCase.Name }}</div>
</div>
<div class="col-12 col-lg-4">
<div class="text-muted small">Статус</div>
<div class="fw-semibold">{{ selectedCase.Status }}</div>
</div>
<div class="col-12 col-lg-4">
<div class="text-muted small">Params ID</div>
<div class="fw-semibold">{{ selectedCase.ParamsID }}</div>
</div>
</div>
<details class="mb-3">
<summary class="fw-semibold">ControlCase</summary>
<div class="table-responsive mt-2">
<table class="table table-sm align-middle mb-0">
<tbody>
<tr v-for="([key, value]) in Object.entries(caseObject(selectedCase))" :key="key">
<th class="table-light" style="width: 240px">{{ key }}</th>
<td>{{ formatValue(value) }}</td>
</tr>
</tbody>
</table>
</div>
</details>
<div class="table-responsive mb-3">
<div class="fw-semibold mb-2">Params</div>
<table class="table table-sm align-middle mb-0">
<tbody>
<tr v-for="([key, value]) in Object.entries(paramsObject(selectedCase))" :key="key">
<th class="table-light" style="width: 240px">{{ key }}</th>
<td>{{ formatValue(value) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<div class="position-relative" style="min-height: 360px;">
<div ref="caseChartRef" style="height: 360px; width: 100%;"></div>
<div
v-if="!selectedCase && !selectedCaseLoading && !selectedCaseError"
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center text-muted"
>
Нажмите на точку на верхнем графике, чтобы увидеть расчетный случай.
</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
<button
v-for="option in fieldOptions"
:key="option.value"
type="button"
class="btn btn-sm"
:class="selectedField === option.value ? 'btn-primary' : 'btn-outline-primary'"
@click="selectedField = option.value"
:disabled="!selectedFieldMap"
>
{{ option.label }}
</button>
</div>
<div class="position-relative mt-3" style="min-height: 420px;">
<div ref="fieldMapRef" style="height: 420px; width: 100%;"></div>
<div
v-if="selectedFieldMapLoading"
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-white bg-opacity-75"
>
<div class="text-muted">Загрузка карты полей...</div>
</div>
<div
v-else-if="selectedFieldMapError"
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center text-danger"
>
{{ selectedFieldMapError }}
</div>
<div
v-else-if="!selectedFieldMap"
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center text-muted"
>
Нажмите на точку графика `psi_m`, чтобы показать карту полей.
</div>
</div>
</div>
</div>
</section>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { analizeApi, recalculateAnalize } from '@/api.ts'
import { formatApiError } from '@/api_client.ts'
import type { Analize } from '@/models.ts'
const items = ref<Analize[]>([])
const loading = ref(false)
const error = ref('')
const recalculatingId = ref<number | null>(null)
function normalizeAnalize(item: Analize | Record<string, unknown>): Analize {
const record = item as Record<string, unknown>
return {
ID: Number(record.ID ?? record.id ?? 0),
CreatedAt: String(record.CreatedAt ?? record.createdAt ?? ''),
UpdatedAt: String(record.UpdatedAt ?? record.updatedAt ?? ''),
DeletedAt: (record.DeletedAt ?? record.deletedAt ?? null) as Analize['DeletedAt'],
Name: String(record.Name ?? record.name ?? ''),
CaseID: Number(record.CaseID ?? record.case_id ?? 0),
CaseName: String(record.CaseName ?? record.case_name ?? ''),
PsiMax: Number(record.PsiMax ?? record.psi_max ?? 0),
PsiLMax: Number(record.PsiLMax ?? record.psi_l_max ?? 0),
Omega: Number(record.Omega ?? record.omega ?? 0),
}
}
function formatDate(value: string | undefined) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
async function loadItems() {
loading.value = true
error.value = ''
try {
const response = await analizeApi.listAll()
items.value = response.map((item) => normalizeAnalize(item))
} catch (err) {
error.value = formatApiError(err, 'Не удалось загрузить analize.')
} finally {
loading.value = false
}
}
async function recalculate(item: Analize) {
recalculatingId.value = item.ID
error.value = ''
try {
const updated = await recalculateAnalize(item.ID)
const normalized = normalizeAnalize(updated)
items.value = items.value.map((current) => (current.ID === normalized.ID ? normalized : current))
} catch (err) {
error.value = formatApiError(err, 'Не удалось пересчитать analize.')
} finally {
recalculatingId.value = null
}
}
onMounted(loadItems)
</script>
<template>
<section class="d-flex flex-column gap-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<h1 class="h3 mb-1">Analize</h1>
<p class="text-muted mb-0">Результаты обработки расчетных случаев</p>
</div>
<button class="btn btn-outline-secondary" type="button" @click="loadItems" :disabled="loading">
Обновить
</button>
<RouterLink class="btn btn-outline-primary" to="/analize/chart">График</RouterLink>
</div>
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<div v-if="loading" class="p-4 text-center text-muted">Загрузка...</div>
<div v-else class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Название</th>
<th>Case</th>
<th>PsiMax</th>
<th>PsiLMax</th>
<th>Omega</th>
<th>Создан</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
<tr v-if="items.length === 0">
<td colspan="8" class="text-center text-muted py-4">Записи не найдены</td>
</tr>
<tr v-for="item in items" :key="item.ID">
<td class="fw-semibold">{{ item.ID }}</td>
<td>{{ item.Name || '—' }}</td>
<td>
<div class="d-flex flex-column">
<RouterLink
class="fw-medium text-decoration-none"
:to="{ name: 'control-case-detail', params: { id: item.CaseID } }"
>
{{ item.CaseName || '—' }}
</RouterLink>
<small class="text-muted">#{{ item.CaseID }}</small>
</div>
</td>
<td>{{ item.PsiMax }}</td>
<td>{{ item.PsiLMax }}</td>
<td>{{ item.Omega }}</td>
<td>{{ formatDate(item.CreatedAt) }}</td>
<td class="text-end">
<button
class="btn btn-sm btn-outline-primary"
type="button"
@click="recalculate(item)"
:disabled="recalculatingId === item.ID"
>
{{ recalculatingId === item.ID ? 'Пересчет...' : 'Пересчитать' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import * as echarts from 'echarts'
import { getControlCaseChartData, type CsvChartResponse } from '@/api.ts'
import { formatApiError } from '@/api_client.ts'
const route = useRoute()
const containerRef = ref<HTMLDivElement | null>(null)
const loading = ref(false)
const error = ref('')
const data = ref<CsvChartResponse | null>(null)
let chart: echarts.ECharts | null = null
const id = computed(() => Number(route.params.id))
function buildOption(payload: CsvChartResponse): echarts.EChartsOption {
const [xKey, ...seriesKeys] = payload.columns
const rows = payload.rows
const xValues = rows.map((row) => row[0] ?? '')
const series = seriesKeys.map((name, index) => ({
name,
type: 'line' as const,
showSymbol: false,
smooth: true,
data: rows.map((row) => row[index + 1] ?? null),
}))
return {
tooltip: { trigger: 'axis' },
legend: { top: 0 },
grid: { left: 48, right: 24, top: 48, bottom: 48, containLabel: true },
xAxis: {
type: 'category',
name: xKey ?? 'x',
data: xValues,
},
yAxis: { type: 'value' },
series,
}
}
function renderChart() {
if (!chart || !data.value) return
chart.setOption(buildOption(data.value), true)
}
async function loadData() {
if (!Number.isFinite(id.value)) {
error.value = 'Некорректный ID'
data.value = null
return
}
loading.value = true
error.value = ''
try {
data.value = await getControlCaseChartData(id.value)
renderChart()
} catch (err) {
error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
data.value = null
} finally {
loading.value = false
}
}
function handleResize() {
chart?.resize()
}
onMounted(async () => {
if (containerRef.value) {
chart = echarts.init(containerRef.value)
window.addEventListener('resize', handleResize)
}
await loadData()
})
watch(id, loadData)
watch(data, renderChart)
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
chart?.dispose()
chart = null
})
</script>
<template>
<section class="d-flex flex-column gap-3">
<div class="d-flex align-items-center justify-content-between gap-2 flex-wrap">
<div>
<RouterLink class="link-secondary text-decoration-none" :to="{ name: 'control-case-detail', params: { id } }">
Назад к кейсу
</RouterLink>
<h1 class="h3 mt-2 mb-0">График control case {{ id }}</h1>
</div>
<button class="btn btn-outline-secondary" type="button" @click="loadData" :disabled="loading">
Обновить
</button>
</div>
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="position-relative" style="min-height: 560px;">
<div ref="containerRef" style="height: 560px; width: 100%;"></div>
<div
v-if="loading"
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-white bg-opacity-75"
>
<div class="text-muted">Загрузка...</div>
</div>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { controlCaseApi, recalculateControlCaseAnalysis } from '@/api.ts'
import { formatApiError } from '@/api_client.ts'
import type { ControlCase } from '@/models.ts'
const route = useRoute()
const item = ref<ControlCase | null>(null)
const loading = ref(false)
const recalculating = ref(false)
const error = ref('')
const statusMessage = ref('')
const id = computed(() => Number(route.params.id))
function formatValue(value: unknown) {
if (value == null || value === '') return '—'
if (typeof value === 'object') {
if ('Time' in (value as Record<string, unknown>) && 'Valid' in (value as Record<string, unknown>)) {
const deletedAt = value as { Time?: string; Valid?: boolean }
return deletedAt.Valid ? (deletedAt.Time ?? '—') : '—'
}
return JSON.stringify(value)
}
return String(value)
}
function formatDate(value: string | undefined) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
async function loadItem() {
if (!Number.isFinite(id.value)) {
error.value = 'Некорректный ID'
item.value = null
return
}
loading.value = true
error.value = ''
try {
item.value = await controlCaseApi.retrieve(id.value)
} catch (err) {
error.value = formatApiError(err, 'Не удалось загрузить control_case.')
item.value = null
} finally {
loading.value = false
}
}
async function recalculateAnalysis() {
if (!Number.isFinite(id.value)) return
recalculating.value = true
error.value = ''
statusMessage.value = ''
try {
await recalculateControlCaseAnalysis(id.value)
statusMessage.value = 'Анализ пересчитан.'
} catch (err) {
error.value = formatApiError(err, 'Не удалось пересчитать анализ.')
} finally {
recalculating.value = false
}
}
onMounted(loadItem)
watch(id, loadItem)
</script>
<template>
<section class="d-flex flex-column gap-3">
<div class="d-flex align-items-center justify-content-between gap-2 flex-wrap">
<div>
<RouterLink class="link-secondary text-decoration-none" to="/control-cases">
Назад к списку
</RouterLink>
<h1 class="h3 mt-2 mb-0">Control case {{ id }}</h1>
</div>
<RouterLink
v-if="item"
class="btn btn-outline-primary"
:to="{ name: 'control-case-chart', params: { id } }"
>
График
</RouterLink>
<button
v-if="item"
class="btn btn-outline-success"
type="button"
@click="recalculateAnalysis"
:disabled="recalculating"
>
{{ recalculating ? 'Пересчет...' : 'Пересчитать анализ' }}
</button>
</div>
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
<div v-if="statusMessage" class="alert alert-success mb-0" role="alert">{{ statusMessage }}</div>
<div v-if="loading" class="card shadow-sm">
<div class="card-body text-center text-muted py-5">Загрузка...</div>
</div>
<template v-else-if="item">
<div class="card shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<div class="text-muted small">Название</div>
<div class="fw-semibold">{{ item.Name || '—' }}</div>
</div>
<span class="badge text-bg-secondary fs-6">{{ item.Status || '—' }}</span>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">ID</dt>
<dd class="col-sm-9">{{ item.ID }}</dd>
<dt class="col-sm-3">Params ID</dt>
<dd class="col-sm-9">{{ item.ParamsID }}</dd>
<dt class="col-sm-3">Создан</dt>
<dd class="col-sm-9">{{ formatDate(item.CreatedAt) }}</dd>
<dt class="col-sm-3">Обновлён</dt>
<dd class="col-sm-9">{{ formatDate(item.UpdatedAt) }}</dd>
<dt class="col-sm-3">Удалён</dt>
<dd class="col-sm-9">{{ formatValue(item.DeletedAt) }}</dd>
</dl>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold">Params</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<tbody>
<tr v-for="(value, key) in item.Params" :key="key">
<th class="table-light" style="width: 240px">{{ key }}</th>
<td>{{ formatValue(value) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<div v-else class="alert alert-warning mb-0" role="alert">Запись не найдена</div>
</section>
</template>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { controlCaseApi } from '@/api.ts'
import { formatApiError, isPaginatedResponse } from '@/api_client.ts'
import type { ControlCase } from '@/models.ts'
const items = ref<ControlCase[]>([])
const loading = ref(false)
const error = ref('')
const page = ref(1)
const totalCount = ref(0)
const serverPageSize = ref(0)
const total = computed(() => totalCount.value)
const totalPages = computed(() => {
const size = serverPageSize.value || items.value.length || 1
return Math.max(1, Math.ceil(totalCount.value / size))
})
const pageStart = computed(() => {
const size = serverPageSize.value || items.value.length
return totalCount.value === 0 || size === 0 ? 0 : (page.value - 1) * size + 1
})
const pageEnd = computed(() => {
const size = serverPageSize.value || items.value.length
return size === 0 ? 0 : Math.min(page.value * size, totalCount.value)
})
async function goToPage(nextPage: number) {
page.value = Math.min(Math.max(1, nextPage), totalPages.value)
await loadItems()
}
function formatDate(value: string | undefined) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
async function loadItems() {
loading.value = true
error.value = ''
try {
const response = await controlCaseApi.list({ page: page.value })
if (isPaginatedResponse(response)) {
items.value = response.results
totalCount.value = response.count
if (serverPageSize.value === 0 && response.results.length > 0) {
serverPageSize.value = response.results.length
}
} else {
items.value = response
totalCount.value = response.length
if (serverPageSize.value === 0 && response.length > 0) {
serverPageSize.value = response.length
}
}
} catch (err) {
error.value = formatApiError(err, 'Не удалось загрузить control_case.')
} finally {
loading.value = false
}
}
onMounted(loadItems)
</script>
<template>
<section class="d-flex flex-column gap-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<h1 class="h3 mb-1">Control cases</h1>
<p class="text-muted mb-0">Всего записей: {{ total }}</p>
</div>
<button class="btn btn-outline-secondary" type="button" @click="loadItems" :disabled="loading">
Обновить
</button>
</div>
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<div v-if="loading" class="p-4 text-center text-muted">Загрузка...</div>
<div v-else class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Название</th>
<th>Статус</th>
<th>Params ID</th>
<th>Создан</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
<tr v-if="items.length === 0">
<td colspan="6" class="text-center text-muted py-4">Записи не найдены</td>
</tr>
<tr v-for="item in items" :key="item.ID">
<td class="fw-semibold">{{ item.ID }}</td>
<td>{{ item.Name || '—' }}</td>
<td><span class="badge text-bg-secondary">{{ item.Status || '—' }}</span></td>
<td>{{ item.ParamsID }}</td>
<td>{{ formatDate(item.CreatedAt) }}</td>
<td class="text-end">
<RouterLink class="btn btn-sm btn-primary" :to="{ name: 'control-case-detail', params: { id: item.ID } }">
Открыть
</RouterLink>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top">
<div class="text-muted small">
Показано {{ pageStart }}-{{ pageEnd }} из {{ totalCount }}
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
<button class="btn btn-outline-secondary" type="button" @click="goToPage(page - 1)" :disabled="page <= 1">Назад</button>
<button class="btn btn-outline-secondary" type="button" disabled>{{ page }} / {{ totalPages }}</button>
<button class="btn btn-outline-secondary" type="button" @click="goToPage(page + 1)" :disabled="page >= totalPages">Вперёд</button>
</div>
</div>
</div>
</div>
</section>
</template>
+646
View File
@@ -0,0 +1,646 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { paramsApi } from "@/api.ts";
import { formatApiError, isPaginatedResponse } from "@/api_client.ts";
import { launchControlCase } from "@/api.ts";
import type { Params } from "@/models.ts";
const router = useRouter();
const items = ref<Params[]>([]);
const loading = ref(false);
const saving = ref(false);
const launchingId = ref<number | null>(null);
const error = ref("");
const selectedId = ref<number | null>(null);
const page = ref(1);
const totalCount = ref(0);
const serverPageSize = ref(0);
const form = reactive<Params>({
id: 0,
created_at: "",
updated_at: "",
deleted_at: null,
rel: 0,
rel_c: 0,
le: 0,
pr: 0,
pe: 0,
ma: 0,
initial_condition: "",
time: 0,
folder_path: "",
});
const createForm = reactive({
rel: 0,
rel_c: 0,
le: 0,
pr: 0,
pe: 0,
ma: 0,
initial_condition: "",
time: 0,
});
const batchForm = reactive({
fixed: {
rel: 0,
rel_c: 0,
le: 0,
pr: 0,
pe: 0,
ma: 0,
initial_condition: "",
time: 0,
},
variable: "rel",
start: 0,
end: 0,
step: 1,
});
const batchRunning = ref(false);
const batchMessage = ref("");
const selectedItem = computed(
() => items.value.find((item) => item.id === selectedId.value) ?? null,
);
const totalPages = computed(() => {
const size = serverPageSize.value || items.value.length || 1;
return Math.max(1, Math.ceil(totalCount.value / size));
});
const pageStart = computed(() => {
const size = serverPageSize.value || items.value.length;
return totalCount.value === 0 || size === 0 ? 0 : (page.value - 1) * size + 1;
});
const pageEnd = computed(() => {
const size = serverPageSize.value || items.value.length;
return size === 0 ? 0 : Math.min(page.value * size, totalCount.value);
});
const variableOptions = ["rel", "rel_c", "le", "pr", "pe", "ma", "time"];
function toNumber(value: string) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function formatDate(value: string | undefined) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function selectItem(item: Params) {
selectedId.value = item.id;
Object.assign(form, structuredClone(item));
}
async function goToPage(nextPage: number) {
page.value = Math.min(Math.max(1, nextPage), totalPages.value);
await loadItems();
}
function resetCreateForm() {
createForm.rel = 0;
createForm.rel_c = 0;
createForm.le = 0;
createForm.pr = 0;
createForm.pe = 0;
createForm.ma = 0;
createForm.initial_condition = "";
createForm.time = 0;
}
function resetBatchForm() {
batchForm.fixed.rel = 0;
batchForm.fixed.rel_c = 0;
batchForm.fixed.le = 0;
batchForm.fixed.pr = 0;
batchForm.fixed.pe = 0;
batchForm.fixed.ma = 0;
batchForm.fixed.initial_condition = "";
batchForm.fixed.time = 0;
batchForm.variable = "rel";
batchForm.start = 0;
batchForm.end = 0;
batchForm.step = 1;
}
function buildRange(start: number, end: number, step: number) {
if (!Number.isFinite(start) || !Number.isFinite(end) || !Number.isFinite(step) || step === 0) {
return [];
}
const values: number[] = [];
const direction = start <= end ? 1 : -1;
const normalizedStep = Math.abs(step) * direction;
const epsilon = Math.abs(normalizedStep) / 100000;
for (let current = start; direction > 0 ? current <= end + epsilon : current >= end - epsilon; current += normalizedStep) {
values.push(Number(current.toFixed(10)));
}
return values;
}
async function loadItems() {
loading.value = true;
error.value = "";
try {
const response = await paramsApi.list({ page: page.value });
if (isPaginatedResponse(response)) {
items.value = response.results;
totalCount.value = response.count;
if (serverPageSize.value === 0 && response.results.length > 0) {
serverPageSize.value = response.results.length;
}
} else {
items.value = response;
totalCount.value = response.length;
if (serverPageSize.value === 0 && response.length > 0) {
serverPageSize.value = response.length;
}
}
if (!selectedId.value) {
const first = items.value[0];
if (first) selectItem(first);
}
} catch (err) {
error.value = formatApiError(err, "Не удалось загрузить params.");
} finally {
loading.value = false;
}
}
async function saveItem() {
if (!selectedId.value) return;
saving.value = true;
error.value = "";
try {
const updated = await paramsApi.update(selectedId.value, {
...form,
rel: Number(form.rel),
rel_c: Number(form.rel_c),
le: Number(form.le),
pr: Number(form.pr),
pe: Number(form.pe),
time: Number(form.time),
});
await loadItems();
selectItem(updated);
} catch (err) {
error.value = formatApiError(err, "Не удалось сохранить params.");
} finally {
saving.value = false;
}
}
async function createItem() {
saving.value = true;
error.value = "";
try {
const created = await paramsApi.create({
rel: Number(createForm.rel),
rel_c: Number(createForm.rel_c),
le: Number(createForm.le),
pr: Number(createForm.pr),
pe: Number(createForm.pe),
ma: Number(createForm.ma),
initial_condition: createForm.initial_condition,
time: Number(createForm.time),
});
selectItem(created);
await loadItems();
resetCreateForm();
} catch (err) {
error.value = formatApiError(err, "Не удалось создать params.");
} finally {
saving.value = false;
}
}
async function createBatch() {
batchRunning.value = true;
batchMessage.value = "";
error.value = "";
try {
const values = buildRange(batchForm.start, batchForm.end, batchForm.step);
if (values.length === 0) {
throw new Error("Некорректный диапазон или шаг.");
}
for (const value of values) {
const payload = {
rel: Number(batchForm.fixed.rel),
rel_c: Number(batchForm.fixed.rel_c),
le: Number(batchForm.fixed.le),
pr: Number(batchForm.fixed.pr),
pe: Number(batchForm.fixed.pe),
ma: Number(batchForm.fixed.ma),
initial_condition: batchForm.fixed.initial_condition,
time: Number(batchForm.fixed.time),
} as Record<string, string | number>;
payload[batchForm.variable] = value;
const created = await paramsApi.create(payload as never);
await launchControlCase({
params_id: created.id,
name: `${batchForm.variable}-${value}`,
});
}
batchMessage.value = `Создано ${values.length} расчетных случаев.`;
await loadItems();
} catch (err) {
error.value = formatApiError(err, "Не удалось создать пакет расчетов.");
} finally {
batchRunning.value = false;
}
}
async function launchParams(item: Params) {
launchingId.value = item.id;
error.value = "";
try {
const controlCase = await launchControlCase({
params_id: item.id,
name: `case-${item.id}`,
});
await router.push({ name: "control-case-detail", params: { id: controlCase.ID } });
} catch (err) {
error.value = formatApiError(err, "Не удалось запустить расчет.");
} finally {
launchingId.value = null;
}
}
onMounted(loadItems);
</script>
<template>
<section class="d-flex flex-column gap-3">
<div
class="d-flex flex-wrap justify-content-between align-items-center gap-2"
>
<div>
<h1 class="h3 mb-1">Params</h1>
<p class="text-muted mb-0">Таблица параметров с редактированием</p>
</div>
<button
class="btn btn-outline-secondary"
type="button"
@click="loadItems"
:disabled="loading"
>
Обновить
</button>
</div>
<div v-if="error" class="alert alert-danger mb-0" role="alert">
{{ error }}
</div>
<div v-if="batchMessage" class="alert alert-success mb-0" role="alert">
{{ batchMessage }}
</div>
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold">Быстрый расчет</div>
<div class="card-body">
<form class="row g-3" @submit.prevent="createBatch">
<div class="col-12 col-lg-4">
<label class="form-label">Переменный параметр</label>
<select v-model="batchForm.variable" class="form-select">
<option v-for="item in variableOptions" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<div class="col-4 col-lg-2">
<label class="form-label">Start</label>
<input v-model.number="batchForm.start" type="number" step="any" class="form-control" />
</div>
<div class="col-4 col-lg-2">
<label class="form-label">End</label>
<input v-model.number="batchForm.end" type="number" step="any" class="form-control" />
</div>
<div class="col-4 col-lg-2">
<label class="form-label">Step</label>
<input v-model.number="batchForm.step" type="number" step="any" class="form-control" />
</div>
<div class="col-12">
<div class="text-muted small mb-2">Фиксированные параметры</div>
</div>
<div class="col-6 col-lg-2">
<label class="form-label">rel</label>
<input v-model.number="batchForm.fixed.rel" type="number" step="any" class="form-control" />
</div>
<div class="col-6 col-lg-2">
<label class="form-label">rel_c</label>
<input v-model.number="batchForm.fixed.rel_c" type="number" step="any" class="form-control" />
</div>
<div class="col-6 col-lg-2">
<label class="form-label">le</label>
<input v-model.number="batchForm.fixed.le" type="number" step="any" class="form-control" />
</div>
<div class="col-6 col-lg-2">
<label class="form-label">pr</label>
<input v-model.number="batchForm.fixed.pr" type="number" step="any" class="form-control" />
</div>
<div class="col-6 col-lg-2">
<label class="form-label">pe</label>
<input v-model.number="batchForm.fixed.pe" type="number" step="any" class="form-control" />
</div>
<div class="col-6 col-lg-2">
<label class="form-label">ma</label>
<input v-model.number="batchForm.fixed.ma" type="number" step="any" class="form-control" />
</div>
<div class="col-12 col-lg-4">
<label class="form-label">initial_condition</label>
<input v-model="batchForm.fixed.initial_condition" type="text" class="form-control" />
</div>
<div class="col-12 col-lg-4">
<label class="form-label">time</label>
<input v-model.number="batchForm.fixed.time" type="number" step="any" class="form-control" />
</div>
<div class="col-12 d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary" type="button" @click="resetBatchForm" :disabled="batchRunning">
Сбросить
</button>
<button class="btn btn-primary" type="submit" :disabled="batchRunning">
{{ batchRunning ? "Создание..." : "Создать серию" }}
</button>
</div>
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<div v-if="loading" class="p-4 text-center text-muted">Загрузка...</div>
<div v-else class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>id</th>
<th>rel</th>
<th>rel_c</th>
<th>le</th>
<th>pr</th>
<th>pe</th>
<th>ma</th>
<th>Initial</th>
<th>time</th>
<th>folder_path</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
<tr class="table-success">
<td class="fw-semibold text-success">+</td>
<td>
<input
v-model.number="createForm.rel"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Rel"
/>
</td>
<td>
<input
v-model.number="createForm.rel_c"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="RelC"
/>
</td>
<td>
<input
v-model.number="createForm.le"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Le"
/>
</td>
<td>
<input
v-model.number="createForm.pr"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Pr"
/>
</td>
<td>
<input
v-model.number="createForm.pe"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Pe"
/>
</td>
<td>
<input
v-model.number="createForm.ma"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Ma"
/>
</td>
<td>
<input
v-model="createForm.initial_condition"
type="text"
class="form-control form-control-sm"
placeholder="Initial"
/>
</td>
<td>
<input
v-model.number="createForm.time"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Time"
/>
</td>
<td class="text-muted small">new</td>
<td class="text-end">
<button
class="btn btn-sm btn-success"
type="button"
@click="createItem"
:disabled="saving"
>
{{ saving ? "Сохранение..." : "Добавить" }}
</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="11" class="text-center text-muted py-4">
Записи не найдены
</td>
</tr>
<tr
v-for="item in items"
:key="item.id"
:class="{ 'table-primary': item.id === selectedId }"
>
<td class="fw-semibold">{{ item.id }}</td>
<td>{{ item.rel }}</td>
<td>{{ item.rel_c }}</td>
<td>{{ item.le }}</td>
<td>{{ item.pr }}</td>
<td>{{ item.pe }}</td>
<td>{{ item.ma }}</td>
<td class="text-truncate" style="max-width: 180px">
{{ item.initial_condition || "—" }}
</td>
<td>{{ item.time }}</td>
<td class="text-truncate" style="max-width: 220px">
{{ item.folder_path || "—" }}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<button
class="btn btn-outline-primary"
type="button"
@click="selectItem(item)"
>
Редактировать
</button>
<button
class="btn btn-success"
type="button"
@click="launchParams(item)"
:disabled="launchingId === item.id"
>
{{ launchingId === item.id ? 'Запуск...' : 'Запустить' }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top">
<div class="text-muted small">
Показано {{ pageStart }}-{{ pageEnd }} из {{ totalCount }}
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
<button class="btn btn-outline-secondary" type="button" @click="goToPage(page - 1)" :disabled="page <= 1">
Назад
</button>
<button class="btn btn-outline-secondary" type="button" disabled>
{{ page }} / {{ totalPages }}
</button>
<button class="btn btn-outline-secondary" type="button" @click="goToPage(page + 1)" :disabled="page >= totalPages">
Вперёд
</button>
</div>
</div>
</div>
</div>
<div v-if="selectedItem" class="card shadow-sm">
<div
class="card-header bg-white d-flex justify-content-between align-items-center flex-wrap gap-2"
>
<div>
<div class="text-muted small">Редактирование</div>
<div class="fw-semibold">Params #{{ selectedItem.id }}</div>
</div>
<div class="text-muted small">
Created: {{ formatDate(selectedItem.created_at) }}
</div>
</div>
<div class="card-body">
<form class="row g-3" @submit.prevent="saveItem">
<div class="col-md-2">
<label class="form-label">rel</label>
<input v-model="form.rel" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">rel_c</label>
<input v-model="form.rel_c" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">le</label>
<input v-model="form.le" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">pr</label>
<input v-model="form.pr" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">pe</label>
<input
v-model="form.pe"
type="number"
step="any"
class="form-control"
/>
</div>
<div class="col-md-2">
<label class="form-label">ma</label>
<input
v-model="form.ma"
type="number"
step="any"
class="form-control"
/>
</div>
<div class="col-md-2">
<label class="form-label">time</label>
<input
v-model="form.time"
type="number"
step="any"
class="form-control"
/>
</div>
<div class="col-12">
<label class="form-label">initial_condition</label>
<input
v-model="form.initial_condition"
type="text"
class="form-control"
/>
</div>
<div class="col-12">
<label class="form-label">folder_path</label>
<input v-model="form.folder_path" type="text" class="form-control" />
</div>
<div class="col-12 d-flex justify-content-end gap-2">
<button class="btn btn-primary" type="submit" :disabled="saving">
{{ saving ? "Сохранение..." : "Сохранить" }}
</button>
</div>
</form>
</div>
</div>
</section>
</template>