Edit.vue 16 KB

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