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:
@@ -8,7 +8,9 @@
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
"dev": "vite serve --mode development",
|
||||
"build:prod": "vite build --mode production && vue-tsc --noEmit",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"build:prod": "vite build --mode production",
|
||||
"verify": "pnpm run typecheck && pnpm run build:prod",
|
||||
"lint:eslint": "eslint --fix --ext .ts,.js,.vue ./src ",
|
||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
||||
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix"
|
||||
@@ -24,7 +26,7 @@
|
||||
"path-browserify": "^1.0.1",
|
||||
"pinia": "^2.0.33",
|
||||
"qrcode.vue": "3.4.1",
|
||||
"vue": "^3.2.45",
|
||||
"vue": "^3.5.42",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "^4.1.6",
|
||||
@@ -54,14 +56,14 @@
|
||||
"stylelint-config-recommended-vue": "^1.4.0",
|
||||
"stylelint-config-standard": "^32.0.0",
|
||||
"stylelint-config-standard-scss": "^8.0.0",
|
||||
"typescript": "^4.9.3",
|
||||
"typescript": "^5.9.3",
|
||||
"unocss": "^0.50.1",
|
||||
"unplugin-auto-import": "^0.13.0",
|
||||
"unplugin-icons": "^0.15.1",
|
||||
"unplugin-vue-components": "^0.23.0",
|
||||
"vite": "^4.3.1",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vue-tsc": "^0.35.0"
|
||||
"vue-tsc": "^3.3.11"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
||||
Generated
+305
-320
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user