|
@@ -10,6 +10,7 @@ import {
|
|
|
ClusterOutlined,
|
|
ClusterOutlined,
|
|
|
ArrowUpOutlined,
|
|
ArrowUpOutlined,
|
|
|
ArrowDownOutlined,
|
|
ArrowDownOutlined,
|
|
|
|
|
+ HolderOutlined,
|
|
|
} from '@ant-design/icons-vue';
|
|
} from '@ant-design/icons-vue';
|
|
|
import { Modal } from 'ant-design-vue';
|
|
import { Modal } from 'ant-design-vue';
|
|
|
|
|
|
|
@@ -22,9 +23,11 @@ const { t } = useI18n();
|
|
|
// "lead value + N more" pill per criterion (matches the legacy pill
|
|
// "lead value + N more" pill per criterion (matches the legacy pill
|
|
|
// layout); full lists surface via tooltip on hover.
|
|
// layout); full lists surface via tooltip on hover.
|
|
|
//
|
|
//
|
|
|
-// Reorder uses up/down buttons in the action menu rather than the
|
|
|
|
|
-// jQuery-Sortable drag handle the legacy panel used — same effect,
|
|
|
|
|
-// no extra dep. The mobile column layout drops source/network/
|
|
|
|
|
|
|
+// Reorder via Pointer Events on the grip icon — unified mouse +
|
|
|
|
|
+// touch + pen path so the same code works on desktop and mobile
|
|
|
|
|
+// (HTML5 drag doesn't fire from touch on iOS Safari, hence the
|
|
|
|
|
+// switch). Up/down buttons in the action menu stay as a keyboard
|
|
|
|
|
+// fallback. The mobile column layout drops source/network/
|
|
|
// destination criteria for readability.
|
|
// destination criteria for readability.
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
const props = defineProps({
|
|
@@ -162,6 +165,58 @@ function moveDown(idx) {
|
|
|
[rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
|
|
[rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const draggedIndex = ref(null);
|
|
|
|
|
+const dropTargetIndex = ref(null);
|
|
|
|
|
+let dragStartY = 0;
|
|
|
|
|
+let dragMoved = false;
|
|
|
|
|
+
|
|
|
|
|
+function onHandlePointerDown(idx, ev) {
|
|
|
|
|
+ if (ev.button != null && ev.button !== 0) return;
|
|
|
|
|
+ ev.preventDefault();
|
|
|
|
|
+ draggedIndex.value = idx;
|
|
|
|
|
+ dropTargetIndex.value = idx;
|
|
|
|
|
+ dragStartY = ev.clientY;
|
|
|
|
|
+ dragMoved = false;
|
|
|
|
|
+ document.addEventListener('pointermove', onDragPointerMove);
|
|
|
|
|
+ document.addEventListener('pointerup', onDragPointerUp);
|
|
|
|
|
+ document.addEventListener('pointercancel', onDragPointerUp);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onDragPointerMove(ev) {
|
|
|
|
|
+ if (draggedIndex.value == null) return;
|
|
|
|
|
+ if (!dragMoved && Math.abs(ev.clientY - dragStartY) < 5) return;
|
|
|
|
|
+ dragMoved = true;
|
|
|
|
|
+ const el = document.elementFromPoint(ev.clientX, ev.clientY);
|
|
|
|
|
+ if (!el) return;
|
|
|
|
|
+ const tr = el.closest('tr[data-row-key]');
|
|
|
|
|
+ if (!tr) return;
|
|
|
|
|
+ const idx = Number(tr.getAttribute('data-row-key'));
|
|
|
|
|
+ if (Number.isFinite(idx)) dropTargetIndex.value = idx;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onDragPointerUp() {
|
|
|
|
|
+ document.removeEventListener('pointermove', onDragPointerMove);
|
|
|
|
|
+ document.removeEventListener('pointerup', onDragPointerUp);
|
|
|
|
|
+ document.removeEventListener('pointercancel', onDragPointerUp);
|
|
|
|
|
+ const from = draggedIndex.value;
|
|
|
|
|
+ const to = dropTargetIndex.value;
|
|
|
|
|
+ draggedIndex.value = null;
|
|
|
|
|
+ dropTargetIndex.value = null;
|
|
|
|
|
+ if (!dragMoved || from == null || to == null || from === to) return;
|
|
|
|
|
+ const rules = props.templateSettings.routing.rules;
|
|
|
|
|
+ const [moved] = rules.splice(from, 1);
|
|
|
|
|
+ rules.splice(to, 0, moved);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function rowProps(_record, index) {
|
|
|
|
|
+ const classes = [];
|
|
|
|
|
+ if (draggedIndex.value === index) classes.push('row-dragging');
|
|
|
|
|
+ if (dropTargetIndex.value === index && draggedIndex.value !== index) {
|
|
|
|
|
+ classes.push(index > draggedIndex.value ? 'drop-after' : 'drop-before');
|
|
|
|
|
+ }
|
|
|
|
|
+ return { class: classes.join(' ') };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// === Columns =========================================================
|
|
// === Columns =========================================================
|
|
|
// Computed so titles re-render after a locale swap.
|
|
// Computed so titles re-render after a locale swap.
|
|
|
const desktopColumns = computed(() => [
|
|
const desktopColumns = computed(() => [
|
|
@@ -170,14 +225,31 @@ const desktopColumns = computed(() => [
|
|
|
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
|
|
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
|
|
|
{ title: 'Destination', align: 'left', key: 'destination' },
|
|
{ title: 'Destination', align: 'left', key: 'destination' },
|
|
|
{ title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
|
|
{ title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
|
|
|
- { title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'target' },
|
|
|
|
|
|
|
+ { title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'outbound' },
|
|
|
|
|
+ { title: t('pages.xray.Balancers'), align: 'left', width: 150, key: 'balancer' },
|
|
|
]);
|
|
]);
|
|
|
-const mobileColumns = computed(() => [
|
|
|
|
|
- { title: '#', align: 'center', width: 70, key: 'action' },
|
|
|
|
|
- { title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' },
|
|
|
|
|
- { title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' },
|
|
|
|
|
-]);
|
|
|
|
|
-const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
|
|
|
|
|
|
|
+const columns = computed(() => desktopColumns.value);
|
|
|
|
|
+
|
|
|
|
|
+function ruleCriteriaChips(rule) {
|
|
|
|
|
+ const chips = [];
|
|
|
|
|
+ if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
|
|
|
|
|
+ if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
|
|
|
|
|
+ if (rule.port) chips.push({ label: 'Port', value: rule.port });
|
|
|
|
|
+ if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
|
|
|
|
|
+ if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
|
|
|
|
|
+ if (rule.network) chips.push({ label: 'L4', value: rule.network });
|
|
|
|
|
+ if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
|
|
|
|
|
+ if (rule.user) chips.push({ label: 'User', value: rule.user });
|
|
|
|
|
+ if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });
|
|
|
|
|
+ return chips;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function chipPreview(value) {
|
|
|
|
|
+ const parts = csv(value);
|
|
|
|
|
+ if (parts.length === 0) return '';
|
|
|
|
|
+ if (parts.length === 1) return parts[0];
|
|
|
|
|
+ return `${parts[0]} +${parts.length - 1}`;
|
|
|
|
|
+}
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
<template>
|
|
@@ -189,12 +261,84 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|
|
{{ t('pages.xray.Routings') }}
|
|
{{ t('pages.xray.Routings') }}
|
|
|
</a-button>
|
|
</a-button>
|
|
|
|
|
|
|
|
- <a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
|
|
|
|
|
- :scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table">
|
|
|
|
|
|
|
+ <!-- Mobile: stacked cards. The desktop a-table doesn't fit on a
|
|
|
|
|
+ phone (~520px of columns alone), so render each rule as a
|
|
|
|
|
+ compact card with the routing summary + criteria chips. -->
|
|
|
|
|
+ <div v-if="isMobile" class="rule-list">
|
|
|
|
|
+ <div v-for="(rule, index) in rows" :key="rule.key" class="rule-card" :class="{
|
|
|
|
|
+ 'row-dragging': draggedIndex === index,
|
|
|
|
|
+ 'drop-before': dropTargetIndex === index && draggedIndex != null && index < draggedIndex,
|
|
|
|
|
+ 'drop-after': dropTargetIndex === index && draggedIndex != null && index > draggedIndex,
|
|
|
|
|
+ }" :data-row-key="index">
|
|
|
|
|
+ <div class="rule-card-head">
|
|
|
|
|
+ <HolderOutlined class="drag-handle" @pointerdown="onHandlePointerDown(index, $event)" />
|
|
|
|
|
+ <span class="rule-number">#{{ index + 1 }}</span>
|
|
|
|
|
+ <a-dropdown :trigger="['click']">
|
|
|
|
|
+ <a-button shape="circle" size="small">
|
|
|
|
|
+ <MoreOutlined />
|
|
|
|
|
+ </a-button>
|
|
|
|
|
+ <template #overlay>
|
|
|
|
|
+ <a-menu>
|
|
|
|
|
+ <a-menu-item @click="openEdit(index)">
|
|
|
|
|
+ <EditOutlined /> {{ t('edit') }}
|
|
|
|
|
+ </a-menu-item>
|
|
|
|
|
+ <a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
|
|
|
|
+ <ArrowUpOutlined />
|
|
|
|
|
+ </a-menu-item>
|
|
|
|
|
+ <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
|
|
|
|
+ <ArrowDownOutlined />
|
|
|
|
|
+ </a-menu-item>
|
|
|
|
|
+ <a-menu-item class="danger" @click="confirmDelete(index)">
|
|
|
|
|
+ <DeleteOutlined /> {{ t('delete') }}
|
|
|
|
|
+ </a-menu-item>
|
|
|
|
|
+ </a-menu>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </a-dropdown>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="rule-flow">
|
|
|
|
|
+ <div class="flow-side">
|
|
|
|
|
+ <span class="flow-label">{{ t('pages.xray.Inbounds') }}</span>
|
|
|
|
|
+ <a-tag v-if="rule.inboundTag" color="blue" class="flow-tag">
|
|
|
|
|
+ {{ chipPreview(rule.inboundTag) }}
|
|
|
|
|
+ </a-tag>
|
|
|
|
|
+ <span v-else class="criterion-empty">any</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="flow-arrow">→</span>
|
|
|
|
|
+ <div class="flow-side flow-side-target">
|
|
|
|
|
+ <span class="flow-label">{{
|
|
|
|
|
+ rule.balancerTag ? (t('pages.xray.balancer') || 'Balancer') : t('pages.xray.Outbounds')
|
|
|
|
|
+ }}</span>
|
|
|
|
|
+ <a-tag v-if="rule.outboundTag" color="green" class="flow-tag">
|
|
|
|
|
+ <ExportOutlined /> {{ rule.outboundTag }}
|
|
|
|
|
+ </a-tag>
|
|
|
|
|
+ <a-tag v-else-if="rule.balancerTag" color="purple" class="flow-tag">
|
|
|
|
|
+ <ClusterOutlined /> {{ rule.balancerTag }}
|
|
|
|
|
+ </a-tag>
|
|
|
|
|
+ <span v-else class="criterion-empty">—</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="ruleCriteriaChips(rule).length" class="rule-criteria">
|
|
|
|
|
+ <a-tooltip v-for="chip in ruleCriteriaChips(rule)" :key="chip.label" :title="`${chip.label}: ${chip.value}`">
|
|
|
|
|
+ <span class="criterion-chip">
|
|
|
|
|
+ <span class="criterion-chip-label">{{ chip.label }}</span>
|
|
|
|
|
+ <span class="criterion-chip-value">{{ chipPreview(chip.value) }}</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </a-tooltip>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="!rows.length" class="rule-empty">—</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <a-table v-else :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
|
|
|
|
|
+ :scroll="{ x: 1150 }" size="small" class="routing-table" :custom-row="rowProps">
|
|
|
<template #bodyCell="{ column, record, index }">
|
|
<template #bodyCell="{ column, record, index }">
|
|
|
<!-- ============== # / actions ============== -->
|
|
<!-- ============== # / actions ============== -->
|
|
|
<template v-if="column.key === 'action'">
|
|
<template v-if="column.key === 'action'">
|
|
|
<div class="action-cell">
|
|
<div class="action-cell">
|
|
|
|
|
+ <HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'"
|
|
|
|
|
+ @pointerdown="onHandlePointerDown(index, $event)" />
|
|
|
<span class="row-index">{{ index + 1 }}</span>
|
|
<span class="row-index">{{ index + 1 }}</span>
|
|
|
<a-dropdown :trigger="['click']">
|
|
<a-dropdown :trigger="['click']">
|
|
|
<a-button shape="circle" size="small">
|
|
<a-button shape="circle" size="small">
|
|
@@ -228,7 +372,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|
|
<span class="criterion-label">IP</span>
|
|
<span class="criterion-label">IP</span>
|
|
|
<span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
|
|
<span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
|
|
|
<span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1
|
|
<span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1
|
|
|
- }}</span>
|
|
|
|
|
|
|
+ }}</span>
|
|
|
</span>
|
|
</span>
|
|
|
</a-tooltip>
|
|
</a-tooltip>
|
|
|
<a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
|
|
<a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
|
|
@@ -259,7 +403,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|
|
<span class="criterion-label">L4</span>
|
|
<span class="criterion-label">L4</span>
|
|
|
<span class="criterion-value">{{ csv(record.network)[0] }}</span>
|
|
<span class="criterion-value">{{ csv(record.network)[0] }}</span>
|
|
|
<span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1
|
|
<span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1
|
|
|
- }}</span>
|
|
|
|
|
|
|
+ }}</span>
|
|
|
</span>
|
|
</span>
|
|
|
</a-tooltip>
|
|
</a-tooltip>
|
|
|
<a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
|
|
<a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
|
|
@@ -267,7 +411,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|
|
<span class="criterion-label">Protocol</span>
|
|
<span class="criterion-label">Protocol</span>
|
|
|
<span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
|
|
<span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
|
|
|
<span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1
|
|
<span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1
|
|
|
- }}</span>
|
|
|
|
|
|
|
+ }}</span>
|
|
|
</span>
|
|
</span>
|
|
|
</a-tooltip>
|
|
</a-tooltip>
|
|
|
<a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
|
|
<a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
|
|
@@ -295,7 +439,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|
|
<span class="criterion-label">Domain</span>
|
|
<span class="criterion-label">Domain</span>
|
|
|
<span class="criterion-value">{{ csv(record.domain)[0] }}</span>
|
|
<span class="criterion-value">{{ csv(record.domain)[0] }}</span>
|
|
|
<span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1
|
|
<span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1
|
|
|
- }}</span>
|
|
|
|
|
|
|
+ }}</span>
|
|
|
</span>
|
|
</span>
|
|
|
</a-tooltip>
|
|
</a-tooltip>
|
|
|
<a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
|
|
<a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
|
|
@@ -303,7 +447,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|
|
<span class="criterion-label">Port</span>
|
|
<span class="criterion-label">Port</span>
|
|
|
<span class="criterion-value">{{ csv(record.port)[0] }}</span>
|
|
<span class="criterion-value">{{ csv(record.port)[0] }}</span>
|
|
|
<span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1
|
|
<span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1
|
|
|
- }}</span>
|
|
|
|
|
|
|
+ }}</span>
|
|
|
</span>
|
|
</span>
|
|
|
</a-tooltip>
|
|
</a-tooltip>
|
|
|
<span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
|
|
<span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
|
|
@@ -326,25 +470,32 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|
|
<span class="criterion-label">User</span>
|
|
<span class="criterion-label">User</span>
|
|
|
<span class="criterion-value">{{ csv(record.user)[0] }}</span>
|
|
<span class="criterion-value">{{ csv(record.user)[0] }}</span>
|
|
|
<span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1
|
|
<span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1
|
|
|
- }}</span>
|
|
|
|
|
|
|
+ }}</span>
|
|
|
</span>
|
|
</span>
|
|
|
</a-tooltip>
|
|
</a-tooltip>
|
|
|
<span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
|
|
<span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
- <!-- ============== Outbound / balancer target ============== -->
|
|
|
|
|
- <template v-else-if="column.key === 'target'">
|
|
|
|
|
|
|
+ <!-- ============== Outbound ============== -->
|
|
|
|
|
+ <template v-else-if="column.key === 'outbound'">
|
|
|
<div class="target-cell">
|
|
<div class="target-cell">
|
|
|
<div v-if="record.outboundTag" class="target-row">
|
|
<div v-if="record.outboundTag" class="target-row">
|
|
|
<ExportOutlined class="target-icon" />
|
|
<ExportOutlined class="target-icon" />
|
|
|
<a-tag color="green">{{ record.outboundTag }}</a-tag>
|
|
<a-tag color="green">{{ record.outboundTag }}</a-tag>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <span v-else class="criterion-empty">—</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ============== Balancer ============== -->
|
|
|
|
|
+ <template v-else-if="column.key === 'balancer'">
|
|
|
|
|
+ <div class="target-cell">
|
|
|
<div v-if="record.balancerTag" class="target-row">
|
|
<div v-if="record.balancerTag" class="target-row">
|
|
|
<ClusterOutlined class="target-icon" />
|
|
<ClusterOutlined class="target-icon" />
|
|
|
<a-tag color="purple">{{ record.balancerTag }}</a-tag>
|
|
<a-tag color="purple">{{ record.balancerTag }}</a-tag>
|
|
|
</div>
|
|
</div>
|
|
|
- <span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty">—</span>
|
|
|
|
|
|
|
+ <span v-else class="criterion-empty">—</span>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
</template>
|
|
</template>
|
|
@@ -362,6 +513,36 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|
|
gap: 6px;
|
|
gap: 6px;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.drag-handle {
|
|
|
|
|
+ cursor: grab;
|
|
|
|
|
+ opacity: 0.35;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ padding: 4px;
|
|
|
|
|
+ margin: -4px;
|
|
|
|
|
+ touch-action: none;
|
|
|
|
|
+ transition: opacity 0.15s;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.drag-handle:hover {
|
|
|
|
|
+ opacity: 0.8;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.drag-handle:active {
|
|
|
|
|
+ cursor: grabbing;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+:deep(.row-dragging) {
|
|
|
|
|
+ opacity: 0.4;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+:deep(.drop-before > td) {
|
|
|
|
|
+ box-shadow: inset 0 2px 0 0 #1677ff;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+:deep(.drop-after > td) {
|
|
|
|
|
+ box-shadow: inset 0 -2px 0 0 #1677ff;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.row-index {
|
|
.row-index {
|
|
|
font-weight: 500;
|
|
font-weight: 500;
|
|
|
opacity: 0.7;
|
|
opacity: 0.7;
|
|
@@ -429,4 +610,136 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|
|
.danger {
|
|
.danger {
|
|
|
color: #ff4d4f;
|
|
color: #ff4d4f;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+/* === Mobile card list ====================================== */
|
|
|
|
|
+.rule-list {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.rule-card {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ padding: 10px 12px;
|
|
|
|
|
+ background: var(--bg-card, #fff);
|
|
|
|
|
+ border: 1px solid rgba(128, 128, 128, 0.15);
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ transition: opacity 0.15s, box-shadow 0.15s;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.rule-card.row-dragging {
|
|
|
|
|
+ opacity: 0.4;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.rule-card.drop-before {
|
|
|
|
|
+ box-shadow: inset 0 2px 0 0 #1677ff;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.rule-card.drop-after {
|
|
|
|
|
+ box-shadow: inset 0 -2px 0 0 #1677ff;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.rule-card-head {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.rule-number {
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ opacity: 0.75;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.rule-flow {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.flow-side {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 3px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.flow-side-target {
|
|
|
|
|
+ align-items: flex-end;
|
|
|
|
|
+ text-align: right;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.flow-label {
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ text-transform: uppercase;
|
|
|
|
|
+ letter-spacing: 0.05em;
|
|
|
|
|
+ opacity: 0.55;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.flow-tag {
|
|
|
|
|
+ max-width: 100%;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.flow-arrow {
|
|
|
|
|
+ font-size: 16px;
|
|
|
|
|
+ opacity: 0.45;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.rule-criteria {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+ padding-top: 6px;
|
|
|
|
|
+ border-top: 1px dashed rgba(128, 128, 128, 0.2);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.criterion-chip {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: baseline;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+ padding: 1px 6px;
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ background: rgba(128, 128, 128, 0.08);
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ max-width: 100%;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.criterion-chip-label {
|
|
|
|
|
+ font-size: 9px;
|
|
|
|
|
+ text-transform: uppercase;
|
|
|
|
|
+ letter-spacing: 0.04em;
|
|
|
|
|
+ opacity: 0.6;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.criterion-chip-value {
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.rule-empty {
|
|
|
|
|
+ padding: 24px;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ opacity: 0.4;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+:global(body.dark) .rule-card {
|
|
|
|
|
+ background: rgba(255, 255, 255, 0.04);
|
|
|
|
|
+ border-color: rgba(255, 255, 255, 0.08);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+:global(body.dark) .criterion-chip {
|
|
|
|
|
+ background: rgba(255, 255, 255, 0.06);
|
|
|
|
|
+}
|
|
|
</style>
|
|
</style>
|