Подготовить HY2XS к production-сборке

This commit is contained in:
2026-04-25 23:13:12 +05:00
commit 84a4e94567
277 changed files with 26513 additions and 0 deletions
@@ -0,0 +1,975 @@
<template>
<div class="app-container">
<div class="search">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item :label="$t('account.remark')" prop="remark">
<el-input
v-model="queryParams.remark"
:placeholder="$t('account.remark')"
clearable
style="width: 200px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item :label="$t('account.username')" prop="username">
<el-input
v-model="queryParams.username"
:placeholder="$t('account.username')"
clearable
style="width: 200px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item :label="$t('common.deleted')" prop="deleted">
<el-select
v-model="queryParams.deleted"
:placeholder="$t('common.all')"
clearable
style="width: 200px"
>
<el-option :label="$t('common.enable')" value="0" />
<el-option :label="$t('common.disable')" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleQuery"
>{{ $t("common.search") }}
</el-button>
</el-form-item>
<el-form-item>
<el-button :icon="Refresh" @click="resetQuery"
>{{ $t("common.reset") }}
</el-button>
</el-form-item>
<el-form-item>
<el-button :icon="Plus" @click="handleAdd"
>{{ $t("common.add") }}
</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>
<el-card shadow="never">
<el-table v-loading="loading" :data="records">
<el-table-column
key="id"
:label="$t('common.id')"
align="center"
prop="id"
/>
<el-table-column
key="remark"
:label="$t('account.remark')"
align="center"
prop="remark"
/>
<el-table-column
key="username"
:label="$t('account.username')"
align="center"
prop="username"
/>
<el-table-column
key="role"
:label="$t('account.role')"
align="center"
prop="role"
/>
<el-table-column
key="quota"
:label="$t('account.quota')"
align="center"
prop="quota"
>
<template #default="scope">
{{ formatBytes(scope.row.quota) }}
</template>
</el-table-column>
<el-table-column
key="download"
:label="$t('account.download')"
align="center"
prop="download"
>
<template #default="scope">
{{ formatBytes(scope.row.download) }}
</template>
</el-table-column>
<el-table-column
key="upload"
:label="$t('account.upload')"
align="center"
prop="upload"
>
<template #default="scope">
{{ formatBytes(scope.row.upload) }}
</template>
</el-table-column>
<el-table-column
key="online"
:label="$t('account.onlineStatus')"
align="center"
prop="online"
>
<template #default="scope">
<el-tag v-if="scope.row.online" type="success"
>{{ $t("account.online") }}
</el-tag>
<el-tag v-else type="info">{{ $t("account.offline") }}</el-tag>
</template>
</el-table-column>
<el-table-column
key="device"
:label="$t('account.device')"
align="center"
prop="device"
/>
<el-table-column
key="deviceNo"
:label="$t('account.deviceNo')"
align="center"
prop="deviceNo"
/>
<el-table-column
key="kickUtilTime"
:label="$t('account.kickUtilTimeLast')"
align="center"
prop="kickUtilTime"
>
<template #default="scope">
{{ calculateTimeDifference(scope.row.kickUtilTime) }}
</template>
</el-table-column>
<el-table-column
key="expireTime"
:label="$t('account.expireTime')"
align="center"
prop="expireTime"
width="160"
>
<template #default="scope">
{{ timestampToDateTime(scope.row.expireTime) }}
</template>
</el-table-column>
<el-table-column
key="loginAt"
:label="$t('account.loginAt')"
align="center"
prop="loginAt"
width="160"
>
<template #default="scope">
{{
scope.row.loginAt ? timestampToDateTime(scope.row.loginAt) : "-"
}}
</template>
</el-table-column>
<el-table-column
key="conAt"
:label="$t('account.conAt')"
align="center"
prop="conAt"
width="160"
>
<template #default="scope">
{{ scope.row.conAt ? timestampToDateTime(scope.row.conAt) : "-" }}
</template>
</el-table-column>
<el-table-column
:label="$t('common.createTime')"
align="center"
prop="createTime"
width="160"
>
<template #default="scope">
{{ timestampToDateTime(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column
key="deleted"
:label="$t('common.deleted')"
align="center"
prop="deleted"
>
<template #default="scope">
<el-tag v-if="scope.row.deleted === 0" type="success"
>{{ $t("common.enable") }}
</el-tag>
<el-tag v-else type="danger">{{ $t("common.disable") }}</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('common.operate')"
align="center"
width="300"
>
<template #default="scope">
<el-button type="primary" link @click="handleSubscribe(scope.row)"
>{{ $t("common.subscribe") }}
</el-button>
<el-button type="primary" link @click="handleNodeUrl(scope.row)"
>{{ $t("common.nodeUrl") }}
</el-button>
<el-button type="primary" link @click="handleQrCode(scope.row)">
{{ $t("common.nodeQrCode") }}
</el-button>
<el-popconfirm
title="Are you sure to reset traffic?"
@confirm="resetTraffic(scope.row)"
>
<template #reference>
<el-button type="primary" link
>{{ $t("common.resetTraffic") }}
</el-button>
</template>
</el-popconfirm>
<el-button type="primary" link @click="handleUpdate(scope.row)"
>{{ $t("common.edit") }}
</el-button>
<el-button type="danger" link @click="handleDelete(scope.row)"
>{{ $t("common.delete") }}
</el-button>
<el-button type="danger" link @click="handleKick(scope.row)"
>{{ $t("account.kick") }}
</el-button>
<el-popconfirm
:title="$t('account.releaseKickTip')"
@confirm="confirmReleaseKick(scope.row)"
v-if="calculateTimeDifference(scope.row.kickUtilTime) !== '-'"
>
<template #reference>
<el-button type="danger" link
>{{ $t("account.releaseKick") }}
</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<pagination
v-if="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="handleQuery"
/>
</el-card>
<el-dialog
:title="dialog.title"
v-model="dialog.visible"
width="620px"
append-to-body
@close="closeDialog"
>
<el-form
ref="dataFormRef"
:rules="
dialog.title === t('common.add')
? dataFormAddRules
: dataFormUpdateRules
"
:model="dataForm"
label-width="100px"
>
<el-form-item :label="$t('account.remark')" prop="remark">
<el-input
v-model="dataForm.remark"
:placeholder="$t('account.remark')"
maxlength="50"
clearable
/>
</el-form-item>
<el-form-item :label="$t('account.username')" prop="username">
<el-input
v-model="dataForm.username"
:placeholder="$t('account.username')"
maxlength="50"
clearable
/>
</el-form-item>
<el-form-item :label="$t('account.pass')" prop="pass">
<el-input
v-model="dataForm.pass"
:placeholder="$t('account.pass')"
maxlength="50"
clearable
type="password"
show-password
ref="dataFormPassRef"
/>
</el-form-item>
<el-form-item :label="$t('account.conPass')" prop="conPass">
<el-input
v-model="dataForm.conPass"
:placeholder="$t('account.conPass')"
maxlength="50"
clearable
type="password"
show-password
ref="dataFormConPassRef"
/>
</el-form-item>
<el-form-item :label="$t('account.quota')" prop="quota">
<unit-select :setValue="setQuota" :valueTmp="quotaTmp" />
</el-form-item>
<el-form-item :label="$t('account.deviceNo')" prop="deviceNo">
<el-input-number
v-model="dataForm.deviceNo"
:placeholder="$t('account.deviceNo')"
:min="1"
:controls="false"
:precision="0"
clearable
style="width: 220px"
/>
</el-form-item>
<el-form-item :label="$t('account.expireTime')" prop="expireTime">
<el-date-picker
v-model="dataForm.expireTime"
type="datetime"
:placeholder="$t('account.expireTime')"
value-format="x"
:shortcuts="shortcuts"
clearable
/>
</el-form-item>
<el-form-item :label="$t('common.deleted')" prop="deleted">
<el-radio-group v-model="dataForm.deleted">
<el-radio :label="0">{{ $t("common.enable") }}</el-radio>
<el-radio :label="1">{{ $t("common.disable") }}</el-radio>
</el-radio-group>
</el-form-item>
</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
:title="dialogKick.title"
v-model="dialogKick.visible"
width="600px"
append-to-body
@close="closeDialogKick"
>
<el-form ref="kickFormRef" :model="kickForm" label-width="100px">
<el-form-item :label="$t('account.kickUtilTime')" prop="kickUtilTime">
<el-date-picker
v-model="kickForm.kickUtilTime"
type="datetime"
:placeholder="$t('account.kickUtilTime')"
value-format="x"
:shortcuts="shortcutsKick"
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitKickForm"
>{{ $t("common.confirm") }}
</el-button>
<el-button @click="closeDialogKick"
>{{ $t("common.cancel") }}
</el-button>
</div>
</template>
</el-dialog>
<el-dialog
:title="qrCodeDialog.title"
v-model="qrCodeDialog.visible"
width="600px"
append-to-body
@close="qrCodeDialog.visible = false"
>
<el-form style="text-align: center">
<el-image
style="width: 300px; height: 300px"
:src="qrCodeSrc"
></el-image>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="qrCodeDialog.visible = false"
>{{ $t("common.confirm") }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts">
import {
AccountForm,
AccountPageDto,
AccountUpdateDto,
AccountVo,
KickAccountForm,
} from "@/api/account/types";
import {
saveAccountApi,
deleteAccountApi,
getAccountApi,
pageAccountApi,
updateAccountApi,
exportAccountApi,
releaseKickAccountApi,
importAccountApi,
resetTrafficApi,
} from "@/api/account";
import { Search, Plus, Refresh } from "@element-plus/icons-vue";
import {
timestampToDateTime,
getMonthLater,
getWeekLater,
getYearLater,
calculateTimeDifference,
getHourLater,
getDayLater,
} from "@/utils/time";
import { formatBytes } from "@/utils/byte";
import {
hysteria2KickApi,
hysteria2SubscribeUrlApi,
hysteria2UrlApi,
} from "@/api/hysteria2";
import {
UploadFile,
UploadRawFile,
UploadRequestOptions,
} from "element-plus/lib/components";
import { useI18n } from "vue-i18n";
import {
Hysteria2SubscribeUrlDto,
Hysteria2UrlDto,
} from "@/api/hysteria2/types";
import copy from "copy-to-clipboard";
import { useRoute } from "vue-router";
const { t } = useI18n();
const route = useRoute();
const queryFormRef = ref(ElForm); // 查询表单
const dataFormRef = ref(ElForm); // 用户表单
const kickFormRef = ref(ElForm); // 下线表单
const dataFormPassRef = ref(ElInput);
const dataFormConPassRef = ref(ElInput);
const dataFormAddRules = {
remark: [
{
min: 0,
max: 32,
message: "Remark format is incorrect",
trigger: ["change", "blur"],
},
],
username: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Username format is incorrect",
trigger: ["change", "blur"],
},
],
pass: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Pass format is incorrect",
trigger: ["change", "blur"],
},
],
conPass: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "ConPass format is incorrect",
trigger: ["change", "blur"],
},
],
expireTime: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
],
deviceNo: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
],
deleted: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
],
};
const dataFormUpdateRules = {
remark: [
{
min: 0,
max: 32,
message: "Remark format is incorrect",
trigger: ["change", "blur"],
},
],
username: [
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Username format is incorrect",
trigger: ["change", "blur"],
},
],
pass: [
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Pass format is incorrect",
trigger: ["change", "blur"],
},
],
conPass: [
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Pass format is incorrect",
trigger: ["change", "blur"],
},
],
};
const shortcuts = [
{
text: "A week later",
value: getWeekLater,
},
{
text: "A month later",
value: getMonthLater,
},
{
text: "A year later",
value: getYearLater,
},
];
const shortcutsKick = [
{
text: "A hour later",
value: getHourLater,
},
{
text: "A day later",
value: getDayLater,
},
{
text: "A week later",
value: getWeekLater,
},
{
text: "A month later",
value: getMonthLater,
},
];
const state = reactive({
loading: true,
total: 0,
records: [] as AccountVo[],
dialog: {
visible: false,
} as DialogType,
dialogKick: {
visible: false,
} as DialogType,
dataForm: {
quota: 0,
expireTime: getMonthLater(),
deviceNo: 6,
deleted: 0,
} as AccountForm,
kickForm: {
kickUtilTime: getHourLater(),
} as KickAccountForm,
queryParams: {
remark: undefined,
username: undefined,
deleted: undefined,
pageNum: 1,
pageSize: 10,
} as AccountPageDto,
quotaTmp: 0,
fileList: [] as UploadFile[],
qrCodeDialog: {
title: "QR Code",
visible: false,
} as DialogType,
qrCodeSrc: "",
});
const {
loading,
total,
records,
dialog,
dialogKick,
dataForm,
kickForm,
queryParams,
quotaTmp,
fileList,
qrCodeDialog,
qrCodeSrc,
} = toRefs(state);
const resetDataForm = () => {
Object.assign(state.dataForm, {
id: undefined,
quota: 0,
expireTime: getMonthLater(),
deleted: 0,
});
quotaTmp.value = 0;
};
/**
* 查询
*/
const handleQuery = async () => {
state.loading = true;
try {
const { data } = await pageAccountApi(state.queryParams);
state.records = data.records;
state.total = data.total;
} finally {
state.loading = false;
}
};
/**
* 重置
*/
const resetQuery = () => {
queryFormRef.value.resetFields();
handleQuery();
};
/**
* 保存
**/
const handleAdd = () => {
state.dialog = {
title: t("common.add"),
visible: true,
};
};
/**
* 修改
**/
const handleUpdate = async (row: { [key: string]: any }) => {
const id = row.id;
const { data } = await getAccountApi({ id: id });
Object.assign(state.dataForm, data);
quotaTmp.value = data.quota;
dialog.value = {
title: t("common.update"),
visible: true,
};
};
const setQuota = (newQuota: number) => {
state.dataForm.quota = newQuota;
};
/**
* 表单提交
*/
const submitForm = () => {
dataFormRef.value.validate((valid: any) => {
if (valid) {
const accountId = state.dataForm.id;
let accountUpdateDto: AccountUpdateDto = { ...state.dataForm };
if (accountId) {
updateAccountApi(accountUpdateDto).then(() => {
ElMessage.success(t("common.success"));
closeDialog();
handleQuery();
});
} else {
saveAccountApi(accountUpdateDto).then(() => {
ElMessage.success(t("common.success"));
closeDialog();
handleQuery();
});
}
}
});
};
/**
* 下线表单提交
*/
const submitKickForm = () => {
kickFormRef.value.validate((valid: any) => {
if (valid) {
const params = { ...state.kickForm };
hysteria2KickApi(params).then(() => {
ElMessage.success(t("common.success"));
closeDialogKick();
handleQuery();
});
}
});
};
/**
* 删除
*/
const handleDelete = (row: { [key: string]: any }) => {
const id = row.id;
const username = row.username;
ElMessageBox.confirm(
"Are you sure to delete the data item with the username「" +
username +
"」?",
"Warning",
{
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
type: "warning",
}
)
.then(() => {
deleteAccountApi({ id: id }).then(() => {
ElMessage.success(t("common.success"));
handleQuery();
});
})
.catch(() => ElMessage.info(t("common.cancel")));
};
/**
* 强制用户下线
* @param row
*/
const handleKick = (row: { [key: string]: any }) => {
state.kickForm.ids = [row.id];
dialogKick.value = {
title: t("account.kickTip"),
visible: true,
};
};
/**
* 解除下线状态
* @param row
*/
const confirmReleaseKick = (row: { [key: string]: any }) => {
releaseKickAccountApi({ id: row.id }).then(() => {
ElMessage.success(t("account.releaseSuccess"));
handleQuery();
});
};
/**
* 关闭用户弹窗
*/
const closeDialog = () => {
dialog.value.visible = false;
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
if (dialog.value.title == t("common.update")) {
resetDataForm();
}
};
/**
* 关闭下线弹窗
*/
const closeDialogKick = () => {
dialogKick.value.visible = false;
kickFormRef.value.resetFields();
kickFormRef.value.clearValidate();
};
/**
* 导入
*/
const handleImport = (params: UploadRequestOptions) => {
if (state.fileList.length > 0) {
let formData = new FormData();
formData.append("file", params.file);
importAccountApi(formData).then(() => {
ElMessage.success(t("common.success"));
});
state.fileList = [];
}
};
const beforeImport = (file: UploadRawFile) => {
if (!file.name.endsWith(".json")) {
ElMessage.error("file format not supported");
return false;
}
if (file.size / 1024 / 1024 > 2) {
ElMessage.error("the file is too big, less than 2 MB");
return false;
}
};
/**
* 导出
*/
const handleExport = () => {
exportAccountApi().then((res) => {
const blob = new Blob([res.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 = res.headers["content-disposition"];
a.download = dis.split("attachment; filename=")[1];
// 模拟点击下载
a.click();
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.success"));
});
};
const handleSubscribe = async (row: { [key: string]: any }) => {
try {
const dto: Hysteria2SubscribeUrlDto = {
accountId: row.id,
protocol: window.location.protocol,
host: window.location.host,
};
const { data } = await hysteria2SubscribeUrlApi(dto);
copy(data.url);
ElMessage.success(t("common.copySuccess"));
} catch (e) {
/* empty */
}
};
const handleNodeUrl = async (row: { [key: string]: any }) => {
try {
const dto: Hysteria2UrlDto = {
accountId: row.id,
hostname: window.location.hostname,
};
const { data } = await hysteria2UrlApi(dto);
copy(data.url);
ElMessage.success(t("common.copySuccess"));
} catch (e) {
/* empty */
}
};
const handleQrCode = async (row: { [key: string]: any }) => {
try {
const dto: Hysteria2UrlDto = {
accountId: row.id,
hostname: window.location.hostname,
};
const { data } = await hysteria2UrlApi(dto);
state.qrCodeSrc = "data:image/png;base64," + data.qrCode;
state.qrCodeDialog.visible = true;
} catch (e) {
/* empty */
}
};
const resetTraffic = async (row: { [key: string]: any }) => {
try {
await resetTrafficApi({ id: row.id });
ElMessage.success(t("common.success"));
await handleQuery();
} catch (e) {
/* empty */
}
};
onMounted(() => {
// 初始化用户列表数据
handleQuery();
if (route.query.focus === "change-pass") {
nextTick(() => {
handleUpdate({ id: 1 }).then(() => {
setTimeout(() => {
const inputPass = dataFormPassRef.value.$el.querySelector(
".el-input__wrapper input"
);
if (inputPass) {
inputPass.focus();
}
}, 50);
});
});
}
});
</script>
@@ -0,0 +1,486 @@
<template>
<div class="app-container">
<div class="search">
<el-form inline>
<el-form-item>
<el-button type="primary" @click="submitForm" :icon="Select">
{{ $t("common.save") }}
</el-button>
</el-form-item>
<el-form-item>
<el-button @click="handleRestartServer">
<template #icon>
<i-ep-refreshRight />
</template>
{{ $t("config.restartServer") }}
</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>
<el-card shadow="never">
<el-form
ref="dataFormRef"
:rules="dataFormRules"
:model="dataForm"
label-position="top"
>
<el-form-item :label="$t('config.huiWebPort')" prop="huiWebPort">
<el-input
v-model="dataForm.huiWebPort"
:placeholder="$t('config.huiWebPort')"
clearable
/>
</el-form-item>
<el-form-item :label="$t('config.huiWebContext')" prop="huiWebContext">
<el-input
v-model="dataForm.huiWebContext"
:placeholder="$t('config.huiWebContext')"
clearable
/>
</el-form-item>
<el-form-item
:label="$t('config.hysteria2TrafficTime')"
prop="hysteria2TrafficTime"
>
<el-input
v-model="dataForm.hysteria2TrafficTime"
:placeholder="$t('config.hysteria2TrafficTime')"
clearable
/>
</el-form-item>
<el-form-item :label="$t('config.huiHttps')" prop="huiHttps">
<el-select v-model="huiHttps" style="width: 50%" ref="huiHttpsRef">
<el-option
v-for="item in huiHttpsList"
:key="item.key"
:label="item.key"
:value="item.value"
/>
</el-select>
<el-button v-if="huiHttps" @click="setCertPath"
>{{ t("config.useHysteria2Cert") }}
</el-button>
</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%"
clearable
/>
<el-upload
style="height: 32px"
ref="uploadCrtFile"
action=""
:file-list="crtFileList"
:http-request="uploadCertFile"
accept=".crt"
:before-upload="
() => {
crtFileList = [];
}
"
:show-file-list="false"
:limit="1"
>
<template #trigger>
<el-button>{{ t("config.uploadCrtFile") }}</el-button>
</template>
</el-upload>
</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%"
clearable
/>
<el-upload
style="height: 32px"
ref="uploadKeyFile"
action=""
:file-list="keyFileList"
:http-request="uploadCertFile"
accept=".key"
:before-upload="
() => {
keyFileList = [];
}
"
:show-file-list="false"
:limit="1"
>
<template #trigger>
<el-button>{{ t("config.uploadKeyFile") }}</el-button>
</template>
</el-upload>
</el-form-item>
<el-tooltip
:content="$t('config.resetTrafficCronTip')"
placement="bottom"
>
<el-form-item
:label="$t('config.resetTrafficCron')"
prop="resetTrafficCron"
>
<el-select
v-model="dataForm.resetTrafficCron"
filterable
allow-create
clearable
:placeholder="$t('config.resetTrafficCron')"
style="width: 50%"
>
<el-option
v-for="item in cronResetTraffic"
:key="item.value"
:label="item.key"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-tooltip>
</el-form>
</el-card>
</div>
</template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts">
import { Select } from "@element-plus/icons-vue";
import {
exportConfigApi,
hysteria2AcmePathApi,
importConfigApi,
listConfigApi,
restartServerApi,
updateConfigsApi,
uploadCertFileApi,
} from "@/api/config";
import { ConfigsUpdateDto } from "@/api/config/types";
import {
UploadFile,
UploadRawFile,
UploadRequestOptions,
} from "element-plus/lib/components";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import { UploadUserFile } from "element-plus";
const { t } = useI18n();
const route = useRoute();
const dataFormRef = ref(ElForm);
const huiHttpsRef = ref(ElSelect);
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: "Required",
trigger: ["change", "blur"],
},
{
pattern: /^\d+$/,
message: "field must be a integer",
trigger: ["change", "blur"],
},
],
huiWebContext: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
{
pattern: /^\/([a-z0-9]+(\/[a-z0-9]+)*)?$/,
message: "field must start with / and contain only lowercase letters (a-z) and numbers (0-9)",
trigger: ["change", "blur"],
},
],
hysteria2TrafficTime: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
{
pattern: /^\d+(\.\d)?$/,
message: "field must be a number with up to one decimal place",
trigger: ["change", "blur"],
},
],
};
const state = reactive({
dataForm: {
huiWebPort: "8081",
huiWebContext: "/",
hysteria2TrafficTime: "1",
huiCrtPath: "",
huiKeyPath: "",
resetTrafficCron: "",
},
huiHttps: 0,
fileList: [] as UploadUserFile[],
crtFileList: [] as UploadUserFile[],
keyFileList: [] as UploadUserFile[],
});
const { dataForm, huiHttps, fileList, crtFileList, keyFileList } =
toRefs(state);
const submitForm = () => {
dataFormRef.value.validate((valid: boolean) => {
if (valid) {
if (state.huiHttps) {
if (!state.dataForm.huiCrtPath || !state.dataForm.huiKeyPath) {
ElMessage.error("crt and key required");
return;
}
}
if (!state.huiHttps) {
state.dataForm.huiCrtPath = "";
state.dataForm.huiKeyPath = "";
}
let configs: ConfigsUpdateDto[] = [
{
key: huiWebPortKey,
value: state.dataForm.huiWebPort,
},
{
key: huiWebContext,
value: state.dataForm.huiWebContext,
},
{
key: hysteria2TrafficTimeKey,
value: state.dataForm.hysteria2TrafficTime,
},
{
key: huiCrtPathKey,
value: state.dataForm.huiCrtPath,
},
{
key: huiKeyPathKey,
value: state.dataForm.huiKeyPath,
},
{
key: resetTrafficCronKey,
value: state.dataForm.resetTrafficCron,
},
];
updateConfigsApi({ configUpdateDtos: configs }).then(() => {
ElMessage.success(t("common.success"));
});
}
});
};
const setConfig = async () => {
const { data } = await listConfigApi({
keys: [
huiCrtPathKey,
huiWebContext,
huiKeyPathKey,
huiWebPortKey,
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) {
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("file format not supported");
return false;
}
if (file.size / 1024 / 1024 > 2) {
ElMessage.error("the file is too big, less than 2 MB");
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 */
}
};
const setCertPath = async () => {
try {
const { data } = await hysteria2AcmePathApi();
const { crtPath, keyPath } = data;
state.dataForm.huiCrtPath = crtPath;
state.dataForm.huiKeyPath = keyPath;
} catch (e) {
/* empty */
}
};
const uploadCertFile = async (params: UploadRequestOptions) => {
try {
if (
!params.file.name.endsWith(".crt") &&
!params.file.name.endsWith(".key")
) {
ElMessage.error("file format not supported");
}
if (params.file.size > 1024 * 1024) {
ElMessage.error("the file is too big");
}
let formData = new FormData();
formData.append("file", params.file);
const { data } = await uploadCertFileApi(formData);
if (params.file.name.endsWith(".crt")) {
state.dataForm.huiCrtPath = data;
} else if (params.file.name.endsWith(".key")) {
state.dataForm.huiKeyPath = data;
}
} catch (e) {
/* empty */
}
};
const handleRestartServer = async () => {
try {
ElMessageBox.confirm("Are you sure to restart panel?", "Warning", {
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
type: "warning",
}).then(() => {
restartServerApi();
ElMessage.success(t("config.restartTip"));
});
} 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>
<style lang="scss" scoped>
.el-card .el-form {
max-width: 800px;
margin: 0 auto;
}
</style>
@@ -0,0 +1,97 @@
<!-- setup 无法设置组件名称组件名称keepAlive必须 -->
<script lang="ts">
export default {
name: "Page401",
};
</script>
<script setup lang="ts">
import { reactive, toRefs } from "vue";
import { useRouter } from "vue-router";
const state = reactive({
errGif: new URL(`../../assets/401_images/401.gif`, import.meta.url).href,
});
const { errGif } = toRefs(state);
const router = useRouter();
function back() {
router.back();
}
</script>
<template>
<div class="errPage-container">
<el-button class="pan-back-btn" @click="back">Back</el-button>
<el-row>
<el-col :span="12">
<h1 class="text-jumbo text-ginormous">Oops!</h1>
<h2>You do not have permission to access this page</h2>
<ul class="list-unstyled">
<li>Or you can go:</li>
<li class="link-type">
<router-link to="/">Back to Home</router-link>
</li>
</ul>
</el-col>
<el-col :span="12">
<img
:src="errGif"
width="313"
height="428"
alt="Girl has dropped her ice cream."
/>
</el-col>
</el-row>
</div>
</template>
<style lang="scss" scoped>
.errPage-container {
width: 800px;
max-width: 100%;
margin: 100px auto;
.pan-back-btn {
color: #fff;
background: #008489;
border: none !important;
}
.pan-gif {
display: block;
margin: 0 auto;
}
.pan-img {
display: block;
width: 100%;
margin: 0 auto;
}
.text-jumbo {
font-size: 60px;
font-weight: 700;
color: #484848;
}
.list-unstyled {
font-size: 14px;
li {
padding-bottom: 5px;
}
a {
color: #008489;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
</style>
+271
View File
@@ -0,0 +1,271 @@
<!-- setup 无法设置组件名称组件名称keepAlive必须 -->
<script lang="ts">
export default {
name: "Page404",
};
</script>
<script setup lang="ts">
function message() {
return "The webmaster said that you can not enter this page...";
}
</script>
<template>
<div class="wscn-http404-container">
<div class="wscn-http404">
<div class="pic-404">
<img
class="pic-404__parent"
src="@/assets/404_images/404.png"
alt="404"
/>
<img
class="pic-404__child left"
src="@/assets/404_images/404_cloud.png"
alt="404"
/>
<img
class="pic-404__child mid"
src="@/assets/404_images/404_cloud.png"
alt="404"
/>
<img
class="pic-404__child right"
src="@/assets/404_images/404_cloud.png"
alt="404"
/>
</div>
<div class="bullshit">
<div class="bullshit__oops">OOPS!</div>
<div class="bullshit__headline">{{ message() }}</div>
<div class="bullshit__info">
Please check that the URL you entered is correct, or click the button
below to return to the homepage.
</div>
<a href="" class="bullshit__return-home">Back to home</a>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.wscn-http404-container {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
}
.wscn-http404 {
position: relative;
width: 1200px;
padding: 0 50px;
overflow: hidden;
.pic-404 {
position: relative;
float: left;
width: 600px;
overflow: hidden;
&__parent {
width: 100%;
}
&__child {
position: absolute;
&.left {
top: 17px;
left: 220px;
width: 80px;
opacity: 0;
animation-name: cloudLeft;
animation-duration: 2s;
animation-timing-function: linear;
animation-delay: 1s;
animation-fill-mode: forwards;
}
&.mid {
top: 10px;
left: 420px;
width: 46px;
opacity: 0;
animation-name: cloudMid;
animation-duration: 2s;
animation-timing-function: linear;
animation-delay: 1.2s;
animation-fill-mode: forwards;
}
&.right {
top: 100px;
left: 500px;
width: 62px;
opacity: 0;
animation-name: cloudRight;
animation-duration: 2s;
animation-timing-function: linear;
animation-delay: 1s;
animation-fill-mode: forwards;
}
@keyframes cloudLeft {
0% {
top: 17px;
left: 220px;
opacity: 0;
}
20% {
top: 33px;
left: 188px;
opacity: 1;
}
80% {
top: 81px;
left: 92px;
opacity: 1;
}
100% {
top: 97px;
left: 60px;
opacity: 0;
}
}
@keyframes cloudMid {
0% {
top: 10px;
left: 420px;
opacity: 0;
}
20% {
top: 40px;
left: 360px;
opacity: 1;
}
70% {
top: 130px;
left: 180px;
opacity: 1;
}
100% {
top: 160px;
left: 120px;
opacity: 0;
}
}
@keyframes cloudRight {
0% {
top: 100px;
left: 500px;
opacity: 0;
}
20% {
top: 120px;
left: 460px;
opacity: 1;
}
80% {
top: 180px;
left: 340px;
opacity: 1;
}
100% {
top: 200px;
left: 300px;
opacity: 0;
}
}
}
}
.bullshit {
position: relative;
float: left;
width: 300px;
padding: 30px 0;
overflow: hidden;
&__oops {
margin-bottom: 20px;
font-size: 32px;
font-weight: bold;
line-height: 40px;
color: #1482f0;
opacity: 0;
animation-name: slideUp;
animation-duration: 0.5s;
animation-fill-mode: forwards;
}
&__headline {
margin-bottom: 10px;
font-size: 20px;
font-weight: bold;
line-height: 24px;
color: #222;
opacity: 0;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.1s;
animation-fill-mode: forwards;
}
&__info {
margin-bottom: 30px;
font-size: 13px;
line-height: 21px;
color: grey;
opacity: 0;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.2s;
animation-fill-mode: forwards;
}
&__return-home {
display: block;
float: left;
width: 110px;
height: 36px;
font-size: 14px;
line-height: 36px;
color: #fff;
text-align: center;
cursor: pointer;
background: #1482f0;
border-radius: 100px;
opacity: 0;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.3s;
animation-fill-mode: forwards;
}
@keyframes slideUp {
0% {
opacity: 0;
transform: translateY(60px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
}
}
</style>
@@ -0,0 +1,379 @@
<template>
<div class="flex gap-2">
<el-tag
:key="item"
v-for="item in outbounds"
@close="handleClose(item)"
@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-dialog
:title="outboundInfoDialog.title"
v-model="outboundInfoDialog.visible"
width="600px"
append-to-body
@close="outboundInfoDialog.visible = false"
>
<el-form label-position="top">
<el-tooltip
:content="$t('hysteria.config.outbounds.name')"
placement="bottom"
>
<el-form-item label="name" prop="name">
<el-tag>{{ outboundInfo.name }}</el-tag>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.type')"
placement="bottom"
>
<el-form-item label="type" prop="type">
<el-tag>{{ outboundInfo.type }}</el-tag>
</el-form-item>
</el-tooltip>
<template v-if="outboundInfo.type === 'socks5'">
<el-tooltip
:content="$t('hysteria.config.outbounds.socks5.addr')"
placement="bottom"
>
<el-form-item label="socks5.addr" prop="socks5.addr">
<el-tag>{{ outboundInfo.socks5.addr }}</el-tag>
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.outbounds.socks5.username')"
placement="bottom"
>
<el-form-item label="socks5.username" prop="outboundInfo.username">
<el-tag>{{ outboundInfo.socks5.username }}</el-tag>
</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-tag>{{ outboundInfo.socks5.password }}</el-tag>
</el-form-item>
</el-tooltip>
</template>
<template v-if="outboundInfo.type === 'http'">
<el-tooltip
:content="$t('hysteria.config.outbounds.http.url')"
placement="bottom"
>
<el-form-item label="http.url" prop="http.url">
<el-tag>{{ outboundInfo.http.url }}</el-tag>
</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-tag>{{ outboundInfo.http.insecure }}</el-tag>
</el-form-item>
</el-tooltip>
</template>
<template v-if="outboundInfo.type === 'direct'">
<el-tooltip
:content="$t('hysteria.config.outbounds.direct.mode')"
placement="bottom"
>
<el-form-item label="direct.mode" prop="direct.mode">
<el-tag>{{ outboundInfo.direct.mode }}</el-tag>
</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-tag>{{ outboundInfo.direct.bindIPv4 }}</el-tag>
</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-tag>{{ outboundInfo.direct.bindIPv6 }}</el-tag>
</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-tag>{{ outboundInfo.direct.bindDevice }}</el-tag>
</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-tag>{{ outboundInfo.direct.fastOpen }}</el-tag>
</el-form-item>
</el-tooltip>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="outboundInfoDialog.visible = false"
>{{ $t("common.cancel") }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default {
name: "outbounds",
};
</script>
<script setup lang="ts">
import {
defaultHysteria2ServerConfigOutbound,
Hysteria2ServerConfigOutbound,
} from "@/api/config/types";
import { PropType } from "vue";
import { deepCopy } from "@/utils/copy";
const props = defineProps({
outbounds: {
required: true,
type: Array as PropType<Array<Hysteria2ServerConfigOutbound>>,
default: (): Array<Hysteria2ServerConfigOutbound> => [],
},
});
const emit = defineEmits<{
(
event: "update:outbounds",
value: Array<Hysteria2ServerConfigOutbound>
): void;
}>();
const outbounds = useVModel(props, "outbounds", emit);
const dataFormRef = ref(ElForm);
const state = reactive({
dataForm: {
...defaultHysteria2ServerConfigOutbound,
} as Hysteria2ServerConfigOutbound,
dialog: {
title: "Add Outbound",
visible: false,
} as DialogType,
outboundInfoDialog: {
title: "Outbound Info",
visible: false,
},
outboundInfo: {} as Hysteria2ServerConfigOutbound,
});
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 handleInfo = (outbound: Hysteria2ServerConfigOutbound) => {
state.outboundInfo = 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("name cannot be repeated");
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>
.flex.gap-2 {
flex-wrap: wrap;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,339 @@
<template>
<div class="dashboard-container">
<el-card shadow="never">
<el-row justify="space-between">
<el-col :span="12" :xs="24">
<div class="flex h-full items-center">
<img
class="w-20 h-20 mr-5 rounded-full"
src="/src/assets/logo.png"
/>
<div>
<p>{{ greetings }}</p>
<p class="text-sm text-gray">
{{ $t("account.createTime") }}:
{{ timestampToDateTime(account.createTime) }}
</p>
</div>
</div>
</el-col>
<el-col :span="12" :xs="24">
<div class="flex h-full items-center" style="justify-content: right">
<el-button type="primary" :icon="Share" @click="handleSubscribe">
{{ $t("common.subscribe") }}
</el-button>
<el-button
type="primary"
:icon="Share"
@click="handleSubscribeQrCode"
>
{{ $t("common.subscribeQrCode") }}
</el-button>
<el-button type="primary" :icon="Share" @click="handleNodeUrl">
{{ $t("common.nodeUrl") }}
</el-button>
<el-button type="primary" :icon="Share" @click="handleUrlQrCode">
{{ $t("common.nodeQrCode") }}
</el-button>
</div>
</el-col>
</el-row>
</el-card>
<el-row :gutter="10" class="mt-3">
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("account.quota") }}
</span>
<el-tag type="success"
>{{ formatStorageUnit(account.quota) }}
</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ formatBytes(account.quota) }}
</div>
<svg-icon icon-class="quota" size="2em" />
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("account.download") }}
</span>
<el-tag type="success"
>{{ formatStorageUnit(account.download) }}
</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ formatBytes(account.download) }}
</div>
<svg-icon icon-class="download" size="2em" />
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("account.upload") }}
</span>
<el-tag type="success"
>{{ formatStorageUnit(account.upload) }}
</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ formatBytes(account.upload) }}
</div>
<svg-icon icon-class="upload" size="2em" />
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("account.expireTime") }}
</span>
<el-tag type="success">{{ $t("info.expireTime") }}</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ timestampToDateTime(account.expireTime) }}
</div>
<svg-icon icon-class="expire-time" size="2em" />
</div>
</el-card>
</el-col>
</el-row>
<el-dialog
:title="qrCodeDialog.title"
v-model="qrCodeDialog.visible"
width="600px"
append-to-body
@close="qrCodeDialog.visible = false"
>
<el-form style="text-align: center">
<el-image
style="width: 300px; height: 300px"
:src="qrCodeSrc"
></el-image>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="qrCodeDialog.visible = false"
>{{ $t("common.confirm") }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts">
import { getAccountApi, verifyDefaultPassApi } from "@/api/account";
import { AccountVo } from "@/api/account/types";
import { useAccountStore } from "@/store/modules/account";
import { timestampToDateTime } from "@/utils/time";
import { formatBytes, formatStorageUnit } from "@/utils/byte";
import { Share } from "@element-plus/icons-vue";
import { useI18n } from "vue-i18n";
import {
Hysteria2SubscribeUrlDto,
Hysteria2UrlDto,
} from "@/api/hysteria2/types";
import { hysteria2SubscribeUrlApi, hysteria2UrlApi } from "@/api/hysteria2";
import copy from "copy-to-clipboard";
const { t } = useI18n();
const accountStore = useAccountStore();
const date: Date = new Date();
const greetings = computed(() => {
const hours = date.getHours();
if (hours >= 6 && hours < 8) {
return t("info.greeting1");
} else if (hours >= 8 && hours < 12) {
return t("info.greeting2") + accountStore.username + "";
} else if (hours >= 12 && hours < 18) {
return t("info.greeting3") + accountStore.username + "";
} else if (hours >= 18 && hours < 24) {
return t("info.greeting4") + accountStore.username + "";
} else if (hours >= 0 && hours < 6) {
return t("info.greeting5");
}
return "Hello HY2XS";
});
const state = reactive({
account: {} as AccountVo,
qrCodeDialog: {
title: "QR Code",
visible: false,
} as DialogType,
qrCodeSrc: "",
});
const { qrCodeDialog, account, qrCodeSrc } = toRefs(state);
const handleSubscribe = async () => {
try {
const dto: Hysteria2SubscribeUrlDto = {
accountId: accountStore.id,
protocol: window.location.protocol,
host: window.location.host,
};
const { data } = await hysteria2SubscribeUrlApi(dto);
copy(data.url);
ElMessage.success(t("common.copySuccess"));
} catch (e) {
/* empty */
}
};
const handleSubscribeQrCode = async () => {
try {
const dto: Hysteria2SubscribeUrlDto = {
accountId: accountStore.id,
protocol: window.location.protocol,
host: window.location.host,
};
const { data } = await hysteria2SubscribeUrlApi(dto);
state.qrCodeSrc = "data:image/png;base64," + data.qrCode;
state.qrCodeDialog.visible = true;
} catch (e) {
/* empty */
}
};
const handleNodeUrl = async () => {
try {
const dto: Hysteria2UrlDto = {
accountId: accountStore.id,
hostname: window.location.hostname,
};
const { data } = await hysteria2UrlApi(dto);
copy(data.url);
ElMessage.success(t("common.copySuccess"));
} catch (e) {
/* empty */
}
};
const handleUrlQrCode = async () => {
try {
const dto: Hysteria2UrlDto = {
accountId: accountStore.id,
hostname: window.location.hostname,
};
const { data } = await hysteria2UrlApi(dto);
state.qrCodeSrc = "data:image/png;base64," + data.qrCode;
state.qrCodeDialog.visible = true;
} catch (e) {
/* empty */
}
};
onMounted(() => {
getAccountApi({ id: accountStore.id }).then((response) => {
Object.assign(state.account, response.data);
});
if (accountStore.roles.indexOf("admin") != -1) {
verifyDefaultPassApi().then((response) => {
if (response.data) {
ElNotification({
title: t("common.securityRisk"),
dangerouslyUseHTMLString: true,
message: t("common.defaultPassTip"),
type: "warning",
});
}
});
if (window.location.protocol !== "https:") {
ElNotification({
title: t("common.securityRisk"),
dangerouslyUseHTMLString: true,
message: t("common.noHttpsTip"),
type: "warning",
});
}
}
});
</script>
<style lang="scss" scoped>
.dashboard-container {
position: relative;
padding: 24px;
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.github-corner {
position: absolute;
top: 0;
right: 0;
z-index: 1;
border: 0;
}
.data-box {
display: flex;
justify-content: space-between;
padding: 20px;
font-weight: bold;
color: var(--el-text-color-regular);
background: var(--el-bg-color-overlay);
border-color: var(--el-border-color);
box-shadow: var(--el-box-shadow-dark);
}
.svg-icon {
fill: currentcolor !important;
}
}
.flex.h-full.items-center {
.el-button {
margin: 10px;
}
}
@media (max-width: 768px) {
.flex.h-full.items-center {
justify-content: center;
}
}
@media (max-width: 634px) {
.flex.h-full.items-center {
flex-direction: column;
}
}
</style>
@@ -0,0 +1,111 @@
<template>
<div class="app-container">
<div class="search">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item :label="$t('log.numLine')" prop="numLine">
<el-select
v-model="queryParams.numLine"
style="width: 200px"
@change="setRecords"
>
<el-option label="100" value="100" />
<el-option label="200" value="200" />
<el-option label="300" value="300" />
</el-select>
</el-form-item>
<el-form-item prop="export">
<el-button @click="handleExport">
<template #icon>
<i-ep-download />
</template>
{{ $t("common.export") }}
</el-button>
</el-form-item>
<el-form-item prop="refresh">
<el-button @click="setRecords">
<template #icon>
<i-ep-refresh />
</template>
{{ $t("common.refresh") }}
</el-button>
</el-form-item>
</el-form>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="records">
<el-table-column
key="level"
label="level"
align="center"
prop="level"
/>
<el-table-column key="msg" label="msg" align="center" prop="msg" />
<el-table-column key="time" label="time" align="center" prop="time" />
</el-table>
</el-card>
</div>
</template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts">
import { LogDto, LogHysteria2Vo } from "@/api/log/types";
import { exportLogApi, logHysteria2Api } from "@/api/log";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const state = reactive({
loading: true,
total: 0,
records: [] as LogHysteria2Vo[],
queryParams: {
numLine: 100,
} as LogDto,
});
const { loading, records, queryParams } = toRefs(state);
const setRecords = async () => {
try {
state.loading = true;
const { data } = await logHysteria2Api(state.queryParams);
state.records = data.records;
state.total = data.total;
} finally {
state.loading = false;
}
};
const handleExport = async () => {
let response = await exportLogApi({ option: 1 });
try {
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(() => {
setRecords();
});
</script>
<style lang="scss" scoped></style>
@@ -0,0 +1,111 @@
<template>
<div class="app-container">
<div class="search">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item :label="$t('log.numLine')" prop="numLine">
<el-select
v-model="queryParams.numLine"
style="width: 200px"
@change="setRecords"
>
<el-option label="100" value="100" />
<el-option label="200" value="200" />
<el-option label="300" value="300" />
</el-select>
</el-form-item>
<el-form-item prop="export">
<el-button @click="handleExport">
<template #icon>
<i-ep-download />
</template>
{{ $t("common.export") }}
</el-button>
</el-form-item>
<el-form-item prop="refresh">
<el-button @click="setRecords">
<template #icon>
<i-ep-refresh />
</template>
{{ $t("common.refresh") }}
</el-button>
</el-form-item>
</el-form>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="records">
<el-table-column
key="level"
label="level"
align="center"
prop="level"
/>
<el-table-column key="msg" label="msg" align="center" prop="msg" />
<el-table-column key="time" label="time" align="center" prop="time" />
</el-table>
</el-card>
</div>
</template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts">
import { LogDto, LogSystemVo } from "@/api/log/types";
import { exportLogApi, logSystemApi } from "@/api/log";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const state = reactive({
loading: true,
total: 0,
records: [] as LogSystemVo[],
queryParams: {
numLine: 100,
} as LogDto,
});
const { loading, records, queryParams } = toRefs(state);
const setRecords = async () => {
try {
state.loading = true;
const { data } = await logSystemApi(state.queryParams);
state.records = data.records;
state.total = data.total;
} finally {
state.loading = false;
}
};
const handleExport = async () => {
let response = await exportLogApi({ option: 0 });
try {
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(() => {
setRecords();
});
</script>
<style lang="scss" scoped></style>
+240
View File
@@ -0,0 +1,240 @@
<template>
<div class="login-container">
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
>
<div class="flex text-white items-center py-4">
<span class="text-2xl flex-1 text-center">{{ $t("login.title") }}</span>
<lang-select style="color: #fff" />
</div>
<el-form-item prop="username">
<div class="p-2 text-white">
<svg-icon icon-class="user" />
</div>
<el-input
class="flex-1"
ref="username"
size="large"
v-model="loginForm.username"
:placeholder="$t('login.username')"
name="username"
/>
</el-form-item>
<el-tooltip
:disabled="isCapslock === false"
content="Caps lock is On"
placement="right"
>
<el-form-item prop="pass">
<span class="p-2 text-white">
<svg-icon icon-class="password" />
</span>
<el-input
class="flex-1"
v-model="loginForm.pass"
:placeholder="$t('login.password')"
:type="passVisible === false ? 'password' : 'input'"
size="large"
name="pass"
@keyup="checkCapslock"
@keyup.enter="handleLogin"
/>
<span class="mr-2" @click="passVisible = !passVisible">
<svg-icon
:icon-class="passVisible === false ? 'eye' : 'eye-open'"
class="text-white cursor-pointer"
/>
</span>
</el-form-item>
</el-tooltip>
<el-button
size="default"
:loading="loading"
type="primary"
class="w-full"
@click.prevent="handleLogin"
>{{ $t("login.login") }}
</el-button>
</el-form>
</div>
</template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts">
import router from "@/router";
import LangSelect from "@/components/LangSelect/index.vue";
import SvgIcon from "@/components/SvgIcon/index.vue";
// 状态管理依赖
import { useAccountStore } from "@/store/modules/account";
// API依赖
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
import { AccountLoginDto } from "@/api/account/types";
const accountStore = useAccountStore();
const route = useRoute();
/**
* 按钮loading
*/
const loading = ref(false);
/**
* 是否大写锁定
*/
const isCapslock = ref(false);
/**
* 密码是否可见
*/
const passVisible = ref(false);
/**
* 登录表单引用
*/
const loginFormRef = ref(ElForm);
/**
* 登录表单
*/
const loginForm = ref<AccountLoginDto>({
username: "",
pass: "",
});
const loginRules = {
username: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Username format is incorrect",
trigger: ["change", "blur"],
},
],
pass: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Password format is incorrect",
trigger: ["change", "blur"],
},
],
};
/**
* 检查输入大小写状态
*/
const checkCapslock = (e: any) => {
const { key } = e;
isCapslock.value = key && key.length === 1 && key >= "A" && key <= "Z";
};
/**
* 登录
*/
const handleLogin = () => {
loginFormRef.value.validate((valid: boolean) => {
if (valid) {
loading.value = true;
const params = { ...loginForm.value };
accountStore
.login(params)
.then(() => {
const query: LocationQuery = route.query;
const redirect = (query.redirect as LocationQueryValue) ?? "/";
const otherQueryParams = Object.keys(query).reduce(
(acc: any, cur: string) => {
if (cur !== "redirect") {
acc[cur] = query[cur];
}
return acc;
},
{}
);
router.push({ path: redirect, query: otherQueryParams });
})
.catch(() => {})
.finally(() => {
loading.value = false;
});
}
});
};
</script>
<style lang="scss" scoped>
.login-container {
width: 100%;
min-height: 100%;
overflow: hidden;
background-color: #2d3a4b;
.login-form {
width: 520px;
max-width: 100%;
padding: 160px 35px 0;
margin: 0 auto;
overflow: hidden;
}
}
.el-form-item {
background: rgb(0 0 0 / 10%);
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 5px;
}
.el-input {
background: transparent;
// 子组件 scoped 无效,使用 :deep
:deep(.el-input__wrapper) {
padding: 0;
background: transparent;
box-shadow: none;
.el-input__inner {
color: #fff;
background: transparent;
border: 0;
border-radius: 0;
caret-color: #fff;
&:-webkit-autofill {
box-shadow: 0 0 0 1000px transparent inset !important;
-webkit-text-fill-color: #fff !important;
}
// 设置输入框自动填充的延迟属性
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus,
&:-webkit-autofill:active {
transition: color 99999s ease-out, background-color 99999s ease-out;
transition-delay: 99999s;
}
}
}
}
</style>
@@ -0,0 +1,222 @@
<template>
<div class="dashboard-container">
<el-row :gutter="10" class="mt-3">
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.huiVersion") }}
</span>
<el-tag type="success">version</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ systemMonitor.huiVersion ? systemMonitor.huiVersion : "-" }}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.cpuPercent") }}
</span>
<el-tag type="success">%</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{
systemMonitor.cpuPercent ? systemMonitor.cpuPercent + "%" : "-"
}}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.memPercent") }}
</span>
<el-tag type="success">%</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{
systemMonitor.memPercent ? systemMonitor.memPercent + "%" : "-"
}}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.diskPercent") }}
</span>
<el-tag type="success">%</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{
systemMonitor.diskPercent
? systemMonitor.diskPercent + "%"
: "-"
}}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.hysteria2Version") }}
</span>
<el-tag type="success">version</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ hysteria2Monitor.version ? hysteria2Monitor.version : "-" }}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.hysteria2Running") }}
</span>
<el-tag type="success">running</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div
class="text-lg text-right"
:style="
hysteria2Monitor.running === undefined
? '-'
: hysteria2Monitor.running
? 'color: #2ecc71'
: 'color: #e74c3c'
"
>
{{
hysteria2Monitor.running === undefined
? "-"
: hysteria2Monitor.running
? $t("monitor.hysteria2RunningTrue")
: $t("monitor.hysteria2RunningFalse")
}}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.hysteria2UserTotal") }}
</span>
<el-tag type="success">account</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ hysteria2Monitor.userTotal }}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.hysteria2DeviceTotal") }}
</span>
<el-tag type="success">device</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ hysteria2Monitor.deviceTotal }}
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts">
import { monitorHysteria2Api, monitorSystemApi } from "@/api/monitor";
const state = reactive({
systemMonitor: {
huiVersion: "",
cpuPercent: 0,
memPercent: 0,
diskPercent: 0,
},
hysteria2Monitor: {
userTotal: 0,
deviceTotal: 0,
version: undefined,
running: undefined,
},
});
const { systemMonitor, hysteria2Monitor } = toRefs(state);
const setMonitor = () => {
monitorSystemApi().then((response) => {
const { data } = response;
Object.assign(state.systemMonitor, data);
});
monitorHysteria2Api().then((response) => {
const { data } = response;
Object.assign(state.hysteria2Monitor, data);
});
};
onMounted(() => {
setMonitor();
});
</script>
<style lang="scss" scoped>
.dashboard-container {
position: relative;
padding: 24px;
.svg-icon {
fill: currentcolor !important;
}
.el-col {
margin-bottom: 10px;
}
}
</style>
@@ -0,0 +1,15 @@
<template>
<div />
</template>
<script setup lang="ts">
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
const { params, query } = route;
const { path } = params;
router.replace({ path: "/" + path, query });
</script>