feat(frontend): вернуть проверку типов SFC-шаблонов и закрыть все 142 ошибки

Vue 3.2.45 -> 3.5.42, TypeScript 4.9.3 -> 5.9.3, vue-tsc 0.35.0 -> 3.3.11.
Element Plus, Vue Router, Pinia, Vite и VueUse не тронуты: ни один из них не
является предусловием работающего typechecker.

vue-tsc 0.35 был не просто инертен — на Vue 3.5 он ломается сам (TS7026: нет
JSX.IntrinsicElements), потому что не знает vue/jsx-runtime. То есть проверка,
которая ничего не находила, ещё и не пережила бы обновление Vue.

Современный vue-tsc даёт 142 ошибки, а не ~155, и картина однороднее ожидаемой:
141 x TS18048 и 1 x TS2322, всё в двух файлах представления Hysteria.
Предсказанного класса DefaultRow/PeerVo в таблице пиров не оказалось вовсе.

Все 142 — одно и то же: шаблон обращается к необязательным секциям конфига
(dataForm.tls.cert, dataForm.acme.dns.config, dataForm.resolver.https.sni).
Необязательны они правильно: так устроен upstream YAML. Инвариант «секция есть
всегда» существовал, но держался на порядке присваиваний внутри компонента.

Закрыто одним преобразованием на границе API вместо 141 `?.` или `as any`:
api/config/hysteriaViewModel.ts даёт Hysteria2ServerConfigView, где присутствие
каждой секции — свойство типа, и normalizeHysteriaViewModel(). types.ts
остаётся описанием того, что приходит по сети.

Побочно закрыто мёртвое UI: редактор outbounds (кнопка «+», диалог создания,
удаление тегов, emit update:outbounds) не мог ничего сохранить — страница
отрисована с :disabled="true", родитель передаёт :outbounds без v-model, а
маршрутов записи серверного конфига в API нет. Оператор мог добавить outbound и
уйти в уверенности, что изменил конфигурацию сервера.

