Подготовить HY2XS к production-сборке
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user