v25.9.17
This commit is contained in:
心隨緣動 2025-09-17 23:45:33 +08:00 committed by GitHub
parent 5451570d43
commit 6fd483f648
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -6,9 +6,28 @@
border-radius: 1rem;
overflow-x: hidden;
}
// 【新增】: 确保表格标题不换行
.ant-table-thead > tr > th {
white-space: nowrap;
}
.dark .ant-table:not(.ant-table-expanded-row .ant-table) {
outline-color: var(--dark-color-table-ring);
}
.ant-modal-body .ant-input-group.ant-input-group-compact {
display: block;
}
.ant-modal-body .ant-input-group.ant-input-group-compact .ant-input-affix-wrapper,
.ant-modal-body .ant-input-group.ant-input-group-compact .ant-input-search-button {
width: 100%;
}
.ant-modal-body .ant-input-group.ant-input-group-compact .ant-input {
white-space: normal;
height: auto;
min-height: 80px; /* 调整此值以控制显示行数 */
}
.ant-table .ant-table-content .ant-table-scroll .ant-table-body {
overflow-y: hidden;
}
@ -273,12 +292,18 @@
</a-menu-item>
</a-menu>
</a-dropdown>
<a-button type="primary" icon="thunderbolt" @click="openOneClickConfig">
<template v-if="!isMobile">{{ i18n "pages.inbounds.oneClickConfig" }}</template>
</a-button>
<a-button type="primary" icon="link" @click="handleSubscriptionConversion">
<template v-if="!isMobile">{{ i18n "pages.inbounds.is_subConversion" }}</template>
</a-button>
</a-space>
</template>
<template #extra>
<a-button-group>
<a-button icon="sync" @click="manualRefresh" :loading="refreshing"></a-button>
<a-popover placement="bottomRight" trigger="click" :overlay-class-name="themeSwitcher.currentTheme">
<a-popover placement="bottomRight" trigger="click" :overlay-class-name="themeSwitcher.currentTheme">
<template #title>
<div class="ant-custom-popover-title">
<a-switch v-model="isRefreshEnabled" @change="toggleRefresh" size="small"></a-switch>
@ -667,11 +692,76 @@
</template>
</a-table>
</a-space>
</a-card>
</a-card>
</a-col>
</a-row>
</transition>
</a-spin>
<a-modal
:title="`{{ i18n "pages.inbounds.oneClick.title" }}`"
:visible="oneClickModalVisible"
:ok-text="`{{ i18n "pages.inbounds.confirmCreate" }}`"
:cancel-text="`{{ i18n "cancel" }}`"
:class="themeSwitcher.currentTheme"
@ok="handleOneClickOk"
@cancel="handleOneClickCancel"
>
<a-tabs default-active-key="1">
<a-tab-pane key="1" :tab="`{{ i18n "pages.inbounds.oneClick.generateTab" }}`">
<div v-if="!inboundLink" style="padding: 24px;">
<p>{{ i18n "pages.inbounds.oneClick.prompt" }}</p>
<a-form-item :label="`{{ i18n "pages.inbounds.oneClick.presetType" }}`">
<a-button @click="handleOneClickOk('vless_reality')" block style="margin-bottom: 10px;">
{{ i18n "pages.inbounds.oneClick.preset.vless_reality" }}
</a-button>
<a-button @click="handleOneClickOk('vless_tls_encryption')" block style="margin-bottom: 10px;">
{{ i18n "pages.inbounds.oneClick.preset.vless_tls_encryption" }}
</a-button>
<a-button @click="handleOneClickOk('switch_vision_seed')" disabled block>
{{ i18n "pages.inbounds.oneClick.preset.switch_vision_seed_dev" }}
</a-button>
</a-form-item>
<p><b>{{ i18n "pages.inbounds.oneClick.descriptionTitle" }}</b></p>
<p>{{ i18n "pages.inbounds.oneClick.descriptionContent" }}</p>
</div>
<div v-else>
<p><b>{{ i18n "pages.inbounds.oneClick.linkCreated" }}</b></p>
<a-input-search
:value="inboundLink"
read-only
:enter-button="`{{ i18n "copy" }}`"
@search="copyLink(inboundLink)"
/>
</div>
</a-spin>
</a-tab-pane>
<a-tab-pane v-if="linkHistory.length > 0" key="2" :tab="`{{ i18n "pages.inbounds.oneClick.historyTab" }}`">
<div style="padding: 24px;">
<a-table
:dataSource="linkHistory"
:pagination="false"
:columns="linkHistoryColumns"
rowKey="time"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'link'">
<a-input-search
:value="record.link"
read-only
:enter-button="`{{ i18n "copy" }}`"
@search="copyLink(record.link)"
/>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
</a-modal>
</a-layout-content>
</a-layout>
</a-layout>
@ -733,7 +823,7 @@
dataIndex: "deviceLimit",
align: "center",
width: 40,
customRender: (text) => (text > 0 ? text : i18n("pages.inbounds.unlimited")),
customRender: (text) => (text > 0 ? text : {{ i18n "pages.inbounds.unlimited" }}),
}, {
title: '{{ i18n "pages.inbounds.traffic" }}',
align: 'center',
@ -776,14 +866,14 @@
{ title: '{{ i18n "pages.inbounds.client" }}', width: 80, scopedSlots: { customRender: 'client' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'allTime' } },
<!-- 中文注释: 在子表格中新增“限速”列 -->
{
// 中文注释: 在子表格中新增“限速”列
{
title: '{{ i18n "pages.inbounds.speedLimit" }}',
width: 80,
align: 'center',
dataIndex: 'speedLimit',
customRender: (text) => (text > 0 ? `${text} KB/s` : i18n("pages.inbounds.unlimited")),
customRender: (text) => (text > 0 ? `${text} KB/s` : {{ i18n "pages.inbounds.unlimited" }}),
},
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
@ -834,7 +924,62 @@
showAlert: false,
ipLimitEnable: false,
pageSize: 50,
inboundLink: '', // 新增:用来存储生成的可复制链接
// 【中文注释】: 新增数据属性,用于控制“一键配置”模态框的显示状态
oneClickModalVisible: false,
// 【新增】: 用于存储最近生成的链接记录
linkHistory: [],
// 【新增】: 定义链接历史记录表格的列
linkHistoryColumns: [
{
title: `{{ i18n "pages.inbounds.oneClick.historyType" }}`,
dataIndex: 'type',
width: 150,
},
{
title: `{{ i18n "pages.inbounds.oneClick.historyLink" }}`,
dataIndex: 'link',
},
{
title: `{{ i18n "pages.inbounds.oneClick.historyTime" }}`,
dataIndex: 'time',
width: 220,
},
],
// 【中文注释】: 新增数据属性,用于存储“一键配置”模态框中选择的预设值
oneClickPreset: 'vless_reality',
// 【中文注释】: 新增Reality SNI的备选列表用于随机选择
realityDestinations: [
'tesla.com:443',
'sega.com:443',
'microsoft.com:443',
'apple.com:443',
'icloud.com:443',
'amazon.com:443',
'nintendo.com:443',
'lovelive-anime.jp:443',
'meta.com:443',
'stanford.edu:443',
],
},
mounted() {
this.getDefaultSettings();
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
} else {
this.getDBInbounds();
}
// 【新增】: 从本地存储加载历史记录
const history = localStorage.getItem('oneClickLinkHistory');
if (history) {
this.linkHistory = JSON.parse(history);
}
},
methods: {
loading(spinning = true) {
this.loadingStates.spinning = spinning;
@ -849,7 +994,7 @@
await this.getLastOnlineMap();
await this.getOnlineUsers();
this.setInbounds(msg.obj);
setTimeout(() => {
this.refreshing = false;
@ -1016,6 +1161,348 @@
this.searchedInbounds = this.dbInbounds.slice();
}
},
// 【修改后】: 按照固定长度 2,4,6,8,10,12,14,16 来生成 shortIds
generateShortIds() {
const chars = "0123456789abcdef"; // 只使用十六进制字符
const lengths = [2, 4, 6, 8, 10, 12, 14, 16];
const shortIds = [];
for (const len of lengths) {
let shortId = "";
for (let j = 0; j < len; j++) {
shortId += chars[Math.floor(Math.random() * chars.length)];
}
shortIds.push(shortId);
}
return shortIds;
},
// 【新增】: 保存链接到历史记录,并同步到本地存储
saveLinkToHistory(type, link) {
const newRecord = { type: type, link: link, time: new Date().toLocaleString() };
this.linkHistory.unshift(newRecord); // 添加到数组开头
if (this.linkHistory.length > 10) {
this.linkHistory.pop(); // 保持最多10条记录
}
localStorage.setItem('oneClickLinkHistory', JSON.stringify(this.linkHistory));
},
// 【中文注释】: 以下是新增和修改后的方法,用于处理“一键配置”和“订阅转换”
// 【中文注释】: 打开“一键配置”模态框
openOneClickConfig() {
this.oneClickModalVisible = true;
},
// 【中文注释】: 处理“一键配置”模态框的确认操作
async handleOneClickOk(presetType) {
this.loadingStates.spinning = true;
// 【中文注释】: 根据选择的预设值调用不同的创建函数
if (presetType === 'vless_reality') {
await this.createVlessRealityInbound();
} else if (presetType === 'vless_tls_encryption') {
await this.createVlessTlsEncryptionInbound();
} else if (presetType === 'switch') {
this.$message.info('Switch 模式配置尚未实现');
} else {
this.$message.error('未知的预设配置!');
}
// 【中文注释】: 无论如何,最后都关闭加载状态
this.loadingStates.spinning = false;
},
// 【中文注释】: 处理“一键配置”模态框的取消操作
handleOneClickCancel() {
this.oneClickModalVisible = false;
this.inboundLink = ''; // 关闭时清空链接,以便下次重新打开时显示选项
},
// 【中文注释】: 创建 VLESS + Reality 入站的具体实现
async createVlessRealityInbound() {
try {
// 【中文注释】: 使用 Promise.all 并行请求后端的两个接口以获取密钥对和UUID
// 【重要】: 这里调用的都是项目中已经存在的后端接口
const [keyPairMsg, uuidMsg] = await Promise.all([
HttpUtil.get('/panel/api/server/getNewX25519Cert'),
HttpUtil.get('/panel/api/server/getNewUUID')
]);
if (!keyPairMsg.success || !keyPairMsg.obj || !uuidMsg.success || !uuidMsg.obj) {
this.$message.error('无法从服务器获取 Reality 密钥对或 UUID请检查后端服务');
return;
}
const { privateKey, publicKey } = keyPairMsg.obj;
const { uuid } = uuidMsg.obj;
const remark = Array.from({length:8}, () =>
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
[Math.floor(Math.random() * 62)]
).join("");
// 随机生成 10000-55535 之间的端口
const port = Math.floor(Math.random() * (55535 - 10000 + 1)) + 10000;
// 【中文注释】: 从预设列表中随机选择一个SNI
const randomDest = this.realityDestinations[Math.floor(Math.random() * this.realityDestinations.length)];
// 从 randomDest (例如 'microsoft.com:443') 中提取出域名 ('microsoft.com')
const randomSni = randomDest.split(':')[0];
// 【随机生成】: 调用新的函数 generateShortIds()
const shortIds = this.generateShortIds();
const linkSid = shortIds[Math.floor(Math.random() * shortIds.length)];
const data = {
up: 0,
down: 0,
total: 0,
remark: remark,
enable: true,
expiryTime: 0,
listen: '',
port: port,
protocol: 'vless',
settings: JSON.stringify({
clients: [{
id: uuid, // 【中文注释】: 使用后端生成的 UUID
flow: "xtls-rprx-vision",
email: remark,
level: 0
}],
decryption: "none",
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "reality",
realitySettings: {
show: false,
target: randomDest,
xver: 0,
serverNames: [ randomSni, `www.${randomSni}` ],
privateKey: privateKey,
publicKey: publicKey,
maxClientVer: "",
minClientVer: "",
maxTimediff: 0,
mldsa65Seed: "", // 【中文注释】: 一般情况下【留空】
"shortIds": shortIds,
"spiderX": "/"
},
tcpSettings: {
acceptProxyProtocol: false,
header: {
type: "none"
}
}
}),
// 【中文注释】:构建完整的 sniffing 对象
sniffing: JSON.stringify({
enabled: true,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
}),
};
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
if (msg.success) {
const link = `vless://${uuid}@${window.location.hostname}:${port}?type=tcp&encryption=none&security=reality&pbk=${publicKey}&fp=chrome&sni=${randomSni}&sid=${linkSid}&spx=%2F&flow=xtls-rprx-vision#${remark}-${remark}`;
this.inboundLink = link;
// 【新增】: 调用保存方法,将链接保存到历史记录
this.saveLinkToHistory('vless_reality', link);
this.$message.success('一键配置入站创建成功!');
await this.getDBInbounds();
} else {
// 如果后端返回失败,显示后端的消息
this.$message.error('创建失败: ' + msg.msg);
}
} catch (err) {
console.error("创建一键配置入站时出错:", err);
this.$message.error('创建过程中发生网络错误,请检查后端日志。');
}
},
// 【方法】: 创建 VLESS + TLS + Encryption 的入站配置
async createVlessTlsEncryptionInbound() {
try {
const [encMsg, uuidMsg] = await Promise.all([
HttpUtil.get('/panel/api/server/getNewVlessEnc'),
HttpUtil.get('/panel/api/server/getNewUUID')
]);
// 【修正】: 增加对 API 返回数据的严格检查
if (!encMsg.success || !encMsg.obj || !Array.isArray(encMsg.obj.auths) || encMsg.obj.auths.length === 0 || !uuidMsg.success || !uuidMsg.obj) {
this.$message.error('无法从服务器获取 Vless 加密配置或 UUID请检查后端服务');
return;
}
// 【修正】: 遍历找到正确的 ML-KEM-768 密钥
const mlkemAuth = encMsg.obj.auths.find(auth => auth.label === "ML-KEM-768, Post-Quantum");
if (!mlkemAuth || !mlkemAuth.decryption || !mlkemAuth.encryption) {
this.$message.error('未能从服务器获取到 ML-KEM-768 加密密钥,请检查 Xray 版本!');
return;
}
// 【修正】: 正确从嵌套数据中解构出 decryption 和 encryption
const { decryption, encryption } = mlkemAuth;
const { uuid } = uuidMsg.obj;
const remark = Array.from({length:8}, () =>
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
[Math.floor(Math.random() * 62)]
).join("");
// 随机生成 10000-55535 之间的端口
const port = Math.floor(Math.random() * (55535 - 10000 + 1)) + 10000;
// 【重要】: 随机选择 SNI
const randomDest = this.realityDestinations[Math.floor(Math.random() * this.realityDestinations.length)];
const serverName = randomDest.split(':')[0]; // 从域名列表中提取 SNI
// 【必须】: 动态设置证书路径
const certPath = `/root/cert/${window.location.hostname}/fullchain.pem`;
const keyPath = `/root/cert/${window.location.hostname}/privkey.pem`;
const data = {
up: 0,
down: 0,
total: 0,
remark: remark,
enable: true,
expiryTime: 0,
listen: '',
port: port,
protocol: 'vless',
settings: JSON.stringify({
clients: [{
id: uuid, // 【中文注释】: 使用后端生成的 UUID
flow: "xtls-rprx-vision",
email: remark,
level: 0
}],
decryption: decryption, // 使用从 API 获取的 decryption
encryption: encryption, // 【重要】: 加上 encryption 参数
selectedAuth: "ML-KEM-768, Post-Quantum"
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "tls",
tcpSettings: {
acceptProxyProtocol: false,
header: {
type: "none"
}
},
tlsSettings: {
alpn: ["http/1.1"],
certificates: [{
buildChain: false,
certificateFile: certPath,
keyFile: keyPath,
oneTimeLoading: false,
usage: "encipherment"
}],
cipherSuites: "",
disableSystemRoot: false,
echForceQuery: "none",
echServerKeys: "",
enableSessionResumption: false,
maxVersion: "1.3",
minVersion: "1.2",
rejectUnknownSni: false,
serverName: serverName, // 使用我们定义的 SNI
verifyPeerCertInNames: ["dns.google", "cloudflare-dns.com"]
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
}),
};
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
if (msg.success) {
// 【重要】: 分享链接格式,添加 encryption 和 flow
const link = `vless://${uuid}@${window.location.hostname}:${port}?type=tcp&encryption=${encryption}&security=tls&fp=chrome&alpn=http%2F1.1&sni=${serverName}&flow=xtls-rprx-vision#${remark}-${remark}`;
this.inboundLink = link;
// 【新增】: 调用保存方法,将链接保存到历史记录
this.saveLinkToHistory('vless_tls_encryption', link);
this.$message.success('VLESS + TLS + Encryption 配置创建成功!');
await this.getDBInbounds();
} else {
this.$message.error('创建失败: ' + msg.msg);
}
} catch (err) {
console.error("创建 VLESS + TLS + Encryption 入站时出错:", err);
this.$message.error('创建过程中发生网络错误,请检查后端日志。');
}
},
async copyLink(linkToCopy) {
const link = linkToCopy || this.inboundLink;
if (!link) {
this.$message.warning('没有可复制的链接。');
return;
}
try {
await navigator.clipboard.writeText(link);
this.$message.success('链接已成功复制到剪贴板!');
} catch (err) {
this.$message.error('复制失败,请手动复制。');
}
},
// 【中文注释】: 处理“订阅转换”按钮的点击事件 (已更新逻辑)
handleSubscriptionConversion() {
this.$confirm({
title: '{{ i18n "pages.inbounds.subConversion.modalTitle"}}',
content: '{{ i18n "pages.inbounds.subConversion.modalContent"}}',
okText: '{{ i18n "pages.inbounds.subConversion.modalOk"}}',
cancelText: '{{ i18n "cancel" }}',
class: themeSwitcher.currentTheme,
onOk: () => {
const subConverterUrl = `https://${window.location.hostname}:15268`;
const loadingMessage = this.$message.loading('正在检测订阅转换服务...', 0);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
fetch(subConverterUrl, {
method: 'GET',
mode: 'no-cors',
signal: controller.signal
})
.then(response => {
clearTimeout(timeoutId);
loadingMessage();
this.$message.success('服务检测成功,正在跳转...', 2);
window.open(subConverterUrl, '_blank');
})
.catch(err => {
clearTimeout(timeoutId);
loadingMessage();
this.$info({
title: '{{ i18n "pages.inbounds.subConversion.notFoundTitle"}}',
content: '{{ i18n "pages.inbounds.subConversion.notFoundContent"}}',
class: themeSwitcher.currentTheme,
});
});
},
});
},
generalActions(action) {
switch (action.key) {
case "import":
@ -1148,9 +1635,9 @@
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
<!-- 新增这一行 -->
deviceLimit: dbInbound.deviceLimit,
// 新增这一行
deviceLimit: dbInbound.deviceLimit,
listen: inbound.listen,
port: inbound.port,
@ -1174,8 +1661,8 @@
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
<!-- 新增这一行 -->
deviceLimit: dbInbound.deviceLimit,
// 新增这一行
deviceLimit: dbInbound.deviceLimit,
listen: inbound.listen,
port: inbound.port,
@ -1335,8 +1822,8 @@
checkFallback(dbInbound) {
newDbInbound = new DBInbound(dbInbound);
if (dbInbound.listen.startsWith("@")){
rootInbound = this.inbounds.find((i) =>
i.isTcp &&
rootInbound = this.inbounds.find((i) =>
i.isTcp &&
['trojan','vless'].includes(i.protocol) &&
i.settings.fallbacks.find(f => f.dest === dbInbound.listen)
);
@ -1390,17 +1877,17 @@
await this.getDBInbounds();
}
},
getInboundClients(dbInbound) {
<!-- 先把 dbInbound 转成 inbound 对象 -->
// 先把 dbInbound 转成 inbound 对象
const inbound = dbInbound.toInbound();
const clients = inbound.clients || [];
<!-- 遍历 clients保证 speedLimit 字段存在且是数字 -->
// 遍历 clients保证 speedLimit 字段存在且是数字
return clients.map(c => ({
...c, <!-- 保留原有字段 -->
speedLimit: Number(c.speedLimit ?? 0), <!-- 如果没设置就默认为 0 -->
}));
...c, // 保留原有字段
speedLimit: Number(c.speedLimit ?? 0), // 如果没设置就默认为 0
}));
},
resetClientTraffic(client, dbInboundId, confirmation = true) {
if (confirmation){