fix(admin): убрать каналы утечки секретов и остатки H UI из runtime
Экспорт в панели формировался через os.Create в /var/lib/hy2xs-admin/export, и файл там оставался навсегда. При includeSecrets=true это означало расшифрованные секреты пиров — фактические учётные данные доступа — в открытом виде на диске, накапливающиеся с каждым нажатием кнопки. Выгрузки формируются в памяти, каталога export/ больше нет. Generic export/import таблицы config удалён целиком. Он исключал только сырой Hysteria YAML, а в той же таблице лежат JWT_SECRET, PEER_SECRET_KEY, PEER_SECRET_ENCRYPTION_KEY и HYSTERIA2_TRAFFIC_STATS_SECRET: кнопка Export выгружала их открытым текстом, импорт позволял подменить. Для PEER_SECRET_ENCRYPTION_KEY подмена ломает расшифровку секретов уже существующих пиров. Production-сценария у этой пары не было. Импорт пиров шёл мимо всей валидации, которую проходит обычное создание пира: в базу попадало имя любой длины и с любыми символами, disabled с произвольным числом, отрицательные счётчики. Файл применялся построчно, поэтому ошибка в середине оставляла список наполовину изменённым, а импорт мог перезаписать bootstrap-admin-peer, чей секрет продублирован в bootstrap-admin.secret. Партия проверяется целиком до первой записи, неизвестные поля отклоняются. Убран слой сетевых настроек панели: H_UI_WEB_PORT, H_UI_WEB_CONTEXT, H_UI_CRT_PATH, H_UI_KEY_PATH и собственный TLS. Оркестратор передавал порт аргументом, админка писала его в SQLite и тут же читала обратно, а UI показывал поля disabled — второй источник истины, из которого ничего нельзя было изменить. HUI_DATA/HUI_LOG заменены на HY2XS_DATA_DIR/HY2XS_LOG_DIR, база переименована в hy2xs-admin.db, reference-схема — в schema.sql. API namespace разделён по природе маршрутов: операторский API на /api, machine-auth Hysteria на /internal/hysteria/auth. Путь machine-auth — runtime-контракт, он уезжает в config.yaml и post-install.env, поэтому объявлен одной константой на компонент. Go-санитайзер экспорта вырезал секреты из URL только у ключей url/addr: будущее upstream-поле с другим именем уносило учётные данные и access_token целиком, а URL внутри списков не обрабатывались вовсе. Граница определяется значением, а не именем ключа — как в TS-санитайзере оркестратора. Заодно индикатор загрузки и цвета 401/404 переведены на брендовый токен: NProgress приходил со своим #29d и был единственным элементом вне палитры.
This commit is contained in:
@@ -11,6 +11,9 @@ import {
|
||||
// Серверный конфиг Hysteria доступен панели только на чтение и на выгрузку:
|
||||
// им владеет install-оркестратор. Клиентов записи, импорта, перезапуска и
|
||||
// загрузки сертификатов здесь нет — соответствующих маршрутов не существует.
|
||||
//
|
||||
// Generic-выгрузки таблицы `config` здесь тоже нет: она отдавала JWT- и
|
||||
// peer-ключи в открытом виде. Перенос пиров делают ImportPeer/ExportPeer.
|
||||
|
||||
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
|
||||
return request({
|
||||
@@ -43,25 +46,6 @@ export function updateConfigsApi(data: ConfigUpdateDto): AxiosPromise {
|
||||
});
|
||||
}
|
||||
|
||||
export function exportConfigApi(): AxiosPromise {
|
||||
return request({
|
||||
url: "/config/exportConfig",
|
||||
method: "post",
|
||||
responseType: "blob",
|
||||
});
|
||||
}
|
||||
|
||||
export function importConfigApi(data: FormData): AxiosPromise {
|
||||
return request({
|
||||
url: "/config/importConfig",
|
||||
method: "post",
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function exportHysteria2ConfigApi(): AxiosPromise {
|
||||
return request({
|
||||
url: "/config/exportHysteria2Config",
|
||||
|
||||
@@ -97,8 +97,6 @@ export default {
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
securityRisk: "Security Risks",
|
||||
defaultPassTip: `Please change the default login password as soon as possible, It is recommended to set a strong password to protect your account security. <a href="/#/admin/change-password" style="color: #00BFFF">Click here</a> to change`,
|
||||
noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Click here</a> to enable`,
|
||||
required: "Required",
|
||||
warning: "Warning",
|
||||
fileFormatUnsupported: "File format not supported",
|
||||
@@ -180,25 +178,15 @@ export default {
|
||||
},
|
||||
config: {
|
||||
orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure",
|
||||
huiWebPort: "HY2XS admin Web Port",
|
||||
huiWebContext: "HY2XS admin Web Context",
|
||||
hysteria2TrafficTime: "Hysteria2 Traffic Time",
|
||||
huiCrtPath: "HY2XS admin CRT File Path",
|
||||
huiKeyPath: "HY2XS admin KEY File Path",
|
||||
useHysteria2Cert: "Use Hysteria2 cert",
|
||||
huiHttps: "Open https on the panel",
|
||||
resetTrafficCron: "Reset traffic schedule task",
|
||||
resetTrafficCronTip:
|
||||
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3",
|
||||
resetTrafficMonth: "Run once a month, midnight, first of month",
|
||||
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
|
||||
mustBeInteger: "Field must be an integer",
|
||||
invalidWebContext:
|
||||
"Field must start with / and contain only lowercase letters (a-z) and numbers (0-9)",
|
||||
invalidTrafficTime: "Field must be a number with up to one decimal place",
|
||||
},
|
||||
monitor: {
|
||||
huiVersion: "HY2XS admin Version",
|
||||
cpuPercent: "CPU Usage",
|
||||
memPercent: "Memory Usage",
|
||||
diskPercent: "Disk Usage",
|
||||
|
||||
@@ -94,8 +94,6 @@ export default {
|
||||
yes: "Да",
|
||||
no: "Нет",
|
||||
securityRisk: "Риски безопасности",
|
||||
defaultPassTip: `Смените пароль по умолчанию как можно скорее. <a href="/#/admin/change-password" style="color: #00BFFF">Перейти к смене</a>`,
|
||||
noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Открыть настройки</a>`,
|
||||
required: "Обязательное поле",
|
||||
warning: "Внимание",
|
||||
fileFormatUnsupported: "Формат файла не поддерживается",
|
||||
@@ -176,25 +174,15 @@ export default {
|
||||
},
|
||||
config: {
|
||||
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
|
||||
huiWebPort: "Порт HY2XS admin",
|
||||
huiWebContext: "Web-контекст HY2XS admin",
|
||||
hysteria2TrafficTime: "Период учёта трафика Hysteria2",
|
||||
huiCrtPath: "Путь к CRT HY2XS admin",
|
||||
huiKeyPath: "Путь к KEY HY2XS admin",
|
||||
useHysteria2Cert: "Использовать сертификат Hysteria2",
|
||||
huiHttps: "Включить HTTPS панели",
|
||||
resetTrafficCron: "Расписание сброса трафика",
|
||||
resetTrafficCronTip: "Cron-выражение для планового сброса трафика",
|
||||
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
|
||||
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
|
||||
mustBeInteger: "Поле должно быть целым числом",
|
||||
invalidWebContext:
|
||||
"Поле должно начинаться с / и содержать только строчные буквы (a-z) и цифры (0-9)",
|
||||
invalidTrafficTime:
|
||||
"Поле должно быть числом максимум с одним знаком после запятой",
|
||||
},
|
||||
monitor: {
|
||||
huiVersion: "Версия HY2XS admin",
|
||||
cpuPercent: "CPU",
|
||||
memPercent: "Память",
|
||||
diskPercent: "Диск",
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useAdminStoreHook } from "@/store/modules/admin";
|
||||
import { usePermissionStoreHook } from "@/store/modules/permission";
|
||||
|
||||
import NProgress from "nprogress";
|
||||
// Порядок важен: сначала vendor-база, затем тема HY2XS поверх неё.
|
||||
import "nprogress/nprogress.css";
|
||||
import "@/styles/nprogress.scss";
|
||||
|
||||
NProgress.configure({ showSpinner: false }); // Индикатор загрузки
|
||||
|
||||
|
||||
@@ -26,9 +26,14 @@
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
// Цвет фона выбранной строки
|
||||
// Цвет фона выбранной строки.
|
||||
//
|
||||
// Раньше здесь стоял литерал `#e1f3d8b5` — бледно-зелёный из исходного
|
||||
// admin-шаблона, не имеющий отношения к палитре HY2XS. Подсветка выделения —
|
||||
// это семантика «активный элемент», поэтому берётся самый светлый оттенок
|
||||
// брендового primary.
|
||||
.el-table__body tr.current-row td {
|
||||
background-color: #e1f3d8b5 !important;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
|
||||
// Единая высота header у card
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Тема NProgress.
|
||||
//
|
||||
// Vendor-стили nprogress приходят со своим фирменным синим `#29d`. Он никогда
|
||||
// не переопределялся, поэтому индикатор загрузки был единственным элементом
|
||||
// интерфейса вне брендовой палитры HY2XS.
|
||||
//
|
||||
// Здесь сознательно не пишется `#ff4e2f`: второй литерал того же цвета сразу
|
||||
// стал бы вторым источником истины. Индикатор привязан к той же переменной,
|
||||
// что и весь остальной интерфейс, и поменяется вместе с ней.
|
||||
#nprogress {
|
||||
.bar {
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.peg {
|
||||
box-shadow:
|
||||
0 0 10px var(--el-color-primary),
|
||||
0 0 5px var(--el-color-primary);
|
||||
}
|
||||
|
||||
// Spinner сейчас выключен через NProgress.configure({ showSpinner: false }),
|
||||
// но правило оставлено: если его когда-нибудь включат, синий не вернётся.
|
||||
.spinner-icon {
|
||||
border-top-color: var(--el-color-primary);
|
||||
border-left-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,12 @@ import { useAdminStoreHook } from "@/store/modules/admin";
|
||||
import i18n from "@/lang/index";
|
||||
|
||||
const dynamicBase = (window as any).__dynamic_base__ || "";
|
||||
const API_BASE = "/hui";
|
||||
// Операторский API живёт под /api. Прежний префикс «hui» был наследием H UI:
|
||||
// под ним лежали и machine-to-machine auth Hysteria, и JWT-защищённый
|
||||
// админский API, хотя middleware у них разные.
|
||||
// Значение синхронизировано с constant.AdminAPIBase в админке и
|
||||
// ADMIN_API_BASE в оркестраторе.
|
||||
const API_BASE = "/api";
|
||||
const t = i18n.global.t;
|
||||
// Создание axios instance
|
||||
const service = axios.create({
|
||||
|
||||
@@ -7,31 +7,6 @@
|
||||
{{ $t("common.save") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-upload
|
||||
v-model:file-list="fileList"
|
||||
:http-request="handleImport"
|
||||
:show-file-list="false"
|
||||
accept=".json"
|
||||
:limit="1"
|
||||
:before-upload="beforeImport"
|
||||
>
|
||||
<el-button>
|
||||
<template #icon>
|
||||
<i-ep-upload />
|
||||
</template>
|
||||
{{ $t("common.import") }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleExport">
|
||||
<template #icon>
|
||||
<i-ep-download />
|
||||
</template>
|
||||
{{ $t("common.export") }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
@@ -49,22 +24,6 @@
|
||||
:model="dataForm"
|
||||
label-position="top"
|
||||
>
|
||||
<el-form-item :label="$t('config.huiWebPort')" prop="huiWebPort">
|
||||
<el-input
|
||||
v-model="dataForm.huiWebPort"
|
||||
:placeholder="$t('config.huiWebPort')"
|
||||
disabled
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('config.huiWebContext')" prop="huiWebContext">
|
||||
<el-input
|
||||
v-model="dataForm.huiWebContext"
|
||||
:placeholder="$t('config.huiWebContext')"
|
||||
disabled
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="$t('config.hysteria2TrafficTime')"
|
||||
prop="hysteria2TrafficTime"
|
||||
@@ -75,47 +34,6 @@
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('config.huiHttps')" prop="huiHttps">
|
||||
<el-select
|
||||
v-model="huiHttps"
|
||||
style="width: 50%"
|
||||
ref="huiHttpsRef"
|
||||
disabled
|
||||
>
|
||||
<el-option
|
||||
v-for="item in huiHttpsList"
|
||||
:key="item.key"
|
||||
:label="item.key"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="huiHttps"
|
||||
:label="$t('config.huiCrtPath')"
|
||||
prop="huiCrtPath"
|
||||
>
|
||||
<el-input
|
||||
v-model="dataForm.huiCrtPath"
|
||||
:placeholder="$t('config.huiCrtPath')"
|
||||
style="width: 50%"
|
||||
disabled
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="huiHttps"
|
||||
:label="$t('config.huiKeyPath')"
|
||||
prop="huiKeyPath"
|
||||
>
|
||||
<el-input
|
||||
v-model="dataForm.huiKeyPath"
|
||||
:placeholder="$t('config.huiKeyPath')"
|
||||
style="width: 50%"
|
||||
disabled
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-tooltip
|
||||
:content="$t('config.resetTrafficCronTip')"
|
||||
placement="bottom"
|
||||
@@ -153,66 +71,32 @@ export default {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Сетевые настройки самой панели (порт, web-контекст, HTTPS, пути к
|
||||
// сертификатам) здесь отсутствуют намеренно: ими владеет install-оркестратор
|
||||
// через /etc/hy2xs/hy2xs.env и systemd-юнит. Раньше они лежали в SQLite и
|
||||
// показывались тут в disabled-виде — второй источник истины, из которого
|
||||
// ничего нельзя было изменить.
|
||||
//
|
||||
// Generic import/export конфигурации удалён: он выгружал таблицу `config`
|
||||
// целиком, вместе с JWT- и peer-ключами.
|
||||
import { Select } from "@element-plus/icons-vue";
|
||||
import {
|
||||
exportConfigApi,
|
||||
importConfigApi,
|
||||
listConfigApi,
|
||||
updateConfigsApi,
|
||||
} from "@/api/config";
|
||||
import { listConfigApi, updateConfigsApi } from "@/api/config";
|
||||
import { ConfigsUpdateDto } from "@/api/config/types";
|
||||
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const dataFormRef = ref(ElForm);
|
||||
const huiHttpsRef = ref<any>(null);
|
||||
|
||||
const huiWebPortKey = "H_UI_WEB_PORT";
|
||||
const huiWebContext = "H_UI_WEB_CONTEXT";
|
||||
const hysteria2TrafficTimeKey = "HYSTERIA2_TRAFFIC_TIME";
|
||||
const huiCrtPathKey = "H_UI_CRT_PATH";
|
||||
const huiKeyPathKey = "H_UI_KEY_PATH";
|
||||
const resetTrafficCronKey = "RESET_TRAFFIC_CRON";
|
||||
|
||||
const huiHttpsList = [
|
||||
{ key: t("common.yes"), value: 1 },
|
||||
{ key: t("common.no"), value: 0 },
|
||||
];
|
||||
|
||||
const cronResetTraffic = [
|
||||
{ key: t("config.resetTrafficMonth"), value: "@monthly" },
|
||||
{ key: t("config.resetTrafficWeek"), value: "@weekly" },
|
||||
];
|
||||
|
||||
const dataFormRules = {
|
||||
huiWebPort: [
|
||||
{
|
||||
required: true,
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: /^\d+$/,
|
||||
message: t("config.mustBeInteger"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
huiWebContext: [
|
||||
{
|
||||
required: true,
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: /^\/([a-z0-9]+(\/[a-z0-9]+)*)?$/,
|
||||
message: t("config.invalidWebContext"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
hysteria2TrafficTime: [
|
||||
{
|
||||
required: true,
|
||||
@@ -229,18 +113,12 @@ const dataFormRules = {
|
||||
|
||||
const state = reactive({
|
||||
dataForm: {
|
||||
huiWebPort: "8081",
|
||||
huiWebContext: "/",
|
||||
hysteria2TrafficTime: "1",
|
||||
huiCrtPath: "",
|
||||
huiKeyPath: "",
|
||||
resetTrafficCron: "",
|
||||
},
|
||||
huiHttps: 0,
|
||||
fileList: [] as UploadFile[],
|
||||
});
|
||||
|
||||
const { dataForm, huiHttps, fileList } = toRefs(state);
|
||||
const { dataForm } = toRefs(state);
|
||||
|
||||
const submitForm = () => {
|
||||
dataFormRef.value.validate((valid: boolean) => {
|
||||
@@ -265,94 +143,20 @@ const submitForm = () => {
|
||||
|
||||
const setConfig = async () => {
|
||||
const { data } = await listConfigApi({
|
||||
keys: [
|
||||
huiCrtPathKey,
|
||||
huiWebContext,
|
||||
huiKeyPathKey,
|
||||
huiWebPortKey,
|
||||
hysteria2TrafficTimeKey,
|
||||
resetTrafficCronKey,
|
||||
],
|
||||
keys: [hysteria2TrafficTimeKey, resetTrafficCronKey],
|
||||
});
|
||||
|
||||
data.forEach((configVo) => {
|
||||
if (configVo.key === huiWebPortKey) {
|
||||
state.dataForm.huiWebPort = configVo.value;
|
||||
} else if (configVo.key === huiWebContext) {
|
||||
state.dataForm.huiWebContext = configVo.value;
|
||||
} else if (configVo.key === hysteria2TrafficTimeKey) {
|
||||
if (configVo.key === hysteria2TrafficTimeKey) {
|
||||
state.dataForm.hysteria2TrafficTime = configVo.value;
|
||||
} else if (configVo.key === huiCrtPathKey) {
|
||||
state.dataForm.huiCrtPath = configVo.value;
|
||||
} else if (configVo.key === huiKeyPathKey) {
|
||||
state.dataForm.huiKeyPath = configVo.value;
|
||||
} else if (configVo.key === resetTrafficCronKey) {
|
||||
state.dataForm.resetTrafficCron = configVo.value;
|
||||
}
|
||||
});
|
||||
|
||||
if (state.dataForm.huiCrtPath != "" && state.dataForm.huiKeyPath != "") {
|
||||
state.huiHttps = 1;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (params: UploadRequestOptions) => {
|
||||
if (state.fileList.length > 0) {
|
||||
try {
|
||||
let formData = new FormData();
|
||||
formData.append("file", params.file);
|
||||
await importConfigApi(formData);
|
||||
ElMessage.success(t("common.success"));
|
||||
state.fileList = [];
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
} finally {
|
||||
await setConfig();
|
||||
}
|
||||
}
|
||||
};
|
||||
const beforeImport = (file: UploadRawFile) => {
|
||||
if (!file.name.endsWith(".json")) {
|
||||
ElMessage.error(t("common.fileFormatUnsupported"));
|
||||
return false;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 2) {
|
||||
ElMessage.error(t("common.fileTooLarge"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
let response = await exportConfigApi();
|
||||
const blob = new Blob([response.data], {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
let url = window.URL.createObjectURL(blob);
|
||||
let a = document.createElement("a");
|
||||
document.body.appendChild(a);
|
||||
a.href = url;
|
||||
let dis = response.headers["content-disposition"];
|
||||
a.download = dis.split("attachment; filename=")[1];
|
||||
// Имитация клика для скачивания
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
ElMessage.success(t("common.success"));
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setConfig();
|
||||
if (route.query.focus === "huiHttps") {
|
||||
nextTick(() => {
|
||||
const input = huiHttpsRef.value.$el.querySelector(".el-input__inner");
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ function back() {
|
||||
|
||||
.pan-back-btn {
|
||||
color: #fff;
|
||||
background: #008489;
|
||||
background: var(--el-color-primary);
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ function back() {
|
||||
}
|
||||
|
||||
a {
|
||||
color: #008489;
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -209,7 +209,7 @@ function message() {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
line-height: 40px;
|
||||
color: #1482f0;
|
||||
color: var(--el-color-primary);
|
||||
opacity: 0;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
@@ -251,7 +251,7 @@ function message() {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: #1482f0;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 100px;
|
||||
opacity: 0;
|
||||
animation-name: slideUp;
|
||||
|
||||
@@ -21,7 +21,8 @@ const pathSrc = path.resolve(__dirname, "src");
|
||||
const INVALID_CHAR_REGEX = /[\u0000-\u001F"#$&*+,:;<=>?[\]^`{|}\u007F]/g;
|
||||
const DRIVE_LETTER_REGEX = /^[a-z]:/i;
|
||||
const DEV_SERVER_PORT = 8080;
|
||||
const API_BASE = "/hui";
|
||||
// Синхронизировано с src/utils/request.ts и constant.AdminAPIBase.
|
||||
const API_BASE = "/api";
|
||||
|
||||
export default defineConfig((): UserConfig => {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user