ClientFormModal.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. <script setup>
  2. import { computed, ref, watch } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import dayjs from 'dayjs';
  5. import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
  6. import {
  7. HttpUtil,
  8. RandomUtil,
  9. SizeFormatter,
  10. ColorUtils,
  11. } from '@/utils';
  12. import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
  13. import DateTimePicker from '@/components/DateTimePicker.vue';
  14. const { t } = useI18n();
  15. // Add OR edit a single client on a multi-user inbound (VMess / VLess /
  16. // Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
  17. // flows through the same modal — same here.
  18. //
  19. // On submit we serialize the client via its toString() (which is just
  20. // JSON.stringify of toJson()) and post it inside a one-element clients
  21. // array so the Go side reuses the same parsing path as the inbound
  22. // settings update.
  23. const props = defineProps({
  24. open: { type: Boolean, default: false },
  25. mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
  26. dbInbound: { type: Object, default: null },
  27. clientIndex: { type: Number, default: null },
  28. // Sidecar config from the inbounds page — controls visibility of
  29. // the Subscription, Telegram, and IP-limit fields.
  30. subEnable: { type: Boolean, default: false },
  31. tgBotEnable: { type: Boolean, default: false },
  32. ipLimitEnable: { type: Boolean, default: false },
  33. trafficDiff: { type: Number, default: 0 },
  34. });
  35. const emit = defineEmits(['update:open', 'saved']);
  36. // === Reactive draft =================================================
  37. const inbound = ref(null);
  38. const client = ref(null);
  39. const oldClientId = ref('');
  40. const clientStats = ref(null);
  41. const saving = ref(false);
  42. const delayedStart = ref(false);
  43. const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
  44. const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
  45. const protocol = computed(() => inbound.value?.protocol);
  46. const isVmessOrVless = computed(() =>
  47. protocol.value === Protocols.VMESS || protocol.value === Protocols.VLESS,
  48. );
  49. const isTrojanOrSS = computed(() =>
  50. protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
  51. );
  52. const expiryDate = computed({
  53. get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
  54. set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
  55. });
  56. const delayedExpireDays = computed({
  57. get: () => {
  58. if (!client.value || client.value.expiryTime >= 0) return 0;
  59. return client.value.expiryTime / -86400000;
  60. },
  61. set: (days) => {
  62. if (!client.value) return;
  63. client.value.expiryTime = -86400000 * (days || 0);
  64. },
  65. });
  66. const totalGB = computed({
  67. get: () => {
  68. if (!client.value || !client.value.totalGB) return 0;
  69. return Math.round((client.value.totalGB / SizeFormatter.ONE_GB) * 100) / 100;
  70. },
  71. set: (gb) => {
  72. if (!client.value) return;
  73. client.value.totalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
  74. },
  75. });
  76. const isExpired = computed(() => {
  77. if (props.mode !== 'edit' || !client.value) return false;
  78. return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
  79. });
  80. const isTrafficExhausted = computed(() => {
  81. if (!clientStats.value || clientStats.value.total <= 0) return false;
  82. return clientStats.value.up + clientStats.value.down >= clientStats.value.total;
  83. });
  84. function getClientId(proto, c) {
  85. switch (proto) {
  86. case Protocols.TROJAN: return c.password;
  87. case Protocols.SHADOWSOCKS: return c.email;
  88. case Protocols.HYSTERIA: return c.auth;
  89. default: return c.id;
  90. }
  91. }
  92. function makeNewClient(proto, parsed) {
  93. switch (proto) {
  94. case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
  95. case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
  96. case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
  97. case Protocols.SHADOWSOCKS: {
  98. const method = parsed.settings.method;
  99. return new Inbound.ShadowsocksSettings.Shadowsocks(
  100. method,
  101. RandomUtil.randomShadowsocksPassword(method),
  102. );
  103. }
  104. case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
  105. default: return null;
  106. }
  107. }
  108. watch(() => props.open, (next) => {
  109. if (!next) return;
  110. if (!props.dbInbound) return;
  111. const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
  112. inbound.value = parsed;
  113. delayedStart.value = false;
  114. if (props.mode === 'edit') {
  115. const idx = props.clientIndex ?? 0;
  116. client.value = parsed.clients[idx];
  117. if (client.value && client.value.expiryTime < 0) delayedStart.value = true;
  118. oldClientId.value = getClientId(parsed.protocol, client.value);
  119. } else {
  120. const c = makeNewClient(parsed.protocol, parsed);
  121. if (c) parsed.clients.push(c);
  122. client.value = parsed.clients[parsed.clients.length - 1];
  123. oldClientId.value = '';
  124. }
  125. clientStats.value = (props.dbInbound.clientStats || []).find(
  126. (s) => s.email === client.value?.email,
  127. ) || null;
  128. });
  129. function close() {
  130. emit('update:open', false);
  131. }
  132. function randomEmail() {
  133. if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
  134. }
  135. function randomId() {
  136. if (client.value) client.value.id = RandomUtil.randomUUID();
  137. }
  138. function randomPassword() {
  139. if (!client.value || !inbound.value) return;
  140. if (inbound.value.protocol === Protocols.SHADOWSOCKS) {
  141. client.value.password = RandomUtil.randomShadowsocksPassword(
  142. inbound.value.settings.method,
  143. );
  144. } else {
  145. client.value.password = RandomUtil.randomSeq(10);
  146. }
  147. }
  148. function randomAuth() {
  149. if (client.value) client.value.auth = RandomUtil.randomSeq(10);
  150. }
  151. function randomSubId() {
  152. if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
  153. }
  154. const clientIpsText = ref('');
  155. async function loadClientIps() {
  156. if (!client.value?.email) return;
  157. const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${client.value.email}`);
  158. if (!msg?.success) {
  159. clientIpsText.value = msg?.obj || '';
  160. return;
  161. }
  162. let ips = msg.obj;
  163. if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
  164. try {
  165. const parsed = JSON.parse(ips);
  166. ips = Array.isArray(parsed) ? parsed.join('\n') : ips;
  167. } catch (_e) {
  168. // leave as raw
  169. }
  170. }
  171. clientIpsText.value = ips || '';
  172. }
  173. async function clearClientIps() {
  174. if (!client.value?.email) return;
  175. const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${client.value.email}`);
  176. if (msg?.success) clientIpsText.value = '';
  177. }
  178. async function resetClientTraffic() {
  179. if (!clientStats.value || !client.value?.email) return;
  180. const msg = await HttpUtil.post(
  181. `/panel/api/inbounds/${props.dbInbound.id}/resetClientTraffic/${client.value.email}`,
  182. );
  183. if (msg?.success) {
  184. clientStats.value.up = 0;
  185. clientStats.value.down = 0;
  186. }
  187. }
  188. async function submit() {
  189. if (!client.value || !inbound.value) return;
  190. saving.value = true;
  191. try {
  192. const payload = {
  193. id: props.dbInbound.id,
  194. settings: `{"clients": [${client.value.toString()}]}`,
  195. };
  196. const url = props.mode === 'edit'
  197. ? `/panel/api/inbounds/updateClient/${oldClientId.value}`
  198. : '/panel/api/inbounds/addClient';
  199. const msg = await HttpUtil.post(url, payload);
  200. if (msg?.success) {
  201. emit('saved');
  202. close();
  203. }
  204. } finally {
  205. saving.value = false;
  206. }
  207. }
  208. const title = computed(() =>
  209. props.mode === 'edit' ? t('pages.client.edit') : t('pages.client.add'),
  210. );
  211. </script>
  212. <template>
  213. <a-modal :open="open" :title="title"
  214. :ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')" :cancel-text="t('close')"
  215. :confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
  216. <a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
  217. {{ t('depleted') }}
  218. </a-tag>
  219. <a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ sm: { span: 8 } }"
  220. :wrapper-col="{ sm: { span: 14 } }">
  221. <a-form-item :label="t('enable')">
  222. <a-switch v-model:checked="client.enable" />
  223. </a-form-item>
  224. <a-form-item>
  225. <template #label>
  226. {{ t('pages.inbounds.email') }}
  227. <SyncOutlined class="random-icon" @click="randomEmail" />
  228. </template>
  229. <a-input v-model:value="client.email" />
  230. </a-form-item>
  231. <a-form-item v-if="isTrojanOrSS">
  232. <template #label>
  233. {{ t('password') }}
  234. <SyncOutlined class="random-icon" @click="randomPassword" />
  235. </template>
  236. <a-input v-model:value="client.password" />
  237. </a-form-item>
  238. <a-form-item v-if="protocol === Protocols.HYSTERIA">
  239. <template #label>
  240. {{ t('password') }}
  241. <SyncOutlined class="random-icon" @click="randomAuth" />
  242. </template>
  243. <a-input v-model:value="client.auth" />
  244. </a-form-item>
  245. <a-form-item v-if="isVmessOrVless">
  246. <template #label>
  247. ID
  248. <SyncOutlined class="random-icon" @click="randomId" />
  249. </template>
  250. <a-input v-model:value="client.id" />
  251. </a-form-item>
  252. <a-form-item v-if="protocol === Protocols.VMESS" :label="t('security')">
  253. <a-select v-model:value="client.security">
  254. <a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
  255. {{ key }}
  256. </a-select-option>
  257. </a-select>
  258. </a-form-item>
  259. <a-form-item v-if="client.email && subEnable">
  260. <template #label>
  261. {{ t('subscription.title') }}
  262. <SyncOutlined class="random-icon" @click="randomSubId" />
  263. </template>
  264. <a-input v-model:value="client.subId" />
  265. </a-form-item>
  266. <a-form-item v-if="client.email && tgBotEnable" label="Telegram ID">
  267. <a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
  268. </a-form-item>
  269. <a-form-item v-if="client.email" :label="t('comment')">
  270. <a-input v-model:value="client.comment" />
  271. </a-form-item>
  272. <a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
  273. <a-input-number v-model:value="client.limitIp" :min="0" />
  274. </a-form-item>
  275. <a-form-item v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
  276. :label="t('pages.inbounds.IPLimitlog')">
  277. <a-textarea v-model:value="clientIpsText" readonly :placeholder="t('pages.inbounds.IPLimitlogDesc')"
  278. :auto-size="{ minRows: 3, maxRows: 8 }" @click="loadClientIps" />
  279. <a-button type="link" size="small" danger @click="clearClientIps">
  280. <template #icon>
  281. <DeleteOutlined />
  282. </template>
  283. {{ t('pages.inbounds.IPLimitlogclear') }}
  284. </a-button>
  285. </a-form-item>
  286. <a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
  287. <a-select v-model:value="client.flow">
  288. <a-select-option value="">{{ t('none') }}</a-select-option>
  289. <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
  290. {{ key }}
  291. </a-select-option>
  292. </a-select>
  293. </a-form-item>
  294. <a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
  295. <a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
  296. </a-form-item>
  297. <a-form-item>
  298. <template #label>
  299. <a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
  300. </template>
  301. <a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
  302. </a-form-item>
  303. <a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
  304. <a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
  305. {{ SizeFormatter.sizeFormat(clientStats.up) }} /
  306. {{ SizeFormatter.sizeFormat(clientStats.down) }}
  307. ({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
  308. </a-tag>
  309. <a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
  310. <RetweetOutlined class="action-icon" @click="resetClientTraffic" />
  311. </a-tooltip>
  312. </a-form-item>
  313. <a-form-item :label="t('pages.client.delayedStart')">
  314. <a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
  315. </a-form-item>
  316. <a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
  317. <a-input-number v-model:value="delayedExpireDays" :min="0" />
  318. </a-form-item>
  319. <a-form-item v-else>
  320. <template #label>
  321. <a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
  322. }}</a-tooltip>
  323. </template>
  324. <DateTimePicker v-model:value="expiryDate" />
  325. <a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
  326. </a-form-item>
  327. <a-form-item v-if="client.expiryTime !== 0">
  328. <template #label>
  329. <a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
  330. </template>
  331. <a-input-number v-model:value="client.reset" :min="0" />
  332. </a-form-item>
  333. </a-form>
  334. </a-modal>
  335. </template>
  336. <style scoped>
  337. .status-banner {
  338. display: block;
  339. margin-bottom: 10px;
  340. text-align: center;
  341. }
  342. .random-icon,
  343. .action-icon {
  344. margin-left: 4px;
  345. cursor: pointer;
  346. color: var(--ant-primary-color, #1890ff);
  347. }
  348. </style>