package.json: typecheck и build:prod разделены, verify запускает их по порядку.
Раньше `vite build && vue-tsc` сначала тратил время на production bundle и
только потом сообщал о типовой ошибке.
This commit is contained in:
2026-08-30 07:04:28 +05:00
parent cf094f6e6f
commit 02ea33520d
9 changed files with 726 additions and 730 deletions
@@ -0,0 +1,314 @@
import type {
Hysteria2ServerConfig,
Hysteria2ServerConfigOutbound,
} from "./types";
/**
* Нормализация конфига Hysteria на границе API.
*
* Зачем этот файл существует.
*
* `Hysteria2ServerConfig` описывает то, что РЕАЛЬНО приходит по сети, и почти
* все его секции необязательны — потому что необязательны они и в upstream
* YAML. Панель при этом показывает их как обычную форму: `dataForm.tls.cert`,
* `dataForm.acme.dns.config`, `dataForm.resolver.https.sni`.
*
* Пока проверка типов SFC-шаблонов не работала, это выглядело безобидно.
* Современный `vue-tsc` даёт на этом 141 ошибку `TS18048` в двух файлах — и он
* прав: обращение через возможно отсутствующий объект в рантайме падает.
* Спасало только то, что форма строится merge'ем поверх полного объекта
* значений по умолчанию, то есть инвариант «секция есть всегда» существовал,
* но держался на порядке присваиваний внутри компонента и нигде не был
* выражен типом.
*
* Два способа это закрыть неверны:
*
* `?.` в 141 месте шаблона — прячет вопрос «а что показывать, если секции
* нет», не отвечая на него, и делает шаблон нечитаемым;
*
* `as any` — выключает ровно ту проверку, ради которой обновлялся
* typechecker.
*
* Здесь выбран третий: одно преобразование на входе. Ответ приходит в
* `Hysteria2ServerConfig` (как есть, с необязательными секциями), а форма
* работает с `Hysteria2ServerConfigView`, где присутствие каждой секции —
* свойство типа. Шаблону больше не нужно знать ни одного нюанса
* необязательности upstream-схемы.
*
* Важно, чего этот слой НЕ делает: он не участвует в экспорте. Выгрузка
* серверного конфига идёт на backend от исходного YAML и сохраняет поля, о
* которых HY2XS ещё не знает (см. docs/04). View-модель — только для
* отображения, поэтому потеря неизвестных полей здесь безвредна.
*/
/**
* DeepRequired делает обязательными все поля на всех уровнях.
*
* Массивы обрабатываются отдельно: без этой ветки `T[]` попал бы в `object` и
* маппинг прошёлся бы по свойствам самого массива.
*/
type DeepRequired<T> = T extends (infer U)[]
? DeepRequired<U>[]
: T extends object
? { [K in keyof T]-?: DeepRequired<NonNullable<T[K]>> }
: T;
/** Конфиг Hysteria в том виде, в котором его показывает панель. */
export type Hysteria2ServerConfigView = DeepRequired<Hysteria2ServerConfig>;
/** Один outbound в том же виде. */
export type Hysteria2ServerConfigOutboundView =
DeepRequired<Hysteria2ServerConfigOutbound>;
/**
* Полное значение по умолчанию: каждая секция заполнена.
*
* Тип здесь не декоративный. `Hysteria2ServerConfigView` требует все поля, и
* добавление секции в `Hysteria2ServerConfig` сломает компиляцию ровно здесь —
* то есть новое поле upstream нельзя молча не отобразить.
*/
export const defaultHysteria2ServerConfigView: Hysteria2ServerConfigView = {
listen: ":443",
tls: {
cert: "",
key: "",
sniGuard: "",
clientCA: "",
},
ech: {
keyPath: "",
},
acme: {
domains: [],
email: "",
ca: "letsencrypt",
listenHost: "0.0.0.0",
dir: "/var/lib/hysteria/acme",
type: "",
http: {
altPort: 8888,
},
tls: {
altPort: 44333,
},
dns: {
name: "cloudflare",
config: {},
},
disableHTTP: false,
disableTLSALPN: false,
altHTTPPort: 80,
altTLSALPNPort: 443,
},
obfs: {
type: "gecko",
salamander: {
password: "",
},
gecko: {
password: "",
minPacketSize: 512,
maxPacketSize: 1200,
},
},
quic: {
initStreamReceiveWindow: 8388608,
maxStreamReceiveWindow: 8388608,
initConnReceiveWindow: 20971520,
maxConnReceiveWindow: 20971520,
maxIdleTimeout: "30s",
maxIncomingStreams: 1024,
disablePathMTUDiscovery: false,
disableStatelessReset: false,
},
bandwidth: {
up: "50 mbps",
down: "50 mbps",
disableLossCompensation: false,
},
congestion: {
type: "bbr",
bbrProfile: "standard",
},
ignoreClientBandwidth: false,
speedTest: false,
disableUDP: false,
udpIdleTimeout: "60s",
resolver: {
type: "",
tcp: {
addr: "8.8.8.8:53",
timeout: "4s",
},
udp: {
addr: "8.8.4.4:53",
timeout: "4s",
},
tls: {
addr: "1.1.1.1:853",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
https: {
addr: "1.1.1.1:443",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
},
sniff: {
enable: true,
timeout: "2s",
rewriteDomain: false,
tcpPorts: "80,443,8000-9000",
udpPorts: "all",
},
acl: {
file: "",
inline: [],
geoip: "",
geosite: "",
geoUpdateInterval: "168h",
},
outbounds: [],
trafficStats: {
listen: ":9999",
},
masquerade: {
type: "",
file: {
dir: "",
},
proxy: {
url: "",
rewriteHost: true,
insecure: false,
xForwarded: false,
},
string: {
content: "hello stupid world",
headers: {},
statusCode: 200,
},
listenHTTP: ":80",
listenHTTPS: ":443",
forceHTTPS: true,
},
mimic: {
enabled: false,
interface: "",
xdpMode: "",
path: "",
extraArgs: [],
},
realm: {
stunServers: [],
stunTimeout: "",
punchTimeout: "",
heartbeatInterval: "",
insecure: false,
ipMode: "",
portMapping: {
enabled: false,
timeout: "",
lifetime: "",
},
},
};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" && value !== null && !Array.isArray(value)
);
}
/**
* Рекурсивное наложение ответа сервера на значение по умолчанию.
*
* `null` и `undefined` игнорируются намеренно: в YAML отсутствующая секция и
* секция со значением `null` означают одно и то же — «не задано», — и обе
* обязаны оставить значение по умолчанию, а не обнулить поле формы.
*
* Массивы заменяются целиком, а не сливаются поэлементно: список ACL-правил
* или outbounds с сервера — это весь список, а не патч к дефолтному.
*/
function mergeInto(target: Record<string, unknown>, source: unknown): void {
if (!isPlainObject(source)) {
return;
}
for (const [key, value] of Object.entries(source)) {
if (value === null || value === undefined) {
continue;
}
if (Array.isArray(value)) {
target[key] = value;
continue;
}
if (isPlainObject(value)) {
const existing = target[key];
if (!isPlainObject(existing)) {
target[key] = {};
}
mergeInto(target[key] as Record<string, unknown>, value);
continue;
}
target[key] = value;
}
}
function cloneDefaults(): Hysteria2ServerConfigView {
// structuredClone есть во всех целевых браузерах и, в отличие от
// JSON.parse(JSON.stringify(...)), не тратит проход на сериализацию.
return structuredClone(defaultHysteria2ServerConfigView);
}
/**
* Приводит ответ сервера к модели, с которой работает форма.
*
* Пустой или отсутствующий ответ даёт полное значение по умолчанию: это то же
* состояние, в котором форма находится до первого запроса.
*/
export function normalizeHysteriaViewModel(
raw: Hysteria2ServerConfig | null | undefined
): Hysteria2ServerConfigView {
const view = cloneDefaults();
mergeInto(view as unknown as Record<string, unknown>, raw);
return view;
}
/** Значение по умолчанию для одного outbound. */
export const defaultHysteria2ServerConfigOutboundView: Hysteria2ServerConfigOutboundView =
{
name: "",
type: "socks5",
socks5: {
addr: "",
username: "",
password: "",
},
http: {
url: "",
insecure: false,
},
direct: {
mode: "auto",
bindIPv4: "",
bindIPv6: "",
bindDevice: "",
fastOpen: false,
},
};
/**
* Тот же приём для одного outbound: список приходит с необязательными
* подблоками, а карточка показывает их как обычные поля.
*/
export function normalizeOutboundViewModel(
raw: Hysteria2ServerConfigOutbound | null | undefined
): Hysteria2ServerConfigOutboundView {
const view = structuredClone(defaultHysteria2ServerConfigOutboundView);
mergeInto(view as unknown as Record<string, unknown>, raw);
return view;
}
+11 -145
View File
@@ -19,6 +19,17 @@ export interface ConfigUpdateDto {
configUpdateDtos: Array<ConfigsUpdateDto>;
}
/**
* Конфиг Hysteria в том виде, в котором он ПРИХОДИТ ПО СЕТИ.
*
* Необязательность секций здесь не оплошность: ровно так устроен upstream YAML,
* и модель обязана его повторять, иначе она врала бы об ответе сервера.
*
* Форма панели работает не с этим типом, а с `Hysteria2ServerConfigView` из
* `hysteriaViewModel.ts`, где все секции обязательны. Значения по умолчанию
* живут там же: пока они лежали здесь, рядом с описанием ответа сервера, они
* выглядели частью протокола, хотя относятся исключительно к отображению.
*/
export interface Hysteria2ServerConfig {
listen: string;
tls?: {
@@ -169,129 +180,6 @@ export interface Hysteria2ServerConfig {
};
}
export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
listen: ":443",
tls: {
cert: "",
key: "",
sniGuard: "",
},
acme: {
domains: [],
email: "",
ca: "letsencrypt",
listenHost: "0.0.0.0",
dir: "/var/lib/hysteria/acme",
type: "",
http: {
altPort: 8888,
},
tls: {
altPort: 44333,
},
dns: {
name: "cloudflare",
config: {},
},
disableHTTP: false,
disableTLSALPN: false,
altHTTPPort: 80,
altTLSALPNPort: 443,
},
obfs: {
type: "gecko",
gecko: {
password: "",
minPacketSize: 512,
maxPacketSize: 1200,
},
},
quic: {
initStreamReceiveWindow: 8388608,
maxStreamReceiveWindow: 8388608,
initConnReceiveWindow: 20971520,
maxConnReceiveWindow: 20971520,
maxIdleTimeout: "30s",
maxIncomingStreams: 1024,
disablePathMTUDiscovery: false,
disableStatelessReset: false,
},
bandwidth: {
up: "50 mbps",
down: "50 mbps",
disableLossCompensation: false,
},
congestion: {
type: "bbr",
bbrProfile: "standard",
},
ignoreClientBandwidth: false,
speedTest: false,
disableUDP: false,
udpIdleTimeout: "60s",
resolver: {
type: "",
tcp: {
addr: "8.8.8.8:53",
timeout: "4s",
},
udp: {
addr: "8.8.4.4:53",
timeout: "4s",
},
tls: {
addr: "1.1.1.1:853",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
https: {
addr: "1.1.1.1:443",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
},
sniff: {
enable: true,
timeout: "2s",
rewriteDomain: false,
tcpPorts: "80,443,8000-9000",
udpPorts: "all",
},
acl: {
file: "",
inline: [],
geoip: "",
geosite: "",
geoUpdateInterval: "168h",
},
outbounds: [],
trafficStats: {
listen: ":9999",
},
masquerade: {
type: "",
file: {
dir: "",
},
proxy: {
url: "",
rewriteHost: true,
insecure: false,
xForwarded: false,
},
string: {
content: "hello stupid world",
headers: {},
statusCode: 200,
},
listenHTTP: ":80",
listenHTTPS: ":443",
forceHTTPS: true,
},
};
export interface Hysteria2ServerConfigOutbound {
name: string;
type: string;
@@ -313,28 +201,6 @@ export interface Hysteria2ServerConfigOutbound {
};
}
export const defaultHysteria2ServerConfigOutbound: Hysteria2ServerConfigOutbound =
{
name: "",
type: "socks5",
socks5: {
addr: "",
username: undefined,
password: undefined,
},
http: {
url: "",
insecure: false,
},
direct: {
mode: "auto",
bindIPv4: undefined,
bindIPv6: undefined,
bindDevice: undefined,
fastOpen: false,
},
};
export interface Tab {
name: string;
desc: string;
+2 -1
View File
@@ -80,6 +80,7 @@ export default {
add: "Add",
confirm: "Confirm",
cancel: "Cancel",
close: "Close",
copySuccess: "Copy successful",
nodeUrl: "Node URL",
nodeQrCode: "Node QR Code",
@@ -226,7 +227,7 @@ export default {
addConfigItem: "Add Config Item",
hysteria2Version: "Hysteria2 Version",
hysteria2Running: "Hysteria2 Status",
addOutbound: "Add Outbound",
outboundsEmpty: "No outbounds are configured on the server",
listen: "Listen",
tls: "TLS",
obfs: "Obfuscation",
+4 -1
View File
@@ -77,6 +77,7 @@ export default {
add: "Добавить",
confirm: "Подтвердить",
cancel: "Отмена",
close: "Закрыть",
copySuccess: "Скопировано",
nodeUrl: "URL узла",
nodeQrCode: "QR узла",
@@ -222,7 +223,9 @@ export default {
addConfigItem: "Добавить параметр",
hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2",
addOutbound: "Добавить outbound",
// addOutbound удалён вместе с редактором outbounds: страница read-only,
// маршрутов записи серверного конфига в API нет.
outboundsEmpty: "Outbounds в конфигурации сервера не заданы",
listen: "Адрес прослушивания",
tls: "TLS",
obfs: "Маскировка",
+1
View File
@@ -40,6 +40,7 @@ declare module '@vue/runtime-core' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
@@ -1,152 +1,17 @@
<template>
<div class="flex gap-2">
<el-tag
:key="item"
v-for="item in outbounds"
@close="handleClose(item)"
:key="item.name"
@click="handleInfo(item)"
size="large"
closable
>
{{ item.name }}
</el-tag>
<el-button @click="handleAdd">+</el-button>
<el-dialog
:title="dialog.title"
v-model="dialog.visible"
width="600px"
append-to-body
@close="closeDialog"
>
<el-form ref="dataFormRef" label-position="top" :model="dataForm">
<el-tooltip
:content="$t('hysteria.config.outbounds.name')"
placement="bottom"
>
<el-form-item label="name" prop="name">
<el-input v-model="dataForm.name" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.type')"
placement="bottom"
>
<el-form-item label="type" prop="type">
<el-select v-model="dataForm.type" style="width: 100%">
<el-option
v-for="item in outboundTypes"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-tooltip>
<template v-if="dataForm.type === 'socks5'">
<el-tooltip
:content="$t('hysteria.config.outbounds.socks5.addr')"
placement="bottom"
>
<el-form-item label="socks5.addr" prop="socks5.addr">
<el-input v-model="dataForm.socks5.addr" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.socks5.username')"
placement="bottom"
>
<el-form-item label="socks5.username" prop="socks5.username">
<el-input v-model="dataForm.socks5.username" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.socks5.password')"
placement="bottom"
>
<el-form-item label="socks5.password" prop="socks5.password">
<el-input v-model="dataForm.socks5.password" clearable />
</el-form-item>
</el-tooltip>
</template>
<template v-if="dataForm.type === 'http'">
<el-tooltip
:content="$t('hysteria.config.outbounds.http.url')"
placement="bottom"
>
<el-form-item label="http.url" prop="http.url">
<el-input v-model="dataForm.http.url" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.http.insecure')"
placement="bottom"
>
<el-form-item label="http.insecure" prop="http.insecure">
<el-switch v-model="dataForm.http.insecure" />
</el-form-item>
</el-tooltip>
</template>
<template v-if="dataForm.type === 'direct'">
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.mode')"
placement="bottom"
>
<el-form-item label="direct.mode" prop="direct.mode">
<el-select v-model="dataForm.direct.mode" style="width: 100%">
<el-option
v-for="item in outboundDirectModes"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.bindIPv4')"
placement="bottom"
>
<el-form-item label="direct.bindIPv4" prop="direct.bindIPv4">
<el-input v-model="dataForm.direct.bindIPv4" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.bindIPv6')"
placement="bottom"
>
<el-form-item label="direct.bindIPv6" prop="direct.bindIPv6">
<el-input v-model="dataForm.direct.bindIPv6" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.bindDevice')"
placement="bottom"
>
<el-form-item label="direct.bindDevice" prop="direct.bindDevice">
<el-input v-model="dataForm.direct.bindDevice" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.fastOpen')"
placement="bottom"
>
<el-form-item label="direct.fastOpen" prop="direct.fastOpen">
<el-switch v-model="dataForm.direct.fastOpen" />
</el-form-item>
</el-tooltip>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"
>{{ $t("common.confirm") }}
</el-button>
<el-button @click="closeDialog">{{ $t("common.cancel") }}</el-button>
</div>
</template>
</el-dialog>
<el-text v-if="!outbounds.length" type="info">
{{ $t("hysteria.outboundsEmpty") }}
</el-text>
<el-dialog
:title="outboundInfoDialog.title"
@@ -185,7 +50,7 @@
:content="$t('hysteria.config.outbounds.socks5.username')"
placement="bottom"
>
<el-form-item label="socks5.username" prop="outboundInfo.username">
<el-form-item label="socks5.username" prop="socks5.username">
<el-tag>{{ outboundInfo.socks5.username }}</el-tag>
</el-form-item>
</el-tooltip>
@@ -262,7 +127,7 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="outboundInfoDialog.visible = false"
>{{ $t("common.cancel") }}
>{{ $t("common.close") }}
</el-button>
</div>
</template>
@@ -277,16 +142,36 @@ export default {
</script>
<script setup lang="ts">
import type { Hysteria2ServerConfigOutbound } from "@/api/config/types";
import {
defaultHysteria2ServerConfigOutbound,
Hysteria2ServerConfigOutbound,
} from "@/api/config/types";
defaultHysteria2ServerConfigOutboundView,
normalizeOutboundViewModel,
type Hysteria2ServerConfigOutboundView,
} from "@/api/config/hysteriaViewModel";
import { PropType } from "vue";
import { deepCopy } from "@/utils/copy";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
// Компонент только показывает outbounds, и это не сужение возможностей, а
// приведение UI в соответствие с тем, что продукт умеет.
//
// Что было. Здесь жил полноценный редактор: кнопка «+», диалог создания,
// submitForm с валидацией, closable-теги с удалением и `emit("update:outbounds")`.
// Ни одно из этих действий не могло ничего сохранить:
//
// родительская форма отрисована с `:disabled="true"` — страница read-only и
// прямо сообщает, что конфигом Hysteria владеет `hy2xs-orchestrator reconfigure`;
//
// родитель передаёт `:outbounds="dataForm.outbounds"` БЕЗ v-model, то есть
// слушателя у `update:outbounds` не существует;
//
// маршрутов записи серверного конфига в API нет — они удалены вместе с
// мёртвым updater/config-write слоем.
//
// То есть оператор мог добавить outbound, увидеть его в списке и уйти в
// уверенности, что изменил конфигурацию сервера. Изменения не переживали даже
// перехода на другую вкладку.
const props = defineProps({
outbounds: {
required: true,
@@ -295,84 +180,27 @@ const props = defineProps({
},
});
const emit = defineEmits<{
(
event: "update:outbounds",
value: Array<Hysteria2ServerConfigOutbound>
): void;
}>();
const outbounds = useVModel(props, "outbounds", emit);
const dataFormRef = ref(ElForm);
const outbounds = computed(() => props.outbounds ?? []);
const state = reactive({
dataForm: {
...defaultHysteria2ServerConfigOutbound,
} as Hysteria2ServerConfigOutbound,
dialog: {
title: t("hysteria.addOutbound"),
visible: false,
} as DialogType,
outboundInfoDialog: {
title: t("hysteria.outbounds"),
visible: false,
},
outboundInfo: {} as Hysteria2ServerConfigOutbound,
// Карточка работает с нормализованной моделью по той же причине, что и
// основная форма: подблоки socks5/http/direct в схеме необязательны, а
// шаблон обращается к их полям напрямую.
outboundInfo: {
...defaultHysteria2ServerConfigOutboundView,
} as Hysteria2ServerConfigOutboundView,
});
const { dataForm, dialog, outboundInfoDialog, outboundInfo } = toRefs(state);
const outboundTypes = ["socks5", "http", "direct"];
const outboundDirectModes = ["auto", "64", "46", "6", "4"];
const handleAdd = () => {
state.dialog.visible = true;
};
const handleClose = (outbound: Hysteria2ServerConfigOutbound): void => {
const index = outbounds.value.indexOf(outbound);
if (index !== -1) {
outbounds.value.splice(index, 1);
}
};
const { outboundInfoDialog, outboundInfo } = toRefs(state);
const handleInfo = (outbound: Hysteria2ServerConfigOutbound) => {
state.outboundInfo = outbound;
state.outboundInfo = normalizeOutboundViewModel(outbound);
state.outboundInfoDialog.visible = true;
};
const submitForm = () => {
dataFormRef.value.validate((valid: any) => {
if (valid) {
if (outbounds.value.some((item) => item.name === state.dataForm.name)) {
ElMessage.error(t("common.invalid"));
return;
}
if (state.dataForm.type === "socks5") {
state.dataForm.http = undefined;
state.dataForm.direct = undefined;
} else if (state.dataForm.type === "http") {
state.dataForm.socks5 = undefined;
state.dataForm.direct = undefined;
} else if (state.dataForm.type === "direct") {
state.dataForm.socks5 = undefined;
state.dataForm.http = undefined;
}
let outbound = deepCopy(state.dataForm);
outbounds.value.push(outbound);
closeDialog();
}
});
};
const closeDialog = (): void => {
state.dialog.visible = false;
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
};
</script>
<style lang="scss" scoped>
+44 -48
View File
@@ -313,11 +313,7 @@
</el-select>
</el-form-item>
</el-tooltip>
<template
v-if="
dataForm.obfs.type === 'salamander' && dataForm.obfs.salamander
"
>
<template v-if="dataForm.obfs.type === 'salamander'">
<el-tooltip
:content="$t('hysteria.config.obfs.salamander.password')"
placement="bottom"
@@ -333,9 +329,7 @@
</el-form-item>
</el-tooltip>
</template>
<template
v-if="dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko"
>
<template v-if="dataForm.obfs.type === 'gecko'">
<el-tooltip
:content="$t('hysteria.config.obfs.gecko.password')"
placement="bottom"
@@ -1048,13 +1042,14 @@ export default {
<script setup lang="ts">
import {
defaultHysteria2ServerConfig,
Hysteria2ServerConfig,
} from "@/api/config/types";
defaultHysteria2ServerConfigView,
normalizeHysteriaViewModel,
type Hysteria2ServerConfigView,
} from "@/api/config/hysteriaViewModel";
import Outbounds from "./components/Outbounds/index.vue";
import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
import { useI18n } from "vue-i18n";
import { assignWith, deepCopy } from "@/utils/copy";
import { deepCopy } from "@/utils/copy";
import { dashboardSummaryApi } from "@/api/dashboard";
const { t } = useI18n();
@@ -1109,7 +1104,9 @@ const resolverTypes = ref<string[]>(["tcp", "udp", "tls", "https"]);
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
const state = reactive({
dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig,
dataForm: deepCopy(
defaultHysteria2ServerConfigView
) as Hysteria2ServerConfigView,
activeName: "listen",
tlsType: "acme",
aclType: "inline",
@@ -1172,45 +1169,44 @@ const handleExport = async () => {
const setConfig = () => {
getHysteria2ConfigApi().then((response) => {
const data = response.data;
if (data) {
state.tlsType = data?.tls?.cert && data?.tls?.key ? "tls" : "acme";
state.aclType = data?.acl?.inline ? "inline" : "file";
state.obfs = !!data?.obfs;
state.quic = !!data?.quic;
state.bandwidth = !!data?.bandwidth;
state.congestion = !!data?.congestion;
state.speedTest = !!data?.speedTest;
state.udp = !!data?.disableUDP || !!data?.udpIdleTimeout;
state.resolver = !!data?.resolver;
state.sniff = !!data?.sniff;
state.acl = !!data?.acl;
state.outbounds = !!data?.outbounds;
state.masquerade = !!data?.masquerade;
state.dataForm = deepCopy(defaultHysteria2ServerConfig);
assignWith(state.dataForm, data);
dropInactiveObfsSubtype(state.dataForm, data);
if (!data) {
return;
}
// Какие вкладки показывать, решает ОТВЕТ СЕРВЕРА, а не форма: в форме после
// нормализации есть все секции, потому что это её инвариант. Эти флаги —
// единственное место, где присутствие секции в исходном конфиге ещё имеет
// значение, и поэтому они читаются из `data`, а не из `state.dataForm`.
state.tlsType = data.tls?.cert && data.tls?.key ? "tls" : "acme";
state.aclType = data.acl?.inline ? "inline" : "file";
state.obfs = !!data.obfs;
state.quic = !!data.quic;
state.bandwidth = !!data.bandwidth;
state.congestion = !!data.congestion;
state.speedTest = !!data.speedTest;
state.udp = !!data.disableUDP || !!data.udpIdleTimeout;
state.resolver = !!data.resolver;
state.sniff = !!data.sniff;
state.acl = !!data.acl;
state.outbounds = !!data.outbounds;
state.masquerade = !!data.masquerade;
state.dataForm = normalizeHysteriaViewModel(data);
});
};
// Форма строится как merge дефолта и ответа API, поэтому неактивная ветка obfs
// осталась бы от дефолта и UI показал бы блок, которого нет в конфиге сервера.
const dropInactiveObfsSubtype = (
form: Hysteria2ServerConfig,
data: Hysteria2ServerConfig
) => {
if (!form.obfs) {
return;
}
if (form.obfs.type !== "gecko" || !data?.obfs?.gecko) {
delete form.obfs.gecko;
}
if (form.obfs.type !== "salamander" || !data?.obfs?.salamander) {
delete form.obfs.salamander;
}
};
// dropInactiveObfsSubtype здесь больше нет.
//
// Функция удаляла из формы неактивную ветку obfs, потому что форма строилась
// merge'ем поверх дефолта и показала бы блок, которого нет в конфиге сервера.
// Но какой блок показывать, всегда однозначно определял `obfs.type` — а он
// приходит с сервера. Проверка присутствия дублировала проверку типа и была
// единственной причиной, по которой шаблону приходилось писать
// `v-if="dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko"`.
//
// Теперь ветку выбирает только тип, а присутствие обеих обеспечивает
// нормализация.
const setHysteria2Monitor = async () => {
const { data } = await dashboardSummaryApi();