ClientFormModal.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. <script setup>
  2. import { computed, reactive, ref, watch } from 'vue';
  3. import { useI18n } from 'vue-i18n';
  4. import { message } from 'ant-design-vue';
  5. import dayjs from 'dayjs';
  6. import { HttpUtil, RandomUtil } from '@/utils';
  7. import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
  8. const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
  9. const props = defineProps({
  10. open: { type: Boolean, default: false },
  11. mode: { type: String, default: 'add' },
  12. client: { type: Object, default: null },
  13. inbounds: { type: Array, default: () => [] },
  14. attachedIds: { type: Array, default: () => [] },
  15. ipLimitEnable: { type: Boolean, default: false },
  16. tgBotEnable: { type: Boolean, default: false },
  17. save: { type: Function, required: true },
  18. });
  19. const emit = defineEmits(['update:open']);
  20. const { t } = useI18n();
  21. const submitting = ref(false);
  22. const form = reactive(emptyForm());
  23. function emptyForm() {
  24. return {
  25. email: '',
  26. subId: '',
  27. uuid: '',
  28. password: '',
  29. auth: '',
  30. flow: '',
  31. reverseTag: '',
  32. totalGB: 0,
  33. expiryDate: null,
  34. delayedStart: false,
  35. delayedDays: 0,
  36. limitIp: 0,
  37. tgId: 0,
  38. comment: '',
  39. enable: true,
  40. inboundIds: [],
  41. };
  42. }
  43. const isEdit = computed(() => props.mode === 'edit');
  44. watch(
  45. () => props.open,
  46. (next) => {
  47. if (!next) return;
  48. Object.assign(form, emptyForm());
  49. if (isEdit.value && props.client) {
  50. form.email = props.client.email || '';
  51. form.subId = props.client.subId || '';
  52. form.uuid = props.client.uuid || '';
  53. form.password = props.client.password || '';
  54. form.auth = props.client.auth || '';
  55. form.flow = props.client.flow || '';
  56. form.reverseTag = props.client.reverse?.tag || '';
  57. form.totalGB = bytesToGB(props.client.totalGB || 0);
  58. const et = Number(props.client.expiryTime) || 0;
  59. if (et < 0) {
  60. form.delayedStart = true;
  61. form.delayedDays = Math.round(et / -86400000);
  62. form.expiryDate = null;
  63. } else {
  64. form.delayedStart = false;
  65. form.delayedDays = 0;
  66. form.expiryDate = et > 0 ? dayjs(et) : null;
  67. }
  68. form.limitIp = props.client.limitIp || 0;
  69. form.tgId = Number(props.client.tgId) || 0;
  70. form.comment = props.client.comment || '';
  71. form.enable = !!props.client.enable;
  72. form.inboundIds = Array.isArray(props.attachedIds) ? [...props.attachedIds] : [];
  73. void loadIps();
  74. } else {
  75. form.email = RandomUtil.randomLowerAndNum(9);
  76. form.uuid = RandomUtil.randomUUID();
  77. form.subId = RandomUtil.randomLowerAndNum(16);
  78. form.password = RandomUtil.randomLowerAndNum(16);
  79. form.auth = RandomUtil.randomLowerAndNum(16);
  80. }
  81. },
  82. );
  83. function bytesToGB(bytes) {
  84. if (!bytes || bytes <= 0) return 0;
  85. return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
  86. }
  87. function gbToBytes(gb) {
  88. if (!gb || gb <= 0) return 0;
  89. return Math.round(gb * 1024 * 1024 * 1024);
  90. }
  91. const MULTI_CLIENT_PROTOCOLS = new Set([
  92. 'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
  93. ]);
  94. const inboundOptions = computed(() =>
  95. (props.inbounds || [])
  96. .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol))
  97. .map((ib) => ({
  98. label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
  99. value: ib.id,
  100. title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
  101. })),
  102. );
  103. const flowCapableIds = computed(() => {
  104. const ids = new Set();
  105. for (const row of props.inbounds || []) {
  106. if (row?.tlsFlowCapable) ids.add(row.id);
  107. }
  108. return ids;
  109. });
  110. const showFlow = computed(() =>
  111. (form.inboundIds || []).some((id) => flowCapableIds.value.has(id)),
  112. );
  113. watch(showFlow, (next) => {
  114. if (!next) form.flow = '';
  115. });
  116. const vlessLikeIds = computed(() => {
  117. const ids = new Set();
  118. for (const row of props.inbounds || []) {
  119. if (row && row.protocol === 'vless') {
  120. ids.add(row.id);
  121. }
  122. }
  123. return ids;
  124. });
  125. const showReverseTag = computed(() =>
  126. (form.inboundIds || []).some((id) => vlessLikeIds.value.has(id)),
  127. );
  128. watch(showReverseTag, (next) => {
  129. if (!next) form.reverseTag = '';
  130. });
  131. const clientIps = ref([]);
  132. const ipsLoading = ref(false);
  133. const ipsClearing = ref(false);
  134. async function loadIps() {
  135. if (!isEdit.value || !props.client?.email) return;
  136. ipsLoading.value = true;
  137. try {
  138. const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(props.client.email)}`);
  139. if (!msg?.success) { clientIps.value = []; return; }
  140. const arr = Array.isArray(msg.obj) ? msg.obj : [];
  141. clientIps.value = arr.filter((x) => typeof x === 'string' && x.length > 0);
  142. } finally {
  143. ipsLoading.value = false;
  144. }
  145. }
  146. async function clearIps() {
  147. if (!isEdit.value || !props.client?.email) return;
  148. ipsClearing.value = true;
  149. try {
  150. const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${encodeURIComponent(props.client.email)}`);
  151. if (msg?.success) clientIps.value = [];
  152. } finally {
  153. ipsClearing.value = false;
  154. }
  155. }
  156. function close() {
  157. emit('update:open', false);
  158. }
  159. function regenerateUUID() {
  160. form.uuid = RandomUtil.randomUUID();
  161. }
  162. function regeneratePassword() {
  163. form.password = RandomUtil.randomLowerAndNum(16);
  164. }
  165. function regenerateAuth() {
  166. form.auth = RandomUtil.randomLowerAndNum(16);
  167. }
  168. function regenerateSubId() {
  169. form.subId = RandomUtil.randomLowerAndNum(16);
  170. }
  171. function regenerateEmail() {
  172. form.email = RandomUtil.randomLowerAndNum(12);
  173. }
  174. function onDelayedStartToggle(next) {
  175. if (next) {
  176. form.expiryDate = null;
  177. } else {
  178. form.delayedDays = 0;
  179. }
  180. }
  181. async function onSubmit() {
  182. if (!form.email || form.email.trim() === '') {
  183. message.error(`${t('pages.clients.email')} *`);
  184. return;
  185. }
  186. if (!isEdit.value && (!form.inboundIds || form.inboundIds.length === 0)) {
  187. message.error(t('pages.clients.selectInbound'));
  188. return;
  189. }
  190. const expiryTime = form.delayedStart
  191. ? -86400000 * (Number(form.delayedDays) || 0)
  192. : (form.expiryDate ? form.expiryDate.valueOf() : 0);
  193. const clientPayload = {
  194. email: form.email.trim(),
  195. subId: form.subId,
  196. id: form.uuid,
  197. password: form.password,
  198. auth: form.auth,
  199. flow: showFlow.value ? (form.flow || '') : '',
  200. totalGB: gbToBytes(form.totalGB),
  201. expiryTime,
  202. limitIp: Number(form.limitIp) || 0,
  203. tgId: Number(form.tgId) || 0,
  204. comment: form.comment,
  205. enable: !!form.enable,
  206. };
  207. const reverseTag = showReverseTag.value ? (form.reverseTag || '').trim() : '';
  208. if (reverseTag) {
  209. clientPayload.reverse = { tag: reverseTag };
  210. }
  211. submitting.value = true;
  212. try {
  213. let msg;
  214. if (isEdit.value) {
  215. const original = new Set(props.attachedIds || []);
  216. const next = new Set(form.inboundIds || []);
  217. const toAttach = [...next].filter((id) => !original.has(id));
  218. const toDetach = [...original].filter((id) => !next.has(id));
  219. msg = await props.save(clientPayload, {
  220. isEdit: true,
  221. email: props.client.email,
  222. attach: toAttach,
  223. detach: toDetach,
  224. });
  225. } else {
  226. msg = await props.save(
  227. { client: clientPayload, inboundIds: form.inboundIds },
  228. { isEdit: false },
  229. );
  230. }
  231. if (msg?.success) close();
  232. } finally {
  233. submitting.value = false;
  234. }
  235. }
  236. </script>
  237. <template>
  238. <a-modal :open="open" :title="isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')"
  239. :destroy-on-close="true" :ok-text="isEdit ? t('save') : t('create')" :cancel-text="t('cancel')"
  240. :ok-button-props="{ loading: submitting }" :width="720" @ok="onSubmit" @cancel="close">
  241. <a-form layout="vertical" :model="form">
  242. <a-row :gutter="16">
  243. <a-col :xs="24" :md="12">
  244. <a-form-item :label="t('pages.clients.email')" required>
  245. <a-input-group compact style="display: flex">
  246. <a-input v-model:value="form.email" :placeholder="t('pages.clients.email')" style="flex: 1" />
  247. <a-button @click="regenerateEmail">↻</a-button>
  248. </a-input-group>
  249. </a-form-item>
  250. </a-col>
  251. <a-col :xs="24" :md="12">
  252. <a-form-item :label="t('pages.clients.subId')">
  253. <a-input-group compact style="display: flex">
  254. <a-input v-model:value="form.subId" style="flex: 1" />
  255. <a-button @click="regenerateSubId">↻</a-button>
  256. </a-input-group>
  257. </a-form-item>
  258. </a-col>
  259. </a-row>
  260. <a-row :gutter="16">
  261. <a-col :xs="24" :md="12">
  262. <a-form-item :label="t('pages.clients.hysteriaAuth')">
  263. <a-input-group compact style="display: flex">
  264. <a-input v-model:value="form.auth" style="flex: 1" />
  265. <a-button @click="regenerateAuth">↻</a-button>
  266. </a-input-group>
  267. </a-form-item>
  268. </a-col>
  269. <a-col :xs="24" :md="12">
  270. <a-form-item :label="t('pages.clients.password')">
  271. <a-input-group compact style="display: flex">
  272. <a-input v-model:value="form.password" style="flex: 1" />
  273. <a-button @click="regeneratePassword">↻</a-button>
  274. </a-input-group>
  275. </a-form-item>
  276. </a-col>
  277. </a-row>
  278. <a-row :gutter="16">
  279. <a-col :xs="24" :md="12">
  280. <a-form-item :label="t('pages.clients.uuid')">
  281. <a-input-group compact style="display: flex">
  282. <a-input v-model:value="form.uuid" style="flex: 1" />
  283. <a-button @click="regenerateUUID">↻</a-button>
  284. </a-input-group>
  285. </a-form-item>
  286. </a-col>
  287. <a-col :xs="24" :md="ipLimitEnable ? 8 : 12">
  288. <a-form-item :label="t('pages.clients.totalGB')">
  289. <a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
  290. </a-form-item>
  291. </a-col>
  292. <a-col v-if="ipLimitEnable" :xs="24" :md="4">
  293. <a-form-item :label="t('pages.clients.limitIp')">
  294. <a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
  295. </a-form-item>
  296. </a-col>
  297. </a-row>
  298. <a-row :gutter="16">
  299. <a-col :xs="24" :md="12">
  300. <a-form-item v-if="form.delayedStart" :label="t('pages.clients.expireDays')">
  301. <a-input-number v-model:value="form.delayedDays" :min="0" style="width: 100%" />
  302. </a-form-item>
  303. <a-form-item v-else :label="t('pages.clients.expiryTime')">
  304. <a-date-picker v-model:value="form.expiryDate" show-time style="width: 100%" />
  305. </a-form-item>
  306. </a-col>
  307. <a-col :xs="24" :md="12">
  308. <a-form-item :label="t('pages.clients.delayedStart')">
  309. <a-switch v-model:checked="form.delayedStart" @change="onDelayedStartToggle" />
  310. </a-form-item>
  311. </a-col>
  312. </a-row>
  313. <a-row v-if="showFlow || showReverseTag" :gutter="16">
  314. <a-col v-if="showFlow" :xs="24" :md="12">
  315. <a-form-item :label="t('pages.clients.flow')">
  316. <a-select v-model:value="form.flow">
  317. <a-select-option value="">{{ t('none') }}</a-select-option>
  318. <a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
  319. </a-select>
  320. </a-form-item>
  321. </a-col>
  322. <a-col v-if="showReverseTag" :xs="24" :md="12">
  323. <a-form-item :label="t('pages.clients.reverseTag')">
  324. <a-input v-model:value="form.reverseTag" :placeholder="t('pages.clients.reverseTagPlaceholder')" />
  325. </a-form-item>
  326. </a-col>
  327. </a-row>
  328. <a-row :gutter="16">
  329. <a-col v-if="tgBotEnable" :xs="24" :md="12">
  330. <a-form-item :label="t('pages.clients.telegramId')">
  331. <a-input-number v-model:value="form.tgId" :min="0" :controls="false"
  332. :placeholder="t('pages.clients.telegramIdPlaceholder')" style="width: 100%" />
  333. </a-form-item>
  334. </a-col>
  335. <a-col :xs="24" :md="tgBotEnable ? 12 : 24">
  336. <a-form-item :label="t('pages.clients.comment')">
  337. <a-input v-model:value="form.comment" />
  338. </a-form-item>
  339. </a-col>
  340. </a-row>
  341. <a-form-item :label="t('pages.clients.attachedInbounds')" :required="!isEdit">
  342. <a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions" :show-search="true"
  343. :placeholder="t('pages.clients.selectInbound')"
  344. :filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
  345. </a-form-item>
  346. <a-form-item>
  347. <a-switch v-model:checked="form.enable" />
  348. <span style="margin-left: 8px">{{ t('enable') }}</span>
  349. </a-form-item>
  350. <a-form-item v-if="isEdit && ipLimitEnable" :label="t('pages.clients.ipLog')">
  351. <a-space style="margin-bottom: 8px">
  352. <a-button size="small" :loading="ipsLoading" @click="loadIps">{{ t('refresh') }}</a-button>
  353. <a-button size="small" danger :loading="ipsClearing" :disabled="clientIps.length === 0" @click="clearIps">
  354. {{ t('pages.clients.clearAll') }}
  355. </a-button>
  356. </a-space>
  357. <div v-if="clientIps.length > 0">
  358. <a-tag v-for="(ip, idx) in clientIps" :key="idx" color="blue" style="margin-bottom: 4px">{{ ip }}</a-tag>
  359. </div>
  360. <a-tag v-else>{{ t('tgbot.noIpRecord') }}</a-tag>
  361. </a-form-item>
  362. </a-form>
  363. </a-modal>
  364. </template>