Edit.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. <template>
  2. <el-dialog :title="props.rowData?.id ? '修改规则' : '新增规则'" width="1000" v-model="props.open"
  3. :close-on-click-modal="false" :destroy-on-close="true" @close="onCancel" draggable>
  4. <div class="w-full ml-[-8px]">
  5. <el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px" class="flex flex-wrap">
  6. <el-form-item label="规则名称" prop="ruleName" class="w-1/3">
  7. <el-input v-model="formData.ruleName" type="text" placeholder="请输入规则名称" />
  8. </el-form-item>
  9. </el-form>
  10. <div class="px-4 rounded overflow-y-auto mt-4" style="max-height: calc(100vh - 350px)">
  11. <div class="flex items-start">
  12. <label class="w-[65px] leading-8 text-right"><span class="text-[#f56c6c] mr-1">*</span> 关键字</label>
  13. <div class="flex gap-2 ml-2 flex-wrap w-57vw]">
  14. <el-tag v-for="tag in keywordData" :key="tag" size="large" closable :disable-transitions="false"
  15. @close="handleClose(tag, 'keyword')">
  16. {{ tag }}
  17. </el-tag>
  18. <el-input v-if="inputVisible" ref="InputRef" v-model="inputValue" class="!w-32"
  19. @keyup.enter="handleInputConfirm('keyword')" @blur="handleInputConfirm('keyword')" />
  20. <el-button v-else class="button-new-tag" @click="showInput"> 添加关键字</el-button>
  21. <!-- 注册隐藏的表单项以启用校验 -->
  22. <el-form-item prop="keyword" style="display:none;"></el-form-item>
  23. </div>
  24. </div>
  25. </div>
  26. <div class="px-4 rounded overflow-y-auto mt-4" style="max-height: calc(100vh - 350px)">
  27. <div class="flex items-start">
  28. <label class="w-[65px] leading-8 text-right">IP</label>
  29. <div class="flex gap-2 ml-2 flex-wrap w-57vw]">
  30. <el-tag v-for="tag in ipData" :key="tag" size="large" closable :disable-transitions="false"
  31. @close="handleClose(tag, 'ip')">
  32. {{ tag }}
  33. </el-tag>
  34. <el-input v-if="ipInputVisible" ref="ipInputRef" v-model="ipInputValue" class="!w-32"
  35. @keyup.enter="handleInputConfirm('ip')" @blur="handleInputConfirm('ip')" />
  36. <el-button v-else class="button-new-tag" @click="showIpInput"> 添加IP</el-button>
  37. <!-- 注册隐藏的表单项以启用校验 -->
  38. <el-form-item prop="ip" style="display:none;"></el-form-item>
  39. </div>
  40. </div>
  41. </div>
  42. <div class="px-4 rounded overflow-y-auto mt-4" style="max-height: calc(100vh - 350px)">
  43. <div class="flex items-start">
  44. <label class="w-[66px] leading-8 text-right">添加域名</label>
  45. <div class="flex gap-2 ml-2 flex-wrap w-57vw]">
  46. <el-tag v-for="tag in domainData" :key="tag" size="large" closable :disable-transitions="false"
  47. @close="handleClose(tag, 'domain')">
  48. {{ tag }}
  49. </el-tag>
  50. <el-input v-if="domainInputVisible" ref="domainInputRef" v-model="domainInputValue"
  51. class="!w-32" @keyup.enter="handleInputConfirm('domain')"
  52. @blur="handleInputConfirm('domain')" placeholder="如: example.com" />
  53. <el-button v-else class="button-new-tag" @click="showDomainInput"> 添加域名</el-button>
  54. <!-- 注册隐藏的表单项以启用校验 -->
  55. <el-form-item prop="domain" style="display:none;"></el-form-item>
  56. </div>
  57. </div>
  58. </div>
  59. <el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px"
  60. class="flex flex-wrap mt-4">
  61. <el-form-item label="推送方式" prop="action" class="w-1/2">
  62. <JDictSelect v-model:value="formData.action" placeholder="请选择推送方式" :dictType="'pushMode'"
  63. :selectFirst="true" :styleClass="'w-full'" />
  64. </el-form-item>
  65. <el-form-item label="推送频率" prop="pushFrequency" class="w-1/2">
  66. <el-input v-model="formData.pushFrequency" type="text" placeholder="请输入推送频率" />
  67. </el-form-item>
  68. <el-form-item label="推送图片" prop="pushContent" class="w-1/2 ">
  69. <div class="flex items-start">
  70. <el-switch v-model="formData.pushType" class="mr-2"
  71. @change="oldUrl = formData.pushContent, formData.pushContent = ''" />
  72. <Image v-if="formData.pushType" @change="success" :imageUrl="formData.pushContent"
  73. @update="success" />
  74. </div>
  75. </el-form-item>
  76. <el-form-item v-if="!formData.pushType" label="推送内容" prop="pushContent" class="w-full">
  77. <el-input v-model="formData.pushContent" type="textarea" placeholder="请输入推送内容" />
  78. </el-form-item>
  79. <el-form-item label="备注" prop="remark" class="w-full">
  80. <el-input v-model="formData.remark" type="textarea" placeholder="请输入备注" />
  81. </el-form-item>
  82. </el-form>
  83. </div>
  84. <template #footer>
  85. <span class="dialog-footer">
  86. <el-button @click="onCancel">{{ t('common.cancelButtonText') }}</el-button>
  87. <el-button type="primary" @click="onSubmit"
  88. :disabled="loading">{{ t('common.confirmButtonText') }}</el-button>
  89. </span>
  90. </template>
  91. </el-dialog>
  92. </template>
  93. <script setup name="Edit" lang="ts">
  94. import { update } from '/@/api/marketing/rules';
  95. import { useI18n } from 'vue-i18n';
  96. import { useMessage } from '/@/hooks/message';
  97. let baseURL = import.meta.env.VITE_API_URL;
  98. // 定义子组件向父组件传值/事件
  99. const emit = defineEmits(['update:open', 'onsuccess']);
  100. const props = defineProps({
  101. open: {
  102. type: Boolean,
  103. default: false,
  104. },
  105. rowData: {
  106. type: Object,
  107. default: () => { },
  108. },
  109. });
  110. const onCancel = () => {
  111. emit('update:open', false);
  112. };
  113. const oldUrl = ref('');
  114. const success = (val: string) => {
  115. formData.value.pushContent = val;
  116. };
  117. // 引入组件
  118. const JDictSelect = defineAsyncComponent(() => import('/@/components/JDictSelect/index.vue'));
  119. const Image = defineAsyncComponent(() => import('/@/components/Upload/Image.vue'));
  120. const { t } = useI18n();
  121. // 定义变量内容
  122. const loading = ref(false);
  123. const ruleFormRef = ref();
  124. interface FormDataType {
  125. ruleName: string;
  126. keyword: string[];
  127. ip: string[];
  128. domain: string[];
  129. pushContent: string;
  130. pushFrequency: string;
  131. action: string;
  132. remark: string;
  133. pushType: boolean;
  134. }
  135. const formData = ref<FormDataType>({
  136. ruleName: '',
  137. keyword: [],
  138. ip: [],
  139. domain: [],
  140. pushContent: '',
  141. pushFrequency: '',
  142. action: '1',
  143. remark: '',
  144. pushType: false,
  145. });
  146. // // 表单校验规则
  147. const dataRules = reactive({
  148. ruleName: [{ required: true, message: '规则名称不能为空', trigger: 'blur' }],
  149. keyword: [
  150. {
  151. required: true,
  152. validator: (rule: any, value: any, callback: any) => {
  153. if (!keywordData.value || keywordData.value.length === 0) {
  154. callback(new Error('关键字不能为空'));
  155. } else {
  156. callback();
  157. }
  158. },
  159. trigger: 'blur',
  160. },
  161. ],
  162. pushContent: [{ required: true, message: '推送内容不能为空', trigger: 'blur' }],
  163. ip: [
  164. {
  165. validator: (rule: any, value: any, callback: any) => {
  166. if (!ipData.value || ipData.value.length === 0) {
  167. callback(new Error('IP不能为空'));
  168. } else {
  169. callback();
  170. }
  171. },
  172. trigger: 'blur',
  173. },
  174. ],
  175. domain: [
  176. {
  177. validator: (rule: any, value: any, callback: any) => {
  178. if (!domainData.value || domainData.value.length === 0) {
  179. callback(new Error('域名不能为空'));
  180. } else {
  181. callback();
  182. }
  183. },
  184. trigger: 'blur',
  185. },
  186. ],
  187. pushFrequency: [
  188. { required: true, message: '推送频率不能为空', trigger: 'blur' },
  189. {
  190. pattern: /^(10000|[1-9]\d{0,3}|0)$|^(100%|[1-9]?\d%|0%)$/,
  191. message: '请输入 0-10000 的正整数或 0%-100% 的百分比',
  192. trigger: 'blur',
  193. },
  194. ],
  195. });
  196. //tab相关
  197. const inputValue = ref('');
  198. const ipInputValue = ref('');
  199. const domainInputValue = ref('');
  200. const inputVisible = ref(false);
  201. const ipInputVisible = ref(false);
  202. const domainInputVisible = ref(false);
  203. const InputRef = ref();
  204. const ipInputRef = ref();
  205. const domainInputRef = ref();
  206. const keywordData = ref<string[]>([]);
  207. const ipData = ref<string[]>([]);
  208. const domainData = ref<string[]>([]);
  209. const showInput = () => {
  210. inputVisible.value = true;
  211. nextTick(() => {
  212. InputRef.value!.input!.focus();
  213. });
  214. };
  215. const showIpInput = () => {
  216. ipInputVisible.value = true;
  217. nextTick(() => {
  218. ipInputRef.value!.input!.focus();
  219. });
  220. };
  221. const showDomainInput = () => {
  222. domainInputVisible.value = true;
  223. nextTick(() => {
  224. domainInputRef.value!.input!.focus();
  225. });
  226. };
  227. const handleClose = (tag: string, type: string) => {
  228. if (type === 'keyword') {
  229. keywordData.value = keywordData.value.filter((item: string) => item !== tag);
  230. } else if (type === 'ip') {
  231. ipData.value = ipData.value.filter((item: string) => item !== tag);
  232. } else if (type === 'domain') {
  233. domainData.value = domainData.value.filter((item: string) => item !== tag);
  234. }
  235. };
  236. // 自定义校验函数
  237. const validateIP = (ip: string): boolean => {
  238. // 支持IP段格式:192.168.3.4/255
  239. if (ip.includes('/')) {
  240. const [ipPart, rangePart] = ip.split('/');
  241. const range = parseInt(rangePart);
  242. // 验证IP部分
  243. const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
  244. if (!ipRegex.test(ipPart)) return false;
  245. // 验证范围部分(1-255)
  246. if (isNaN(range) || range < 1 || range > 255) return false;
  247. // 验证IP部分的每个段
  248. const parts = ipPart.split('.');
  249. return parts.every((part) => {
  250. const num = parseInt(part);
  251. return num >= 0 && num <= 255;
  252. });
  253. }
  254. // 普通IP格式
  255. const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
  256. if (!ipRegex.test(ip)) return false;
  257. const parts = ip.split('.');
  258. return parts.every((part) => {
  259. const num = parseInt(part);
  260. return num >= 0 && num <= 255;
  261. });
  262. };
  263. // 检查IP是否在某个IP段内
  264. const isIPInRange = (ip: string, ipRange: string): boolean => {
  265. if (!ipRange.includes('/')) return false;
  266. const [rangeIP, rangePart] = ipRange.split('/');
  267. const range = parseInt(rangePart);
  268. // 将IP转换为数字进行比较
  269. const ipToNumber = (ipStr: string): number => {
  270. const parts = ipStr.split('.').map((part) => parseInt(part));
  271. return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
  272. };
  273. const ipNum = ipToNumber(ip);
  274. const rangeIPNum = ipToNumber(rangeIP);
  275. const rangeEndNum = rangeIPNum + range - 1;
  276. return ipNum >= rangeIPNum && ipNum <= rangeEndNum;
  277. };
  278. // 检查IP是否与现有IP段冲突
  279. const isIPConflict = (newIP: string): boolean => {
  280. // 如果新添加的是IP段,检查是否与现有IP冲突
  281. if (newIP.includes('/')) {
  282. const [rangeIP, rangePart] = newIP.split('/');
  283. const range = parseInt(rangePart);
  284. const rangeIPNum = ipToNumber(rangeIP);
  285. const rangeEndNum = rangeIPNum + range - 1;
  286. // 检查现有IP是否在新IP段内
  287. for (const existingIP of ipData.value) {
  288. if (!existingIP.includes('/')) {
  289. const existingIPNum = ipToNumber(existingIP);
  290. if (existingIPNum >= rangeIPNum && existingIPNum <= rangeEndNum) {
  291. return true;
  292. }
  293. }
  294. }
  295. } else {
  296. // 如果新添加的是单个IP,检查是否在现有IP段内
  297. for (const existingIP of ipData.value) {
  298. if (existingIP.includes('/') && isIPInRange(newIP, existingIP)) {
  299. return true;
  300. }
  301. }
  302. }
  303. return false;
  304. };
  305. // 辅助函数:将IP转换为数字
  306. const ipToNumber = (ipStr: string): number => {
  307. const parts = ipStr.split('.').map((part) => parseInt(part));
  308. return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
  309. };
  310. const validateDomain = (domain: string): boolean => {
  311. // 移除首尾空格
  312. const trimmedDomain = domain.trim();
  313. // 基本长度检查
  314. if (trimmedDomain.length === 0 || trimmedDomain.length > 253) {
  315. return false;
  316. }
  317. // 检查是否包含至少一个点号(顶级域名)
  318. if (!trimmedDomain.includes('.')) {
  319. return false;
  320. }
  321. // 分割域名部分
  322. const parts = trimmedDomain.split('.');
  323. // 检查域名部分数量(至少2部分:主域名.顶级域名)
  324. if (parts.length < 2) {
  325. return false;
  326. }
  327. // 检查每个部分
  328. for (let i = 0; i < parts.length; i++) {
  329. const part = parts[i];
  330. // 每个部分不能为空
  331. if (part.length === 0) {
  332. return false;
  333. }
  334. // 顶级域名至少2个字符
  335. if (i === parts.length - 1 && part.length < 2) {
  336. return false;
  337. }
  338. // 每个部分不能超过63个字符
  339. if (part.length > 63) {
  340. return false;
  341. }
  342. // 检查字符格式:只能包含字母、数字、连字符,不能以连字符开头或结尾
  343. const partRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
  344. if (!partRegex.test(part)) {
  345. return false;
  346. }
  347. // 顶级域名只能包含字母
  348. if (i === parts.length - 1) {
  349. const tldRegex = /^[a-zA-Z]+$/;
  350. if (!tldRegex.test(part)) {
  351. return false;
  352. }
  353. }
  354. }
  355. return true;
  356. };
  357. const handleInputConfirm = (type: string) => {
  358. if (type === 'keyword') {
  359. if (inputValue.value) {
  360. keywordData.value.push(inputValue.value);
  361. }
  362. inputVisible.value = false;
  363. inputValue.value = '';
  364. } else if (type === 'ip') {
  365. if (ipInputValue.value) {
  366. // 校验IP格式
  367. if (validateIP(ipInputValue.value)) {
  368. // 检查IP冲突
  369. if (isIPConflict(ipInputValue.value)) {
  370. useMessage().error('该IP与现有IP段冲突,无法添加');
  371. return;
  372. }
  373. ipData.value.push(ipInputValue.value);
  374. } else {
  375. useMessage().error('请输入正确的IP地址格式,支持单个IP(192.168.1.1)或IP段(192.168.1.1/255)');
  376. return;
  377. }
  378. }
  379. ipInputVisible.value = false;
  380. ipInputValue.value = '';
  381. } else if (type === 'domain') {
  382. if (domainInputValue.value) {
  383. // 校验域名格式
  384. if (validateDomain(domainInputValue.value)) {
  385. domainData.value.push(domainInputValue.value);
  386. } else {
  387. useMessage().error('请输入正确的域名格式,例如:example.com、sub.example.com');
  388. return;
  389. }
  390. }
  391. domainInputVisible.value = false;
  392. domainInputValue.value = '';
  393. }
  394. };
  395. const getpushContent = () => {
  396. if (formData.value.pushContent) {
  397. return formData.value.pushContent.includes('http') ? formData.value.pushContent : baseURL + formData.value.pushContent
  398. } else {
  399. return ''
  400. }
  401. };
  402. const onSubmit = async () => {
  403. // 触发表单校验(包含隐藏注册的 keyword/ip/domain 规则)
  404. try {
  405. await ruleFormRef.value.validate();
  406. } catch (e) {
  407. return;
  408. }
  409. formData.value = {
  410. ...formData.value,
  411. ip: ipData.value,
  412. keyword: keywordData.value,
  413. domain: domainData.value,
  414. pushContent: getpushContent(),
  415. };
  416. if (!formData.value.pushContent) {
  417. return useMessage().error(formData.value.pushType ? '推送图片不能为空' : '推送内容不能为空');
  418. }
  419. try {
  420. loading.value = true;
  421. await update({
  422. ...formData.value,
  423. });
  424. useMessage().success(props.rowData?.id ? t('common.editSuccessText') : t('common.addSuccessText'));
  425. emit('onsuccess');
  426. onCancel();
  427. } catch (err: any) {
  428. useMessage().error(err.msg);
  429. } finally {
  430. loading.value = false;
  431. }
  432. };
  433. watch(
  434. () => props.open,
  435. (val) => {
  436. if (val) {
  437. formData.value = {
  438. ...props.rowData,
  439. };
  440. ipData.value = props.rowData.ip || [];
  441. keywordData.value = props.rowData.keyword || [];
  442. domainData.value = props.rowData.domain || [];
  443. oldUrl.value = props.rowData.pushContent;
  444. }
  445. }
  446. );
  447. </script>
  448. <style lang="scss">
  449. </style>