push.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. <template>
  2. <div class="w-full ml-[-8px] mt-5">
  3. <div class="px-4 rounded overflow-y-auto w-full" style="max-height: calc(100vh - 350px)">
  4. <div class="flex items-start">
  5. <label class="w-[65px] leading-8 text-right">地区</label>
  6. <div class="flex gap-2 ml-2 flex-wrap w-57vw]">
  7. <el-tag v-for="(tag, index) in regionData" :key="index" size="large" closable
  8. :disable-transitions="false" @close="handleClose(tag, 'region')">
  9. {{ tag }}
  10. </el-tag>
  11. <ChineseRegionSelector v-if="regionInputVisible" v-model="regionData" showAll
  12. @update:modelValue="handleRegionDataUpdate" />
  13. <el-button v-else class="button-new-tag" @click="showRegionInput"> 添加地区</el-button>
  14. <!-- 注册隐藏的表单项以启用校验 -->
  15. <el-form-item prop="region" style="display: none"></el-form-item>
  16. </div>
  17. </div>
  18. </div>
  19. <!-- <div class="px-4 rounded overflow-y-auto w-full" style="max-height: calc(100vh - 350px)">
  20. <div class="flex items-start">
  21. <label class="w-[65px] leading-8 text-right">IP</label>
  22. <div class="flex gap-2 ml-2 flex-wrap w-57vw]">
  23. <el-tag v-for="tag in ipData" :key="tag" size="large" closable :disable-transitions="false" @close="handleClose(tag, 'ip')">
  24. {{ tag }}
  25. </el-tag>
  26. <el-input
  27. v-if="ipInputVisible"
  28. ref="ipInputRef"
  29. v-model="ipInputValue"
  30. class="!w-32"
  31. @keyup.enter="handleInputConfirm('ip')"
  32. @blur="handleInputConfirm('ip')"
  33. />
  34. <el-button v-else class="button-new-tag" @click="showIpInput"> 添加IP</el-button>
  35. <el-form-item prop="ip" style="display: none"></el-form-item>
  36. </div>
  37. </div>
  38. </div> -->
  39. <!-- <div class="px-4 rounded overflow-y-auto mt-4 w-full" style="max-height: calc(100vh - 350px)">
  40. <div class="flex items-start">
  41. <label class="w-[66px] leading-8 text-right">添加域名</label>
  42. <div class="flex gap-2 ml-2 flex-wrap w-57vw]">
  43. <el-tag v-for="tag in domainData" :key="tag" size="large" closable :disable-transitions="false"
  44. @close="handleClose(tag, 'domain')">
  45. {{ tag }}
  46. </el-tag>
  47. <el-input v-if="domainInputVisible" ref="domainInputRef" v-model="domainInputValue" class="!w-32"
  48. @keyup.enter="handleInputConfirm('domain')" @blur="handleInputConfirm('domain')"
  49. placeholder="如: example.com" />
  50. <el-button v-else class="button-new-tag" @click="showDomainInput"> 添加域名</el-button>
  51. <el-form-item prop="domain" style="display: none"></el-form-item>
  52. </div>
  53. </div>
  54. </div> -->
  55. <el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px"
  56. class="flex flex-wrap mt-4 w-1/2">
  57. <el-form-item label="推送应用" prop="pushApp" class="w-full">
  58. <el-select v-model="formData.pushApp" placeholder="请选择推送方式" multiple collapse-tags collapse-tags-tooltip
  59. :max-collapse-tags="4" filterable remote class="w-full">
  60. <el-option v-for="item in appOptions" :key="item.value" :label="item.label"
  61. :value="item.value" />
  62. </el-select>
  63. </el-form-item>
  64. <el-form-item label="主动推送" prop="autoPush" class="w-1/2">
  65. <el-switch v-model="formData.autoPush" class="mr-2" />
  66. </el-form-item>
  67. <el-form-item label="推送方式" prop="action" class="w-1/2">
  68. <JDictSelect v-model:value="formData.action" placeholder="请选择推送方式" dictType="pushMode"
  69. :styleClass="'w-full'" />
  70. </el-form-item>
  71. <!-- <el-form-item label="推送频率" prop="pushFrequency" class="w-1/2">
  72. <el-input v-model="formData.pushFrequency" type="text" placeholder="请输入推送频率" />
  73. </el-form-item> -->
  74. <el-form-item label="推送延时" prop="delayPush" class="w-1/2">
  75. <div class="flex items-start w-full">
  76. <el-input v-model="formData.delayPush" type="text" placeholder="请输入推送延时" />
  77. </div>
  78. </el-form-item>
  79. <!-- <el-form-item label="推送图片" prop="pushContent" class="w-full">
  80. <div class="flex items-start">
  81. <el-switch v-model="formData.pushType" class="mr-2"
  82. @change="(oldUrl = formData.pushContent), (formData.pushContent = '')" />
  83. <Image v-if="formData.pushType" @change="success" :imageUrl="formData.pushContent"
  84. @update="success" />
  85. </div>
  86. </el-form-item> -->
  87. <el-form-item label="推送内容" prop="pushContent" class="w-full">
  88. <el-input v-model="formData.pushContent" type="text" placeholder="请输入推送内容" />
  89. </el-form-item>
  90. </el-form>
  91. <el-button type="primary" class="ml-5 mt-4 w-[80px]" @click="onSubmit"
  92. :disabled="loading">{{ t('common.saveBtn') }}</el-button>
  93. </div>
  94. </template>
  95. <script setup name="Edit" lang="ts">
  96. import { saveRule, getAppList } from '/@/api/marketing/config';
  97. import { useI18n } from 'vue-i18n';
  98. import { useMessage } from '/@/hooks/message';
  99. let baseURL = import.meta.env.VITE_API_URL;
  100. // 定义子组件向父组件传值/事件
  101. const emit = defineEmits(['update:open', 'onsuccess']);
  102. const props = defineProps({
  103. open: {
  104. type: Boolean,
  105. default: false,
  106. },
  107. rowData: {
  108. type: Object,
  109. default: () => { },
  110. },
  111. });
  112. const oldUrl = ref('');
  113. const success = (val: string) => {
  114. formData.value.pushContent = val;
  115. };
  116. // 引入组件
  117. const JDictSelect = defineAsyncComponent(() => import('/@/components/JDictSelect/index.vue'));
  118. const Image = defineAsyncComponent(() => import('/@/components/Upload/Image.vue'));
  119. const ChineseRegionSelector = defineAsyncComponent(() => import('/@/components/common/ChineseRegionSelector.vue'));
  120. const { t } = useI18n();
  121. // 定义变量内容
  122. const loading = ref(false);
  123. const ruleFormRef = ref();
  124. const formData = ref<any>({
  125. ruleName: '',
  126. keyword: [],
  127. ip: [],
  128. domain: [],
  129. pushContent: '',
  130. pushFrequency: '',
  131. action: '1',
  132. pushType: false,
  133. delayPush: '',
  134. autoPush: false,
  135. pushApp: [],
  136. pushBundle: []
  137. });
  138. const appOptions = ref<any[]>([]);
  139. const getAppListData = async () => {
  140. const res = await getAppList();
  141. const data = res.data;
  142. appOptions.value = data.map((item: any, index: number) => {
  143. return {
  144. label: item.appName,
  145. value: item.appId,
  146. id: item.id,
  147. bundle: item.bundle || ''
  148. };
  149. });
  150. };
  151. getAppListData();
  152. // // 表单校验规则
  153. const dataRules = reactive({
  154. ruleName: [{ required: true, message: '规则名称不能为空', trigger: 'blur' }],
  155. delayPush: [{ required: true, message: '推送延时不能为空', trigger: 'blur' }],
  156. pushContent: [{ required: true, message: '推送内容不能为空', trigger: 'blur' }],
  157. ip: [
  158. {
  159. validator: (rule: any, value: any, callback: any) => {
  160. if (!ipData.value || ipData.value.length === 0) {
  161. callback(new Error('IP不能为空'));
  162. } else {
  163. callback();
  164. }
  165. },
  166. trigger: 'blur',
  167. },
  168. ],
  169. domain: [
  170. {
  171. validator: (rule: any, value: any, callback: any) => {
  172. if (!domainData.value || domainData.value.length === 0) {
  173. callback(new Error('域名不能为空'));
  174. } else {
  175. callback();
  176. }
  177. },
  178. trigger: 'blur',
  179. },
  180. ],
  181. region: [
  182. {
  183. validator: (rule: any, value: any, callback: any) => {
  184. if (!regionData.value || regionData.value.length === 0) {
  185. callback(new Error('地区不能为空'));
  186. } else {
  187. callback();
  188. }
  189. },
  190. trigger: 'blur',
  191. },
  192. ],
  193. pushFrequency: [
  194. { required: true, message: '推送频率不能为空', trigger: 'blur' },
  195. {
  196. pattern: /^(10000|[1-9]\d{0,3}|0)$|^(100%|[1-9]?\d%|0%)$/,
  197. message: '请输入 0-10000 的正整数或 0%-100% 的百分比',
  198. trigger: 'blur',
  199. },
  200. ],
  201. });
  202. //tab相关
  203. const ipInputValue = ref('');
  204. const domainInputValue = ref('');
  205. const regionInputValue = ref('');
  206. const ipInputVisible = ref(false);
  207. const domainInputVisible = ref(false);
  208. const regionInputVisible = ref(false);
  209. const ipInputRef = ref();
  210. const domainInputRef = ref();
  211. const regionInputRef = ref();
  212. const ipData = ref<string[]>([]);
  213. const domainData = ref<string[]>([]);
  214. const regionData = ref<any[]>([]);
  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 showRegionInput = () => {
  228. regionInputVisible.value = true;
  229. nextTick(() => {
  230. regionInputRef.value?.focus();
  231. });
  232. };
  233. const handleClose = (tag: any, type: string) => {
  234. if (type === 'ip') {
  235. ipData.value = ipData.value.filter((item: string) => item !== tag);
  236. } else if (type === 'region') {
  237. regionData.value = regionData.value.filter((item: any) => item !== tag);
  238. } else if (type === 'domain') {
  239. domainData.value = domainData.value.filter((item: string) => item !== tag);
  240. }
  241. };
  242. // 自定义校验函数
  243. const validateIP = (ip: string): boolean => {
  244. // 支持IP段格式:192.168.3.4/255
  245. if (ip.includes('/')) {
  246. const [ipPart, rangePart] = ip.split('/');
  247. const range = parseInt(rangePart);
  248. // 验证IP部分
  249. const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
  250. if (!ipRegex.test(ipPart)) return false;
  251. // 验证范围部分(1-255)
  252. if (isNaN(range) || range < 1 || range > 255) return false;
  253. // 验证IP部分的每个段
  254. const parts = ipPart.split('.');
  255. return parts.every((part) => {
  256. const num = parseInt(part);
  257. return num >= 0 && num <= 255;
  258. });
  259. }
  260. // 普通IP格式
  261. const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
  262. if (!ipRegex.test(ip)) return false;
  263. const parts = ip.split('.');
  264. return parts.every((part) => {
  265. const num = parseInt(part);
  266. return num >= 0 && num <= 255;
  267. });
  268. };
  269. // 检查IP是否在某个IP段内
  270. const isIPInRange = (ip: string, ipRange: string): boolean => {
  271. if (!ipRange.includes('/')) return false;
  272. const [rangeIP, rangePart] = ipRange.split('/');
  273. const range = parseInt(rangePart);
  274. // 将IP转换为数字进行比较
  275. const ipToNumber = (ipStr: string): number => {
  276. const parts = ipStr.split('.').map((part) => parseInt(part));
  277. return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
  278. };
  279. const ipNum = ipToNumber(ip);
  280. const rangeIPNum = ipToNumber(rangeIP);
  281. const rangeEndNum = rangeIPNum + range - 1;
  282. return ipNum >= rangeIPNum && ipNum <= rangeEndNum;
  283. };
  284. // 检查IP是否与现有IP段冲突
  285. const isIPConflict = (newIP: string): boolean => {
  286. // 如果新添加的是IP段,检查是否与现有IP冲突
  287. if (newIP.includes('/')) {
  288. const [rangeIP, rangePart] = newIP.split('/');
  289. const range = parseInt(rangePart);
  290. const rangeIPNum = ipToNumber(rangeIP);
  291. const rangeEndNum = rangeIPNum + range - 1;
  292. // 检查现有IP是否在新IP段内
  293. for (const existingIP of ipData.value) {
  294. if (!existingIP.includes('/')) {
  295. const existingIPNum = ipToNumber(existingIP);
  296. if (existingIPNum >= rangeIPNum && existingIPNum <= rangeEndNum) {
  297. return true;
  298. }
  299. }
  300. }
  301. } else {
  302. // 如果新添加的是单个IP,检查是否在现有IP段内
  303. for (const existingIP of ipData.value) {
  304. if (existingIP.includes('/') && isIPInRange(newIP, existingIP)) {
  305. return true;
  306. }
  307. }
  308. }
  309. return false;
  310. };
  311. // 辅助函数:将IP转换为数字
  312. const ipToNumber = (ipStr: string): number => {
  313. const parts = ipStr.split('.').map((part) => parseInt(part));
  314. return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
  315. };
  316. const validateDomain = (domain: string): boolean => {
  317. // 移除首尾空格
  318. const trimmedDomain = domain.trim();
  319. // 基本长度检查
  320. if (trimmedDomain.length === 0 || trimmedDomain.length > 253) {
  321. return false;
  322. }
  323. // 检查是否包含至少一个点号(顶级域名)
  324. if (!trimmedDomain.includes('.')) {
  325. return false;
  326. }
  327. // 分割域名部分
  328. const parts = trimmedDomain.split('.');
  329. // 检查域名部分数量(至少2部分:主域名.顶级域名)
  330. if (parts.length < 2) {
  331. return false;
  332. }
  333. // 检查每个部分
  334. for (let i = 0; i < parts.length; i++) {
  335. const part = parts[i];
  336. // 每个部分不能为空
  337. if (part.length === 0) {
  338. return false;
  339. }
  340. // 顶级域名至少2个字符
  341. if (i === parts.length - 1 && part.length < 2) {
  342. return false;
  343. }
  344. // 每个部分不能超过63个字符
  345. if (part.length > 63) {
  346. return false;
  347. }
  348. // 检查字符格式:只能包含字母、数字、连字符,不能以连字符开头或结尾
  349. const partRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
  350. if (!partRegex.test(part)) {
  351. return false;
  352. }
  353. // 顶级域名只能包含字母
  354. if (i === parts.length - 1) {
  355. const tldRegex = /^[a-zA-Z]+$/;
  356. if (!tldRegex.test(part)) {
  357. return false;
  358. }
  359. }
  360. }
  361. return true;
  362. };
  363. const handleInputConfirm = (type: string) => {
  364. if (type === 'ip') {
  365. if (ipInputValue.value) {
  366. if (regionData.value.includes(ipInputValue.value)) {
  367. useMessage().error('该地区已存在');
  368. return;
  369. }
  370. // 校验IP格式
  371. if (validateIP(ipInputValue.value)) {
  372. // 检查IP冲突
  373. if (isIPConflict(ipInputValue.value)) {
  374. useMessage().error('该IP与现有IP段冲突,无法添加');
  375. return;
  376. }
  377. ipData.value.push(ipInputValue.value);
  378. } else {
  379. useMessage().error('请输入正确的IP地址格式,支持单个IP(192.168.1.1)或IP段(192.168.1.1/255)');
  380. return;
  381. }
  382. }
  383. ipInputVisible.value = false;
  384. ipInputValue.value = '';
  385. } else if (type === 'domain') {
  386. if (domainInputValue.value) {
  387. if (regionData.value.includes(domainInputValue.value)) {
  388. useMessage().error('该地区已存在');
  389. return;
  390. }
  391. // 校验域名格式
  392. if (validateDomain(domainInputValue.value)) {
  393. domainData.value.push(domainInputValue.value);
  394. } else {
  395. useMessage().error('请输入正确的域名格式,例如:example.com、sub.example.com');
  396. return;
  397. }
  398. }
  399. domainInputVisible.value = false;
  400. domainInputValue.value = '';
  401. } else if (type === 'region') {
  402. if (regionInputValue.value) {
  403. regionData.value.push(regionInputValue.value);
  404. }
  405. regionInputVisible.value = false;
  406. regionInputValue.value = '';
  407. }
  408. };
  409. // 修改 normalizePushApp 函数
  410. function normalizePushApp(input: any[]): Array<{ id: any; appId: any }> {
  411. if (!Array.isArray(input)) return [];
  412. // 如果输入是字符串数组(appId),转换为对象数组
  413. return input.map((item: any) => {
  414. if (typeof item === 'string') {
  415. // 根据 appId 查找完整信息
  416. const found = appOptions.value.find((opt: any) => opt.value === item);
  417. return found ? { id: found.id, appId: found.value } : { id: null, appId: item };
  418. } else if (item && typeof item === 'object' && 'appId' in item) {
  419. return { id: item.id, appId: item.appId };
  420. }
  421. return { id: null, appId: String(item) };
  422. });
  423. }
  424. // 根据选择的 pushApp 派生 pushBundle(去重)
  425. function derivePushBundle(selected: Array<{ id: any; appId: any }>): string[] {
  426. const bundles = new Set<string>();
  427. selected.forEach((sel) => {
  428. const opt = appOptions.value.find((o: any) => o.value === sel.appId);
  429. if (opt && opt.bundle !== undefined && opt.bundle !== null) {
  430. bundles.add(String(opt.bundle));
  431. }
  432. });
  433. return Array.from(bundles);
  434. }
  435. // 地区数据更新处理
  436. const handleRegionDataUpdate = (newRegionData: any[]) => {
  437. // 1) 规范化映射为字符串;2) 过滤无效项;3) 去重
  438. const mapped = newRegionData
  439. .map((item: any) => {
  440. if (item || item == '全部国家') {
  441. return (
  442. (item?.city
  443. ? `${item.country} - ${item.state} - ${item.city}`
  444. : item?.state
  445. ? `${item.country} - ${item.state}`
  446. : item?.country) || item
  447. );
  448. }
  449. return false;
  450. })
  451. .filter((v: any) => v !== undefined && v !== false && v !== null && v !== '');
  452. const unique = Array.from(new Set(mapped)) as string[];
  453. const hasNonAll = unique.some((v) => v !== '全部国家');
  454. if (hasNonAll) {
  455. // 有其它选项时,去掉“全部国家”
  456. regionData.value = unique.filter((v) => v !== '全部国家');
  457. } else {
  458. // 都是“全部国家”时,仅保留一个
  459. regionData.value = unique.length > 0 ? ['全部国家'] : [];
  460. }
  461. regionInputVisible.value = false;
  462. };
  463. const getpushContent = () => {
  464. if (formData.value.pushContent && formData.value.pushType) {
  465. return formData.value.pushContent.includes('http') ? formData.value.pushContent : baseURL + formData.value.pushContent;
  466. } else {
  467. return formData.value.pushContent;
  468. }
  469. };
  470. const onSubmit = async () => {
  471. // 触发表单校验(包含隐藏注册的 keyword/ip/domain 规则)
  472. try {
  473. await ruleFormRef.value.validate();
  474. } catch (e) {
  475. return;
  476. }
  477. let pushFrequency = formData.value.pushFrequency;
  478. if (typeof pushFrequency === 'string' && pushFrequency.includes('%')) {
  479. // 如果包含百分比符号,去除百分比符号并除以100
  480. const num = parseFloat(pushFrequency.replace('%', ''));
  481. if (!isNaN(num)) {
  482. pushFrequency = num / 100;
  483. }
  484. }
  485. if (regionData.value.length < 1) {
  486. regionData.value = ['全部国家'];
  487. }
  488. formData.value = {
  489. ...formData.value,
  490. ip: ipData.value,
  491. domain: domainData.value,
  492. pushAddr: regionData.value,
  493. pushContent: getpushContent(),
  494. pushType: false,
  495. pushFrequency: pushFrequency.toString(),
  496. pushApp: normalizePushApp(formData.value.pushApp),
  497. pushBundle: derivePushBundle(normalizePushApp(formData.value.pushApp)),
  498. };
  499. if (!formData.value.pushContent) {
  500. return useMessage().error(formData.value.pushType ? '推送图片不能为空' : '推送内容不能为空');
  501. }
  502. try {
  503. loading.value = true;
  504. await saveRule({
  505. ...formData.value,
  506. pushFrequency: pushFrequency.toString(),
  507. });
  508. useMessage().success(t('common.editSuccessText'));
  509. emit('onsuccess');
  510. } catch (err: any) {
  511. useMessage().error(err.msg);
  512. } finally {
  513. loading.value = false;
  514. }
  515. };
  516. // 格式化数据展示
  517. const formatNum = (value: string | number = 0) => {
  518. let num = Number(value);
  519. if (num > 0 && num < 1) {
  520. return (num * 100).toFixed(0) + '%';
  521. } else if (num >= 1 && num < 10000) {
  522. return num;
  523. }
  524. return '';
  525. };
  526. // 修改 watch 部分
  527. watch(
  528. () => props.rowData,
  529. async (val) => {
  530. if (val) {
  531. // 确保 appOptions 已加载
  532. if (appOptions.value.length === 0) {
  533. await getAppListData();
  534. }
  535. formData.value = {
  536. ...val,
  537. action: val?.action || '1',
  538. pushFrequency: formatNum(val?.pushFrequency),
  539. pushApp: val.pushApp ? val.pushApp.map((app: any) => {
  540. // 如果是对象,提取 appId;如果是字符串,直接使用
  541. return typeof app === 'object' ? app.appId : app;
  542. }) : [],
  543. pushBundle: val.pushBundle || [],
  544. };
  545. ipData.value = val.ip || [];
  546. domainData.value = val.domain || [];
  547. regionData.value = val.pushAddr || ['全部国家'];
  548. oldUrl.value = val.pushContent;
  549. }
  550. },
  551. { deep: false } // 避免深度监听
  552. );
  553. </script>
  554. <style lang="scss"></style>