123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612 |
- <template>
- <div class="w-full ml-[-8px] mt-5">
- <div class="px-4 rounded overflow-y-auto w-full" style="max-height: calc(100vh - 350px)">
- <div class="flex items-start">
- <label class="w-[65px] leading-8 text-right">地区</label>
- <div class="flex gap-2 ml-2 flex-wrap w-57vw]">
- <el-tag v-for="(tag, index) in regionData" :key="index" size="large" closable
- :disable-transitions="false" @close="handleClose(tag, 'region')">
- {{ tag }}
- </el-tag>
- <ChineseRegionSelector v-if="regionInputVisible" v-model="regionData" showAll
- @update:modelValue="handleRegionDataUpdate" />
- <el-button v-else class="button-new-tag" @click="showRegionInput"> 添加地区</el-button>
- <!-- 注册隐藏的表单项以启用校验 -->
- <el-form-item prop="region" style="display: none"></el-form-item>
- </div>
- </div>
- </div>
- <!-- <div class="px-4 rounded overflow-y-auto w-full" style="max-height: calc(100vh - 350px)">
- <div class="flex items-start">
- <label class="w-[65px] leading-8 text-right">IP</label>
- <div class="flex gap-2 ml-2 flex-wrap w-57vw]">
- <el-tag v-for="tag in ipData" :key="tag" size="large" closable :disable-transitions="false" @close="handleClose(tag, 'ip')">
- {{ tag }}
- </el-tag>
- <el-input
- v-if="ipInputVisible"
- ref="ipInputRef"
- v-model="ipInputValue"
- class="!w-32"
- @keyup.enter="handleInputConfirm('ip')"
- @blur="handleInputConfirm('ip')"
- />
- <el-button v-else class="button-new-tag" @click="showIpInput"> 添加IP</el-button>
- <el-form-item prop="ip" style="display: none"></el-form-item>
- </div>
- </div>
- </div> -->
- <!-- <div class="px-4 rounded overflow-y-auto mt-4 w-full" style="max-height: calc(100vh - 350px)">
- <div class="flex items-start">
- <label class="w-[66px] leading-8 text-right">添加域名</label>
- <div class="flex gap-2 ml-2 flex-wrap w-57vw]">
- <el-tag v-for="tag in domainData" :key="tag" size="large" closable :disable-transitions="false"
- @close="handleClose(tag, 'domain')">
- {{ tag }}
- </el-tag>
- <el-input v-if="domainInputVisible" ref="domainInputRef" v-model="domainInputValue" class="!w-32"
- @keyup.enter="handleInputConfirm('domain')" @blur="handleInputConfirm('domain')"
- placeholder="如: example.com" />
- <el-button v-else class="button-new-tag" @click="showDomainInput"> 添加域名</el-button>
- <el-form-item prop="domain" style="display: none"></el-form-item>
- </div>
- </div>
- </div> -->
- <el-form ref="ruleFormRef" :model="formData" :rules="dataRules" label-width="90px"
- class="flex flex-wrap mt-4 w-1/2">
- <el-form-item label="推送应用" prop="pushApp" class="w-full">
- <el-select v-model="formData.pushApp" placeholder="请选择推送方式" multiple collapse-tags collapse-tags-tooltip
- :max-collapse-tags="4" filterable remote class="w-full">
- <el-option v-for="item in appOptions" :key="item.value" :label="item.label"
- :value="item.value" />
- </el-select>
- </el-form-item>
- <el-form-item label="主动推送" prop="autoPush" class="w-1/2">
- <el-switch v-model="formData.autoPush" class="mr-2" />
- </el-form-item>
- <el-form-item label="推送方式" prop="action" class="w-1/2">
- <JDictSelect v-model:value="formData.action" placeholder="请选择推送方式" dictType="pushMode"
- :styleClass="'w-full'" />
- </el-form-item>
- <!-- <el-form-item label="推送频率" prop="pushFrequency" class="w-1/2">
- <el-input v-model="formData.pushFrequency" type="text" placeholder="请输入推送频率" />
- </el-form-item> -->
- <el-form-item label="推送延时" prop="delayPush" class="w-1/2">
- <div class="flex items-start w-full">
- <el-input v-model="formData.delayPush" type="text" placeholder="请输入推送延时" />
- </div>
- </el-form-item>
- <!-- <el-form-item label="推送图片" prop="pushContent" class="w-full">
- <div class="flex items-start">
- <el-switch v-model="formData.pushType" class="mr-2"
- @change="(oldUrl = formData.pushContent), (formData.pushContent = '')" />
- <Image v-if="formData.pushType" @change="success" :imageUrl="formData.pushContent"
- @update="success" />
- </div>
- </el-form-item> -->
- <el-form-item label="推送内容" prop="pushContent" class="w-full">
- <el-input v-model="formData.pushContent" type="text" placeholder="请输入推送内容" />
- </el-form-item>
- </el-form>
- <el-button type="primary" class="ml-5 mt-4 w-[80px]" @click="onSubmit"
- :disabled="loading">{{ t('common.saveBtn') }}</el-button>
- </div>
- </template>
- <script setup name="Edit" lang="ts">
- import { saveRule, getAppList } from '/@/api/marketing/config';
- import { useI18n } from 'vue-i18n';
- import { useMessage } from '/@/hooks/message';
- let baseURL = import.meta.env.VITE_API_URL;
- // 定义子组件向父组件传值/事件
- const emit = defineEmits(['update:open', 'onsuccess']);
- const props = defineProps({
- open: {
- type: Boolean,
- default: false,
- },
- rowData: {
- type: Object,
- default: () => { },
- },
- });
- const oldUrl = ref('');
- const success = (val: string) => {
- formData.value.pushContent = val;
- };
- // 引入组件
- const JDictSelect = defineAsyncComponent(() => import('/@/components/JDictSelect/index.vue'));
- const Image = defineAsyncComponent(() => import('/@/components/Upload/Image.vue'));
- const ChineseRegionSelector = defineAsyncComponent(() => import('/@/components/common/ChineseRegionSelector.vue'));
- const { t } = useI18n();
- // 定义变量内容
- const loading = ref(false);
- const ruleFormRef = ref();
- const formData = ref<any>({
- ruleName: '',
- keyword: [],
- ip: [],
- domain: [],
- pushContent: '',
- pushFrequency: '',
- action: '1',
- pushType: false,
- delayPush: '',
- autoPush: false,
- pushApp: [],
- pushBundle: []
- });
- const appOptions = ref<any[]>([]);
- const getAppListData = async () => {
- const res = await getAppList();
- const data = res.data;
- appOptions.value = data.map((item: any, index: number) => {
- return {
- label: item.appName,
- value: item.appId,
- id: item.id,
- bundle: item.bundle || ''
- };
- });
- };
- getAppListData();
- // // 表单校验规则
- const dataRules = reactive({
- ruleName: [{ required: true, message: '规则名称不能为空', trigger: 'blur' }],
- delayPush: [{ required: true, message: '推送延时不能为空', trigger: 'blur' }],
- pushContent: [{ required: true, message: '推送内容不能为空', trigger: 'blur' }],
- ip: [
- {
- validator: (rule: any, value: any, callback: any) => {
- if (!ipData.value || ipData.value.length === 0) {
- callback(new Error('IP不能为空'));
- } else {
- callback();
- }
- },
- trigger: 'blur',
- },
- ],
- domain: [
- {
- validator: (rule: any, value: any, callback: any) => {
- if (!domainData.value || domainData.value.length === 0) {
- callback(new Error('域名不能为空'));
- } else {
- callback();
- }
- },
- trigger: 'blur',
- },
- ],
- region: [
- {
- validator: (rule: any, value: any, callback: any) => {
- if (!regionData.value || regionData.value.length === 0) {
- callback(new Error('地区不能为空'));
- } else {
- callback();
- }
- },
- trigger: 'blur',
- },
- ],
- pushFrequency: [
- { required: true, message: '推送频率不能为空', trigger: 'blur' },
- {
- pattern: /^(10000|[1-9]\d{0,3}|0)$|^(100%|[1-9]?\d%|0%)$/,
- message: '请输入 0-10000 的正整数或 0%-100% 的百分比',
- trigger: 'blur',
- },
- ],
- });
- //tab相关
- const ipInputValue = ref('');
- const domainInputValue = ref('');
- const regionInputValue = ref('');
- const ipInputVisible = ref(false);
- const domainInputVisible = ref(false);
- const regionInputVisible = ref(false);
- const ipInputRef = ref();
- const domainInputRef = ref();
- const regionInputRef = ref();
- const ipData = ref<string[]>([]);
- const domainData = ref<string[]>([]);
- const regionData = ref<any[]>([]);
- const showIpInput = () => {
- ipInputVisible.value = true;
- nextTick(() => {
- ipInputRef.value!.input!.focus();
- });
- };
- const showDomainInput = () => {
- domainInputVisible.value = true;
- nextTick(() => {
- domainInputRef.value!.input!.focus();
- });
- };
- const showRegionInput = () => {
- regionInputVisible.value = true;
- nextTick(() => {
- regionInputRef.value?.focus();
- });
- };
- const handleClose = (tag: any, type: string) => {
- if (type === 'ip') {
- ipData.value = ipData.value.filter((item: string) => item !== tag);
- } else if (type === 'region') {
- regionData.value = regionData.value.filter((item: any) => item !== tag);
- } else if (type === 'domain') {
- domainData.value = domainData.value.filter((item: string) => item !== tag);
- }
- };
- // 自定义校验函数
- const validateIP = (ip: string): boolean => {
- // 支持IP段格式:192.168.3.4/255
- if (ip.includes('/')) {
- const [ipPart, rangePart] = ip.split('/');
- const range = parseInt(rangePart);
- // 验证IP部分
- const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
- if (!ipRegex.test(ipPart)) return false;
- // 验证范围部分(1-255)
- if (isNaN(range) || range < 1 || range > 255) return false;
- // 验证IP部分的每个段
- const parts = ipPart.split('.');
- return parts.every((part) => {
- const num = parseInt(part);
- return num >= 0 && num <= 255;
- });
- }
- // 普通IP格式
- const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
- if (!ipRegex.test(ip)) return false;
- const parts = ip.split('.');
- return parts.every((part) => {
- const num = parseInt(part);
- return num >= 0 && num <= 255;
- });
- };
- // 检查IP是否在某个IP段内
- const isIPInRange = (ip: string, ipRange: string): boolean => {
- if (!ipRange.includes('/')) return false;
- const [rangeIP, rangePart] = ipRange.split('/');
- const range = parseInt(rangePart);
- // 将IP转换为数字进行比较
- const ipToNumber = (ipStr: string): number => {
- const parts = ipStr.split('.').map((part) => parseInt(part));
- return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
- };
- const ipNum = ipToNumber(ip);
- const rangeIPNum = ipToNumber(rangeIP);
- const rangeEndNum = rangeIPNum + range - 1;
- return ipNum >= rangeIPNum && ipNum <= rangeEndNum;
- };
- // 检查IP是否与现有IP段冲突
- const isIPConflict = (newIP: string): boolean => {
- // 如果新添加的是IP段,检查是否与现有IP冲突
- if (newIP.includes('/')) {
- const [rangeIP, rangePart] = newIP.split('/');
- const range = parseInt(rangePart);
- const rangeIPNum = ipToNumber(rangeIP);
- const rangeEndNum = rangeIPNum + range - 1;
- // 检查现有IP是否在新IP段内
- for (const existingIP of ipData.value) {
- if (!existingIP.includes('/')) {
- const existingIPNum = ipToNumber(existingIP);
- if (existingIPNum >= rangeIPNum && existingIPNum <= rangeEndNum) {
- return true;
- }
- }
- }
- } else {
- // 如果新添加的是单个IP,检查是否在现有IP段内
- for (const existingIP of ipData.value) {
- if (existingIP.includes('/') && isIPInRange(newIP, existingIP)) {
- return true;
- }
- }
- }
- return false;
- };
- // 辅助函数:将IP转换为数字
- const ipToNumber = (ipStr: string): number => {
- const parts = ipStr.split('.').map((part) => parseInt(part));
- return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
- };
- const validateDomain = (domain: string): boolean => {
- // 移除首尾空格
- const trimmedDomain = domain.trim();
- // 基本长度检查
- if (trimmedDomain.length === 0 || trimmedDomain.length > 253) {
- return false;
- }
- // 检查是否包含至少一个点号(顶级域名)
- if (!trimmedDomain.includes('.')) {
- return false;
- }
- // 分割域名部分
- const parts = trimmedDomain.split('.');
- // 检查域名部分数量(至少2部分:主域名.顶级域名)
- if (parts.length < 2) {
- return false;
- }
- // 检查每个部分
- for (let i = 0; i < parts.length; i++) {
- const part = parts[i];
- // 每个部分不能为空
- if (part.length === 0) {
- return false;
- }
- // 顶级域名至少2个字符
- if (i === parts.length - 1 && part.length < 2) {
- return false;
- }
- // 每个部分不能超过63个字符
- if (part.length > 63) {
- return false;
- }
- // 检查字符格式:只能包含字母、数字、连字符,不能以连字符开头或结尾
- const partRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
- if (!partRegex.test(part)) {
- return false;
- }
- // 顶级域名只能包含字母
- if (i === parts.length - 1) {
- const tldRegex = /^[a-zA-Z]+$/;
- if (!tldRegex.test(part)) {
- return false;
- }
- }
- }
- return true;
- };
- const handleInputConfirm = (type: string) => {
- if (type === 'ip') {
- if (ipInputValue.value) {
- if (regionData.value.includes(ipInputValue.value)) {
- useMessage().error('该地区已存在');
- return;
- }
- // 校验IP格式
- if (validateIP(ipInputValue.value)) {
- // 检查IP冲突
- if (isIPConflict(ipInputValue.value)) {
- useMessage().error('该IP与现有IP段冲突,无法添加');
- return;
- }
- ipData.value.push(ipInputValue.value);
- } else {
- useMessage().error('请输入正确的IP地址格式,支持单个IP(192.168.1.1)或IP段(192.168.1.1/255)');
- return;
- }
- }
- ipInputVisible.value = false;
- ipInputValue.value = '';
- } else if (type === 'domain') {
- if (domainInputValue.value) {
- if (regionData.value.includes(domainInputValue.value)) {
- useMessage().error('该地区已存在');
- return;
- }
- // 校验域名格式
- if (validateDomain(domainInputValue.value)) {
- domainData.value.push(domainInputValue.value);
- } else {
- useMessage().error('请输入正确的域名格式,例如:example.com、sub.example.com');
- return;
- }
- }
- domainInputVisible.value = false;
- domainInputValue.value = '';
- } else if (type === 'region') {
- if (regionInputValue.value) {
- regionData.value.push(regionInputValue.value);
- }
- regionInputVisible.value = false;
- regionInputValue.value = '';
- }
- };
- // 修改 normalizePushApp 函数
- function normalizePushApp(input: any[]): Array<{ id: any; appId: any }> {
- if (!Array.isArray(input)) return [];
-
- // 如果输入是字符串数组(appId),转换为对象数组
- return input.map((item: any) => {
- if (typeof item === 'string') {
- // 根据 appId 查找完整信息
- const found = appOptions.value.find((opt: any) => opt.value === item);
- return found ? { id: found.id, appId: found.value } : { id: null, appId: item };
- } else if (item && typeof item === 'object' && 'appId' in item) {
- return { id: item.id, appId: item.appId };
- }
- return { id: null, appId: String(item) };
- });
- }
- // 根据选择的 pushApp 派生 pushBundle(去重)
- function derivePushBundle(selected: Array<{ id: any; appId: any }>): string[] {
- const bundles = new Set<string>();
- selected.forEach((sel) => {
- const opt = appOptions.value.find((o: any) => o.value === sel.appId);
- if (opt && opt.bundle !== undefined && opt.bundle !== null) {
- bundles.add(String(opt.bundle));
- }
- });
- return Array.from(bundles);
- }
- // 地区数据更新处理
- const handleRegionDataUpdate = (newRegionData: any[]) => {
- // 1) 规范化映射为字符串;2) 过滤无效项;3) 去重
- const mapped = newRegionData
- .map((item: any) => {
- if (item || item == '全部国家') {
- return (
- (item?.city
- ? `${item.country} - ${item.state} - ${item.city}`
- : item?.state
- ? `${item.country} - ${item.state}`
- : item?.country) || item
- );
- }
- return false;
- })
- .filter((v: any) => v !== undefined && v !== false && v !== null && v !== '');
- const unique = Array.from(new Set(mapped)) as string[];
- const hasNonAll = unique.some((v) => v !== '全部国家');
- if (hasNonAll) {
- // 有其它选项时,去掉“全部国家”
- regionData.value = unique.filter((v) => v !== '全部国家');
- } else {
- // 都是“全部国家”时,仅保留一个
- regionData.value = unique.length > 0 ? ['全部国家'] : [];
- }
- regionInputVisible.value = false;
- };
- const getpushContent = () => {
- if (formData.value.pushContent && formData.value.pushType) {
- return formData.value.pushContent.includes('http') ? formData.value.pushContent : baseURL + formData.value.pushContent;
- } else {
- return formData.value.pushContent;
- }
- };
- const onSubmit = async () => {
- // 触发表单校验(包含隐藏注册的 keyword/ip/domain 规则)
- try {
- await ruleFormRef.value.validate();
- } catch (e) {
- return;
- }
- let pushFrequency = formData.value.pushFrequency;
- if (typeof pushFrequency === 'string' && pushFrequency.includes('%')) {
- // 如果包含百分比符号,去除百分比符号并除以100
- const num = parseFloat(pushFrequency.replace('%', ''));
- if (!isNaN(num)) {
- pushFrequency = num / 100;
- }
- }
- if (regionData.value.length < 1) {
- regionData.value = ['全部国家'];
- }
- formData.value = {
- ...formData.value,
- ip: ipData.value,
- domain: domainData.value,
- pushAddr: regionData.value,
- pushContent: getpushContent(),
- pushType: false,
- pushFrequency: pushFrequency.toString(),
- pushApp: normalizePushApp(formData.value.pushApp),
- pushBundle: derivePushBundle(normalizePushApp(formData.value.pushApp)),
- };
- if (!formData.value.pushContent) {
- return useMessage().error(formData.value.pushType ? '推送图片不能为空' : '推送内容不能为空');
- }
- try {
- loading.value = true;
- await saveRule({
- ...formData.value,
- pushFrequency: pushFrequency.toString(),
- });
- useMessage().success(t('common.editSuccessText'));
- emit('onsuccess');
- } catch (err: any) {
- useMessage().error(err.msg);
- } finally {
- loading.value = false;
- }
- };
- // 格式化数据展示
- const formatNum = (value: string | number = 0) => {
- let num = Number(value);
- if (num > 0 && num < 1) {
- return (num * 100).toFixed(0) + '%';
- } else if (num >= 1 && num < 10000) {
- return num;
- }
- return '';
- };
- // 修改 watch 部分
- watch(
- () => props.rowData,
- async (val) => {
- if (val) {
- // 确保 appOptions 已加载
- if (appOptions.value.length === 0) {
- await getAppListData();
- }
-
- formData.value = {
- ...val,
- action: val?.action || '1',
- pushFrequency: formatNum(val?.pushFrequency),
- pushApp: val.pushApp ? val.pushApp.map((app: any) => {
- // 如果是对象,提取 appId;如果是字符串,直接使用
- return typeof app === 'object' ? app.appId : app;
- }) : [],
- pushBundle: val.pushBundle || [],
- };
- ipData.value = val.ip || [];
- domainData.value = val.domain || [];
- regionData.value = val.pushAddr || ['全部国家'];
- oldUrl.value = val.pushContent;
- }
- },
- { deep: false } // 避免深度监听
- );
- </script>
- <style lang="scss"></style>
|