mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-18 12:05:53 +00:00
feat(routing): drag-reorder rules, split balancer column, mobile card layout
- Grip-handle drag-and-drop on the # cell to reorder rules, built on Pointer Events so the same code works for mouse, touch, and pen (HTML5 drag doesn't fire from touch on iOS Safari). 5px threshold keeps quick taps from triggering a reorder; up/down arrow menu items stay as a keyboard/a11y fallback. Drop indicator is a 2px blue line on the target edge; dragged row fades to 40%. - Split the old combined target column into Outbounds and Balancer columns. Each row now has exactly one populated cell — green outbound tag or purple balancer tag. - Mobile drops the a-table (520px+ of column widths overflowed every phone) for a stacked card layout: # + grip + actions on top, an "Inbound → Outbound/Balancer" flow row in the middle, and criteria chips (domain, IP, port, src IP/port, L4, protocol, user, VLESS) below for whichever fields are actually set. Multi-value chips collapse to "first +N" with full value on hover.
This commit is contained in:
parent
102df7a290
commit
46b6f8c66c
1 changed files with 334 additions and 21 deletions
|
|
@ -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
|
// Reorder via Pointer Events on the grip icon — unified mouse +
|
||||||
// jQuery-Sortable drag handle the legacy panel used — same effect,
|
// touch + pen path so the same code works on desktop and mobile
|
||||||
// no extra dep. The mobile column layout drops source/network/
|
// (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(() => [
|
const columns = computed(() => desktopColumns.value);
|
||||||
{ title: '#', align: 'center', width: 70, key: 'action' },
|
|
||||||
{ title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' },
|
function ruleCriteriaChips(rule) {
|
||||||
{ title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' },
|
const chips = [];
|
||||||
]);
|
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
|
||||||
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
|
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"
|
<!-- Mobile: stacked cards. The desktop a-table doesn't fit on a
|
||||||
:scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table">
|
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 ============== -->
|
<!-- ============== Outbound ============== -->
|
||||||
<template v-else-if="column.key === 'target'">
|
<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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue