852 lines
23 KiB
Vue
852 lines
23 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import axios from 'axios';
|
|
import { api } from '@/lib/api';
|
|
import {
|
|
UiBadge,
|
|
UiButton,
|
|
UiInput,
|
|
UiPageHeader,
|
|
} from '@/components/ui';
|
|
import UiIcon from '@/components/UiIcon.vue';
|
|
import AutomationCanvas from '@/components/automations/AutomationCanvas.vue';
|
|
import type { AutomationAddPayload } from '@/components/automations/AutomationCanvas.vue';
|
|
import AutomationInspector from '@/components/automations/AutomationInspector.vue';
|
|
import { toFaDigits } from '@/lib/jalali';
|
|
import type {
|
|
ActivityTypeDefDto,
|
|
AutomationAction,
|
|
AutomationChainMode,
|
|
AutomationConditionField,
|
|
AutomationRuleDto,
|
|
AutomationStep,
|
|
AutomationTrigger,
|
|
CreateAutomationRuleRequest,
|
|
LeadFormDto,
|
|
OrgUserDto,
|
|
TeamDto,
|
|
} from '@mento/shared';
|
|
import {
|
|
AUTOMATION_TRIGGER_LABELS,
|
|
MAX_AUTOMATION_STEPS,
|
|
automationUsesAdvanced,
|
|
conditionFieldsForTrigger,
|
|
firstAutomationAction,
|
|
isAutomationCondition,
|
|
normalizeAutomationSteps,
|
|
packAutomationConfig,
|
|
splitAutomationBranches,
|
|
} from '@mento/shared';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
|
|
const auth = useAuthStore();
|
|
const advanced = computed(
|
|
() => auth.user?.enabledModules?.includes('workflow_automations_advanced') ?? false,
|
|
);
|
|
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
const error = ref('');
|
|
const ok = ref('');
|
|
const items = ref<AutomationRuleDto[]>([]);
|
|
const users = ref<OrgUserDto[]>([]);
|
|
const forms = ref<LeadFormDto[]>([]);
|
|
const teams = ref<TeamDto[]>([]);
|
|
const activityTypeDefs = ref<ActivityTypeDefDto[]>([]);
|
|
|
|
const editingId = ref<string | null>(null);
|
|
const name = ref('قانون بدون نام');
|
|
const trigger = ref<AutomationTrigger>('deal_won');
|
|
const isActive = ref(true);
|
|
const sourceId = ref<string | null>(null);
|
|
const chainMode = ref<AutomationChainMode>('stop');
|
|
const search = ref('');
|
|
const steps = ref<AutomationStep[]>([makeStep('create_activity')]);
|
|
const selected = ref<'trigger' | string>('trigger');
|
|
const savedSnap = ref('');
|
|
|
|
const dirty = computed(() => snap() !== savedSnap.value);
|
|
const selectedForm = computed(() =>
|
|
trigger.value === 'lead_submitted' && sourceId.value
|
|
? forms.value.find((f) => f.id === sourceId.value) ?? null
|
|
: null,
|
|
);
|
|
const needsAdvanced = computed(
|
|
() => !advanced.value && automationUsesAdvanced(steps.value, chainMode.value),
|
|
);
|
|
const canSave = computed(
|
|
() =>
|
|
name.value.trim().length >= 2 &&
|
|
Boolean(firstAutomationAction(steps.value)) &&
|
|
!needsAdvanced.value,
|
|
);
|
|
|
|
const activeCount = computed(() => items.value.filter((r) => r.isActive).length);
|
|
const filteredItems = computed(() => {
|
|
const q = search.value.trim();
|
|
if (!q) return items.value;
|
|
return items.value.filter(
|
|
(r) => r.name.includes(q) || (AUTOMATION_TRIGGER_LABELS[r.trigger] ?? '').includes(q),
|
|
);
|
|
});
|
|
|
|
function makeStep(action: AutomationAction): AutomationStep {
|
|
return {
|
|
id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
kind: 'action',
|
|
action,
|
|
activityType: action === 'create_activity' ? 'task' : undefined,
|
|
points: action === 'add_loyalty_points' ? 100 : undefined,
|
|
notifyChannel: action === 'create_notification' ? 'app' : undefined,
|
|
notifyTarget: action === 'create_notification' ? 'staff' : undefined,
|
|
assigneeId: null,
|
|
notifyUserId: null,
|
|
assignMode: action === 'create_activity' ? 'owner' : undefined,
|
|
dueOffsetMinutes: action === 'create_activity' ? 0 : undefined,
|
|
};
|
|
}
|
|
|
|
function conditionDefaults(field: AutomationConditionField): Partial<AutomationStep> {
|
|
if (field === 'deal_amount') return { conditionOp: 'gte', conditionValue: 0, conditionKey: null };
|
|
if (field === 'ticket_priority') {
|
|
return { conditionOp: 'eq', conditionValue: 'normal', conditionKey: null };
|
|
}
|
|
if (field === 'form_field') {
|
|
return { conditionOp: 'contains', conditionValue: '', conditionValues: [], conditionKey: null };
|
|
}
|
|
return { conditionOp: 'is_set', conditionValue: undefined, conditionKey: null };
|
|
}
|
|
|
|
function allowedConditionFields(t: AutomationTrigger): AutomationConditionField[] {
|
|
const all = conditionFieldsForTrigger(t);
|
|
return advanced.value ? all : all.filter((f) => f !== 'form_field');
|
|
}
|
|
|
|
function makeCondition(): AutomationStep {
|
|
const field = allowedConditionFields(trigger.value)[0] ?? 'has_person';
|
|
return {
|
|
id: `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
kind: 'condition',
|
|
conditionField: field,
|
|
...conditionDefaults(field),
|
|
};
|
|
}
|
|
|
|
function snap() {
|
|
return JSON.stringify({
|
|
name: name.value.trim(),
|
|
trigger: trigger.value,
|
|
isActive: isActive.value,
|
|
sourceId: sourceId.value,
|
|
chainMode: chainMode.value,
|
|
steps: steps.value,
|
|
});
|
|
}
|
|
|
|
function markClean() {
|
|
savedSnap.value = snap();
|
|
}
|
|
|
|
function errText(e: unknown, fallback: string) {
|
|
if (axios.isAxiosError(e) && !e.response) return 'اتصال به API برقرار نشد.';
|
|
const msg = axios.isAxiosError(e) ? e.response?.data?.message : null;
|
|
if (Array.isArray(msg) && msg.length) return msg.join(' ');
|
|
if (typeof msg === 'string' && msg.trim()) return msg;
|
|
return fallback;
|
|
}
|
|
|
|
function hydrate(row?: AutomationRuleDto | null) {
|
|
if (!row) {
|
|
editingId.value = null;
|
|
name.value = 'قانون بدون نام';
|
|
trigger.value = 'deal_won';
|
|
isActive.value = true;
|
|
sourceId.value = null;
|
|
chainMode.value = advanced.value ? 'branches' : 'stop';
|
|
steps.value = [makeStep('create_activity')];
|
|
selected.value = 'trigger';
|
|
markClean();
|
|
return;
|
|
}
|
|
editingId.value = row.id;
|
|
name.value = row.name;
|
|
trigger.value = row.trigger;
|
|
isActive.value = row.isActive;
|
|
sourceId.value = row.config.sourceId ?? null;
|
|
chainMode.value = row.config.chainMode ?? 'stop';
|
|
steps.value = normalizeAutomationSteps(row.action, row.config).map((s, i) => ({
|
|
...s,
|
|
id: s.id || `step-${i}`,
|
|
kind: s.kind === 'condition' ? 'condition' : 'action',
|
|
assigneeId: s.assigneeId ?? null,
|
|
notifyUserId: s.notifyUserId ?? null,
|
|
notifyChannel:
|
|
s.notifyChannel ??
|
|
(s.action === 'create_notification' ? 'app' : undefined),
|
|
notifyTarget:
|
|
s.notifyTarget ??
|
|
(s.action === 'create_notification' ? 'staff' : undefined),
|
|
activityType:
|
|
s.activityType ?? (s.action === 'create_activity' ? 'task' : undefined),
|
|
points: s.points ?? (s.action === 'add_loyalty_points' ? 100 : undefined),
|
|
assignMode:
|
|
s.assignMode ??
|
|
(s.action === 'create_activity' ? (s.assigneeId ? 'user' : 'owner') : undefined),
|
|
assigneeTeamId: s.assigneeTeamId ?? null,
|
|
assigneeUserIds: s.assigneeUserIds ?? [],
|
|
dueOffsetMinutes: s.dueOffsetMinutes ?? null,
|
|
activityTypeDefId: s.activityTypeDefId ?? null,
|
|
conditionKey: s.conditionKey ?? null,
|
|
conditionValues: s.conditionValues ?? [],
|
|
}));
|
|
if (!steps.value.length) steps.value = [makeStep('create_activity')];
|
|
selected.value = 'trigger';
|
|
markClean();
|
|
}
|
|
|
|
function confirmLeave() {
|
|
if (!dirty.value) return true;
|
|
return confirm('تغییرات ذخیره نشده از بین میرود. ادامه میدهید؟');
|
|
}
|
|
|
|
function startNew() {
|
|
if (!confirmLeave()) return;
|
|
hydrate(null);
|
|
}
|
|
|
|
function openRule(row: AutomationRuleDto) {
|
|
if (editingId.value === row.id) return;
|
|
if (!confirmLeave()) return;
|
|
hydrate(row);
|
|
}
|
|
|
|
function addStep(payload: AutomationAddPayload, index: number) {
|
|
if (steps.value.length >= MAX_AUTOMATION_STEPS) {
|
|
error.value = `حداکثر ${toFaDigits(MAX_AUTOMATION_STEPS)} مرحله در هر قانون مجاز است.`;
|
|
return;
|
|
}
|
|
error.value = '';
|
|
const step =
|
|
payload.kind === 'condition'
|
|
? makeCondition()
|
|
: makeStep(payload.action);
|
|
steps.value.splice(index, 0, step);
|
|
selected.value = step.id;
|
|
}
|
|
|
|
function removeStep(stepId: string) {
|
|
if (steps.value.length <= 1) {
|
|
error.value = 'حداقل یک مرحله باید در زنجیره باقی بماند.';
|
|
return;
|
|
}
|
|
const i = steps.value.findIndex((s) => s.id === stepId);
|
|
if (i < 0) return;
|
|
steps.value.splice(i, 1);
|
|
if (selected.value === stepId) {
|
|
const next = steps.value[Math.max(0, i - 1)];
|
|
selected.value = next?.id ?? 'trigger';
|
|
}
|
|
}
|
|
|
|
function removeRange(start: number, end: number) {
|
|
const removed = steps.value.slice(start, end);
|
|
const rest = [...steps.value.slice(0, start), ...steps.value.slice(end)];
|
|
if (!rest.some((s) => !isAutomationCondition(s))) {
|
|
error.value = 'حداقل یک عمل باید در قانون باقی بماند.';
|
|
return;
|
|
}
|
|
if (!confirm('این شاخه و همهٔ گرههایش حذف شود؟')) return;
|
|
steps.value = rest;
|
|
if (removed.some((s) => s.id === selected.value)) selected.value = 'trigger';
|
|
}
|
|
|
|
function addBranch() {
|
|
if (steps.value.length + 2 > MAX_AUTOMATION_STEPS) {
|
|
error.value = `حداکثر ${toFaDigits(MAX_AUTOMATION_STEPS)} مرحله در هر قانون مجاز است.`;
|
|
return;
|
|
}
|
|
error.value = '';
|
|
const cond = makeCondition();
|
|
steps.value.push(cond, makeStep('create_activity'));
|
|
selected.value = cond.id;
|
|
}
|
|
|
|
function ruleSummary(row: AutomationRuleDto) {
|
|
const list = normalizeAutomationSteps(row.action, row.config);
|
|
const actions = list.filter((s) => !isAutomationCondition(s)).length;
|
|
const conds = list.length - actions;
|
|
const parts = [`${toFaDigits(actions)} عمل`];
|
|
if (conds) parts.push(`${toFaDigits(conds)} شرط`);
|
|
if (row.config.chainMode === 'branches') {
|
|
const n = splitAutomationBranches(list).branches.length;
|
|
if (n) parts.push(`${toFaDigits(n)} شاخه`);
|
|
}
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
error.value = '';
|
|
try {
|
|
const [rulesRes, usersRes, formsRes, teamsRes, typesRes] = await Promise.all([
|
|
api.get<AutomationRuleDto[]>('/automations'),
|
|
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
|
api.get<LeadFormDto[]>('/lead-forms').catch(() => ({ data: [] as LeadFormDto[] })),
|
|
api.get<TeamDto[]>('/teams').catch(() => ({ data: [] as TeamDto[] })),
|
|
api
|
|
.get<ActivityTypeDefDto[]>('/activities/types')
|
|
.catch(() => ({ data: [] as ActivityTypeDefDto[] })),
|
|
]);
|
|
items.value = rulesRes.data;
|
|
users.value = usersRes.data;
|
|
forms.value = formsRes.data;
|
|
teams.value = teamsRes.data;
|
|
activityTypeDefs.value = typesRes.data.filter((t) => t.isActive && !t.isSystem);
|
|
if (editingId.value) {
|
|
const cur = items.value.find((x) => x.id === editingId.value);
|
|
if (cur && !dirty.value) hydrate(cur);
|
|
else if (!cur) hydrate(items.value[0] ?? null);
|
|
}
|
|
} catch (e) {
|
|
error.value = errText(e, 'بارگذاری اتوماسیون ناموفق بود.');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
const action = firstAutomationAction(steps.value);
|
|
if (!name.value.trim() || !action) {
|
|
error.value = !action ? 'حداقل یک عمل در زنجیره لازم است.' : 'نام قانون را وارد کنید.';
|
|
return;
|
|
}
|
|
saving.value = true;
|
|
error.value = '';
|
|
ok.value = '';
|
|
try {
|
|
const payload: CreateAutomationRuleRequest = {
|
|
name: name.value.trim(),
|
|
trigger: trigger.value,
|
|
action,
|
|
isActive: isActive.value,
|
|
config: packAutomationConfig(steps.value, {
|
|
sourceId: trigger.value === 'lead_submitted' || trigger.value === 'sms_link_clicked'
|
|
? sourceId.value
|
|
: null,
|
|
chainMode: chainMode.value,
|
|
}),
|
|
};
|
|
if (editingId.value) {
|
|
await api.patch(`/automations/${editingId.value}`, payload);
|
|
ok.value = 'قانون بهروز شد.';
|
|
} else {
|
|
const { data } = await api.post<AutomationRuleDto>('/automations', payload);
|
|
editingId.value = data.id;
|
|
ok.value = 'قانون ساخته شد.';
|
|
}
|
|
markClean();
|
|
await load();
|
|
} catch (e) {
|
|
error.value = errText(e, 'ذخیره قانون ناموفق بود.');
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function removeRule(id: string) {
|
|
if (!confirm('قانون حذف شود؟')) return;
|
|
try {
|
|
await api.delete(`/automations/${id}`);
|
|
if (editingId.value === id) hydrate(null);
|
|
await load();
|
|
} catch (e) {
|
|
error.value = errText(e, 'حذف ناموفق بود.');
|
|
}
|
|
}
|
|
|
|
async function toggleActive(row: AutomationRuleDto) {
|
|
try {
|
|
await api.patch(`/automations/${row.id}`, { isActive: !row.isActive });
|
|
if (editingId.value === row.id) isActive.value = !row.isActive;
|
|
await load();
|
|
} catch (e) {
|
|
error.value = errText(e, 'تغییر وضعیت ناموفق بود.');
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await load();
|
|
if (editingId.value) return;
|
|
hydrate(items.value[0] ?? null);
|
|
});
|
|
|
|
watch(trigger, (next, prev) => {
|
|
if (prev === 'lead_submitted' && next !== 'lead_submitted') sourceId.value = null;
|
|
const allowed = allowedConditionFields(next);
|
|
const fallback = allowed[0] ?? 'has_person';
|
|
for (const step of steps.value) {
|
|
if (!isAutomationCondition(step)) continue;
|
|
if (step.conditionField && allowed.includes(step.conditionField)) continue;
|
|
step.conditionField = fallback;
|
|
Object.assign(step, conditionDefaults(fallback));
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section class="crm-page">
|
|
<UiPageHeader
|
|
title="اتوماسیون"
|
|
lead="مثل یک بوم کاری: گره «اگر» را به چند عمل پشتسرهم وصل کنید."
|
|
>
|
|
<template #actions>
|
|
<UiButton variant="ghost" :disabled="loading" @click="load">
|
|
بروزرسانی
|
|
</UiButton>
|
|
<UiButton variant="primary" @click="startNew">
|
|
<UiIcon name="plus" :size="16" />
|
|
قانون جدید
|
|
</UiButton>
|
|
</template>
|
|
</UiPageHeader>
|
|
|
|
<p v-if="error" class="error-msg">{{ error }}</p>
|
|
<p v-if="ok" class="ok-msg">{{ ok }}</p>
|
|
|
|
<div class="workspace">
|
|
<aside class="rules">
|
|
<div class="rules-head">
|
|
<h2>قوانین</h2>
|
|
<span class="counts">
|
|
{{ toFaDigits(items.length) }} قانون · {{ toFaDigits(activeCount) }} فعال
|
|
</span>
|
|
</div>
|
|
<UiBadge :variant="advanced ? 'accent' : 'neutral'" size="sm" class="level">
|
|
{{ advanced ? 'اتوماسیون حرفهای' : 'اتوماسیون ساده' }}
|
|
</UiBadge>
|
|
<UiInput
|
|
v-if="items.length > 5"
|
|
v-model="search"
|
|
placeholder="جستجوی قانون…"
|
|
class="rules-search"
|
|
/>
|
|
<ul>
|
|
<li v-if="!editingId" class="on draft">
|
|
<div class="pick">
|
|
<span class="pick-top">
|
|
<strong>{{ name.trim() || 'قانون جدید' }}</strong>
|
|
<UiBadge variant="warning" size="sm">ذخیره نشده</UiBadge>
|
|
</span>
|
|
<span class="chain-preview">{{ AUTOMATION_TRIGGER_LABELS[trigger] }}</span>
|
|
</div>
|
|
</li>
|
|
<li
|
|
v-for="row in filteredItems"
|
|
:key="row.id"
|
|
:class="{ on: editingId === row.id, off: !row.isActive }"
|
|
>
|
|
<button class="pick" type="button" @click="openRule(row)">
|
|
<span class="pick-top">
|
|
<span class="dot" :class="{ live: row.isActive }" />
|
|
<strong>{{ row.name }}</strong>
|
|
<UiBadge v-if="editingId === row.id && dirty" variant="warning" size="sm">
|
|
تغییر داده شده
|
|
</UiBadge>
|
|
</span>
|
|
<span class="chain-preview">
|
|
{{ AUTOMATION_TRIGGER_LABELS[row.trigger] ?? row.trigger }}
|
|
</span>
|
|
<span class="chain-meta">{{ ruleSummary(row) }}</span>
|
|
</button>
|
|
<div class="row-actions">
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
:title="row.isActive ? 'غیرفعال کردن' : 'فعال کردن'"
|
|
@click="toggleActive(row)"
|
|
>
|
|
<span class="switch" :class="{ on: row.isActive }" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn danger"
|
|
title="حذف قانون"
|
|
@click="removeRule(row.id)"
|
|
>
|
|
<UiIcon name="trash" :size="15" />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
<p v-if="!items.length && !loading" class="muted">
|
|
هنوز قانونی نیست؛ از «قانون جدید» شروع کنید.
|
|
</p>
|
|
<p v-else-if="items.length && !filteredItems.length" class="muted">موردی پیدا نشد.</p>
|
|
</aside>
|
|
|
|
<div class="studio">
|
|
<p v-if="needsAdvanced" class="warn-msg">
|
|
این قانون از شاخه، شرط روی پاسخ فرم یا تقسیم نوبتی استفاده میکند که فقط در اتوماسیون
|
|
حرفهای فعال است. برای ذخیره، این موارد را بردارید یا از پشتیبانی ارتقا بخواهید.
|
|
</p>
|
|
<div class="toolbar">
|
|
<div class="name-field">
|
|
<span class="name-label">نام قانون:</span>
|
|
<UiInput v-model="name" required minlength="2" maxlength="120" placeholder="نام قانون اتوماسیون…" />
|
|
</div>
|
|
<label class="check">
|
|
<input v-model="isActive" type="checkbox" />
|
|
<span>فعال</span>
|
|
</label>
|
|
<UiButton
|
|
variant="primary"
|
|
type="button"
|
|
:disabled="saving || !canSave"
|
|
:loading="saving"
|
|
@click="save"
|
|
>
|
|
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره قانون' : 'ساخت قانون' }}
|
|
</UiButton>
|
|
</div>
|
|
|
|
<div class="stage">
|
|
<AutomationCanvas
|
|
:trigger="trigger"
|
|
:steps="steps"
|
|
:selected="selected"
|
|
:chain-mode="chainMode"
|
|
:form="selectedForm"
|
|
:users="users"
|
|
:teams="teams"
|
|
@select="selected = $event"
|
|
@add="addStep"
|
|
@remove="removeStep"
|
|
@remove-range="removeRange"
|
|
@add-branch="addBranch"
|
|
/>
|
|
<AutomationInspector
|
|
v-model:trigger="trigger"
|
|
v-model:steps="steps"
|
|
v-model:source-id="sourceId"
|
|
v-model:chain-mode="chainMode"
|
|
:advanced="advanced"
|
|
:selected="selected"
|
|
:users="users"
|
|
:forms="forms"
|
|
:teams="teams"
|
|
:activity-type-defs="activityTypeDefs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.error-msg {
|
|
color: var(--color-danger);
|
|
font-size: var(--fs-sm);
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.ok-msg {
|
|
color: var(--color-success);
|
|
font-size: var(--fs-sm);
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.workspace {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: var(--sp-4);
|
|
align-items: start;
|
|
min-width: 0;
|
|
}
|
|
|
|
.rules,
|
|
.studio {
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-xl);
|
|
background: var(--color-surface);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.rules {
|
|
padding: var(--sp-3);
|
|
display: grid;
|
|
gap: var(--sp-2);
|
|
align-content: start;
|
|
}
|
|
|
|
.rules-head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: var(--sp-2);
|
|
padding-inline: var(--sp-1);
|
|
}
|
|
|
|
.rules-head h2 {
|
|
margin: 0;
|
|
font-size: var(--fs-base);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.counts {
|
|
color: var(--color-text-muted);
|
|
font-size: var(--fs-xs);
|
|
}
|
|
|
|
.level {
|
|
justify-self: start;
|
|
}
|
|
|
|
.rules ul {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: grid;
|
|
gap: 0.35rem;
|
|
max-height: 60vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.rules ul:empty {
|
|
display: none;
|
|
}
|
|
|
|
.rules li {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-1);
|
|
border: 1px solid transparent;
|
|
border-inline-start: 3px solid transparent;
|
|
border-radius: var(--radius);
|
|
padding: 0.5rem 0.6rem;
|
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
|
}
|
|
|
|
.rules li:hover {
|
|
background: var(--color-surface-muted, #f8fafc);
|
|
}
|
|
|
|
.rules li.on {
|
|
border-color: var(--color-primary);
|
|
background: var(--color-primary-surface);
|
|
}
|
|
|
|
.rules li.draft {
|
|
border-style: dashed;
|
|
border-inline-start-style: solid;
|
|
}
|
|
|
|
.rules li.off .pick strong {
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.pick {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: grid;
|
|
gap: 0.1rem;
|
|
text-align: start;
|
|
border: 0;
|
|
background: none;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
color: inherit;
|
|
}
|
|
|
|
.pick-top {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
min-width: 0;
|
|
}
|
|
|
|
.pick-top strong {
|
|
font-size: var(--fs-sm);
|
|
color: var(--color-text-emphasis);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.dot {
|
|
width: 0.5rem;
|
|
height: 0.5rem;
|
|
border-radius: 999px;
|
|
background: #cbd5e1;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.dot.live {
|
|
background: #16a34a;
|
|
}
|
|
|
|
.chain-preview {
|
|
color: var(--color-text);
|
|
font-size: var(--fs-xs);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.chain-meta {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
.row-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.15rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.icon-btn {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border: 0;
|
|
border-radius: var(--radius);
|
|
background: none;
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.icon-btn:hover {
|
|
background: rgba(15, 23, 42, 0.06);
|
|
}
|
|
|
|
.icon-btn.danger {
|
|
opacity: 0;
|
|
}
|
|
|
|
.rules li:hover .icon-btn.danger,
|
|
.rules li.on .icon-btn.danger,
|
|
.icon-btn.danger:focus-visible {
|
|
opacity: 1;
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
.switch {
|
|
position: relative;
|
|
width: 1.9rem;
|
|
height: 1.05rem;
|
|
border-radius: 999px;
|
|
background: #cbd5e1;
|
|
transition: background var(--transition-fast);
|
|
}
|
|
|
|
.switch::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0.15rem;
|
|
inset-inline-start: 0.15rem;
|
|
width: 0.75rem;
|
|
height: 0.75rem;
|
|
border-radius: 999px;
|
|
background: #fff;
|
|
transition: inset-inline-start var(--transition-fast);
|
|
}
|
|
|
|
.switch.on {
|
|
background: #16a34a;
|
|
}
|
|
|
|
.switch.on::after {
|
|
inset-inline-start: 1rem;
|
|
}
|
|
|
|
.warn-msg {
|
|
margin: 0;
|
|
padding: var(--sp-2) var(--sp-3);
|
|
border: 1px solid #fcd34d;
|
|
border-radius: var(--radius);
|
|
background: #fffbeb;
|
|
color: #92400e;
|
|
font-size: var(--fs-sm);
|
|
}
|
|
|
|
.studio {
|
|
padding: var(--sp-4);
|
|
display: grid;
|
|
gap: var(--sp-3);
|
|
min-width: 0;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--sp-3);
|
|
align-items: center;
|
|
}
|
|
|
|
.name-field {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
flex: 1 1 16rem;
|
|
}
|
|
|
|
.name-label {
|
|
font-size: var(--fs-sm);
|
|
font-weight: 600;
|
|
color: var(--color-text-muted);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.check {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
color: var(--color-text);
|
|
font-size: var(--fs-sm);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.stage {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr);
|
|
gap: var(--sp-3);
|
|
min-width: 0;
|
|
}
|
|
|
|
.stage :deep(.canvas) {
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.muted {
|
|
color: var(--color-text-muted);
|
|
font-size: var(--fs-sm);
|
|
}
|
|
|
|
@media (min-width: 1100px) {
|
|
.workspace {
|
|
grid-template-columns: minmax(15rem, 18rem) minmax(0, 1fr);
|
|
}
|
|
|
|
.rules {
|
|
position: sticky;
|
|
top: var(--sp-3);
|
|
}
|
|
|
|
.rules ul {
|
|
max-height: calc(100vh - 16rem);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 1099px) {
|
|
.rules ul {
|
|
max-height: 16rem;
|
|
}
|
|
}
|
|
</style>
|