fix
This commit is contained in:
Generated
+11
-1
@@ -13,7 +13,8 @@
|
|||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "beta",
|
"vue": "beta",
|
||||||
"vue-router": "^5.1.0"
|
"vue-router": "^5.1.0",
|
||||||
|
"vue-toastification": "^2.0.0-rc.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
@@ -3542,6 +3543,15 @@
|
|||||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-toastification": {
|
||||||
|
"version": "2.0.0-rc.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-toastification/-/vue-toastification-2.0.0-rc.5.tgz",
|
||||||
|
"integrity": "sha512-q73e5jy6gucEO/U+P48hqX+/qyXDozAGmaGgLFm5tXX4wJBcVsnGp4e/iJqlm9xzHETYOilUuwOUje2Qg1JdwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-tsc": {
|
"node_modules/vue-tsc": {
|
||||||
"version": "3.3.7",
|
"version": "3.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.7.tgz",
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "beta",
|
"vue": "beta",
|
||||||
"vue-router": "^5.1.0"
|
"vue-router": "^5.1.0",
|
||||||
|
"vue-toastification": "^2.0.0-rc.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import { RouterLink, RouterView } from 'vue-router'
|
|||||||
<RouterLink class="nav-link" active-class="active" to="/analize/chart">
|
<RouterLink class="nav-link" active-class="active" to="/analize/chart">
|
||||||
Analize graph
|
Analize graph
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink class="nav-link" active-class="active" to="/sql">
|
||||||
|
SQL
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ export const controlCaseApi = {
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async restart(id: number) {
|
||||||
|
const response = await apiClient.post<ControlCase>(
|
||||||
|
`/control_case/${id}/restart`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AnalizeSeriesPoint {
|
export interface AnalizeSeriesPoint {
|
||||||
@@ -135,3 +142,24 @@ export const analizeApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface SQLExecutePayload {
|
||||||
|
sql: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SQLExecuteResponse {
|
||||||
|
type: "select" | "exec";
|
||||||
|
columns?: string[];
|
||||||
|
rows?: unknown[][];
|
||||||
|
rows_affected?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sqlApi = {
|
||||||
|
async execute(payload: SQLExecutePayload) {
|
||||||
|
const response = await apiClient.post<SQLExecuteResponse>(
|
||||||
|
"/sql/execute",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
|
type OptionItem = { value: string; label: string }
|
||||||
|
type AnalizePoint = { case_id: number; case_name: string; x: number; omega: number; psi_max: number }
|
||||||
|
type GroupedSeries = { group_value: number; group_label: string; points: AnalizePoint[] }
|
||||||
|
type ChartPoint = {
|
||||||
|
value: [number, number]
|
||||||
|
case_id: number
|
||||||
|
case_name: string
|
||||||
|
group_label?: string
|
||||||
|
x: number
|
||||||
|
omega: number | null
|
||||||
|
psi_max: number | null
|
||||||
|
psi_max2: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartSettingsStorageKey = 'analize-chart-settings'
|
||||||
|
const parameterOptions = [
|
||||||
|
{ value: 'Rel', label: 'Rel' },
|
||||||
|
{ value: 'RelC', label: 'RelC' },
|
||||||
|
{ value: 'Le', label: 'Le' },
|
||||||
|
{ value: 'Sc', label: 'Sc' },
|
||||||
|
{ value: 'Pe', label: 'Pe' },
|
||||||
|
{ value: 'Ma', label: 'Ma' },
|
||||||
|
{ value: 'Time', label: 'Time' },
|
||||||
|
] as const satisfies readonly OptionItem[]
|
||||||
|
type ParameterValue = (typeof parameterOptions)[number]['value']
|
||||||
|
type GraphSettings = {
|
||||||
|
selectedParameter: ParameterValue
|
||||||
|
groupParameter: '' | ParameterValue
|
||||||
|
showTrend: boolean
|
||||||
|
filterParameter: '' | ParameterValue
|
||||||
|
filterMin: string
|
||||||
|
filterMax: string
|
||||||
|
}
|
||||||
|
const parameterValues = new Set<string>(parameterOptions.map((option) => option.value))
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedParameter: string
|
||||||
|
groupParameter: string
|
||||||
|
showTrend: boolean
|
||||||
|
filterParameter: string
|
||||||
|
filterMin: string
|
||||||
|
filterMax: string
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
chartTitle: string
|
||||||
|
seriesData: AnalizePoint[]
|
||||||
|
groupedSeriesData: GroupedSeries[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selectedParameter': [value: string]
|
||||||
|
'update:groupParameter': [value: string]
|
||||||
|
'update:showTrend': [value: boolean]
|
||||||
|
'update:filterParameter': [value: string]
|
||||||
|
'update:filterMin': [value: string]
|
||||||
|
'update:filterMax': [value: string]
|
||||||
|
refresh: []
|
||||||
|
resetFilters: []
|
||||||
|
selectCase: [caseId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
let chart: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
const selectedParameterModel = computed({
|
||||||
|
get: () => props.selectedParameter,
|
||||||
|
set: (value) => emit('update:selectedParameter', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupParameterModel = computed({
|
||||||
|
get: () => props.groupParameter,
|
||||||
|
set: (value) => emit('update:groupParameter', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const showTrendModel = computed({
|
||||||
|
get: () => props.showTrend,
|
||||||
|
set: (value) => emit('update:showTrend', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterParameterModel = computed({
|
||||||
|
get: () => props.filterParameter,
|
||||||
|
set: (value) => emit('update:filterParameter', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterMinModel = computed({
|
||||||
|
get: () => props.filterMin,
|
||||||
|
set: (value) => emit('update:filterMin', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterMaxModel = computed({
|
||||||
|
get: () => props.filterMax,
|
||||||
|
set: (value) => emit('update:filterMax', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedLabel = computed(
|
||||||
|
() => parameterOptions.find((item) => item.value === props.selectedParameter)?.label ?? props.selectedParameter,
|
||||||
|
)
|
||||||
|
|
||||||
|
const groupLabel = computed(
|
||||||
|
() => parameterOptions.find((item) => item.value === props.groupParameter)?.label ?? props.groupParameter,
|
||||||
|
)
|
||||||
|
|
||||||
|
const groupOptions = computed(() => [
|
||||||
|
{ value: '', label: 'Без группировки' },
|
||||||
|
...parameterOptions.filter((item) => item.value !== props.selectedParameter),
|
||||||
|
])
|
||||||
|
|
||||||
|
function isParameterValue(value: unknown): value is ParameterValue {
|
||||||
|
return typeof value === 'string' && parameterValues.has(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedSettings() {
|
||||||
|
if (typeof window === 'undefined') return {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(window.localStorage.getItem(chartSettingsStorageKey) ?? '{}') as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGraphSettings() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const settings: GraphSettings = {
|
||||||
|
selectedParameter: props.selectedParameter as ParameterValue,
|
||||||
|
groupParameter: props.groupParameter as '' | ParameterValue,
|
||||||
|
showTrend: props.showTrend,
|
||||||
|
filterParameter: props.filterParameter as '' | ParameterValue,
|
||||||
|
filterMin: props.filterMin,
|
||||||
|
filterMax: props.filterMax,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(chartSettingsStorageKey, JSON.stringify({ ...storedSettings(), ...settings }))
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors so chart controls keep working in restricted browsers.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreGraphSettings() {
|
||||||
|
const settings = storedSettings() as Partial<GraphSettings>
|
||||||
|
|
||||||
|
if (isParameterValue(settings.selectedParameter)) {
|
||||||
|
emit('update:selectedParameter', settings.selectedParameter)
|
||||||
|
}
|
||||||
|
if (settings.groupParameter === '' || isParameterValue(settings.groupParameter)) {
|
||||||
|
emit('update:groupParameter', settings.groupParameter === settings.selectedParameter ? '' : settings.groupParameter)
|
||||||
|
}
|
||||||
|
if (typeof settings.showTrend === 'boolean') {
|
||||||
|
emit('update:showTrend', settings.showTrend)
|
||||||
|
}
|
||||||
|
if (settings.filterParameter === '' || isParameterValue(settings.filterParameter)) {
|
||||||
|
emit('update:filterParameter', settings.filterParameter)
|
||||||
|
}
|
||||||
|
if (typeof settings.filterMin === 'string') {
|
||||||
|
emit('update:filterMin', settings.filterMin)
|
||||||
|
}
|
||||||
|
if (typeof settings.filterMax === 'string') {
|
||||||
|
emit('update:filterMax', settings.filterMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function psiMaxSquared(point: AnalizePoint) {
|
||||||
|
return point.psi_max * point.psi_max
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartPoint(point: AnalizePoint, y: number, groupLabel?: string): ChartPoint {
|
||||||
|
return {
|
||||||
|
value: [point.x, y],
|
||||||
|
case_id: point.case_id,
|
||||||
|
case_name: point.case_name,
|
||||||
|
group_label: groupLabel,
|
||||||
|
x: point.x,
|
||||||
|
omega: point.omega,
|
||||||
|
psi_max: point.psi_max,
|
||||||
|
psi_max2: psiMaxSquared(point),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPsiMaxSquaredTrend(points: AnalizePoint[], groupLabel?: string) {
|
||||||
|
const source = points
|
||||||
|
.map((point) => ({ ...point, psi_max2: psiMaxSquared(point) }))
|
||||||
|
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.psi_max2) && point.psi_max2 > 0)
|
||||||
|
|
||||||
|
if (source.length < 2) return null
|
||||||
|
|
||||||
|
const xMean = source.reduce((sum, point) => sum + point.x, 0) / source.length
|
||||||
|
const yMean = source.reduce((sum, point) => sum + point.psi_max2, 0) / source.length
|
||||||
|
const denominator = source.reduce((sum, point) => sum + (point.x - xMean) ** 2, 0)
|
||||||
|
if (denominator === 0) return null
|
||||||
|
|
||||||
|
const slope = source.reduce((sum, point) => sum + (point.x - xMean) * (point.psi_max2 - yMean), 0) / denominator
|
||||||
|
const intercept = yMean - slope * xMean
|
||||||
|
const sorted = [...source].sort((a, b) => a.x - b.x)
|
||||||
|
const first = sorted[0]
|
||||||
|
const last = sorted[sorted.length - 1]
|
||||||
|
if (!first || !last) return null
|
||||||
|
const zeroX = slope === 0 ? first.x : -intercept / slope
|
||||||
|
const startX = Math.min(first.x, last.x, zeroX)
|
||||||
|
const endX = Math.max(first.x, last.x, zeroX)
|
||||||
|
|
||||||
|
return [startX, endX].map((x) => ({
|
||||||
|
value: [x, slope * x + intercept],
|
||||||
|
case_id: 0,
|
||||||
|
case_name: 'Линейный тренд',
|
||||||
|
group_label: groupLabel,
|
||||||
|
x,
|
||||||
|
omega: null,
|
||||||
|
psi_max: null,
|
||||||
|
psi_max2: slope * x + intercept,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOption() {
|
||||||
|
if (props.groupParameter && props.groupedSeriesData.length > 0) {
|
||||||
|
const titleGroup = groupLabel.value || props.groupParameter
|
||||||
|
return {
|
||||||
|
title: { text: `Omega / PsiMax^2 vs ${props.chartTitle || selectedLabel.value} grouped by ${titleGroup}` },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const p = params.data
|
||||||
|
const isOmega = params.seriesName.includes('Omega')
|
||||||
|
const valueLabel = isOmega ? 'Omega' : 'PsiMax^2'
|
||||||
|
const value = isOmega ? p.omega : p.psi_max2
|
||||||
|
return [
|
||||||
|
`<strong>${p.case_name}</strong>`,
|
||||||
|
`${selectedLabel.value}: ${p.x}`,
|
||||||
|
`${titleGroup}: ${p.group_label ?? '—'}`,
|
||||||
|
`${valueLabel}: ${value}`,
|
||||||
|
].join('<br/>')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
top: 0,
|
||||||
|
type: 'scroll',
|
||||||
|
selected: Object.fromEntries(props.groupedSeriesData.map((group) => [`${group.group_label} · Omega`, false])),
|
||||||
|
},
|
||||||
|
grid: { left: 56, right: 32, top: 80, bottom: 56, containLabel: true },
|
||||||
|
dataZoom: [
|
||||||
|
{ type: 'inside', xAxisIndex: 0 },
|
||||||
|
{ type: 'inside', yAxisIndex: [0, 1] },
|
||||||
|
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
||||||
|
],
|
||||||
|
xAxis: { type: 'value', name: selectedLabel.value },
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: 'Omega', position: 'left' },
|
||||||
|
{ type: 'value', name: 'PsiMax^2', position: 'right' },
|
||||||
|
],
|
||||||
|
series: props.groupedSeriesData.flatMap((group) => {
|
||||||
|
const trend = props.showTrend ? buildPsiMaxSquaredTrend(group.points, group.group_label) : null
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: `${group.group_label} · Omega`,
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
connectNulls: true,
|
||||||
|
showSymbol: true,
|
||||||
|
yAxisIndex: 0,
|
||||||
|
encode: { x: 0, y: 1 },
|
||||||
|
data: group.points.map((point) => chartPoint(point, point.omega, group.group_label)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${group.group_label} · PsiMax^2`,
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
connectNulls: true,
|
||||||
|
showSymbol: true,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
encode: { x: 0, y: 1 },
|
||||||
|
data: group.points.map((point) => chartPoint(point, psiMaxSquared(point), group.group_label)),
|
||||||
|
},
|
||||||
|
...(trend
|
||||||
|
? [{
|
||||||
|
name: `${group.group_label} · trend PsiMax^2`,
|
||||||
|
type: 'line',
|
||||||
|
showSymbol: false,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
encode: { x: 0, y: 1 },
|
||||||
|
lineStyle: { type: 'dashed', width: 2 },
|
||||||
|
data: trend,
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trend = props.showTrend ? buildPsiMaxSquaredTrend(props.seriesData) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: { text: `Omega / PsiMax^2 vs ${props.chartTitle || selectedLabel.value}` },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const p = params.data
|
||||||
|
const isOmega = params.seriesName === 'Omega'
|
||||||
|
const valueLabel = isOmega ? 'Omega' : 'PsiMax^2'
|
||||||
|
const value = isOmega ? p.omega : p.psi_max2
|
||||||
|
return [`<strong>${p.case_name}</strong>`, `${selectedLabel.value}: ${p.x}`, `${valueLabel}: ${value}`].join('<br/>')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: { top: 28, selected: { Omega: false } },
|
||||||
|
grid: { left: 56, right: 32, top: 80, bottom: 56, containLabel: true },
|
||||||
|
dataZoom: [
|
||||||
|
{ type: 'inside', xAxisIndex: 0 },
|
||||||
|
{ type: 'inside', yAxisIndex: [0, 1] },
|
||||||
|
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
||||||
|
],
|
||||||
|
xAxis: { type: 'value', name: selectedLabel.value },
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: 'Omega', position: 'left' },
|
||||||
|
{ type: 'value', name: 'PsiMax^2', position: 'right' },
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Omega',
|
||||||
|
type: 'scatter',
|
||||||
|
symbolSize: 10,
|
||||||
|
yAxisIndex: 0,
|
||||||
|
encode: { x: 0, y: 1 },
|
||||||
|
data: props.seriesData.map((point) => chartPoint(point, point.omega)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PsiMax^2',
|
||||||
|
type: 'scatter',
|
||||||
|
symbolSize: 10,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
encode: { x: 0, y: 1 },
|
||||||
|
data: props.seriesData.map((point) => chartPoint(point, psiMaxSquared(point))),
|
||||||
|
},
|
||||||
|
...(trend
|
||||||
|
? [{
|
||||||
|
name: 'trend PsiMax^2',
|
||||||
|
type: 'line',
|
||||||
|
showSymbol: false,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
encode: { x: 0, y: 1 },
|
||||||
|
lineStyle: { type: 'dashed', width: 2 },
|
||||||
|
data: trend,
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
if (!chart) return
|
||||||
|
chart.setOption(buildOption(), true)
|
||||||
|
requestAnimationFrame(() => chart?.resize())
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
chart?.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreGraphSettings()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
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) {
|
||||||
|
emit('selectCase', caseId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
renderChart()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.seriesData, props.groupedSeriesData, props.showTrend, props.chartTitle, props.selectedParameter, props.groupParameter],
|
||||||
|
renderChart,
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => [props.selectedParameter, props.groupParameter, props.showTrend, props.filterParameter, props.filterMin, props.filterMax],
|
||||||
|
saveGraphSettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
chart?.dispose()
|
||||||
|
chart = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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="selectedParameterModel" class="form-select">
|
||||||
|
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="groupParameterModel" class="form-select">
|
||||||
|
<option v-for="option in groupOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label class="form-check form-switch mb-0 text-nowrap">
|
||||||
|
<input v-model="showTrendModel" class="form-check-input" type="checkbox" />
|
||||||
|
<span class="form-check-label">Тренд</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @click="emit('refresh')" :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="filterParameterModel" 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="filterMinModel" type="number" step="any" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label">Max</label>
|
||||||
|
<input v-model="filterMaxModel" 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="emit('resetFilters')" :disabled="loading">
|
||||||
|
Сбросить фильтр
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" type="button" @click="emit('refresh')" :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>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import ParamsEditCard from '@/components/params/ParamsEditCard.vue'
|
||||||
|
import type { ControlCase, InitialCondition, Params } from '@/models.ts'
|
||||||
|
import { controlCaseStatusMeta } from '@/statuses.ts'
|
||||||
|
|
||||||
|
type FieldOption = { value: string; label: string }
|
||||||
|
type PsiViewMode = 'time' | 'fft'
|
||||||
|
type CasePoint = { t: number; psi_m: number }
|
||||||
|
type CasePsiLPoint = { t: number; psi_l: number }
|
||||||
|
type PsiSpectrum = {
|
||||||
|
time_step: number
|
||||||
|
points: {
|
||||||
|
psi_m: { frequency: number; amplitude: number }[]
|
||||||
|
psi_l: { frequency: number; amplitude: number }[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type FieldMap = {
|
||||||
|
requested_t: number
|
||||||
|
stage_t: number
|
||||||
|
rows: number
|
||||||
|
cols: number
|
||||||
|
fields: Record<string, number[][]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartSettingsStorageKey = 'analize-chart-settings'
|
||||||
|
const fieldOptions = [
|
||||||
|
{ value: 'psi', label: 'psi' },
|
||||||
|
{ value: 'phi', label: 'phi' },
|
||||||
|
{ value: 'T', label: 'T' },
|
||||||
|
{ value: 'C', label: 'C' },
|
||||||
|
] as const satisfies readonly FieldOption[]
|
||||||
|
type FieldValue = (typeof fieldOptions)[number]['value']
|
||||||
|
type CaseSettings = {
|
||||||
|
selectedField: FieldValue
|
||||||
|
psiViewMode: PsiViewMode
|
||||||
|
}
|
||||||
|
const fieldValues = new Set<string>(fieldOptions.map((option) => option.value))
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedCaseId: number | null
|
||||||
|
selectedCase: ControlCase | null
|
||||||
|
selectedCaseLoading: boolean
|
||||||
|
selectedCaseError: string
|
||||||
|
selectedCasePoints: CasePoint[]
|
||||||
|
selectedCasePsiLPoints: CasePsiLPoint[]
|
||||||
|
selectedCaseSpectrum: PsiSpectrum | null
|
||||||
|
selectedFieldMap: FieldMap | null
|
||||||
|
selectedFieldMapLoading: boolean
|
||||||
|
selectedFieldMapError: string
|
||||||
|
selectedField: string
|
||||||
|
psiViewMode: PsiViewMode
|
||||||
|
initialConditions: InitialCondition[]
|
||||||
|
paramsEditModalOpen: boolean
|
||||||
|
paramsSaving: boolean
|
||||||
|
paramsForm: Params
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selectedField': [value: string]
|
||||||
|
'update:psiViewMode': [value: PsiViewMode]
|
||||||
|
loadFieldMap: [time: number]
|
||||||
|
openParamsEditModal: []
|
||||||
|
closeParamsEditModal: []
|
||||||
|
saveParams: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const caseChartRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const fieldMapRef = ref<HTMLDivElement | null>(null)
|
||||||
|
let caseChart: echarts.ECharts | null = null
|
||||||
|
let fieldMapChart: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
const selectedFieldModel = computed({
|
||||||
|
get: () => props.selectedField,
|
||||||
|
set: (value) => emit('update:selectedField', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const psiViewModeModel = computed({
|
||||||
|
get: () => props.psiViewMode,
|
||||||
|
set: (value) => emit('update:psiViewMode', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedCaseParams = computed(() => {
|
||||||
|
const record = caseObject(props.selectedCase)
|
||||||
|
return (record.params ?? record.Params ?? null) as Params | null
|
||||||
|
})
|
||||||
|
|
||||||
|
function isFieldValue(value: unknown): value is FieldValue {
|
||||||
|
return typeof value === 'string' && fieldValues.has(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPsiViewMode(value: unknown): value is PsiViewMode {
|
||||||
|
return value === 'time' || value === 'fft'
|
||||||
|
}
|
||||||
|
|
||||||
|
function storedSettings() {
|
||||||
|
if (typeof window === 'undefined') return {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(window.localStorage.getItem(chartSettingsStorageKey) ?? '{}') as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCaseSettings() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const settings: CaseSettings = {
|
||||||
|
selectedField: props.selectedField as FieldValue,
|
||||||
|
psiViewMode: props.psiViewMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(chartSettingsStorageKey, JSON.stringify({ ...storedSettings(), ...settings }))
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors so chart controls keep working in restricted browsers.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCaseSettings() {
|
||||||
|
const settings = storedSettings() as Partial<CaseSettings>
|
||||||
|
|
||||||
|
if (isFieldValue(settings.selectedField)) {
|
||||||
|
emit('update:selectedField', settings.selectedField)
|
||||||
|
}
|
||||||
|
if (isPsiViewMode(settings.psiViewMode)) {
|
||||||
|
emit('update:psiViewMode', settings.psiViewMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCaseOption() {
|
||||||
|
if (props.psiViewMode === 'fft') {
|
||||||
|
return {
|
||||||
|
title: { text: 'FFT spectrum of psi' },
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { top: 0 },
|
||||||
|
grid: { left: 56, right: 24, top: 48, bottom: 56, containLabel: true },
|
||||||
|
dataZoom: [
|
||||||
|
{ type: 'inside', xAxisIndex: 0 },
|
||||||
|
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
||||||
|
],
|
||||||
|
xAxis: { type: 'value', name: 'f' },
|
||||||
|
yAxis: { type: 'value', name: 'Amplitude' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'psi_m',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
data: props.selectedCaseSpectrum?.points.psi_m.map((point) => [point.frequency, point.amplitude]) ?? [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'psi_l',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
data: props.selectedCaseSpectrum?.points.psi_l.map((point) => [point.frequency, point.amplitude]) ?? [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: { text: 'psi_m / psi_l / time' },
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { top: 0 },
|
||||||
|
grid: { left: 56, right: 24, top: 48, bottom: 48, containLabel: true },
|
||||||
|
dataZoom: [
|
||||||
|
{ type: 'inside', xAxisIndex: 0 },
|
||||||
|
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
||||||
|
],
|
||||||
|
xAxis: { type: 'value', name: 't' },
|
||||||
|
yAxis: { type: 'value', name: 'psi' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'psi_m',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
data: props.selectedCasePoints.map((point) => [point.t, point.psi_m]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'psi_l',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
data: props.selectedCasePsiLPoints.map((point) => [point.t, point.psi_l]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCaseChart() {
|
||||||
|
if (!caseChart) return
|
||||||
|
caseChart.setOption(buildCaseOption(), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldMapOption() {
|
||||||
|
const payload = props.selectedFieldMap
|
||||||
|
if (!payload) return null
|
||||||
|
|
||||||
|
const matrix = payload.fields[props.selectedField] ?? []
|
||||||
|
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: ${props.selectedField}`,
|
||||||
|
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>${props.selectedField}</strong>`, `x: ${x}`, `y: ${y}`, `value: ${value}`].join('<br/>')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { left: 56, right: 32, top: 64, bottom: 48, containLabel: true },
|
||||||
|
dataZoom: [
|
||||||
|
{ type: 'inside', xAxisIndex: 0 },
|
||||||
|
{ type: 'inside', yAxisIndex: 0 },
|
||||||
|
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
||||||
|
],
|
||||||
|
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 || !props.selectedFieldMap) return
|
||||||
|
const option = buildFieldMapOption()
|
||||||
|
if (option) {
|
||||||
|
fieldMapChart.setOption(option, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFieldMap() {
|
||||||
|
if (!props.selectedFieldMap) {
|
||||||
|
fieldMapChart?.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
caseChart?.resize()
|
||||||
|
fieldMapChart?.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreCaseSettings()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (caseChartRef.value) {
|
||||||
|
caseChart = echarts.init(caseChartRef.value)
|
||||||
|
caseChart.on('click', (params: any) => {
|
||||||
|
const t = Number(params?.data?.[0])
|
||||||
|
if (Number.isFinite(t)) {
|
||||||
|
emit('loadFieldMap', t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
renderCaseChart()
|
||||||
|
}
|
||||||
|
if (fieldMapRef.value) {
|
||||||
|
fieldMapChart = echarts.init(fieldMapRef.value)
|
||||||
|
renderFieldMap()
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.selectedCasePoints, props.selectedCasePsiLPoints, props.selectedCaseSpectrum, props.psiViewMode],
|
||||||
|
renderCaseChart,
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
watch(() => [props.selectedField, props.selectedFieldMap], renderFieldMap, { deep: true })
|
||||||
|
watch(() => props.selectedFieldMap, clearFieldMap)
|
||||||
|
watch(() => [props.selectedField, props.psiViewMode], saveCaseSettings)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
caseChart?.dispose()
|
||||||
|
fieldMapChart?.dispose()
|
||||||
|
caseChart = null
|
||||||
|
fieldMapChart = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
<span class="badge" :class="controlCaseStatusMeta(selectedCase.status).className">
|
||||||
|
{{ controlCaseStatusMeta(selectedCase.status).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="text-muted small">Params ID</div>
|
||||||
|
<div class="fw-semibold">{{ selectedCase.params_id }}</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="d-flex justify-content-between align-items-center gap-2 mb-2">
|
||||||
|
<div class="fw-semibold">Params</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="!selectedCaseParams"
|
||||||
|
@click="emit('openParamsEditModal')"
|
||||||
|
>
|
||||||
|
Редактировать params
|
||||||
|
</button>
|
||||||
|
</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="d-flex flex-wrap gap-2 align-items-center mb-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="psiViewModeModel === 'time' ? 'btn-primary' : 'btn-outline-primary'"
|
||||||
|
type="button"
|
||||||
|
@click="psiViewModeModel = 'time'"
|
||||||
|
:disabled="!selectedCase"
|
||||||
|
>
|
||||||
|
Time
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="psiViewModeModel === 'fft' ? 'btn-primary' : 'btn-outline-primary'"
|
||||||
|
type="button"
|
||||||
|
@click="psiViewModeModel = 'fft'"
|
||||||
|
:disabled="!selectedCaseSpectrum"
|
||||||
|
>
|
||||||
|
FFT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Нажмите на точку на верхнем графике, чтобы увидеть `psi`.
|
||||||
|
</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="selectedFieldModel === option.value ? 'btn-primary' : 'btn-outline-primary'"
|
||||||
|
@click="selectedFieldModel = 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>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="paramsEditModalOpen && selectedCaseParams"
|
||||||
|
class="modal fade show d-block"
|
||||||
|
tabindex="-1"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.self="emit('closeParamsEditModal')"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small">Редактирование параметров расчета</div>
|
||||||
|
<h2 class="modal-title h5 mb-0">Params #{{ selectedCaseParams.id }}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
:disabled="paramsSaving"
|
||||||
|
@click="emit('closeParamsEditModal')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<ParamsEditCard
|
||||||
|
:item="selectedCaseParams"
|
||||||
|
:form="paramsForm"
|
||||||
|
:initial-conditions="initialConditions"
|
||||||
|
:saving="paramsSaving"
|
||||||
|
@submit="emit('saveParams')"
|
||||||
|
@cancel="emit('closeParamsEditModal')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="paramsEditModalOpen && selectedCaseParams" class="modal-backdrop fade show"></div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -10,21 +10,15 @@ defineProps<{
|
|||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
submit: []
|
submit: []
|
||||||
|
cancel: []
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<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">Params #{{ item.id }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-muted small">Created: {{ new Date(item.created_at).toLocaleString() }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<form class="row g-3" @submit.prevent="$emit('submit')">
|
<form class="row g-3" @submit.prevent="$emit('submit')">
|
||||||
|
<div class="col-12 text-muted small">
|
||||||
|
Created: {{ new Date(item.created_at).toLocaleString() }}
|
||||||
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">rel</label>
|
<label class="form-label">rel</label>
|
||||||
<input v-model="form.rel" type="number" step="any" class="form-control" />
|
<input v-model="form.rel" type="number" step="any" class="form-control" />
|
||||||
@@ -49,7 +43,11 @@ defineEmits<{
|
|||||||
<label class="form-label">ma</label>
|
<label class="form-label">ma</label>
|
||||||
<input v-model="form.ma" type="number" step="any" class="form-control" />
|
<input v-model="form.ma" type="number" step="any" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">time</label>
|
||||||
|
<input v-model="form.time" type="number" step="any" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
<label class="form-label">initial_condition</label>
|
<label class="form-label">initial_condition</label>
|
||||||
<select v-model="form.initial_condition_id" class="form-select">
|
<select v-model="form.initial_condition_id" class="form-select">
|
||||||
<option :value="null">Не задано</option>
|
<option :value="null">Не задано</option>
|
||||||
@@ -63,11 +61,12 @@ defineEmits<{
|
|||||||
<input v-model="form.folder_path" type="text" class="form-control" />
|
<input v-model="form.folder_path" type="text" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 d-flex justify-content-end gap-2">
|
<div class="col-12 d-flex justify-content-end gap-2">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" :disabled="saving" @click="$emit('cancel')">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
<button class="btn btn-primary" type="submit" :disabled="saving">
|
<button class="btn btn-primary" type="submit" :disabled="saving">
|
||||||
{{ saving ? 'Сохранение...' : 'Сохранить' }}
|
{{ saving ? 'Сохранение...' : 'Сохранить' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { InitialCondition } from '@/models.ts'
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { controlCaseApi, initialConditionApi, paramsApi } from '@/api.ts'
|
||||||
|
import { formatApiError } from '@/api_client.ts'
|
||||||
|
import { useNotify } from '@/composables/useNotify.ts'
|
||||||
|
import type { InitialCondition, Params } from '@/models.ts'
|
||||||
|
|
||||||
interface BatchForm {
|
interface BatchForm {
|
||||||
fixed: {
|
fixed: {
|
||||||
@@ -18,24 +22,201 @@ interface BatchForm {
|
|||||||
step: number
|
step: number
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
interface StoredBatchInterval {
|
||||||
batchForm: BatchForm
|
variable: string
|
||||||
initialConditions: InitialCondition[]
|
start: number
|
||||||
variableOptions: string[]
|
end: number
|
||||||
running: boolean
|
step: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
created: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const running = ref(false)
|
||||||
submit: []
|
const initialConditions = ref<InitialCondition[]>([])
|
||||||
reset: []
|
const notify = useNotify()
|
||||||
}>()
|
const variableOptions = ['rel', 'rel_c', 'le', 'sc', 'pe', 'ma', 'time']
|
||||||
|
const batchIntervalStorageKey = 'paramsQuickBatch.interval'
|
||||||
|
|
||||||
|
const batchForm = reactive<BatchForm>({
|
||||||
|
fixed: {
|
||||||
|
rel: 0,
|
||||||
|
rel_c: 0,
|
||||||
|
le: 0,
|
||||||
|
sc: 0,
|
||||||
|
pe: 0,
|
||||||
|
ma: 0,
|
||||||
|
initial_condition_id: null,
|
||||||
|
time: 0,
|
||||||
|
},
|
||||||
|
variable: 'rel',
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
step: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetBatchForm() {
|
||||||
|
batchForm.fixed.rel = 0
|
||||||
|
batchForm.fixed.rel_c = 0
|
||||||
|
batchForm.fixed.le = 0
|
||||||
|
batchForm.fixed.sc = 0
|
||||||
|
batchForm.fixed.pe = 0
|
||||||
|
batchForm.fixed.ma = 0
|
||||||
|
batchForm.fixed.initial_condition_id = null
|
||||||
|
batchForm.fixed.time = 0
|
||||||
|
batchForm.variable = 'rel'
|
||||||
|
batchForm.start = 0
|
||||||
|
batchForm.end = 0
|
||||||
|
batchForm.step = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVariableOption(value: string) {
|
||||||
|
return variableOptions.includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreBatchInterval() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(batchIntervalStorageKey)
|
||||||
|
if (!stored) return
|
||||||
|
|
||||||
|
const parsed = JSON.parse(stored) as Partial<StoredBatchInterval>
|
||||||
|
if (typeof parsed.variable === 'string' && isVariableOption(parsed.variable)) {
|
||||||
|
batchForm.variable = parsed.variable
|
||||||
|
}
|
||||||
|
if (typeof parsed.start === 'number' && Number.isFinite(parsed.start)) {
|
||||||
|
batchForm.start = parsed.start
|
||||||
|
}
|
||||||
|
if (typeof parsed.end === 'number' && Number.isFinite(parsed.end)) {
|
||||||
|
batchForm.end = parsed.end
|
||||||
|
}
|
||||||
|
if (typeof parsed.step === 'number' && Number.isFinite(parsed.step)) {
|
||||||
|
batchForm.step = parsed.step
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(batchIntervalStorageKey)
|
||||||
|
} catch {
|
||||||
|
// Ignore storage access errors; the form remains usable without persistence.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBatchInterval() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
batchIntervalStorageKey,
|
||||||
|
JSON.stringify({
|
||||||
|
variable: batchForm.variable,
|
||||||
|
start: Number(batchForm.start),
|
||||||
|
end: Number(batchForm.end),
|
||||||
|
step: Number(batchForm.step),
|
||||||
|
} satisfies StoredBatchInterval),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Ignore storage access errors; the form remains usable without persistence.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInitialConditions() {
|
||||||
|
try {
|
||||||
|
initialConditions.value = await initialConditionApi.listAll()
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(formatApiError(err, 'Не удалось загрузить начальные условия.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillFixedParams(item: Params) {
|
||||||
|
batchForm.fixed.rel = item.rel
|
||||||
|
batchForm.fixed.rel_c = item.rel_c
|
||||||
|
batchForm.fixed.le = item.le
|
||||||
|
batchForm.fixed.sc = item.sc
|
||||||
|
batchForm.fixed.pe = item.pe
|
||||||
|
batchForm.fixed.ma = item.ma
|
||||||
|
batchForm.fixed.initial_condition_id = item.initial_condition_id
|
||||||
|
batchForm.fixed.time = item.time
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefillBatchFormFromLatestParams() {
|
||||||
|
try {
|
||||||
|
const response = await paramsApi.list({ ordering: '-id', per_page: 1 })
|
||||||
|
const latest = Array.isArray(response) ? response[0] : response.results[0]
|
||||||
|
if (latest) fillFixedParams(latest)
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(formatApiError(err, 'Не удалось предзаполнить быстрый расчет.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 createBatch() {
|
||||||
|
running.value = true
|
||||||
|
|
||||||
|
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),
|
||||||
|
sc: Number(batchForm.fixed.sc),
|
||||||
|
pe: Number(batchForm.fixed.pe),
|
||||||
|
ma: Number(batchForm.fixed.ma),
|
||||||
|
initial_condition_id: batchForm.fixed.initial_condition_id,
|
||||||
|
time: Number(batchForm.fixed.time),
|
||||||
|
} as Record<string, string | number | null>
|
||||||
|
|
||||||
|
payload[batchForm.variable] = value
|
||||||
|
|
||||||
|
const created = await paramsApi.create(payload as never)
|
||||||
|
await controlCaseApi.launch({
|
||||||
|
params_id: created.id,
|
||||||
|
name: `${batchForm.variable}-${value}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = `Создано ${values.length} расчетных случаев.`
|
||||||
|
notify.success(message)
|
||||||
|
emit('created')
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(formatApiError(err, 'Не удалось создать пакет расчетов.'))
|
||||||
|
} finally {
|
||||||
|
running.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
restoreBatchInterval()
|
||||||
|
await Promise.all([loadInitialConditions(), prefillBatchFormFromLatestParams()])
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [batchForm.variable, batchForm.start, batchForm.end, batchForm.step],
|
||||||
|
saveBatchInterval,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card shadow-sm">
|
<form @submit.prevent="createBatch">
|
||||||
<div class="card-header bg-white fw-semibold">Быстрый расчет</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form @submit.prevent="$emit('submit')">
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-middle mb-3">
|
<table class="table table-sm align-middle mb-3">
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -112,7 +293,7 @@ defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<div class="d-flex justify-content-end gap-2">
|
||||||
<button class="btn btn-outline-secondary" type="button" @click="$emit('reset')" :disabled="running">
|
<button class="btn btn-outline-secondary" type="button" @click="resetBatchForm" :disabled="running">
|
||||||
Сбросить
|
Сбросить
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" type="submit" :disabled="running">
|
<button class="btn btn-primary" type="submit" :disabled="running">
|
||||||
@@ -120,6 +301,4 @@ defineEmits<{
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -23,13 +23,11 @@ defineProps<{
|
|||||||
page: number
|
page: number
|
||||||
totalPages: number
|
totalPages: number
|
||||||
totalCount: number
|
totalCount: number
|
||||||
pageStart: number
|
|
||||||
pageEnd: number
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
create: []
|
create: []
|
||||||
select: [item: Params]
|
edit: [item: Params]
|
||||||
launch: [item: Params]
|
launch: [item: Params]
|
||||||
goToPage: [page: number]
|
goToPage: [page: number]
|
||||||
}>()
|
}>()
|
||||||
@@ -117,7 +115,7 @@ defineEmits<{
|
|||||||
<td class="text-truncate" style="max-width: 220px">{{ item.folder_path || '—' }}</td>
|
<td class="text-truncate" style="max-width: 220px">{{ item.folder_path || '—' }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
<button class="btn btn-outline-primary" type="button" @click="$emit('select', item)">Редактировать</button>
|
<button class="btn btn-outline-primary" type="button" @click="$emit('edit', item)">Редактировать</button>
|
||||||
<button class="btn btn-success" type="button" @click="$emit('launch', item)" :disabled="launchingId === item.id">
|
<button class="btn btn-success" type="button" @click="$emit('launch', item)" :disabled="launchingId === item.id">
|
||||||
{{ launchingId === item.id ? 'Запуск...' : 'Запустить' }}
|
{{ launchingId === item.id ? 'Запуск...' : 'Запустить' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -128,7 +126,7 @@ defineEmits<{
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top">
|
<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="text-muted small">Всего: {{ totalCount }}</div>
|
||||||
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
|
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
|
||||||
<button class="btn btn-outline-secondary" type="button" @click="$emit('goToPage', page - 1)" :disabled="page <= 1">Назад</button>
|
<button class="btn btn-outline-secondary" type="button" @click="$emit('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" disabled>{{ page }} / {{ totalPages }}</button>
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { computed, ref } from "vue";
|
||||||
|
import {
|
||||||
|
formatApiError,
|
||||||
|
isPaginatedResponse,
|
||||||
|
type BaseEntity,
|
||||||
|
type ListParams,
|
||||||
|
type ModelApi,
|
||||||
|
} from "@/api_client.ts";
|
||||||
|
import { useNotify } from "@/composables/useNotify.ts";
|
||||||
|
|
||||||
|
interface UseModelCollectionOptions<T> {
|
||||||
|
pageSize?: number;
|
||||||
|
loadErrorMessage?: string;
|
||||||
|
saveErrorMessage?: string;
|
||||||
|
deleteErrorMessage?: string;
|
||||||
|
normalize?: (item: T | Record<string, unknown>) => T;
|
||||||
|
notify?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModelCollection<T extends BaseEntity>(
|
||||||
|
api: ModelApi<T>,
|
||||||
|
options: UseModelCollectionOptions<T> = {},
|
||||||
|
) {
|
||||||
|
const items = ref<T[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const page = ref(1);
|
||||||
|
const totalCount = ref(0);
|
||||||
|
const notify = useNotify();
|
||||||
|
const shouldNotify = options.notify ?? true;
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
const size = options.pageSize || items.value.length || 1;
|
||||||
|
return Math.max(1, Math.ceil(totalCount.value / size));
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeItems(values: T[]) {
|
||||||
|
return options.normalize ? values.map((item) => options.normalize?.(item) ?? item) : values;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems(params?: ListParams) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.list({ ...(params ?? {}), page: page.value });
|
||||||
|
if (isPaginatedResponse(response)) {
|
||||||
|
items.value = normalizeItems(response.results);
|
||||||
|
totalCount.value = response.count;
|
||||||
|
} else {
|
||||||
|
items.value = normalizeItems(response);
|
||||||
|
totalCount.value = response.length;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, options.loadErrorMessage);
|
||||||
|
if (shouldNotify) notify.error(error.value);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToPage(nextPage: number, params?: ListParams) {
|
||||||
|
page.value = Math.min(Math.max(1, nextPage), totalPages.value);
|
||||||
|
await loadItems(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createItem(payload: Partial<T>) {
|
||||||
|
saving.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await api.create(payload);
|
||||||
|
await loadItems();
|
||||||
|
if (shouldNotify) notify.success("Запись создана.");
|
||||||
|
return options.normalize ? options.normalize(created) : created;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, options.saveErrorMessage);
|
||||||
|
if (shouldNotify) notify.error(error.value);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateItem(id: number, payload: Partial<T>) {
|
||||||
|
saving.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await api.update(id, payload);
|
||||||
|
await loadItems();
|
||||||
|
if (shouldNotify) notify.success("Запись сохранена.");
|
||||||
|
return options.normalize ? options.normalize(updated) : updated;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, options.saveErrorMessage);
|
||||||
|
if (shouldNotify) notify.error(error.value);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeItem(id: number) {
|
||||||
|
saving.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.remove(id);
|
||||||
|
await loadItems();
|
||||||
|
if (shouldNotify) notify.success("Запись удалена.");
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, options.deleteErrorMessage);
|
||||||
|
if (shouldNotify) notify.error(error.value);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
page,
|
||||||
|
totalCount,
|
||||||
|
totalPages,
|
||||||
|
loadItems,
|
||||||
|
goToPage,
|
||||||
|
createItem,
|
||||||
|
updateItem,
|
||||||
|
removeItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
|
||||||
|
export function useNotify() {
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: (message: string) => toast.error(message),
|
||||||
|
success: (message: string) => toast.success(message),
|
||||||
|
info: (message: string) => toast.info(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||||
|
import Toast from 'vue-toastification'
|
||||||
|
import 'vue-toastification/dist/index.css'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
@@ -9,5 +11,11 @@ const app = createApp(App)
|
|||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(Toast, {
|
||||||
|
position: 'top-right',
|
||||||
|
timeout: 5000,
|
||||||
|
closeOnClick: true,
|
||||||
|
pauseOnHover: true,
|
||||||
|
})
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ const router = createRouter({
|
|||||||
name: 'analize-chart',
|
name: 'analize-chart',
|
||||||
component: () => import('@/views/AnalizeChartView.vue'),
|
component: () => import('@/views/AnalizeChartView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/sql',
|
||||||
|
name: 'sql-executor',
|
||||||
|
component: () => import('@/views/SQLExecutorView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { onMounted, reactive, ref, toRaw, watch } from 'vue'
|
||||||
import * as echarts from 'echarts'
|
import { analizeApi, controlCaseApi, initialConditionApi, paramsApi } from '@/api.ts'
|
||||||
import { analizeApi, controlCaseApi } from '@/api.ts'
|
|
||||||
import { formatApiError } from '@/api_client.ts'
|
import { formatApiError } from '@/api_client.ts'
|
||||||
import type { ControlCase } from '@/models.ts'
|
import AnalizeGraphCard from '@/components/analize/AnalizeGraphCard.vue'
|
||||||
import { controlCaseStatusMeta } from '@/statuses.ts'
|
import SelectedControlCaseCard from '@/components/analize/SelectedControlCaseCard.vue'
|
||||||
|
import { useNotify } from '@/composables/useNotify.ts'
|
||||||
|
import type { ControlCase, InitialCondition, Params } from '@/models.ts'
|
||||||
|
|
||||||
const fieldOptions = [
|
const notify = useNotify()
|
||||||
{ value: 'psi', label: 'psi' },
|
type PsiViewMode = 'time' | 'fft'
|
||||||
{ value: 'phi', label: 'phi' },
|
type AnalizePoint = { case_id: number; case_name: string; x: number; omega: number; psi_max: number }
|
||||||
{ value: 'T', label: 'T' },
|
type GroupedSeries = { group_value: number; group_label: string; points: AnalizePoint[] }
|
||||||
{ value: 'C', label: 'C' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const parameterOptions = [
|
const selectedParameter = ref('Rel')
|
||||||
{ value: 'Rel', label: 'Rel' },
|
|
||||||
{ value: 'RelC', label: 'RelC' },
|
|
||||||
{ value: 'Le', label: 'Le' },
|
|
||||||
{ value: 'Sc', label: 'Sc' },
|
|
||||||
{ value: 'Pe', label: 'Pe' },
|
|
||||||
{ value: 'Ma', label: 'Ma' },
|
|
||||||
{ value: 'Time', label: 'Time' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const selectedParameter = ref<(typeof parameterOptions)[number]['value']>('Rel')
|
|
||||||
const groupParameter = ref('')
|
const groupParameter = ref('')
|
||||||
const showTrend = ref(false)
|
const showTrend = ref(false)
|
||||||
const filterParameter = ref('')
|
const filterParameter = ref('')
|
||||||
const filterMin = ref('')
|
const filterMin = ref('')
|
||||||
const filterMax = ref('')
|
const filterMax = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const paramsSaving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const containerRef = ref<HTMLDivElement | null>(null)
|
|
||||||
const caseChartRef = ref<HTMLDivElement | null>(null)
|
|
||||||
const chartTitle = ref('')
|
const chartTitle = ref('')
|
||||||
const seriesData = ref<{ case_id: number; x: number; omega: number; psi_max: number; case_name: string }[]>([])
|
const seriesData = ref<AnalizePoint[]>([])
|
||||||
const groupedSeriesData = ref<{
|
const groupedSeriesData = ref<GroupedSeries[]>([])
|
||||||
group_value: number
|
|
||||||
group_label: string
|
|
||||||
points: { case_id: number; case_name: string; x: number; omega: number; psi_max: number }[]
|
|
||||||
}[]>([])
|
|
||||||
const selectedCaseId = ref<number | null>(null)
|
const selectedCaseId = ref<number | null>(null)
|
||||||
const selectedCase = ref<ControlCase | null>(null)
|
const selectedCase = ref<ControlCase | null>(null)
|
||||||
const selectedCaseLoading = ref(false)
|
const selectedCaseLoading = ref(false)
|
||||||
@@ -62,385 +46,84 @@ const selectedFieldMap = ref<{
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const selectedFieldMapLoading = ref(false)
|
const selectedFieldMapLoading = ref(false)
|
||||||
const selectedFieldMapError = ref('')
|
const selectedFieldMapError = ref('')
|
||||||
const selectedField = ref<(typeof fieldOptions)[number]['value']>('psi')
|
const selectedField = ref('psi')
|
||||||
const psiViewMode = ref<'time' | 'fft'>('time')
|
const psiViewMode = ref<PsiViewMode>('time')
|
||||||
const fieldMapRef = ref<HTMLDivElement | null>(null)
|
const initialConditions = ref<InitialCondition[]>([])
|
||||||
let chart: echarts.ECharts | null = null
|
const paramsEditModalOpen = ref(false)
|
||||||
let caseChart: echarts.ECharts | null = null
|
const chartControlsReady = ref(false)
|
||||||
let fieldMapChart: echarts.ECharts | null = null
|
const paramsForm = reactive<Params>({
|
||||||
|
id: 0,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
deleted_at: null,
|
||||||
|
rel: 0,
|
||||||
|
rel_c: 0,
|
||||||
|
le: 0,
|
||||||
|
sc: 0,
|
||||||
|
pe: 0,
|
||||||
|
ma: 0,
|
||||||
|
initial_condition_id: null,
|
||||||
|
initial_condition: null,
|
||||||
|
time: 0,
|
||||||
|
folder_path: '',
|
||||||
|
})
|
||||||
|
|
||||||
const selectedLabel = computed(
|
function selectedCaseParams() {
|
||||||
() => parameterOptions.find((item) => item.value === selectedParameter.value)?.label ?? selectedParameter.value,
|
const record = (selectedCase.value ?? {}) as Record<string, unknown>
|
||||||
)
|
return (record.params ?? record.Params ?? null) as Params | null
|
||||||
|
|
||||||
const groupLabel = computed(
|
|
||||||
() => parameterOptions.find((item) => item.value === groupParameter.value)?.label ?? groupParameter.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
const groupOptions = computed(() => [
|
|
||||||
{ value: '', label: 'Без группировки' },
|
|
||||||
...parameterOptions.filter((item) => item.value !== selectedParameter.value),
|
|
||||||
])
|
|
||||||
|
|
||||||
type AnalizePoint = { case_id: number; case_name: string; x: number; omega: number; psi_max: number }
|
|
||||||
type ChartPoint = {
|
|
||||||
value: [number, number]
|
|
||||||
case_id: number
|
|
||||||
case_name: string
|
|
||||||
group_label?: string
|
|
||||||
x: number
|
|
||||||
omega: number | null
|
|
||||||
psi_max: number | null
|
|
||||||
psi_max2: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function psiMaxSquared(point: AnalizePoint) {
|
async function loadInitialConditions() {
|
||||||
return point.psi_max * point.psi_max
|
try {
|
||||||
}
|
initialConditions.value = await initialConditionApi.listAll()
|
||||||
|
} catch (err) {
|
||||||
function chartPoint(point: AnalizePoint, y: number, groupLabel?: string): ChartPoint {
|
notify.error(formatApiError(err, 'Не удалось загрузить начальные условия.'))
|
||||||
return {
|
|
||||||
value: [point.x, y],
|
|
||||||
case_id: point.case_id,
|
|
||||||
case_name: point.case_name,
|
|
||||||
group_label: groupLabel,
|
|
||||||
x: point.x,
|
|
||||||
omega: point.omega,
|
|
||||||
psi_max: point.psi_max,
|
|
||||||
psi_max2: psiMaxSquared(point),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPsiMaxSquaredTrend(points: AnalizePoint[], groupLabel?: string) {
|
function openParamsEditModal() {
|
||||||
const source = points
|
const params = selectedCaseParams()
|
||||||
.map((point) => ({ ...point, psi_max2: psiMaxSquared(point) }))
|
if (!params) return
|
||||||
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.psi_max2) && point.psi_max2 > 0.1)
|
Object.assign(paramsForm, { ...toRaw(params) })
|
||||||
|
paramsEditModalOpen.value = true
|
||||||
if (source.length < 2) return null
|
|
||||||
|
|
||||||
const xMean = source.reduce((sum, point) => sum + point.x, 0) / source.length
|
|
||||||
const yMean = source.reduce((sum, point) => sum + point.psi_max2, 0) / source.length
|
|
||||||
const denominator = source.reduce((sum, point) => sum + (point.x - xMean) ** 2, 0)
|
|
||||||
if (denominator === 0) return null
|
|
||||||
|
|
||||||
const slope = source.reduce((sum, point) => sum + (point.x - xMean) * (point.psi_max2 - yMean), 0) / denominator
|
|
||||||
const intercept = yMean - slope * xMean
|
|
||||||
const sorted = [...source].sort((a, b) => a.x - b.x)
|
|
||||||
const first = sorted[0]
|
|
||||||
const last = sorted[sorted.length - 1]
|
|
||||||
if (!first || !last) return null
|
|
||||||
const zeroX = slope === 0 ? first.x : -intercept / slope
|
|
||||||
const startX = Math.min(first.x, last.x, zeroX)
|
|
||||||
const endX = Math.max(first.x, last.x, zeroX)
|
|
||||||
|
|
||||||
return [startX, endX].map((x) => ({
|
|
||||||
value: [x, slope * x + intercept],
|
|
||||||
case_id: 0,
|
|
||||||
case_name: 'Линейный тренд',
|
|
||||||
group_label: groupLabel,
|
|
||||||
x,
|
|
||||||
omega: null,
|
|
||||||
psi_max: null,
|
|
||||||
psi_max2: slope * x + intercept,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOption() {
|
function closeParamsEditModal() {
|
||||||
if (groupParameter.value && groupedSeriesData.value.length > 0) {
|
if (paramsSaving.value) return
|
||||||
const titleGroup = groupLabel.value || groupParameter.value
|
paramsEditModalOpen.value = false
|
||||||
return {
|
|
||||||
title: { text: `Omega / PsiMax^2 vs ${chartTitle.value || selectedLabel.value} grouped by ${titleGroup}` },
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'item',
|
|
||||||
formatter: (params: any) => {
|
|
||||||
const p = params.data
|
|
||||||
const isOmega = params.seriesName.includes('Omega')
|
|
||||||
const valueLabel = isOmega ? 'Omega' : 'PsiMax^2'
|
|
||||||
const value = isOmega ? p.omega : p.psi_max2
|
|
||||||
return [
|
|
||||||
`<strong>${p.case_name}</strong>`,
|
|
||||||
`${selectedLabel.value}: ${p.x}`,
|
|
||||||
`${titleGroup}: ${p.group_label ?? '—'}`,
|
|
||||||
`${valueLabel}: ${value}`,
|
|
||||||
].join('<br/>')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
legend: { top: 0, type: 'scroll' },
|
|
||||||
grid: { left: 56, right: 32, top: 80, bottom: 56, containLabel: true },
|
|
||||||
dataZoom: [
|
|
||||||
{ type: 'inside', xAxisIndex: 0 },
|
|
||||||
{ type: 'inside', yAxisIndex: [0, 1] },
|
|
||||||
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
|
||||||
],
|
|
||||||
xAxis: { type: 'value', name: selectedLabel.value },
|
|
||||||
yAxis: [
|
|
||||||
{ type: 'value', name: 'Omega', position: 'left' },
|
|
||||||
{ type: 'value', name: 'PsiMax^2', position: 'right' },
|
|
||||||
],
|
|
||||||
series: groupedSeriesData.value.flatMap((group) => {
|
|
||||||
const trend = showTrend.value ? buildPsiMaxSquaredTrend(group.points, group.group_label) : null
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: `${group.group_label} · Omega`,
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
connectNulls: true,
|
|
||||||
showSymbol: true,
|
|
||||||
yAxisIndex: 0,
|
|
||||||
encode: { x: 0, y: 1 },
|
|
||||||
data: group.points.map((point) => chartPoint(point, point.omega, group.group_label)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: `${group.group_label} · PsiMax^2`,
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
connectNulls: true,
|
|
||||||
showSymbol: true,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
encode: { x: 0, y: 1 },
|
|
||||||
data: group.points.map((point) => chartPoint(point, psiMaxSquared(point), group.group_label)),
|
|
||||||
},
|
|
||||||
...(trend
|
|
||||||
? [{
|
|
||||||
name: `${group.group_label} · trend PsiMax^2`,
|
|
||||||
type: 'line',
|
|
||||||
showSymbol: false,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
encode: { x: 0, y: 1 },
|
|
||||||
lineStyle: { type: 'dashed', width: 2 },
|
|
||||||
data: trend,
|
|
||||||
}]
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const trend = showTrend.value ? buildPsiMaxSquaredTrend(seriesData.value) : null
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: { text: `Omega / PsiMax^2 vs ${chartTitle.value || selectedLabel.value}` },
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'item',
|
|
||||||
formatter: (params: any) => {
|
|
||||||
const p = params.data
|
|
||||||
const isOmega = params.seriesName === 'Omega'
|
|
||||||
const valueLabel = isOmega ? 'Omega' : 'PsiMax^2'
|
|
||||||
const value = isOmega ? p.omega : p.psi_max2
|
|
||||||
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 },
|
|
||||||
dataZoom: [
|
|
||||||
{ type: 'inside', xAxisIndex: 0 },
|
|
||||||
{ type: 'inside', yAxisIndex: [0, 1] },
|
|
||||||
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
|
||||||
],
|
|
||||||
xAxis: { type: 'value', name: selectedLabel.value },
|
|
||||||
yAxis: [
|
|
||||||
{ type: 'value', name: 'Omega', position: 'left' },
|
|
||||||
{ type: 'value', name: 'PsiMax^2', position: 'right' },
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Omega',
|
|
||||||
type: 'scatter',
|
|
||||||
symbolSize: 10,
|
|
||||||
yAxisIndex: 0,
|
|
||||||
encode: { x: 0, y: 1 },
|
|
||||||
data: seriesData.value.map((point) => chartPoint(point, point.omega)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'PsiMax^2',
|
|
||||||
type: 'scatter',
|
|
||||||
symbolSize: 10,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
encode: { x: 0, y: 1 },
|
|
||||||
data: seriesData.value.map((point) => chartPoint(point, psiMaxSquared(point))),
|
|
||||||
},
|
|
||||||
...(trend
|
|
||||||
? [{
|
|
||||||
name: 'trend PsiMax^2',
|
|
||||||
type: 'line',
|
|
||||||
showSymbol: false,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
encode: { x: 0, y: 1 },
|
|
||||||
lineStyle: { type: 'dashed', width: 2 },
|
|
||||||
data: trend,
|
|
||||||
}]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChart() {
|
async function saveParams() {
|
||||||
if (!chart) return
|
const params = selectedCaseParams()
|
||||||
chart.setOption(buildOption(), true)
|
if (!params) return
|
||||||
requestAnimationFrame(() => chart?.resize())
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCaseOption() {
|
paramsSaving.value = true
|
||||||
if (psiViewMode.value === 'fft') {
|
|
||||||
return {
|
|
||||||
title: { text: 'FFT spectrum of psi' },
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
legend: { top: 0 },
|
|
||||||
grid: { left: 56, right: 24, top: 48, bottom: 56, containLabel: true },
|
|
||||||
dataZoom: [
|
|
||||||
{ type: 'inside', xAxisIndex: 0 },
|
|
||||||
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
|
||||||
],
|
|
||||||
xAxis: { type: 'value', name: 'f' },
|
|
||||||
yAxis: { type: 'value', name: 'Amplitude' },
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'psi_m',
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
data: selectedCaseSpectrum.value?.points.psi_m.map((point) => [point.frequency, point.amplitude]) ?? [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'psi_l',
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
data: selectedCaseSpectrum.value?.points.psi_l.map((point) => [point.frequency, point.amplitude]) ?? [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
try {
|
||||||
title: { text: 'psi_m / psi_l / time' },
|
const updated = await paramsApi.update(params.id, {
|
||||||
tooltip: { trigger: 'axis' },
|
rel: Number(paramsForm.rel),
|
||||||
legend: { top: 0 },
|
rel_c: Number(paramsForm.rel_c),
|
||||||
grid: { left: 56, right: 24, top: 48, bottom: 48, containLabel: true },
|
le: Number(paramsForm.le),
|
||||||
dataZoom: [
|
sc: Number(paramsForm.sc),
|
||||||
{ type: 'inside', xAxisIndex: 0 },
|
pe: Number(paramsForm.pe),
|
||||||
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
ma: Number(paramsForm.ma),
|
||||||
],
|
initial_condition_id: paramsForm.initial_condition_id,
|
||||||
xAxis: { type: 'value', name: 't' },
|
time: Number(paramsForm.time),
|
||||||
yAxis: { type: 'value', name: 'psi' },
|
folder_path: paramsForm.folder_path,
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'psi_m',
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
data: selectedCasePoints.value.map((point) => [point.t, point.psi_m]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'psi_l',
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
data: selectedCasePsiLPoints.value.map((point) => [point.t, point.psi_l]),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)) {
|
if (selectedCase.value) {
|
||||||
min = 0
|
selectedCase.value = { ...selectedCase.value, params: updated, Params: updated } as ControlCase
|
||||||
max = 1
|
|
||||||
}
|
}
|
||||||
|
paramsEditModalOpen.value = false
|
||||||
return {
|
await loadData()
|
||||||
title: {
|
} catch (err) {
|
||||||
text: `Field map: ${selectedField.value}`,
|
notify.error(formatApiError(err, 'Не удалось сохранить params.'))
|
||||||
subtext: `requested t=${payload.requested_t}, stage t=${payload.stage_t}`,
|
} finally {
|
||||||
},
|
paramsSaving.value = false
|
||||||
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 },
|
|
||||||
dataZoom: [
|
|
||||||
{ type: 'inside', xAxisIndex: 0 },
|
|
||||||
{ type: 'inside', yAxisIndex: 0 },
|
|
||||||
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
|
|
||||||
],
|
|
||||||
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() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -472,9 +155,9 @@ async function loadData() {
|
|||||||
psi_max: point.psi_max,
|
psi_max: point.psi_max,
|
||||||
case_name: point.case_name,
|
case_name: point.case_name,
|
||||||
}))
|
}))
|
||||||
renderChart()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
|
error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
|
||||||
|
notify.error(error.value)
|
||||||
seriesData.value = []
|
seriesData.value = []
|
||||||
groupedSeriesData.value = []
|
groupedSeriesData.value = []
|
||||||
} finally {
|
} finally {
|
||||||
@@ -493,7 +176,6 @@ async function loadSelectedCase(caseId: number) {
|
|||||||
selectedFieldMap.value = null
|
selectedFieldMap.value = null
|
||||||
selectedFieldMapError.value = ''
|
selectedFieldMapError.value = ''
|
||||||
selectedFieldMapLoading.value = false
|
selectedFieldMapLoading.value = false
|
||||||
fieldMapChart?.clear()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [caseData, csvData, spectrumData] = await Promise.all([
|
const [caseData, csvData, spectrumData] = await Promise.all([
|
||||||
@@ -527,10 +209,9 @@ async function loadSelectedCase(caseId: number) {
|
|||||||
.filter((point) => Number.isFinite(point.t) && Number.isFinite(point.psi_l))
|
.filter((point) => Number.isFinite(point.t) && Number.isFinite(point.psi_l))
|
||||||
|
|
||||||
selectedCaseSpectrum.value = spectrumData
|
selectedCaseSpectrum.value = spectrumData
|
||||||
|
|
||||||
renderCaseChart()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
selectedCaseError.value = formatApiError(err, 'Не удалось загрузить расчетный случай.')
|
selectedCaseError.value = formatApiError(err, 'Не удалось загрузить расчетный случай.')
|
||||||
|
notify.error(selectedCaseError.value)
|
||||||
} finally {
|
} finally {
|
||||||
selectedCaseLoading.value = false
|
selectedCaseLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -544,22 +225,15 @@ async function loadFieldMap(time: number) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
selectedFieldMap.value = await controlCaseApi.fieldMap(selectedCaseId.value, time)
|
selectedFieldMap.value = await controlCaseApi.fieldMap(selectedCaseId.value, time)
|
||||||
renderFieldMap()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
selectedFieldMapError.value = formatApiError(err, 'Не удалось загрузить карту полей.')
|
selectedFieldMapError.value = formatApiError(err, 'Не удалось загрузить карту полей.')
|
||||||
|
notify.error(selectedFieldMapError.value)
|
||||||
selectedFieldMap.value = null
|
selectedFieldMap.value = null
|
||||||
fieldMapChart?.clear()
|
|
||||||
} finally {
|
} finally {
|
||||||
selectedFieldMapLoading.value = false
|
selectedFieldMapLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResize() {
|
|
||||||
chart?.resize()
|
|
||||||
caseChart?.resize()
|
|
||||||
fieldMapChart?.resize()
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
filterParameter.value = ''
|
filterParameter.value = ''
|
||||||
filterMin.value = ''
|
filterMin.value = ''
|
||||||
@@ -568,247 +242,54 @@ function resetFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (containerRef.value) {
|
await Promise.all([loadData(), loadInitialConditions()])
|
||||||
chart = echarts.init(containerRef.value)
|
chartControlsReady.value = true
|
||||||
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(selectedParameter, () => chartControlsReady.value && void loadData())
|
||||||
watch(groupParameter, loadData)
|
watch(groupParameter, () => chartControlsReady.value && void loadData())
|
||||||
watch(showTrend, renderChart)
|
|
||||||
watch(selectedField, renderFieldMap)
|
|
||||||
watch(selectedFieldMap, renderFieldMap)
|
|
||||||
watch(psiViewMode, renderCaseChart)
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('resize', handleResize)
|
|
||||||
chart?.dispose()
|
|
||||||
caseChart?.dispose()
|
|
||||||
fieldMapChart?.dispose()
|
|
||||||
chart = null
|
|
||||||
caseChart = null
|
|
||||||
fieldMapChart = null
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="d-flex flex-column gap-3">
|
<section class="d-flex flex-column gap-3">
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<AnalizeGraphCard
|
||||||
<div>
|
v-model:selected-parameter="selectedParameter"
|
||||||
<h1 class="h3 mb-1">Analize graph</h1>
|
v-model:group-parameter="groupParameter"
|
||||||
<p class="text-muted mb-0">Omega, PsiMax² и линейный тренд по выбранному параметру</p>
|
v-model:show-trend="showTrend"
|
||||||
</div>
|
v-model:filter-parameter="filterParameter"
|
||||||
<div class="d-flex gap-2 align-items-center">
|
v-model:filter-min="filterMin"
|
||||||
<select v-model="selectedParameter" class="form-select">
|
v-model:filter-max="filterMax"
|
||||||
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
|
:loading="loading"
|
||||||
{{ option.label }}
|
:error="error"
|
||||||
</option>
|
:chart-title="chartTitle"
|
||||||
</select>
|
:series-data="seriesData"
|
||||||
<select v-model="groupParameter" class="form-select">
|
:grouped-series-data="groupedSeriesData"
|
||||||
<option v-for="option in groupOptions" :key="option.value" :value="option.value">
|
@refresh="loadData"
|
||||||
{{ option.label }}
|
@reset-filters="resetFilters"
|
||||||
</option>
|
@select-case="loadSelectedCase"
|
||||||
</select>
|
/>
|
||||||
<label class="form-check form-switch mb-0 text-nowrap">
|
|
||||||
<input v-model="showTrend" class="form-check-input" type="checkbox" />
|
|
||||||
<span class="form-check-label">Тренд</span>
|
|
||||||
</label>
|
|
||||||
<button class="btn btn-outline-secondary" type="button" @click="loadData" :disabled="loading">
|
|
||||||
Обновить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<SelectedControlCaseCard
|
||||||
<div class="card-body">
|
v-model:selected-field="selectedField"
|
||||||
<div class="row g-3 align-items-end">
|
v-model:psi-view-mode="psiViewMode"
|
||||||
<div class="col-12 col-lg-4">
|
:selected-case-id="selectedCaseId"
|
||||||
<label class="form-label">Фильтр по параметру</label>
|
:selected-case="selectedCase"
|
||||||
<select v-model="filterParameter" class="form-select">
|
:selected-case-loading="selectedCaseLoading"
|
||||||
<option value="">Без фильтра</option>
|
:selected-case-error="selectedCaseError"
|
||||||
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
|
:selected-case-points="selectedCasePoints"
|
||||||
{{ option.label }}
|
:selected-case-psi-l-points="selectedCasePsiLPoints"
|
||||||
</option>
|
:selected-case-spectrum="selectedCaseSpectrum"
|
||||||
</select>
|
:selected-field-map="selectedFieldMap"
|
||||||
</div>
|
:selected-field-map-loading="selectedFieldMapLoading"
|
||||||
<div class="col-6 col-lg-2">
|
:selected-field-map-error="selectedFieldMapError"
|
||||||
<label class="form-label">Min</label>
|
:initial-conditions="initialConditions"
|
||||||
<input v-model="filterMin" type="number" step="any" class="form-control" />
|
:params-edit-modal-open="paramsEditModalOpen"
|
||||||
</div>
|
:params-saving="paramsSaving"
|
||||||
<div class="col-6 col-lg-2">
|
:params-form="paramsForm"
|
||||||
<label class="form-label">Max</label>
|
@load-field-map="loadFieldMap"
|
||||||
<input v-model="filterMax" type="number" step="any" class="form-control" />
|
@open-params-edit-modal="openParamsEditModal"
|
||||||
</div>
|
@close-params-edit-modal="closeParamsEditModal"
|
||||||
<div class="col-12 col-lg-4 d-flex justify-content-end gap-2">
|
@save-params="saveParams"
|
||||||
<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>
|
|
||||||
<span class="badge" :class="controlCaseStatusMeta(selectedCase.status).className">
|
|
||||||
{{ controlCaseStatusMeta(selectedCase.status).label }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-lg-4">
|
|
||||||
<div class="text-muted small">Params ID</div>
|
|
||||||
<div class="fw-semibold">{{ selectedCase.params_id }}</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="d-flex flex-wrap gap-2 align-items-center mb-2">
|
|
||||||
<button
|
|
||||||
class="btn btn-sm"
|
|
||||||
:class="psiViewMode === 'time' ? 'btn-primary' : 'btn-outline-primary'"
|
|
||||||
type="button"
|
|
||||||
@click="psiViewMode = 'time'"
|
|
||||||
:disabled="!selectedCase"
|
|
||||||
>
|
|
||||||
Time
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-sm"
|
|
||||||
:class="psiViewMode === 'fft' ? 'btn-primary' : 'btn-outline-primary'"
|
|
||||||
type="button"
|
|
||||||
@click="psiViewMode = 'fft'"
|
|
||||||
:disabled="!selectedCaseSpectrum"
|
|
||||||
>
|
|
||||||
FFT
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
Нажмите на точку на верхнем графике, чтобы увидеть `psi`.
|
|
||||||
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,30 +2,13 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { analizeApi } from '@/api.ts'
|
import { analizeApi } from '@/api.ts'
|
||||||
import { formatApiError, isPaginatedResponse } from '@/api_client.ts'
|
import { formatApiError } from '@/api_client.ts'
|
||||||
|
import { useModelCollection } from '@/composables/useModelCollection.ts'
|
||||||
|
import { useNotify } from '@/composables/useNotify.ts'
|
||||||
import type { Analize } from '@/models.ts'
|
import type { Analize } from '@/models.ts'
|
||||||
|
|
||||||
const items = ref<Analize[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
const recalculatingId = ref<number | null>(null)
|
const recalculatingId = ref<number | null>(null)
|
||||||
const page = ref(1)
|
const notify = useNotify()
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
function normalizeAnalize(item: Analize | Record<string, unknown>): Analize {
|
function normalizeAnalize(item: Analize | Record<string, unknown>): Analize {
|
||||||
const record = item as Record<string, unknown>
|
const record = item as Record<string, unknown>
|
||||||
@@ -43,43 +26,20 @@ function normalizeAnalize(item: Analize | Record<string, unknown>): Analize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { items, loading, error, page, totalCount, totalPages, loadItems, goToPage } =
|
||||||
|
useModelCollection<Analize>(analizeApi, {
|
||||||
|
loadErrorMessage: 'Не удалось загрузить analize.',
|
||||||
|
normalize: normalizeAnalize,
|
||||||
|
})
|
||||||
|
|
||||||
|
const total = computed(() => totalCount.value)
|
||||||
|
|
||||||
function formatDate(value: string | undefined) {
|
function formatDate(value: string | undefined) {
|
||||||
if (!value) return '—'
|
if (!value) return '—'
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goToPage(nextPage: number) {
|
|
||||||
page.value = Math.min(Math.max(1, nextPage), totalPages.value)
|
|
||||||
await loadItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadItems() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await analizeApi.list({ page: page.value })
|
|
||||||
if (isPaginatedResponse(response)) {
|
|
||||||
items.value = response.results.map((item) => normalizeAnalize(item))
|
|
||||||
totalCount.value = response.count
|
|
||||||
if (serverPageSize.value === 0 && response.results.length > 0) {
|
|
||||||
serverPageSize.value = response.results.length
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
items.value = response.map((item) => normalizeAnalize(item))
|
|
||||||
totalCount.value = response.length
|
|
||||||
if (serverPageSize.value === 0 && response.length > 0) {
|
|
||||||
serverPageSize.value = response.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error.value = formatApiError(err, 'Не удалось загрузить analize.')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recalculate(item: Analize) {
|
async function recalculate(item: Analize) {
|
||||||
recalculatingId.value = item.id
|
recalculatingId.value = item.id
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -90,6 +50,7 @@ async function recalculate(item: Analize) {
|
|||||||
items.value = items.value.map((current) => (current.id === normalized.id ? normalized : current))
|
items.value = items.value.map((current) => (current.id === normalized.id ? normalized : current))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = formatApiError(err, 'Не удалось пересчитать analize.')
|
error.value = formatApiError(err, 'Не удалось пересчитать analize.')
|
||||||
|
notify.error(error.value)
|
||||||
} finally {
|
} finally {
|
||||||
recalculatingId.value = null
|
recalculatingId.value = null
|
||||||
}
|
}
|
||||||
@@ -105,7 +66,7 @@ onMounted(loadItems)
|
|||||||
<h1 class="h3 mb-1">Analize</h1>
|
<h1 class="h3 mb-1">Analize</h1>
|
||||||
<p class="text-muted mb-0">Результаты обработки расчетных случаев. Всего записей: {{ total }}</p>
|
<p class="text-muted mb-0">Результаты обработки расчетных случаев. Всего записей: {{ total }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-outline-secondary" type="button" @click="loadItems" :disabled="loading">
|
<button class="btn btn-outline-secondary" type="button" @click="loadItems()" :disabled="loading">
|
||||||
Обновить
|
Обновить
|
||||||
</button>
|
</button>
|
||||||
<RouterLink class="btn btn-outline-primary" to="/analize/chart">График</RouterLink>
|
<RouterLink class="btn btn-outline-primary" to="/analize/chart">График</RouterLink>
|
||||||
@@ -168,7 +129,7 @@ onMounted(loadItems)
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top">
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top">
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
Показано {{ pageStart }}-{{ pageEnd }} из {{ totalCount }}
|
Всего: {{ totalCount }}
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
|
<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" @click="goToPage(page - 1)" :disabled="page <= 1">Назад</button>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { RouterLink, useRoute } from 'vue-router'
|
|||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import { controlCaseApi, type CsvChartResponse } from '@/api.ts'
|
import { controlCaseApi, type CsvChartResponse } from '@/api.ts'
|
||||||
import { formatApiError } from '@/api_client.ts'
|
import { formatApiError } from '@/api_client.ts'
|
||||||
|
import { useNotify } from '@/composables/useNotify.ts'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const notify = useNotify()
|
||||||
const containerRef = ref<HTMLDivElement | null>(null)
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -53,6 +55,7 @@ function renderChart() {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
if (!Number.isFinite(id.value)) {
|
if (!Number.isFinite(id.value)) {
|
||||||
error.value = 'Некорректный ID'
|
error.value = 'Некорректный ID'
|
||||||
|
notify.error(error.value)
|
||||||
data.value = null
|
data.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -65,6 +68,7 @@ async function loadData() {
|
|||||||
renderChart()
|
renderChart()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
|
error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
|
||||||
|
notify.error(error.value)
|
||||||
data.value = null
|
data.value = null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import { analizeApi, controlCaseApi } from '@/api.ts'
|
import { analizeApi, controlCaseApi } from '@/api.ts'
|
||||||
import { formatApiError } from '@/api_client.ts'
|
import { formatApiError } from '@/api_client.ts'
|
||||||
|
import { useNotify } from '@/composables/useNotify.ts'
|
||||||
import type { ControlCase } from '@/models.ts'
|
import type { ControlCase } from '@/models.ts'
|
||||||
import { controlCaseStatusMeta } from '@/statuses.ts'
|
import { controlCaseStatusMeta } from '@/statuses.ts'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const notify = useNotify()
|
||||||
const item = ref<ControlCase | null>(null)
|
const item = ref<ControlCase | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const recalculating = ref(false)
|
const recalculating = ref(false)
|
||||||
const creatingInitialCondition = ref(false)
|
const creatingInitialCondition = ref(false)
|
||||||
|
const restarting = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const statusMessage = ref('')
|
const statusMessage = ref('')
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ function formatDate(value: string | undefined) {
|
|||||||
async function loadItem() {
|
async function loadItem() {
|
||||||
if (!Number.isFinite(id.value)) {
|
if (!Number.isFinite(id.value)) {
|
||||||
error.value = 'Некорректный ID'
|
error.value = 'Некорректный ID'
|
||||||
|
notify.error(error.value)
|
||||||
item.value = null
|
item.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,7 @@ async function loadItem() {
|
|||||||
item.value = await controlCaseApi.retrieve(id.value)
|
item.value = await controlCaseApi.retrieve(id.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = formatApiError(err, 'Не удалось загрузить control_case.')
|
error.value = formatApiError(err, 'Не удалось загрузить control_case.')
|
||||||
|
notify.error(error.value)
|
||||||
item.value = null
|
item.value = null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -70,6 +75,7 @@ async function recalculateAnalysis() {
|
|||||||
statusMessage.value = 'Анализ пересчитан.'
|
statusMessage.value = 'Анализ пересчитан.'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = formatApiError(err, 'Не удалось пересчитать анализ.')
|
error.value = formatApiError(err, 'Не удалось пересчитать анализ.')
|
||||||
|
notify.error(error.value)
|
||||||
} finally {
|
} finally {
|
||||||
recalculating.value = false
|
recalculating.value = false
|
||||||
}
|
}
|
||||||
@@ -87,11 +93,31 @@ async function createInitialCondition() {
|
|||||||
statusMessage.value = `Начальные условия созданы: ${response.initial_condition.file_path}`
|
statusMessage.value = `Начальные условия созданы: ${response.initial_condition.file_path}`
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = formatApiError(err, 'Не удалось создать начальные условия.')
|
error.value = formatApiError(err, 'Не удалось создать начальные условия.')
|
||||||
|
notify.error(error.value)
|
||||||
} finally {
|
} finally {
|
||||||
creatingInitialCondition.value = false
|
creatingInitialCondition.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restartCalculation() {
|
||||||
|
if (!Number.isFinite(id.value)) return
|
||||||
|
if (!window.confirm('Удалить данные расчета и запустить заново?')) return
|
||||||
|
|
||||||
|
restarting.value = true
|
||||||
|
error.value = ''
|
||||||
|
statusMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
item.value = await controlCaseApi.restart(id.value)
|
||||||
|
statusMessage.value = 'Расчет поставлен на перезапуск.'
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, 'Не удалось перезапустить расчет.')
|
||||||
|
notify.error(error.value)
|
||||||
|
} finally {
|
||||||
|
restarting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadItem)
|
onMounted(loadItem)
|
||||||
watch(id, loadItem)
|
watch(id, loadItem)
|
||||||
</script>
|
</script>
|
||||||
@@ -130,6 +156,15 @@ watch(id, loadItem)
|
|||||||
>
|
>
|
||||||
{{ creatingInitialCondition ? 'Создание...' : 'Взять за начальные условия' }}
|
{{ creatingInitialCondition ? 'Создание...' : 'Взять за начальные условия' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="item"
|
||||||
|
class="btn btn-outline-danger"
|
||||||
|
type="button"
|
||||||
|
@click="restartCalculation"
|
||||||
|
:disabled="restarting"
|
||||||
|
>
|
||||||
|
{{ restarting ? 'Перезапуск...' : 'Перезапустить расчет' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
|
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
|
||||||
|
|||||||
@@ -2,34 +2,44 @@
|
|||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { RouterLink } from "vue-router";
|
import { RouterLink } from "vue-router";
|
||||||
import { controlCaseApi } from "@/api.ts";
|
import { controlCaseApi } from "@/api.ts";
|
||||||
import { formatApiError, isPaginatedResponse } from "@/api_client.ts";
|
import { formatApiError, type ListParams } from "@/api_client.ts";
|
||||||
|
import { useNotify } from "@/composables/useNotify.ts";
|
||||||
import type { ControlCase } from "@/models.ts";
|
import type { ControlCase } from "@/models.ts";
|
||||||
import { controlCaseStatusMeta } from "@/statuses.ts";
|
import { controlCaseStatusMeta } from "@/statuses.ts";
|
||||||
|
import { useModelCollection } from "@/composables/useModelCollection.ts";
|
||||||
|
|
||||||
const items = ref<ControlCase[]>([]);
|
const { items, loading, error, page, totalCount, totalPages, loadItems, goToPage } =
|
||||||
const loading = ref(false);
|
useModelCollection<ControlCase>(controlCaseApi, {
|
||||||
const error = ref("");
|
loadErrorMessage: "Не удалось загрузить control_case.",
|
||||||
const page = ref(1);
|
});
|
||||||
const totalCount = ref(0);
|
|
||||||
const serverPageSize = ref(0);
|
|
||||||
|
|
||||||
|
const selectedStatus = ref("");
|
||||||
|
const selectedCaseIds = ref<number[]>([]);
|
||||||
|
const restarting = ref(false);
|
||||||
|
const notify = useNotify();
|
||||||
const total = computed(() => totalCount.value);
|
const total = computed(() => totalCount.value);
|
||||||
const totalPages = computed(() => {
|
const statusOptions = ["N", "R", "D"];
|
||||||
const size = serverPageSize.value || items.value.length || 1;
|
const restartableItems = computed(() => items.value.filter((item) => canRestart(item)));
|
||||||
return Math.max(1, Math.ceil(totalCount.value / size));
|
const selectedCount = computed(() => selectedCaseIds.value.length);
|
||||||
});
|
const allRestartableSelected = computed(() => {
|
||||||
const pageStart = computed(() => {
|
const ids = restartableItems.value.map((item) => item.id);
|
||||||
const size = serverPageSize.value || items.value.length;
|
return ids.length > 0 && ids.every((id) => selectedCaseIds.value.includes(id));
|
||||||
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) {
|
function filterParams(): ListParams | undefined {
|
||||||
page.value = Math.min(Math.max(1, nextPage), totalPages.value);
|
return selectedStatus.value ? { status: selectedStatus.value } : undefined;
|
||||||
await loadItems();
|
}
|
||||||
|
|
||||||
|
async function loadFilteredItems(resetPage = false) {
|
||||||
|
if (resetPage) {
|
||||||
|
page.value = 1;
|
||||||
|
selectedCaseIds.value = [];
|
||||||
|
}
|
||||||
|
await loadItems(filterParams());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToFilteredPage(nextPage: number) {
|
||||||
|
await goToPage(nextPage, filterParams());
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string | undefined) {
|
function formatDate(value: string | undefined) {
|
||||||
@@ -39,33 +49,68 @@ function formatDate(value: string | undefined) {
|
|||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadItems() {
|
function canRestart(item: ControlCase) {
|
||||||
loading.value = true;
|
return item.status !== "N";
|
||||||
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);
|
function isSelected(id: number) {
|
||||||
|
return selectedCaseIds.value.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleItem(item: ControlCase) {
|
||||||
|
if (!canRestart(item)) return;
|
||||||
|
|
||||||
|
if (isSelected(item.id)) {
|
||||||
|
selectedCaseIds.value = selectedCaseIds.value.filter((id) => id !== item.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCaseIds.value = [...selectedCaseIds.value, item.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVisibleItems() {
|
||||||
|
const visibleIds = restartableItems.value.map((item) => item.id);
|
||||||
|
if (visibleIds.length === 0) return;
|
||||||
|
|
||||||
|
if (allRestartableSelected.value) {
|
||||||
|
selectedCaseIds.value = selectedCaseIds.value.filter((id) => !visibleIds.includes(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCaseIds.value = Array.from(new Set([...selectedCaseIds.value, ...visibleIds]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartSelectedCases() {
|
||||||
|
if (selectedCaseIds.value.length === 0) return;
|
||||||
|
if (!window.confirm(`Перезапустить выбранные кейсы (${selectedCaseIds.value.length})? Данные расчетов будут удалены.`)) return;
|
||||||
|
|
||||||
|
restarting.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
const ids = [...selectedCaseIds.value];
|
||||||
|
const failed: string[] = [];
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
await controlCaseApi.restart(id);
|
||||||
|
} catch (err) {
|
||||||
|
failed.push(`#${id}: ${formatApiError(err, "не удалось перезапустить")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
error.value = `Не удалось перезапустить ${failed.length} из ${ids.length}: ${failed.join("; ")}`;
|
||||||
|
notify.error(error.value);
|
||||||
|
} else {
|
||||||
|
notify.success(`Перезапущено кейсов: ${ids.length}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCaseIds.value = [];
|
||||||
|
restarting.value = false;
|
||||||
|
await loadFilteredItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadFilteredItems());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -77,15 +122,37 @@ onMounted(loadItems);
|
|||||||
<h1 class="h3 mb-1">Control cases</h1>
|
<h1 class="h3 mb-1">Control cases</h1>
|
||||||
<p class="text-muted mb-0">Всего записей: {{ total }}</p>
|
<p class="text-muted mb-0">Всего записей: {{ total }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
<select
|
||||||
|
v-model="selectedStatus"
|
||||||
|
class="form-select"
|
||||||
|
style="width: 220px"
|
||||||
|
:disabled="loading"
|
||||||
|
@change="loadFilteredItems(true)"
|
||||||
|
>
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option v-for="status in statusOptions" :key="status" :value="status">
|
||||||
|
{{ controlCaseStatusMeta(status).label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-danger"
|
||||||
|
type="button"
|
||||||
|
@click="restartSelectedCases"
|
||||||
|
:disabled="selectedCount === 0 || restarting"
|
||||||
|
>
|
||||||
|
{{ restarting ? 'Перезапуск...' : `Перезапустить выбранные (${selectedCount})` }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
@click="loadItems"
|
@click="loadFilteredItems()"
|
||||||
:disabled="loading"
|
:disabled="loading || restarting"
|
||||||
>
|
>
|
||||||
Обновить
|
Обновить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="alert alert-danger mb-0" role="alert">
|
<div v-if="error" class="alert alert-danger mb-0" role="alert">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
@@ -98,6 +165,16 @@ onMounted(loadItems);
|
|||||||
<table class="table table-sm table-hover align-middle mb-0">
|
<table class="table table-sm table-hover align-middle mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 36px">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="allRestartableSelected"
|
||||||
|
:disabled="restartableItems.length === 0 || restarting"
|
||||||
|
aria-label="Выбрать все доступные для перезапуска"
|
||||||
|
@change="toggleVisibleItems"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Название</th>
|
<th>Название</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
@@ -108,11 +185,22 @@ onMounted(loadItems);
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="items.length === 0">
|
<tr v-if="items.length === 0">
|
||||||
<td colspan="6" class="text-center text-muted py-4">
|
<td colspan="7" class="text-center text-muted py-4">
|
||||||
Записи не найдены
|
Записи не найдены
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="item in items" :key="item.id">
|
<tr v-for="item in items" :key="item.id">
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isSelected(item.id)"
|
||||||
|
:disabled="!canRestart(item) || restarting"
|
||||||
|
:title="canRestart(item) ? 'Выбрать для перезапуска' : 'Кейс уже в очереди или выполняется'"
|
||||||
|
:aria-label="`Выбрать control case ${item.id}`"
|
||||||
|
@change="toggleItem(item)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td class="fw-semibold">{{ item.id }}</td>
|
<td class="fw-semibold">{{ item.id }}</td>
|
||||||
<td>{{ item.name || "—" }}</td>
|
<td>{{ item.name || "—" }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -144,7 +232,7 @@ onMounted(loadItems);
|
|||||||
class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top"
|
class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top"
|
||||||
>
|
>
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
Показано {{ pageStart }}-{{ pageEnd }} из {{ totalCount }}
|
Всего: {{ totalCount }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="btn-group btn-group-sm"
|
class="btn-group btn-group-sm"
|
||||||
@@ -154,7 +242,7 @@ onMounted(loadItems);
|
|||||||
<button
|
<button
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
@click="goToPage(page - 1)"
|
@click="goToFilteredPage(page - 1)"
|
||||||
:disabled="page <= 1"
|
:disabled="page <= 1"
|
||||||
>
|
>
|
||||||
Назад
|
Назад
|
||||||
@@ -165,7 +253,7 @@ onMounted(loadItems);
|
|||||||
<button
|
<button
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
@click="goToPage(page + 1)"
|
@click="goToFilteredPage(page + 1)"
|
||||||
:disabled="page >= totalPages"
|
:disabled="page >= totalPages"
|
||||||
>
|
>
|
||||||
Вперёд
|
Вперёд
|
||||||
|
|||||||
@@ -1,29 +1,51 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref, toRaw } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { controlCaseApi, initialConditionApi, paramsApi } from "@/api.ts";
|
import { controlCaseApi, initialConditionApi, paramsApi } from "@/api.ts";
|
||||||
import { formatApiError, isPaginatedResponse } from "@/api_client.ts";
|
import { formatApiError, type ListParams } from "@/api_client.ts";
|
||||||
import ParamsEditCard from "@/components/params/ParamsEditCard.vue";
|
import ParamsEditCard from "@/components/params/ParamsEditCard.vue";
|
||||||
import ParamsQuickBatchCard from "@/components/params/ParamsQuickBatchCard.vue";
|
import ParamsQuickBatchCard from "@/components/params/ParamsQuickBatchCard.vue";
|
||||||
import ParamsTableCard from "@/components/params/ParamsTableCard.vue";
|
import ParamsTableCard from "@/components/params/ParamsTableCard.vue";
|
||||||
|
import { useModelCollection } from "@/composables/useModelCollection.ts";
|
||||||
|
import { useNotify } from "@/composables/useNotify.ts";
|
||||||
import type { InitialCondition, Params } from "@/models.ts";
|
import type { InitialCondition, Params } from "@/models.ts";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
const items = ref<Params[]>([]);
|
|
||||||
const initialConditions = ref<InitialCondition[]>([]);
|
const initialConditions = ref<InitialCondition[]>([]);
|
||||||
const loading = ref(false);
|
|
||||||
const saving = ref(false);
|
|
||||||
const launchingId = ref<number | null>(null);
|
const launchingId = ref<number | null>(null);
|
||||||
const error = ref("");
|
|
||||||
const selectedId = ref<number | null>(null);
|
const selectedId = ref<number | null>(null);
|
||||||
const page = ref(1);
|
const editModalOpen = ref(false);
|
||||||
const totalCount = ref(0);
|
const batchModalOpen = ref(false);
|
||||||
const serverPageSize = ref(0);
|
|
||||||
const batchRunning = ref(false);
|
|
||||||
const batchMessage = ref("");
|
|
||||||
const formsPrefilled = ref(false);
|
const formsPrefilled = ref(false);
|
||||||
|
|
||||||
|
const filterFields = ["rel", "rel_c", "le", "sc", "pe", "ma", "time"] as const;
|
||||||
|
const filters = reactive({
|
||||||
|
ranges: Object.fromEntries(filterFields.map((field) => [field, { min: "", max: "" }])) as Record<
|
||||||
|
(typeof filterFields)[number],
|
||||||
|
{ min: string; max: string }
|
||||||
|
>,
|
||||||
|
initial_condition_id: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
page,
|
||||||
|
totalCount,
|
||||||
|
totalPages,
|
||||||
|
loadItems,
|
||||||
|
goToPage,
|
||||||
|
createItem: createParamsItem,
|
||||||
|
updateItem: updateParamsItem,
|
||||||
|
} = useModelCollection<Params>(paramsApi, {
|
||||||
|
loadErrorMessage: "Не удалось загрузить params.",
|
||||||
|
saveErrorMessage: "Не удалось сохранить params.",
|
||||||
|
});
|
||||||
|
|
||||||
const form = reactive<Params>({
|
const form = reactive<Params>({
|
||||||
id: 0,
|
id: 0,
|
||||||
created_at: "",
|
created_at: "",
|
||||||
@@ -52,47 +74,31 @@ const createForm = reactive({
|
|||||||
time: 0,
|
time: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const batchForm = reactive({
|
|
||||||
fixed: {
|
|
||||||
rel: 0,
|
|
||||||
rel_c: 0,
|
|
||||||
le: 0,
|
|
||||||
sc: 0,
|
|
||||||
pe: 0,
|
|
||||||
ma: 0,
|
|
||||||
initial_condition_id: null as number | null,
|
|
||||||
time: 0,
|
|
||||||
},
|
|
||||||
variable: "rel",
|
|
||||||
start: 0,
|
|
||||||
end: 0,
|
|
||||||
step: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const variableOptions = ["rel", "rel_c", "le", "sc", "pe", "ma", "time"];
|
|
||||||
|
|
||||||
const selectedItem = computed(
|
const selectedItem = computed(
|
||||||
() => items.value.find((item) => item.id === selectedId.value) ?? null,
|
() => 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
function selectItem(item: Params) {
|
function selectItem(item: Params) {
|
||||||
selectedId.value = item.id;
|
selectedId.value = item.id;
|
||||||
Object.assign(form, structuredClone(item));
|
Object.assign(form, { ...toRaw(item) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(item: Params) {
|
||||||
|
selectItem(item);
|
||||||
|
editModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
if (saving.value) return;
|
||||||
|
editModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchModal() {
|
||||||
|
batchModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBatchModal() {
|
||||||
|
batchModalOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillCreateFormFromParams(item: Params) {
|
function fillCreateFormFromParams(item: Params) {
|
||||||
@@ -106,25 +112,8 @@ function fillCreateFormFromParams(item: Params) {
|
|||||||
createForm.time = item.time;
|
createForm.time = item.time;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillBatchFormFromParams(item: Params) {
|
|
||||||
batchForm.fixed.rel = item.rel;
|
|
||||||
batchForm.fixed.rel_c = item.rel_c;
|
|
||||||
batchForm.fixed.le = item.le;
|
|
||||||
batchForm.fixed.sc = item.sc;
|
|
||||||
batchForm.fixed.pe = item.pe;
|
|
||||||
batchForm.fixed.ma = item.ma;
|
|
||||||
batchForm.fixed.initial_condition_id = item.initial_condition_id;
|
|
||||||
batchForm.fixed.time = item.time;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillFormsFromParams(item: Params) {
|
function fillFormsFromParams(item: Params) {
|
||||||
fillCreateFormFromParams(item);
|
fillCreateFormFromParams(item);
|
||||||
fillBatchFormFromParams(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goToPage(nextPage: number) {
|
|
||||||
page.value = Math.min(Math.max(1, nextPage), totalPages.value);
|
|
||||||
await loadItems();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetCreateForm() {
|
function resetCreateForm() {
|
||||||
@@ -138,67 +127,42 @@ function resetCreateForm() {
|
|||||||
createForm.time = 0;
|
createForm.time = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetBatchForm() {
|
function buildFilterParams(): ListParams | undefined {
|
||||||
batchForm.fixed.rel = 0;
|
const params: ListParams = {};
|
||||||
batchForm.fixed.rel_c = 0;
|
|
||||||
batchForm.fixed.le = 0;
|
for (const field of filterFields) {
|
||||||
batchForm.fixed.sc = 0;
|
const range = filters.ranges[field];
|
||||||
batchForm.fixed.pe = 0;
|
if (range.min !== "") params[`${field}__gte`] = Number(range.min);
|
||||||
batchForm.fixed.ma = 0;
|
if (range.max !== "") params[`${field}__lte`] = Number(range.max);
|
||||||
batchForm.fixed.initial_condition_id = null;
|
}
|
||||||
batchForm.fixed.time = 0;
|
|
||||||
batchForm.variable = "rel";
|
if (filters.initial_condition_id !== "") {
|
||||||
batchForm.start = 0;
|
params.initial_condition_id = Number(filters.initial_condition_id);
|
||||||
batchForm.end = 0;
|
}
|
||||||
batchForm.step = 1;
|
|
||||||
|
return Object.keys(params).length > 0 ? params : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRange(start: number, end: number, step: number) {
|
async function loadParamsItems(resetPage = false) {
|
||||||
if (!Number.isFinite(start) || !Number.isFinite(end) || !Number.isFinite(step) || step === 0) {
|
if (resetPage) page.value = 1;
|
||||||
return [];
|
await loadItems(buildFilterParams());
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
if (!selectedId.value) {
|
||||||
const first = items.value[0];
|
const first = items.value[0];
|
||||||
if (first) selectItem(first);
|
if (first) selectItem(first);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
}
|
||||||
error.value = formatApiError(err, "Не удалось загрузить params.");
|
|
||||||
} finally {
|
async function goToFilteredPage(nextPage: number) {
|
||||||
loading.value = false;
|
await goToPage(nextPage, buildFilterParams());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
for (const field of filterFields) {
|
||||||
|
filters.ranges[field].min = "";
|
||||||
|
filters.ranges[field].max = "";
|
||||||
}
|
}
|
||||||
|
filters.initial_condition_id = "";
|
||||||
|
void loadParamsItems(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadInitialConditions() {
|
async function loadInitialConditions() {
|
||||||
@@ -206,6 +170,7 @@ async function loadInitialConditions() {
|
|||||||
initialConditions.value = await initialConditionApi.listAll();
|
initialConditions.value = await initialConditionApi.listAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = formatApiError(err, "Не удалось загрузить начальные условия.");
|
error.value = formatApiError(err, "Не удалось загрузить начальные условия.");
|
||||||
|
notify.error(error.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,11 +192,7 @@ async function prefillFormsFromLatestParams() {
|
|||||||
async function saveItem() {
|
async function saveItem() {
|
||||||
if (!selectedId.value) return;
|
if (!selectedId.value) return;
|
||||||
|
|
||||||
saving.value = true;
|
const updated = await updateParamsItem(selectedId.value, {
|
||||||
error.value = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await paramsApi.update(selectedId.value, {
|
|
||||||
rel: Number(form.rel),
|
rel: Number(form.rel),
|
||||||
rel_c: Number(form.rel_c),
|
rel_c: Number(form.rel_c),
|
||||||
le: Number(form.le),
|
le: Number(form.le),
|
||||||
@@ -243,21 +204,15 @@ async function saveItem() {
|
|||||||
folder_path: form.folder_path,
|
folder_path: form.folder_path,
|
||||||
});
|
});
|
||||||
|
|
||||||
await loadItems();
|
if (updated) {
|
||||||
selectItem(updated);
|
selectItem(updated);
|
||||||
} catch (err) {
|
editModalOpen.value = false;
|
||||||
error.value = formatApiError(err, "Не удалось сохранить params.");
|
await loadParamsItems();
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createItem() {
|
async function createItem() {
|
||||||
saving.value = true;
|
const created = await createParamsItem({
|
||||||
error.value = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = await paramsApi.create({
|
|
||||||
rel: Number(createForm.rel),
|
rel: Number(createForm.rel),
|
||||||
rel_c: Number(createForm.rel_c),
|
rel_c: Number(createForm.rel_c),
|
||||||
le: Number(createForm.le),
|
le: Number(createForm.le),
|
||||||
@@ -268,55 +223,16 @@ async function createItem() {
|
|||||||
time: Number(createForm.time),
|
time: Number(createForm.time),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (created) {
|
||||||
selectItem(created);
|
selectItem(created);
|
||||||
await loadItems();
|
|
||||||
resetCreateForm();
|
resetCreateForm();
|
||||||
} catch (err) {
|
await loadParamsItems();
|
||||||
error.value = formatApiError(err, "Не удалось создать params.");
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createBatch() {
|
async function handleBatchCreated() {
|
||||||
batchRunning.value = true;
|
batchModalOpen.value = false;
|
||||||
batchMessage.value = "";
|
await loadParamsItems();
|
||||||
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),
|
|
||||||
sc: Number(batchForm.fixed.sc),
|
|
||||||
pe: Number(batchForm.fixed.pe),
|
|
||||||
ma: Number(batchForm.fixed.ma),
|
|
||||||
initial_condition_id: batchForm.fixed.initial_condition_id,
|
|
||||||
time: Number(batchForm.fixed.time),
|
|
||||||
} as Record<string, string | number | null>;
|
|
||||||
|
|
||||||
payload[batchForm.variable] = value;
|
|
||||||
|
|
||||||
const created = await paramsApi.create(payload as never);
|
|
||||||
await controlCaseApi.launch({
|
|
||||||
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) {
|
async function launchParams(item: Params) {
|
||||||
@@ -331,13 +247,14 @@ async function launchParams(item: Params) {
|
|||||||
await router.push({ name: "control-case-detail", params: { id: controlCase.id } });
|
await router.push({ name: "control-case-detail", params: { id: controlCase.id } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = formatApiError(err, "Не удалось запустить расчет.");
|
error.value = formatApiError(err, "Не удалось запустить расчет.");
|
||||||
|
notify.error(error.value);
|
||||||
} finally {
|
} finally {
|
||||||
launchingId.value = null;
|
launchingId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadItems(), loadInitialConditions(), prefillFormsFromLatestParams()]);
|
await Promise.all([loadParamsItems(), loadInitialConditions(), prefillFormsFromLatestParams()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -348,27 +265,63 @@ onMounted(async () => {
|
|||||||
<h1 class="h3 mb-1">Params</h1>
|
<h1 class="h3 mb-1">Params</h1>
|
||||||
<p class="text-muted mb-0">Таблица параметров с редактированием</p>
|
<p class="text-muted mb-0">Таблица параметров с редактированием</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-outline-secondary" type="button" @click="loadItems" :disabled="loading">
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
<button class="btn btn-primary" type="button" @click="openBatchModal">
|
||||||
|
Создать серию
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @click="loadParamsItems()" :disabled="loading">
|
||||||
Обновить
|
Обновить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="alert alert-danger mb-0" role="alert">
|
<div v-if="error" class="alert alert-danger mb-0" role="alert">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="batchMessage" class="alert alert-success mb-0" role="alert">
|
<div class="card shadow-sm">
|
||||||
{{ batchMessage }}
|
<div class="card-header bg-white fw-semibold">Фильтры</div>
|
||||||
</div>
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
<ParamsQuickBatchCard
|
<div v-for="field in filterFields" :key="field" class="col-12 col-md-6 col-lg-3">
|
||||||
:batch-form="batchForm"
|
<label class="form-label text-uppercase small text-muted">{{ field }}</label>
|
||||||
:initial-conditions="initialConditions"
|
<div class="input-group input-group-sm">
|
||||||
:variable-options="variableOptions"
|
<input
|
||||||
:running="batchRunning"
|
v-model="filters.ranges[field].min"
|
||||||
@submit="createBatch"
|
class="form-control"
|
||||||
@reset="resetBatchForm"
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:placeholder="`${field} min`"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
v-model="filters.ranges[field].max"
|
||||||
|
class="form-control"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:placeholder="`${field} max`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<label class="form-label small text-muted">initial_condition</label>
|
||||||
|
<select v-model="filters.initial_condition_id" class="form-select form-select-sm">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option v-for="condition in initialConditions" :key="condition.id" :value="condition.id">
|
||||||
|
{{ condition.name || `#${condition.id}` }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-3 d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" type="button" :disabled="loading" @click="loadParamsItems(true)">
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" :disabled="loading" @click="resetFilters">
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ParamsTableCard
|
<ParamsTableCard
|
||||||
:items="items"
|
:items="items"
|
||||||
@@ -381,21 +334,80 @@ onMounted(async () => {
|
|||||||
:page="page"
|
:page="page"
|
||||||
:total-pages="totalPages"
|
:total-pages="totalPages"
|
||||||
:total-count="totalCount"
|
:total-count="totalCount"
|
||||||
:page-start="pageStart"
|
|
||||||
:page-end="pageEnd"
|
|
||||||
@create="createItem"
|
@create="createItem"
|
||||||
@select="selectItem"
|
@edit="openEditModal"
|
||||||
@launch="launchParams"
|
@launch="launchParams"
|
||||||
@go-to-page="goToPage"
|
@go-to-page="goToFilteredPage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="batchModalOpen"
|
||||||
|
class="modal fade show d-block"
|
||||||
|
tabindex="-1"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.self="closeBatchModal"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small">Быстрый расчет</div>
|
||||||
|
<h2 class="modal-title h5 mb-0">Создание серии params</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
@click="closeBatchModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<ParamsQuickBatchCard @created="handleBatchCreated" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="batchModalOpen" class="modal-backdrop fade show"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="editModalOpen && selectedItem"
|
||||||
|
class="modal fade show d-block"
|
||||||
|
tabindex="-1"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.self="closeEditModal"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small">Редактирование</div>
|
||||||
|
<h2 class="modal-title h5 mb-0">Params #{{ selectedItem.id }}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="closeEditModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
<ParamsEditCard
|
<ParamsEditCard
|
||||||
v-if="selectedItem"
|
|
||||||
:item="selectedItem"
|
:item="selectedItem"
|
||||||
:form="form"
|
:form="form"
|
||||||
:initial-conditions="initialConditions"
|
:initial-conditions="initialConditions"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
@submit="saveItem"
|
@submit="saveItem"
|
||||||
|
@cancel="closeEditModal"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="editModalOpen && selectedItem" class="modal-backdrop fade show"></div>
|
||||||
|
</Teleport>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { sqlApi, type SQLExecuteResponse } from '@/api.ts'
|
||||||
|
import { formatApiError } from '@/api_client.ts'
|
||||||
|
import { useNotify } from '@/composables/useNotify.ts'
|
||||||
|
|
||||||
|
const notify = useNotify()
|
||||||
|
const sql = ref('SELECT * FROM params ORDER BY id DESC LIMIT 20')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const result = ref<SQLExecuteResponse | null>(null)
|
||||||
|
|
||||||
|
const isRowsResult = computed(() => result.value?.type === 'select')
|
||||||
|
const rowCount = computed(() => result.value?.rows?.length ?? 0)
|
||||||
|
|
||||||
|
function isReadQuery(value: string) {
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
return normalized.startsWith('select') || normalized.startsWith('with') || normalized.startsWith('pragma')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCell(value: unknown) {
|
||||||
|
if (value == null) return 'NULL'
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSQL() {
|
||||||
|
const query = sql.value.trim()
|
||||||
|
if (!query) {
|
||||||
|
error.value = 'SQL запрос пустой.'
|
||||||
|
notify.error(error.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isReadQuery(query) && !window.confirm('Выполнить SQL запрос, который может изменить данные?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
result.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
result.value = await sqlApi.execute({ sql: query })
|
||||||
|
if (result.value.type === 'exec') {
|
||||||
|
notify.success(`Запрос выполнен. Изменено строк: ${result.value.rows_affected ?? 0}.`)
|
||||||
|
} else {
|
||||||
|
notify.success(`Запрос выполнен. Строк: ${result.value.rows?.length ?? 0}.`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, 'Не удалось выполнить SQL запрос.')
|
||||||
|
notify.error(error.value)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="d-flex flex-column gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">SQL executor</h1>
|
||||||
|
<p class="text-muted mb-0">Выполнение SQL запросов к локальной базе данных.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white fw-semibold">Запрос</div>
|
||||||
|
<div class="card-body d-flex flex-column gap-3">
|
||||||
|
<textarea
|
||||||
|
v-model="sql"
|
||||||
|
class="form-control font-monospace"
|
||||||
|
rows="8"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="SELECT * FROM params LIMIT 20"
|
||||||
|
/>
|
||||||
|
<div class="d-flex justify-content-end gap-2">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" :disabled="loading" @click="sql = ''">
|
||||||
|
Очистить
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" type="button" :disabled="loading" @click="executeSQL">
|
||||||
|
{{ loading ? 'Выполнение...' : 'Выполнить' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="result" class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<span class="fw-semibold">Результат</span>
|
||||||
|
<span v-if="isRowsResult" class="text-muted small">Строк: {{ rowCount }}</span>
|
||||||
|
<span v-else class="text-muted small">Изменено строк: {{ result.rows_affected ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isRowsResult" class="card-body p-0">
|
||||||
|
<div v-if="rowCount === 0" 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 v-for="column in result.columns" :key="column">{{ column }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, rowIndex) in result.rows" :key="rowIndex">
|
||||||
|
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="font-monospace small">
|
||||||
|
{{ formatCell(cell) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card-body">
|
||||||
|
<div class="alert alert-success mb-0" role="status">
|
||||||
|
Запрос выполнен. Изменено строк: {{ result.rows_affected ?? 0 }}.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -99,6 +99,53 @@ func (c *ControlCaseController) Launch(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusCreated, controlCase)
|
ctx.JSON(http.StatusCreated, controlCase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ControlCaseController) Restart(ctx *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var controlCase ControlCase
|
||||||
|
if err := c.db.Preload("Params").Preload("Params.InitialCondition").First(&controlCase, id).Error; err != nil {
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if controlCase.Status == "N" {
|
||||||
|
ctx.JSON(http.StatusConflict, gin.H{"error": "calculation is already queued or running"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if controlCase.ParamsID == 0 {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "params_id is empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folderPath := filepath.Join(BASE_DIR, fmt.Sprintf("%d", controlCase.ParamsID))
|
||||||
|
if err := os.RemoveAll(folderPath); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec("DELETE FROM analizes WHERE case_id = ?", controlCase.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&ControlCase{}).Where("id = ?", controlCase.ID).Update("status", "N").Error
|
||||||
|
}); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.db.Preload("Params").Preload("Params.InitialCondition").First(&controlCase, controlCase.ID).Error; err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, controlCase)
|
||||||
|
}
|
||||||
|
|
||||||
type CSVSeriesResponse struct {
|
type CSVSeriesResponse struct {
|
||||||
Columns []string `json:"columns"`
|
Columns []string `json:"columns"`
|
||||||
Rows [][]interface{} `json:"rows"`
|
Rows [][]interface{} `json:"rows"`
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func RegisterApp(r *gin.Engine, db *gorm.DB) {
|
|||||||
go4rest.RegisterCRUDRoutes(r, "params", params)
|
go4rest.RegisterCRUDRoutes(r, "params", params)
|
||||||
go4rest.RegisterCRUDRoutes(r, "initial_condition", initialConditions)
|
go4rest.RegisterCRUDRoutes(r, "initial_condition", initialConditions)
|
||||||
r.POST("/api/control_case/launch", controller.Launch)
|
r.POST("/api/control_case/launch", controller.Launch)
|
||||||
|
r.POST("/api/control_case/:id/restart", controller.Restart)
|
||||||
r.GET("/api/control_case/:id/chart-data", controller.ChartData)
|
r.GET("/api/control_case/:id/chart-data", controller.ChartData)
|
||||||
r.GET("/api/control_case/:id/psi-spectrum", controller.PSISpectrum)
|
r.GET("/api/control_case/:id/psi-spectrum", controller.PSISpectrum)
|
||||||
r.GET("/api/control_case/:id/field-map", controller.FieldMap)
|
r.GET("/api/control_case/:id/field-map", controller.FieldMap)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"control/analize"
|
"control/analize"
|
||||||
"control/control_case"
|
"control/control_case"
|
||||||
|
"control/sql_executor"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
@@ -22,6 +23,7 @@ func main() {
|
|||||||
|
|
||||||
control_case.RegisterApp(r, db)
|
control_case.RegisterApp(r, db)
|
||||||
analize.RegisterApp(r, db)
|
analize.RegisterApp(r, db)
|
||||||
|
sql_executor.RegisterApp(r, db)
|
||||||
// numJobs := 10
|
// numJobs := 10
|
||||||
//jobs := make(chan control_case.Params, numJobs)
|
//jobs := make(chan control_case.Params, numJobs)
|
||||||
//results := make(chan int, numJobs)
|
//results := make(chan int, numJobs)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from matplotlib import pyplot as plt
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
con = sqlite3.connect("db.sqlite3")
|
||||||
|
|
||||||
|
df = pd.read_sql_query(
|
||||||
|
"""SELECT *
|
||||||
|
FROM analizes a
|
||||||
|
JOIN control_cases c ON c.id = a.case_id
|
||||||
|
JOIN params p ON p.id = c.params_id
|
||||||
|
""",
|
||||||
|
con,
|
||||||
|
)
|
||||||
|
|
||||||
|
df2 = df.query("pe == 2").query("rel_c==1000")
|
||||||
|
|
||||||
|
plt.plot(df2["rel"], df2["psi_max"], ls="o")
|
||||||
|
plt.show()
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package sql_executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SQLController struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecuteSQLRequest struct {
|
||||||
|
SQL string `json:"sql" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecuteSQLResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Columns []string `json:"columns,omitempty"`
|
||||||
|
Rows [][]interface{} `json:"rows,omitempty"`
|
||||||
|
RowsAffected int64 `json:"rows_affected,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSQLController(db *gorm.DB) *SQLController {
|
||||||
|
return &SQLController{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SQLController) Execute(ctx *gin.Context) {
|
||||||
|
var req ExecuteSQLRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.TrimSpace(req.SQL)
|
||||||
|
if query == "" {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "sql is empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isRowsQuery(query) {
|
||||||
|
response, err := c.executeRowsQuery(query)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := c.db.Exec(query)
|
||||||
|
if result.Error != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, ExecuteSQLResponse{
|
||||||
|
Type: "exec",
|
||||||
|
RowsAffected: result.RowsAffected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRowsQuery(query string) bool {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(query))
|
||||||
|
return strings.HasPrefix(lower, "select") || strings.HasPrefix(lower, "with") || strings.HasPrefix(lower, "pragma")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SQLController) executeRowsQuery(query string) (ExecuteSQLResponse, error) {
|
||||||
|
rows, err := c.db.Raw(query).Rows()
|
||||||
|
if err != nil {
|
||||||
|
return ExecuteSQLResponse{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return ExecuteSQLResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resultRows, err := scanRows(rows, columns)
|
||||||
|
if err != nil {
|
||||||
|
return ExecuteSQLResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecuteSQLResponse{
|
||||||
|
Type: "select",
|
||||||
|
Columns: columns,
|
||||||
|
Rows: resultRows,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRows(rows *sql.Rows, columns []string) ([][]interface{}, error) {
|
||||||
|
result := make([][]interface{}, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
values := make([]interface{}, len(columns))
|
||||||
|
valuePointers := make([]interface{}, len(columns))
|
||||||
|
for i := range values {
|
||||||
|
valuePointers[i] = &values[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Scan(valuePointers...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
row := make([]interface{}, len(columns))
|
||||||
|
for i, value := range values {
|
||||||
|
if bytes, ok := value.([]byte); ok {
|
||||||
|
row[i] = string(bytes)
|
||||||
|
} else {
|
||||||
|
row[i] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package sql_executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterApp(r *gin.Engine, db *gorm.DB) {
|
||||||
|
controller := NewSQLController(db)
|
||||||
|
r.POST("/api/sql/execute", controller.Execute)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user