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",
|
"packageManager": "pnpm@9.15.9",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite serve --mode development",
|
"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:eslint": "eslint --fix --ext .ts,.js,.vue ./src ",
|
||||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
||||||
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix"
|
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix"
|
||||||
@@ -24,7 +26,7 @@
|
|||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"pinia": "^2.0.33",
|
"pinia": "^2.0.33",
|
||||||
"qrcode.vue": "3.4.1",
|
"qrcode.vue": "3.4.1",
|
||||||
"vue": "^3.2.45",
|
"vue": "^3.5.42",
|
||||||
"vue-echarts": "^7.0.3",
|
"vue-echarts": "^7.0.3",
|
||||||
"vue-i18n": "9",
|
"vue-i18n": "9",
|
||||||
"vue-router": "^4.1.6",
|
"vue-router": "^4.1.6",
|
||||||
@@ -54,14 +56,14 @@
|
|||||||
"stylelint-config-recommended-vue": "^1.4.0",
|
"stylelint-config-recommended-vue": "^1.4.0",
|
||||||
"stylelint-config-standard": "^32.0.0",
|
"stylelint-config-standard": "^32.0.0",
|
||||||
"stylelint-config-standard-scss": "^8.0.0",
|
"stylelint-config-standard-scss": "^8.0.0",
|
||||||
"typescript": "^4.9.3",
|
"typescript": "^5.9.3",
|
||||||
"unocss": "^0.50.1",
|
"unocss": "^0.50.1",
|
||||||
"unplugin-auto-import": "^0.13.0",
|
"unplugin-auto-import": "^0.13.0",
|
||||||
"unplugin-icons": "^0.15.1",
|
"unplugin-icons": "^0.15.1",
|
||||||
"unplugin-vue-components": "^0.23.0",
|
"unplugin-vue-components": "^0.23.0",
|
||||||
"vite": "^4.3.1",
|
"vite": "^4.3.1",
|
||||||
"vite-plugin-svg-icons": "^2.0.1",
|
"vite-plugin-svg-icons": "^2.0.1",
|
||||||
"vue-tsc": "^0.35.0"
|
"vue-tsc": "^3.3.11"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"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>;
|
configUpdateDtos: Array<ConfigsUpdateDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфиг Hysteria в том виде, в котором он ПРИХОДИТ ПО СЕТИ.
|
||||||
|
*
|
||||||
|
* Необязательность секций здесь не оплошность: ровно так устроен upstream YAML,
|
||||||
|
* и модель обязана его повторять, иначе она врала бы об ответе сервера.
|
||||||
|
*
|
||||||
|
* Форма панели работает не с этим типом, а с `Hysteria2ServerConfigView` из
|
||||||
|
* `hysteriaViewModel.ts`, где все секции обязательны. Значения по умолчанию
|
||||||
|
* живут там же: пока они лежали здесь, рядом с описанием ответа сервера, они
|
||||||
|
* выглядели частью протокола, хотя относятся исключительно к отображению.
|
||||||
|
*/
|
||||||
export interface Hysteria2ServerConfig {
|
export interface Hysteria2ServerConfig {
|
||||||
listen: string;
|
listen: string;
|
||||||
tls?: {
|
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 {
|
export interface Hysteria2ServerConfigOutbound {
|
||||||
name: string;
|
name: string;
|
||||||
type: 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 {
|
export interface Tab {
|
||||||
name: string;
|
name: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export default {
|
|||||||
add: "Add",
|
add: "Add",
|
||||||
confirm: "Confirm",
|
confirm: "Confirm",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
|
close: "Close",
|
||||||
copySuccess: "Copy successful",
|
copySuccess: "Copy successful",
|
||||||
nodeUrl: "Node URL",
|
nodeUrl: "Node URL",
|
||||||
nodeQrCode: "Node QR Code",
|
nodeQrCode: "Node QR Code",
|
||||||
@@ -226,7 +227,7 @@ export default {
|
|||||||
addConfigItem: "Add Config Item",
|
addConfigItem: "Add Config Item",
|
||||||
hysteria2Version: "Hysteria2 Version",
|
hysteria2Version: "Hysteria2 Version",
|
||||||
hysteria2Running: "Hysteria2 Status",
|
hysteria2Running: "Hysteria2 Status",
|
||||||
addOutbound: "Add Outbound",
|
outboundsEmpty: "No outbounds are configured on the server",
|
||||||
listen: "Listen",
|
listen: "Listen",
|
||||||
tls: "TLS",
|
tls: "TLS",
|
||||||
obfs: "Obfuscation",
|
obfs: "Obfuscation",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export default {
|
|||||||
add: "Добавить",
|
add: "Добавить",
|
||||||
confirm: "Подтвердить",
|
confirm: "Подтвердить",
|
||||||
cancel: "Отмена",
|
cancel: "Отмена",
|
||||||
|
close: "Закрыть",
|
||||||
copySuccess: "Скопировано",
|
copySuccess: "Скопировано",
|
||||||
nodeUrl: "URL узла",
|
nodeUrl: "URL узла",
|
||||||
nodeQrCode: "QR узла",
|
nodeQrCode: "QR узла",
|
||||||
@@ -222,7 +223,9 @@ export default {
|
|||||||
addConfigItem: "Добавить параметр",
|
addConfigItem: "Добавить параметр",
|
||||||
hysteria2Version: "Версия Hysteria2",
|
hysteria2Version: "Версия Hysteria2",
|
||||||
hysteria2Running: "Статус Hysteria2",
|
hysteria2Running: "Статус Hysteria2",
|
||||||
addOutbound: "Добавить outbound",
|
// addOutbound удалён вместе с редактором outbounds: страница read-only,
|
||||||
|
// маршрутов записи серверного конфига в API нет.
|
||||||
|
outboundsEmpty: "Outbounds в конфигурации сервера не заданы",
|
||||||
listen: "Адрес прослушивания",
|
listen: "Адрес прослушивания",
|
||||||
tls: "TLS",
|
tls: "TLS",
|
||||||
obfs: "Маскировка",
|
obfs: "Маскировка",
|
||||||
|
|||||||
+1
@@ -40,6 +40,7 @@ declare module '@vue/runtime-core' {
|
|||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
|
ElText: typeof import('element-plus/es')['ElText']
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
|
Hamburger: typeof import('./../components/Hamburger/index.vue')['default']
|
||||||
|
|||||||
@@ -1,152 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<el-tag
|
<el-tag
|
||||||
:key="item"
|
|
||||||
v-for="item in outbounds"
|
v-for="item in outbounds"
|
||||||
@close="handleClose(item)"
|
:key="item.name"
|
||||||
@click="handleInfo(item)"
|
@click="handleInfo(item)"
|
||||||
size="large"
|
size="large"
|
||||||
closable
|
|
||||||
>
|
>
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
|
|
||||||
<el-button @click="handleAdd">+</el-button>
|
<el-text v-if="!outbounds.length" type="info">
|
||||||
|
{{ $t("hysteria.outboundsEmpty") }}
|
||||||
<el-dialog
|
</el-text>
|
||||||
: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-dialog
|
<el-dialog
|
||||||
:title="outboundInfoDialog.title"
|
:title="outboundInfoDialog.title"
|
||||||
@@ -185,7 +50,7 @@
|
|||||||
:content="$t('hysteria.config.outbounds.socks5.username')"
|
:content="$t('hysteria.config.outbounds.socks5.username')"
|
||||||
placement="bottom"
|
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-tag>{{ outboundInfo.socks5.username }}</el-tag>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
@@ -262,7 +127,7 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<el-button @click="outboundInfoDialog.visible = false"
|
<el-button @click="outboundInfoDialog.visible = false"
|
||||||
>{{ $t("common.cancel") }}
|
>{{ $t("common.close") }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -277,16 +142,36 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Hysteria2ServerConfigOutbound } from "@/api/config/types";
|
||||||
import {
|
import {
|
||||||
defaultHysteria2ServerConfigOutbound,
|
defaultHysteria2ServerConfigOutboundView,
|
||||||
Hysteria2ServerConfigOutbound,
|
normalizeOutboundViewModel,
|
||||||
} from "@/api/config/types";
|
type Hysteria2ServerConfigOutboundView,
|
||||||
|
} from "@/api/config/hysteriaViewModel";
|
||||||
import { PropType } from "vue";
|
import { PropType } from "vue";
|
||||||
import { deepCopy } from "@/utils/copy";
|
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
const { t } = useI18n();
|
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({
|
const props = defineProps({
|
||||||
outbounds: {
|
outbounds: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -295,84 +180,27 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const outbounds = computed(() => props.outbounds ?? []);
|
||||||
(
|
|
||||||
event: "update:outbounds",
|
|
||||||
value: Array<Hysteria2ServerConfigOutbound>
|
|
||||||
): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const outbounds = useVModel(props, "outbounds", emit);
|
|
||||||
|
|
||||||
const dataFormRef = ref(ElForm);
|
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
dataForm: {
|
|
||||||
...defaultHysteria2ServerConfigOutbound,
|
|
||||||
} as Hysteria2ServerConfigOutbound,
|
|
||||||
dialog: {
|
|
||||||
title: t("hysteria.addOutbound"),
|
|
||||||
visible: false,
|
|
||||||
} as DialogType,
|
|
||||||
outboundInfoDialog: {
|
outboundInfoDialog: {
|
||||||
title: t("hysteria.outbounds"),
|
title: t("hysteria.outbounds"),
|
||||||
visible: false,
|
visible: false,
|
||||||
},
|
},
|
||||||
outboundInfo: {} as Hysteria2ServerConfigOutbound,
|
// Карточка работает с нормализованной моделью по той же причине, что и
|
||||||
|
// основная форма: подблоки socks5/http/direct в схеме необязательны, а
|
||||||
|
// шаблон обращается к их полям напрямую.
|
||||||
|
outboundInfo: {
|
||||||
|
...defaultHysteria2ServerConfigOutboundView,
|
||||||
|
} as Hysteria2ServerConfigOutboundView,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { dataForm, dialog, outboundInfoDialog, outboundInfo } = toRefs(state);
|
const { 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 handleInfo = (outbound: Hysteria2ServerConfigOutbound) => {
|
const handleInfo = (outbound: Hysteria2ServerConfigOutbound) => {
|
||||||
state.outboundInfo = outbound;
|
state.outboundInfo = normalizeOutboundViewModel(outbound);
|
||||||
state.outboundInfoDialog.visible = true;
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -313,11 +313,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<template
|
<template v-if="dataForm.obfs.type === 'salamander'">
|
||||||
v-if="
|
|
||||||
dataForm.obfs.type === 'salamander' && dataForm.obfs.salamander
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
:content="$t('hysteria.config.obfs.salamander.password')"
|
:content="$t('hysteria.config.obfs.salamander.password')"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
@@ -333,9 +329,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<template
|
<template v-if="dataForm.obfs.type === 'gecko'">
|
||||||
v-if="dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko"
|
|
||||||
>
|
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
:content="$t('hysteria.config.obfs.gecko.password')"
|
:content="$t('hysteria.config.obfs.gecko.password')"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
@@ -1048,13 +1042,14 @@ export default {
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
defaultHysteria2ServerConfig,
|
defaultHysteria2ServerConfigView,
|
||||||
Hysteria2ServerConfig,
|
normalizeHysteriaViewModel,
|
||||||
} from "@/api/config/types";
|
type Hysteria2ServerConfigView,
|
||||||
|
} from "@/api/config/hysteriaViewModel";
|
||||||
import Outbounds from "./components/Outbounds/index.vue";
|
import Outbounds from "./components/Outbounds/index.vue";
|
||||||
import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
|
import { exportHysteria2ConfigApi, getHysteria2ConfigApi } from "@/api/config";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { assignWith, deepCopy } from "@/utils/copy";
|
import { deepCopy } from "@/utils/copy";
|
||||||
import { dashboardSummaryApi } from "@/api/dashboard";
|
import { dashboardSummaryApi } from "@/api/dashboard";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -1109,7 +1104,9 @@ const resolverTypes = ref<string[]>(["tcp", "udp", "tls", "https"]);
|
|||||||
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
|
const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig,
|
dataForm: deepCopy(
|
||||||
|
defaultHysteria2ServerConfigView
|
||||||
|
) as Hysteria2ServerConfigView,
|
||||||
activeName: "listen",
|
activeName: "listen",
|
||||||
tlsType: "acme",
|
tlsType: "acme",
|
||||||
aclType: "inline",
|
aclType: "inline",
|
||||||
@@ -1172,45 +1169,44 @@ const handleExport = async () => {
|
|||||||
const setConfig = () => {
|
const setConfig = () => {
|
||||||
getHysteria2ConfigApi().then((response) => {
|
getHysteria2ConfigApi().then((response) => {
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data) {
|
if (!data) {
|
||||||
state.tlsType = data?.tls?.cert && data?.tls?.key ? "tls" : "acme";
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Какие вкладки показывать, решает ОТВЕТ СЕРВЕРА, а не форма: в форме после
|
||||||
|
// нормализации есть все секции, потому что это её инвариант. Эти флаги —
|
||||||
|
// единственное место, где присутствие секции в исходном конфиге ещё имеет
|
||||||
|
// значение, и поэтому они читаются из `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
|
// dropInactiveObfsSubtype здесь больше нет.
|
||||||
// осталась бы от дефолта и UI показал бы блок, которого нет в конфиге сервера.
|
//
|
||||||
const dropInactiveObfsSubtype = (
|
// Функция удаляла из формы неактивную ветку obfs, потому что форма строилась
|
||||||
form: Hysteria2ServerConfig,
|
// merge'ем поверх дефолта и показала бы блок, которого нет в конфиге сервера.
|
||||||
data: Hysteria2ServerConfig
|
// Но какой блок показывать, всегда однозначно определял `obfs.type` — а он
|
||||||
) => {
|
// приходит с сервера. Проверка присутствия дублировала проверку типа и была
|
||||||
if (!form.obfs) {
|
// единственной причиной, по которой шаблону приходилось писать
|
||||||
return;
|
// `v-if="dataForm.obfs.type === 'gecko' && dataForm.obfs.gecko"`.
|
||||||
}
|
//
|
||||||
if (form.obfs.type !== "gecko" || !data?.obfs?.gecko) {
|
// Теперь ветку выбирает только тип, а присутствие обеих обеспечивает
|
||||||
delete form.obfs.gecko;
|
// нормализация.
|
||||||
}
|
|
||||||
if (form.obfs.type !== "salamander" || !data?.obfs?.salamander) {
|
|
||||||
delete form.obfs.salamander;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setHysteria2Monitor = async () => {
|
const setHysteria2Monitor = async () => {
|
||||||
const { data } = await dashboardSummaryApi();
|
const { data } = await dashboardSummaryApi();
|
||||||
|
|||||||
Reference in New Issue
Block a user