feat: entity relationship refactor — notification trackers, command system, chat actions
Rework entity schema: rename Tracker→NotificationTracker, add CommandConfig/ CommandTracker/CommandTrackerListener entities for decoupled command handling. Commands now resolve through CommandTracker→CommandConfig instead of TelegramBot.commands_config. Smart ref-counted bot polling based on active listeners. Add chat_action to telegram targets. Full frontend CRUD pages for command configs and command trackers. Idempotent SQLite migrations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
-17
@@ -19,7 +19,6 @@
|
||||
"codemirror": "^6.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
@@ -1082,15 +1081,6 @@
|
||||
"acorn": "^8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/adapter-auto": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz",
|
||||
"integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"@sveltejs/kit": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/adapter-static": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
|
||||
@@ -3186,13 +3176,6 @@
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@sveltejs/adapter-auto": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz",
|
||||
"integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@sveltejs/adapter-static": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Providers",
|
||||
"trackers": "Trackers",
|
||||
"notificationTrackers": "Notif. Trackers",
|
||||
"trackingConfigs": "Tracking",
|
||||
"templateConfigs": "Templates",
|
||||
"telegramBots": "Bots",
|
||||
"targets": "Targets",
|
||||
"commandConfigs": "Cmd Configs",
|
||||
"commandTrackers": "Cmd Trackers",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout"
|
||||
@@ -93,8 +95,8 @@
|
||||
"testAndSave": "Test & Save",
|
||||
"saveWithoutTest": "Save without testing"
|
||||
},
|
||||
"trackers": {
|
||||
"title": "Trackers",
|
||||
"notificationTracker": {
|
||||
"title": "Notification Trackers",
|
||||
"description": "Monitor albums for changes",
|
||||
"newTracker": "New Tracker",
|
||||
"cancel": "Cancel",
|
||||
@@ -198,7 +200,9 @@
|
||||
"maxAssetSize": "Max asset size (MB)",
|
||||
"videoWarning": "Video size warning",
|
||||
"disableUrlPreview": "Disable link previews",
|
||||
"sendLargeAsDocuments": "Send large photos as documents"
|
||||
"sendLargeAsDocuments": "Send large photos as documents",
|
||||
"chatAction": "Chat action",
|
||||
"chatActionNone": "None (no action)"
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
@@ -474,6 +478,47 @@
|
||||
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
|
||||
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit."
|
||||
},
|
||||
"commandConfig": {
|
||||
"title": "Command Configs",
|
||||
"description": "Define command settings for Telegram bot interactions",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Default commands",
|
||||
"providerType": "Provider Type",
|
||||
"enabledCommands": "Enabled Commands",
|
||||
"locale": "Locale",
|
||||
"responseMode": "Response Mode",
|
||||
"modeMedia": "Media (photos)",
|
||||
"modeText": "Text only",
|
||||
"defaultCount": "Default Count",
|
||||
"rateLimits": "Rate Limits",
|
||||
"searchCooldown": "Search cooldown (s)",
|
||||
"defaultCooldown": "Default cooldown (s)",
|
||||
"noConfigs": "No command configs yet.",
|
||||
"confirmDelete": "Delete this command config?",
|
||||
"commands": "commands"
|
||||
},
|
||||
"commandTracker": {
|
||||
"title": "Command Trackers",
|
||||
"description": "Manage command trackers and their listeners",
|
||||
"newTracker": "New Tracker",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Family commands",
|
||||
"provider": "Provider",
|
||||
"selectProvider": "Select provider...",
|
||||
"commandConfig": "Command Config",
|
||||
"selectCommandConfig": "Select command config...",
|
||||
"listeners": "Listeners",
|
||||
"addListener": "Add Listener",
|
||||
"removeListener": "Remove",
|
||||
"noTrackers": "No command trackers yet.",
|
||||
"confirmDelete": "Delete this command tracker?",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"noListeners": "No listeners attached.",
|
||||
"selectBot": "Select bot...",
|
||||
"listenerType": "telegram_bot"
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details"
|
||||
@@ -504,7 +549,16 @@
|
||||
"commandsSynced": "Commands synced to Telegram",
|
||||
"targetLinked": "Target linked",
|
||||
"targetUnlinked": "Target unlinked",
|
||||
"botUpdated": "Bot updated"
|
||||
"botUpdated": "Bot updated",
|
||||
"commandConfigSaved": "Command config saved",
|
||||
"commandConfigDeleted": "Command config deleted",
|
||||
"commandTrackerCreated": "Command tracker created",
|
||||
"commandTrackerUpdated": "Command tracker updated",
|
||||
"commandTrackerDeleted": "Command tracker deleted",
|
||||
"commandTrackerEnabled": "Command tracker enabled",
|
||||
"commandTrackerDisabled": "Command tracker disabled",
|
||||
"listenerAdded": "Listener added",
|
||||
"listenerRemoved": "Listener removed"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
"nav": {
|
||||
"dashboard": "Главная",
|
||||
"providers": "Провайдеры",
|
||||
"trackers": "Трекеры",
|
||||
"notificationTrackers": "Трекеры увед.",
|
||||
"trackingConfigs": "Отслеживание",
|
||||
"templateConfigs": "Шаблоны",
|
||||
"telegramBots": "Боты",
|
||||
"targets": "Получатели",
|
||||
"commandConfigs": "Конф. команд",
|
||||
"commandTrackers": "Трекеры команд",
|
||||
"users": "Пользователи",
|
||||
"settings": "Настройки",
|
||||
"logout": "Выход"
|
||||
@@ -93,8 +95,8 @@
|
||||
"testAndSave": "Проверить и сохранить",
|
||||
"saveWithoutTest": "Сохранить без проверки"
|
||||
},
|
||||
"trackers": {
|
||||
"title": "Трекеры",
|
||||
"notificationTracker": {
|
||||
"title": "Трекеры уведомлений",
|
||||
"description": "Отслеживание изменений в альбомах",
|
||||
"newTracker": "Новый трекер",
|
||||
"cancel": "Отмена",
|
||||
@@ -198,7 +200,9 @@
|
||||
"maxAssetSize": "Макс. размер файла (МБ)",
|
||||
"videoWarning": "Предупреждение о размере видео",
|
||||
"disableUrlPreview": "Отключить превью ссылок",
|
||||
"sendLargeAsDocuments": "Отправлять большие фото как документы"
|
||||
"sendLargeAsDocuments": "Отправлять большие фото как документы",
|
||||
"chatAction": "Действие в чате",
|
||||
"chatActionNone": "Нет (без действия)"
|
||||
},
|
||||
"users": {
|
||||
"title": "Пользователи",
|
||||
@@ -474,6 +478,47 @@
|
||||
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
||||
},
|
||||
"commandConfig": {
|
||||
"title": "Конфигурации команд",
|
||||
"description": "Настройки команд для взаимодействия с Telegram-ботами",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Команды по умолчанию",
|
||||
"providerType": "Тип провайдера",
|
||||
"enabledCommands": "Включённые команды",
|
||||
"locale": "Язык",
|
||||
"responseMode": "Режим ответа",
|
||||
"modeMedia": "Медиа (фото)",
|
||||
"modeText": "Только текст",
|
||||
"defaultCount": "Кол-во по умолчанию",
|
||||
"rateLimits": "Ограничения частоты",
|
||||
"searchCooldown": "Кулдаун поиска (с)",
|
||||
"defaultCooldown": "Кулдаун по умолчанию (с)",
|
||||
"noConfigs": "Конфигураций команд пока нет.",
|
||||
"confirmDelete": "Удалить эту конфигурацию команд?",
|
||||
"commands": "команд"
|
||||
},
|
||||
"commandTracker": {
|
||||
"title": "Трекеры команд",
|
||||
"description": "Управление трекерами команд и их слушателями",
|
||||
"newTracker": "Новый трекер",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Семейные команды",
|
||||
"provider": "Провайдер",
|
||||
"selectProvider": "Выберите провайдер...",
|
||||
"commandConfig": "Конфигурация команд",
|
||||
"selectCommandConfig": "Выберите конфигурацию...",
|
||||
"listeners": "Слушатели",
|
||||
"addListener": "Добавить слушателя",
|
||||
"removeListener": "Удалить",
|
||||
"noTrackers": "Трекеров команд пока нет.",
|
||||
"confirmDelete": "Удалить этот трекер команд?",
|
||||
"enabled": "Включён",
|
||||
"disabled": "Отключён",
|
||||
"noListeners": "Нет подключённых слушателей.",
|
||||
"selectBot": "Выберите бота...",
|
||||
"listenerType": "telegram_bot"
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали"
|
||||
@@ -504,7 +549,16 @@
|
||||
"commandsSynced": "Команды синхронизированы с Telegram",
|
||||
"targetLinked": "Получатель привязан",
|
||||
"targetUnlinked": "Получатель отвязан",
|
||||
"botUpdated": "Бот обновлён"
|
||||
"botUpdated": "Бот обновлён",
|
||||
"commandConfigSaved": "Конфигурация команд сохранена",
|
||||
"commandConfigDeleted": "Конфигурация команд удалена",
|
||||
"commandTrackerCreated": "Трекер команд создан",
|
||||
"commandTrackerUpdated": "Трекер команд обновлён",
|
||||
"commandTrackerDeleted": "Трекер команд удалён",
|
||||
"commandTrackerEnabled": "Трекер команд включён",
|
||||
"commandTrackerDisabled": "Трекер команд отключён",
|
||||
"listenerAdded": "Слушатель добавлен",
|
||||
"listenerRemoved": "Слушатель удалён"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
|
||||
@@ -39,11 +39,13 @@
|
||||
const baseNavItems = [
|
||||
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
|
||||
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
|
||||
{ href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
|
||||
{ href: '/notification-trackers', key: 'nav.notificationTrackers', icon: 'mdiRadar' },
|
||||
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
|
||||
{ href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
|
||||
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
{ href: '/command-trackers', key: 'nav.commandTrackers', icon: 'mdiConsoleLine' },
|
||||
{ href: '/command-configs', key: 'nav.commandConfigs', icon: 'mdiCog' },
|
||||
];
|
||||
const navItems = $derived(auth.isAdmin
|
||||
? [...baseNavItems, { href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' }, { href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' }]
|
||||
|
||||
@@ -127,6 +127,9 @@
|
||||
animateCount(0, status.trackers.active, (v) => displayActive = v);
|
||||
animateCount(0, status.trackers.total, (v) => displayTotal = v);
|
||||
animateCount(0, status.targets, (v) => displayTargets = v);
|
||||
if (status.command_trackers !== undefined) {
|
||||
animateCount(0, status.command_trackers, (v) => displayCommandTrackers = v);
|
||||
}
|
||||
}, 200);
|
||||
} catch (err: any) {
|
||||
error = err.message || t('common.error');
|
||||
@@ -135,10 +138,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
let displayCommandTrackers = $state(0);
|
||||
|
||||
const statCards = $derived(status ? [
|
||||
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
|
||||
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
|
||||
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
|
||||
...(status.command_trackers !== undefined ? [{ icon: 'mdiConsoleLine', label: 'nav.commandTrackers', value: displayCommandTrackers, color: '#8b5cf6' }] : []),
|
||||
] : []);
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
const allCommands = [
|
||||
{ key: 'help', icon: 'mdiHelpCircle' },
|
||||
{ key: 'status', icon: 'mdiChartBox' },
|
||||
{ key: 'albums', icon: 'mdiImageMultiple' },
|
||||
{ key: 'events', icon: 'mdiPulse' },
|
||||
{ key: 'summary', icon: 'mdiFileDocumentEdit' },
|
||||
{ key: 'latest', icon: 'mdiImagePlus' },
|
||||
{ key: 'memory', icon: 'mdiHistory' },
|
||||
{ key: 'random', icon: 'mdiDice3' },
|
||||
{ key: 'search', icon: 'mdiMagnify' },
|
||||
{ key: 'find', icon: 'mdiFileSearch' },
|
||||
{ key: 'person', icon: 'mdiAccount' },
|
||||
{ key: 'place', icon: 'mdiMapMarker' },
|
||||
{ key: 'favorites', icon: 'mdiStar' },
|
||||
{ key: 'people', icon: 'mdiAccountGroup' },
|
||||
];
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
icon: '',
|
||||
provider_type: 'immich',
|
||||
enabled_commands: ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'] as string[],
|
||||
locale: 'en',
|
||||
response_mode: 'media',
|
||||
default_count: 5,
|
||||
rate_limits: { search: 30, default: 10 },
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
configs = await api('/command-configs');
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function editConfig(cfg: any) {
|
||||
form = {
|
||||
name: cfg.name,
|
||||
icon: cfg.icon || '',
|
||||
provider_type: cfg.provider_type || 'immich',
|
||||
enabled_commands: [...(cfg.enabled_commands || [])],
|
||||
locale: cfg.locale || 'en',
|
||||
response_mode: cfg.response_mode || 'media',
|
||||
default_count: cfg.default_count || 5,
|
||||
rate_limits: { search: cfg.rate_limits?.search || 30, default: cfg.rate_limits?.default || 10 },
|
||||
};
|
||||
editing = cfg.id;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function toggleCmd(cmd: string) {
|
||||
const enabled = [...form.enabled_commands];
|
||||
const idx = enabled.indexOf(cmd);
|
||||
if (idx >= 0) enabled.splice(idx, 1);
|
||||
else enabled.push(cmd);
|
||||
form.enabled_commands = enabled;
|
||||
}
|
||||
|
||||
async function saveConfig(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
const body = JSON.stringify(form);
|
||||
if (editing) {
|
||||
await api(`/command-configs/${editing}`, { method: 'PUT', body });
|
||||
snackSuccess(t('snack.commandConfigSaved'));
|
||||
} else {
|
||||
await api('/command-configs', { method: 'POST', body });
|
||||
snackSuccess(t('snack.commandConfigSaved'));
|
||||
}
|
||||
form = defaultForm(); showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
function remove(cfg: any) {
|
||||
confirmDelete = {
|
||||
id: cfg.id,
|
||||
onconfirm: async () => {
|
||||
try {
|
||||
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.commandConfigDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('commandConfig.title')} description={t('commandConfig.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('commandConfig.newConfig')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={saveConfig} class="space-y-4">
|
||||
<div>
|
||||
<label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="cfg-name" bind:value={form.name} required placeholder={t('commandConfig.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="cfg-provider-type" class="block text-sm font-medium mb-1">{t('commandConfig.providerType')}</label>
|
||||
<select id="cfg-provider-type" bind:value={form.provider_type}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="immich">Immich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Enabled commands -->
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-2">{t('commandConfig.enabledCommands')}</p>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1">
|
||||
{#each allCommands as cmd}
|
||||
<label class="flex items-center gap-1.5 text-xs cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
|
||||
<input type="checkbox" checked={form.enabled_commands.includes(cmd.key)}
|
||||
onchange={() => toggleCmd(cmd.key)} />
|
||||
<MdiIcon name={cmd.icon} size={14} />
|
||||
/{cmd.key}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('commandConfig.locale')}</label>
|
||||
<select bind:value={form.locale}
|
||||
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('commandConfig.responseMode')}</label>
|
||||
<select bind:value={form.response_mode}
|
||||
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value="media">{t('commandConfig.modeMedia')}</option>
|
||||
<option value="text">{t('commandConfig.modeText')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('commandConfig.defaultCount')}</label>
|
||||
<input type="number" bind:value={form.default_count} min="1" max="20"
|
||||
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('commandConfig.searchCooldown')}</label>
|
||||
<input type="number" bind:value={form.rate_limits.search} min="0" max="300"
|
||||
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-1/2 sm:w-1/4">
|
||||
<label class="block text-xs mb-1">{t('commandConfig.defaultCooldown')}</label>
|
||||
<input type="number" bind:value={form.rate_limits.default} min="0" max="300"
|
||||
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiConsoleLine" message={t('commandConfig.noConfigs')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each configs as cfg}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium">{cfg.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{cfg.provider_type}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500 font-mono">
|
||||
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
|
||||
</span>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{cfg.locale?.toUpperCase()}</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
|
||||
{t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
|
||||
· {t('commandConfig.defaultCount')}: {cfg.default_count}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editConfig(cfg)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(cfg)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
@@ -0,0 +1,314 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import type { ServiceProvider, TelegramBot } from '$lib/types';
|
||||
|
||||
let trackers = $state<any[]>([]);
|
||||
let providers = $state<ServiceProvider[]>([]);
|
||||
let commandConfigs = $state<any[]>([]);
|
||||
let telegramBots = $state<TelegramBot[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
let toggling = $state<Record<number, boolean>>({});
|
||||
|
||||
// Listeners per tracker
|
||||
let listeners = $state<Record<number, any[]>>({});
|
||||
let listenersLoading = $state<Record<number, boolean>>({});
|
||||
let expandedTracker = $state<number | null>(null);
|
||||
let addingListener = $state<Record<number, boolean>>({});
|
||||
let newListenerBotId = $state<Record<number, number>>({});
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
icon: '',
|
||||
provider_id: 0,
|
||||
command_config_id: 0,
|
||||
enabled: true,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
// Filter command configs by selected provider's type
|
||||
let filteredConfigs = $derived(() => {
|
||||
if (!form.provider_id) return commandConfigs;
|
||||
const provider = providers.find(p => p.id === form.provider_id);
|
||||
if (!provider) return commandConfigs;
|
||||
return commandConfigs.filter(c => c.provider_type === provider.type);
|
||||
});
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
[trackers, providers, commandConfigs, telegramBots] = await Promise.all([
|
||||
api('/command-trackers'),
|
||||
api('/providers'),
|
||||
api('/command-configs'),
|
||||
api('/telegram-bots'),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function editTracker(trk: any) {
|
||||
form = {
|
||||
name: trk.name,
|
||||
icon: trk.icon || '',
|
||||
provider_id: trk.provider_id,
|
||||
command_config_id: trk.command_config_id,
|
||||
enabled: trk.enabled,
|
||||
};
|
||||
editing = trk.id;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
async function saveTracker(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
const body = JSON.stringify(form);
|
||||
if (editing) {
|
||||
await api(`/command-trackers/${editing}`, { method: 'PUT', body });
|
||||
snackSuccess(t('snack.commandTrackerUpdated'));
|
||||
} else {
|
||||
await api('/command-trackers', { method: 'POST', body });
|
||||
snackSuccess(t('snack.commandTrackerCreated'));
|
||||
}
|
||||
form = defaultForm(); showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
|
||||
function remove(trk: any) {
|
||||
confirmDelete = {
|
||||
id: trk.id,
|
||||
onconfirm: async () => {
|
||||
try {
|
||||
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.commandTrackerDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function toggleEnabled(trk: any) {
|
||||
toggling = { ...toggling, [trk.id]: true };
|
||||
try {
|
||||
const endpoint = trk.enabled ? 'disable' : 'enable';
|
||||
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
|
||||
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
|
||||
await load();
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
toggling = { ...toggling, [trk.id]: false };
|
||||
}
|
||||
|
||||
function toggleListeners(trkId: number) {
|
||||
if (expandedTracker === trkId) {
|
||||
expandedTracker = null;
|
||||
return;
|
||||
}
|
||||
expandedTracker = trkId;
|
||||
loadListeners(trkId);
|
||||
}
|
||||
|
||||
async function loadListeners(trkId: number) {
|
||||
listenersLoading = { ...listenersLoading, [trkId]: true };
|
||||
try {
|
||||
listeners = { ...listeners, [trkId]: await api(`/command-trackers/${trkId}/listeners`) };
|
||||
} catch { listeners = { ...listeners, [trkId]: [] }; }
|
||||
listenersLoading = { ...listenersLoading, [trkId]: false };
|
||||
}
|
||||
|
||||
async function addListener(trkId: number) {
|
||||
const botId = newListenerBotId[trkId];
|
||||
if (!botId) return;
|
||||
addingListener = { ...addingListener, [trkId]: true };
|
||||
try {
|
||||
await api(`/command-trackers/${trkId}/listeners`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ listener_type: 'telegram_bot', listener_id: botId }),
|
||||
});
|
||||
snackSuccess(t('snack.listenerAdded'));
|
||||
await loadListeners(trkId);
|
||||
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
addingListener = { ...addingListener, [trkId]: false };
|
||||
}
|
||||
|
||||
async function removeListener(trkId: number, listenerId: number) {
|
||||
try {
|
||||
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
|
||||
snackSuccess(t('snack.listenerRemoved'));
|
||||
await loadListeners(trkId);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
function providerName(id: number): string {
|
||||
return providers.find(p => p.id === id)?.name || '?';
|
||||
}
|
||||
function configName(id: number): string {
|
||||
return commandConfigs.find(c => c.id === id)?.name || '?';
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('commandTracker.title')} description={t('commandTracker.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('commandTracker.newTracker')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={saveTracker} class="space-y-3">
|
||||
<div>
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('commandTracker.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('commandTracker.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="trk-provider" class="block text-sm font-medium mb-1">{t('commandTracker.provider')}</label>
|
||||
<select id="trk-provider" bind:value={form.provider_id} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0} disabled>{t('commandTracker.selectProvider')}</option>
|
||||
{#each providers as p}
|
||||
<option value={p.id}>{p.name} ({p.type})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="trk-config" class="block text-sm font-medium mb-1">{t('commandTracker.commandConfig')}</label>
|
||||
<select id="trk-config" bind:value={form.command_config_id} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0} disabled>{t('commandTracker.selectCommandConfig')}</option>
|
||||
{#each filteredConfigs() as c}
|
||||
<option value={c.id}>{c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" bind:checked={form.enabled} />
|
||||
{t('commandTracker.enabled')}
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if trackers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiConsoleLine" message={t('commandTracker.noTrackers')} />
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each trackers as trk}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
|
||||
<p class="font-medium">{trk.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{providerName(trk.provider_id)}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{configName(trk.command_config_id)}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-500'
|
||||
: 'bg-red-500/10 text-red-500'}">
|
||||
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
{#if trk.listener_count !== undefined}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
|
||||
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
|
||||
<button onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]}
|
||||
class="text-xs px-2 py-1 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
{trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
</button>
|
||||
<button onclick={() => toggleListeners(trk.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('commandTracker.listeners')} {expandedTracker === trk.id ? '▲' : '▼'}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listeners section -->
|
||||
{#if expandedTracker === trk.id}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3" transition:slide>
|
||||
{#if listenersLoading[trk.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (listeners[trk.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('commandTracker.noListeners')}</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each listeners[trk.id] as listener}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiIcon name="mdiRobot" size={14} />
|
||||
<span class="font-medium">{listener.name || listener.listener_type}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-500 font-mono">{listener.listener_type}</span>
|
||||
</div>
|
||||
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
|
||||
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add listener -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<select bind:value={newListenerBotId[trk.id]}
|
||||
class="flex-1 px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value={0} disabled selected>{t('commandTracker.selectBot')}</option>
|
||||
{#each telegramBots as bot}
|
||||
<option value={bot.id}>{bot.name} {bot.bot_username ? `(@${bot.bot_username})` : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button onclick={() => addListener(trk.id)} disabled={!newListenerBotId[trk.id] || addingListener[trk.id]}
|
||||
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
|
||||
{addingListener[trk.id] ? t('common.loading') : t('commandTracker.addListener')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
+46
-46
@@ -18,7 +18,7 @@
|
||||
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
let trackers = $state<Tracker[]>([]);
|
||||
let notificationTrackers = $state<Tracker[]>([]);
|
||||
let providers = $state<ServiceProvider[]>([]);
|
||||
let targets = $state<NotificationTarget[]>([]);
|
||||
let trackingConfigs = $state<TrackingConfig[]>([]);
|
||||
@@ -59,8 +59,8 @@
|
||||
async function load() {
|
||||
loadError = '';
|
||||
try {
|
||||
[trackers, providers, targets, trackingConfigs, templateConfigs] = await Promise.all([
|
||||
api('/trackers'), api('/providers'), api('/targets'),
|
||||
[notificationTrackers, providers, targets, trackingConfigs, templateConfigs] = await Promise.all([
|
||||
api('/notification-trackers'), api('/providers'), api('/targets'),
|
||||
api('/tracking-configs'), api('/template-configs'),
|
||||
]);
|
||||
} catch (err: any) {
|
||||
@@ -126,10 +126,10 @@
|
||||
submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.trackerUpdated'));
|
||||
} else {
|
||||
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||
await api('/notification-trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.trackerCreated'));
|
||||
}
|
||||
showForm = false; editing = null; linkWarning = null; await load();
|
||||
@@ -164,7 +164,7 @@
|
||||
if (toggling[tracker.id]) return;
|
||||
toggling = { ...toggling, [tracker.id]: true };
|
||||
try {
|
||||
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||
await api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||
await load();
|
||||
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
|
||||
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
|
||||
@@ -173,7 +173,7 @@
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
await api(`/trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.trackerDeleted'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
@@ -183,10 +183,10 @@
|
||||
let testMenuStyle = $state('');
|
||||
|
||||
const testTypes = [
|
||||
{ key: 'basic', icon: 'mdiSend', labelKey: 'trackers.testBasic' },
|
||||
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'trackers.testPeriodic' },
|
||||
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'trackers.testScheduled' },
|
||||
{ key: 'memory', icon: 'mdiHistory', labelKey: 'trackers.testMemory' },
|
||||
{ key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
||||
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic' },
|
||||
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'notificationTracker.testScheduled' },
|
||||
{ key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory' },
|
||||
];
|
||||
|
||||
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
||||
@@ -195,7 +195,7 @@
|
||||
if (ttTesting[key]) return;
|
||||
ttTesting = { ...ttTesting, [key]: testType };
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
|
||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
|
||||
snackSuccess(t('snack.targetTestSent'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
@@ -237,7 +237,7 @@
|
||||
if (!targetId) return;
|
||||
addingTarget = { ...addingTarget, [trackerId]: true };
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets`, {
|
||||
await api(`/notification-trackers/${trackerId}/targets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
target_id: targetId,
|
||||
@@ -256,7 +256,7 @@
|
||||
|
||||
async function removeTargetLink(trackerId: number, ttId: number) {
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.targetUnlinked'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
@@ -264,7 +264,7 @@
|
||||
|
||||
async function updateTargetLink(trackerId: number, tt: any, field: string, value: any) {
|
||||
try {
|
||||
await api(`/trackers/${trackerId}/targets/${tt.id}`, {
|
||||
await api(`/notification-trackers/${trackerId}/targets/${tt.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ [field]: value }),
|
||||
});
|
||||
@@ -273,10 +273,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('trackers.title')} description={t('trackers.description')}>
|
||||
<PageHeader title={t('notificationTracker.title')} description={t('notificationTracker.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('trackers.cancel') : t('trackers.newTracker')}
|
||||
{showForm ? t('notificationTracker.cancel') : t('notificationTracker.newTracker')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
@@ -292,22 +292,22 @@
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-4">
|
||||
<div>
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('trackers.name')}</label>
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('trackers.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="trk-provider" class="block text-sm font-medium mb-1">{t('trackers.server')}</label>
|
||||
<label for="trk-provider" class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label>
|
||||
<select id="trk-provider" bind:value={form.provider_id} onchange={loadCollections} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0} disabled>{t('trackers.selectServer')}</option>
|
||||
<option value={0} disabled>{t('notificationTracker.selectServer')}</option>
|
||||
{#each providers as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#if collections.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('trackers.albums')} ({collections.length})</label>
|
||||
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')} ({collections.length})</label>
|
||||
<input type="text" bind:value={collectionFilter} placeholder="Filter..."
|
||||
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
|
||||
@@ -327,17 +327,17 @@
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('trackers.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('trackers.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
|
||||
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('notificationTracker.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
|
||||
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{#if linkCheckLoading}{t('trackers.checkingLinks')}{:else}{editing ? t('common.save') : t('trackers.createTracker')}{/if}
|
||||
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
@@ -345,13 +345,13 @@
|
||||
{/if}
|
||||
|
||||
{#if loaded && !loadError}
|
||||
{#if trackers.length === 0 && !showForm}
|
||||
{#if notificationTrackers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<EmptyState icon="mdiRadar" message={t('trackers.noTrackers')} />
|
||||
<EmptyState icon="mdiRadar" message={t('notificationTracker.noTrackers')} />
|
||||
</Card>
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each trackers as tracker}
|
||||
{#each notificationTrackers as tracker}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -359,22 +359,22 @@
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
|
||||
<p class="font-medium">{tracker.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('trackers.linkedTargets')}
|
||||
{(tracker.collection_ids || []).length} {t('notificationTracker.albums_count')} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
|
||||
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { try { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err) { snackError((err as any).message); } }} />
|
||||
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('trackers.pause') : t('trackers.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
||||
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { try { await api(`/notification-trackers/${tracker.id}/trigger`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err) { snackError((err as any).message); } }} />
|
||||
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
||||
<button onclick={() => toggleExpand(tracker.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('trackers.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
|
||||
{t('notificationTracker.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('trackers.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
||||
<IconButton icon="mdiDelete" title={t('notificationTracker.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -382,7 +382,7 @@
|
||||
{#if expandedTracker === tracker.id}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
|
||||
{#if (tracker.tracker_targets || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('trackers.noLinkedTargets')}</p>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('notificationTracker.noLinkedTargets')}</p>
|
||||
{:else}
|
||||
{#each tracker.tracker_targets as tt}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
@@ -391,7 +391,7 @@
|
||||
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
|
||||
{#if !tt.enabled}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('trackers.paused')}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
@@ -413,7 +413,7 @@
|
||||
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
|
||||
</div>
|
||||
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
||||
title={tt.enabled ? t('trackers.pause') : t('trackers.resume')}
|
||||
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
|
||||
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
|
||||
onclick={() => removeTargetLink(tracker.id, tt.id)} variant="danger" />
|
||||
@@ -427,7 +427,7 @@
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<select bind:value={newLinkTargetId[tracker.id]}
|
||||
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1">
|
||||
<option value={0}>— {t('trackers.addTarget')} —</option>
|
||||
<option value={0}>— {t('notificationTracker.addTarget')} —</option>
|
||||
{#each getUnlinkedTargets(tracker) as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each}
|
||||
</select>
|
||||
<select bind:value={newLinkTrackingConfigId[tracker.id]}
|
||||
@@ -463,7 +463,7 @@
|
||||
</div>
|
||||
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
|
||||
{#each testTypes as tt}
|
||||
{@const trackerId = trackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id}
|
||||
{@const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id}
|
||||
<button
|
||||
onclick={() => trackerId && testTrackerTarget(trackerId, Number(testMenuOpen), tt.key)}
|
||||
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
@@ -478,33 +478,33 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal open={linkWarning !== null} title={t('trackers.missingLinksTitle')} onclose={() => { linkWarning = null; }}>
|
||||
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={() => { linkWarning = null; }}>
|
||||
{#if linkWarning}
|
||||
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
|
||||
{t('trackers.missingLinksDesc')}
|
||||
{t('notificationTracker.missingLinksDesc')}
|
||||
</p>
|
||||
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
|
||||
{#each linkWarning.albums as album}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<span class="font-medium">{album.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{album.issue === 'expired' ? t('trackers.expired') : album.issue === 'password-protected' ? t('trackers.passwordProtected') : t('trackers.noLink')}
|
||||
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiInformation" size={14} /> {t('trackers.linksNote')}
|
||||
<MdiIcon name="mdiInformation" size={14} /> {t('notificationTracker.linksNote')}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<button onclick={dismissLinkWarning}
|
||||
class="px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
|
||||
{t('trackers.saveWithoutLinks')}
|
||||
{t('notificationTracker.saveWithoutLinks')}
|
||||
</button>
|
||||
{#if linkWarning.albums.some(a => a.issue === 'missing')}
|
||||
<button onclick={autoCreateLinks} disabled={linkCreating}
|
||||
class="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
|
||||
{linkCreating ? t('common.loading') : t('trackers.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
|
||||
{linkCreating ? t('common.loading') : t('notificationTracker.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -513,7 +513,7 @@
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
message={t('trackers.confirmDelete')}
|
||||
message={t('notificationTracker.confirmDelete')}
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => confirmDelete = null}
|
||||
/>
|
||||
@@ -23,7 +23,7 @@
|
||||
let formType = $state<'telegram' | 'webhook'>('telegram');
|
||||
const defaultForm = () => ({ name: '', icon: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '',
|
||||
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
|
||||
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false });
|
||||
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: '' });
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
let headersError = $state('');
|
||||
@@ -55,7 +55,7 @@
|
||||
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
|
||||
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
|
||||
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
|
||||
ai_captions: c.ai_captions ?? false,
|
||||
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? '',
|
||||
};
|
||||
editing = tgt.id; showTelegramSettings = false; showForm = true;
|
||||
if (form.bot_id) await loadBotChats();
|
||||
@@ -82,7 +82,7 @@
|
||||
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
|
||||
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
||||
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
||||
ai_captions: form.ai_captions }
|
||||
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined }
|
||||
: { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
|
||||
if (editing) {
|
||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
|
||||
@@ -198,6 +198,19 @@
|
||||
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
|
||||
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label for="tgt-chataction" class="block text-xs mb-1">{t('targets.chatAction')}</label>
|
||||
<select id="tgt-chataction" bind:value={form.chat_action}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="">{t('targets.chatActionNone')}</option>
|
||||
<option value="typing">typing</option>
|
||||
<option value="upload_photo">upload_photo</option>
|
||||
<option value="upload_video">upload_video</option>
|
||||
<option value="upload_document">upload_document</option>
|
||||
<option value="record_video">record_video</option>
|
||||
<option value="record_voice">record_voice</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
|
||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
|
||||
</div>
|
||||
@@ -241,6 +254,9 @@
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{#if target.type === 'telegram'}
|
||||
Chat: {#if target.chat_name}{target.chat_name} <span class="font-mono text-xs">({target.config?.chat_id})</span>{:else}{target.config?.chat_id || '***'}{/if}
|
||||
{#if target.config?.chat_action}
|
||||
<span class="text-xs px-1.5 py-0.5 ml-1 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.config.chat_action}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
{target.config?.url || ''}
|
||||
{/if}
|
||||
|
||||
@@ -108,70 +108,38 @@
|
||||
|
||||
let chatTesting = $state<Record<string, boolean>>({});
|
||||
|
||||
// Commands config editing
|
||||
let cmdConfig = $state<Record<number, any>>({});
|
||||
let cmdSaving = $state<Record<number, boolean>>({});
|
||||
let cmdSyncing = $state<Record<number, boolean>>({});
|
||||
let modeChanging = $state<Record<number, boolean>>({});
|
||||
|
||||
const allCommands = [
|
||||
{ key: 'help', icon: 'mdiHelpCircle' },
|
||||
{ key: 'status', icon: 'mdiChartBox' },
|
||||
{ key: 'albums', icon: 'mdiImageMultiple' },
|
||||
{ key: 'events', icon: 'mdiPulse' },
|
||||
{ key: 'summary', icon: 'mdiFileDocumentEdit' },
|
||||
{ key: 'latest', icon: 'mdiImagePlus' },
|
||||
{ key: 'memory', icon: 'mdiHistory' },
|
||||
{ key: 'random', icon: 'mdiDice3' },
|
||||
{ key: 'search', icon: 'mdiMagnify' },
|
||||
{ key: 'find', icon: 'mdiFileSearch' },
|
||||
{ key: 'person', icon: 'mdiAccount' },
|
||||
{ key: 'place', icon: 'mdiMapMarker' },
|
||||
{ key: 'favorites', icon: 'mdiStar' },
|
||||
{ key: 'people', icon: 'mdiAccountGroup' },
|
||||
];
|
||||
// Listener status: command trackers using this bot
|
||||
let botListenerStatus = $state<Record<number, any[]>>({});
|
||||
let botListenerLoading = $state<Record<number, boolean>>({});
|
||||
|
||||
function initCmdConfig(bot: any) {
|
||||
if (!cmdConfig[bot.id]) {
|
||||
const cfg = bot.commands_config || {};
|
||||
cmdConfig = { ...cmdConfig, [bot.id]: {
|
||||
enabled: cfg.enabled || ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'],
|
||||
locale: cfg.locale || 'en',
|
||||
response_mode: cfg.response_mode || 'media',
|
||||
default_count: cfg.default_count || 5,
|
||||
rate_limits: { search: cfg.rate_limits?.search || 30, default: cfg.rate_limits?.default || 10 },
|
||||
}};
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCmd(botId: number, cmd: string) {
|
||||
const cfg = cmdConfig[botId];
|
||||
if (!cfg) return;
|
||||
const enabled = [...cfg.enabled];
|
||||
const idx = enabled.indexOf(cmd);
|
||||
if (idx >= 0) enabled.splice(idx, 1);
|
||||
else enabled.push(cmd);
|
||||
cmdConfig = { ...cmdConfig, [botId]: { ...cfg, enabled } };
|
||||
}
|
||||
|
||||
async function saveCmdConfig(botId: number) {
|
||||
cmdSaving = { ...cmdSaving, [botId]: true };
|
||||
async function loadListenerStatus(botId: number) {
|
||||
botListenerLoading = { ...botListenerLoading, [botId]: true };
|
||||
try {
|
||||
await api(`/telegram-bots/${botId}`, { method: 'PUT', body: JSON.stringify({ commands_config: cmdConfig[botId] }) });
|
||||
await load();
|
||||
snackSuccess(t('snack.botUpdated'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
cmdSaving = { ...cmdSaving, [botId]: false };
|
||||
// Load all command trackers and filter for ones that have this bot as a listener
|
||||
const trackers = await api('/command-trackers');
|
||||
const matched: any[] = [];
|
||||
for (const trk of trackers) {
|
||||
try {
|
||||
const listeners = await api(`/command-trackers/${trk.id}/listeners`);
|
||||
const hasBot = listeners.some((l: any) => l.listener_type === 'telegram_bot' && l.listener_id === botId);
|
||||
if (hasBot) matched.push(trk);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
botListenerStatus = { ...botListenerStatus, [botId]: matched };
|
||||
} catch { botListenerStatus = { ...botListenerStatus, [botId]: [] }; }
|
||||
botListenerLoading = { ...botListenerLoading, [botId]: false };
|
||||
}
|
||||
|
||||
async function syncCommands(botId: number) {
|
||||
cmdSyncing = { ...cmdSyncing, [botId]: true };
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
const res = await api(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
|
||||
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
|
||||
else snackError(res.error || 'Failed');
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
cmdSyncing = { ...cmdSyncing, [botId]: false };
|
||||
modeChanging = { ...modeChanging, [botId]: false };
|
||||
}
|
||||
|
||||
async function switchMode(botId: number, mode: string) {
|
||||
@@ -315,9 +283,14 @@
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => { initCmdConfig(bot); toggleSection(bot.id, 'commands'); }}
|
||||
<button onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('telegramBot.commands')} {expandedSection[bot.id] === 'commands' ? '▲' : '▼'}
|
||||
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline px-2 py-1 flex items-center gap-1">
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
{t('telegramBot.syncCommands')}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
||||
</div>
|
||||
@@ -360,67 +333,35 @@
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Commands section -->
|
||||
{#if expandedSection[bot.id] === 'commands' && cmdConfig[bot.id]}
|
||||
<!-- Listener Status section -->
|
||||
{#if expandedSection[bot.id] === 'listeners'}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-3" in:slide>
|
||||
<!-- Command toggles -->
|
||||
<div>
|
||||
<p class="text-xs font-medium mb-2">{t('telegramBot.enabledCommands')}</p>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1">
|
||||
{#each allCommands as cmd}
|
||||
<label class="flex items-center gap-1.5 text-xs cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
|
||||
<input type="checkbox" checked={cmdConfig[bot.id].enabled.includes(cmd.key)}
|
||||
onchange={() => toggleCmd(bot.id, cmd.key)} />
|
||||
<MdiIcon name={cmd.icon} size={14} />
|
||||
/{cmd.key}
|
||||
</label>
|
||||
{#if botListenerLoading[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (botListenerStatus[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('commandTracker.noListeners')}</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each botListenerStatus[bot.id] as trk}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiIcon name={trk.icon || 'mdiConsoleLine'} size={14} />
|
||||
<span class="font-medium">{trk.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-500'
|
||||
: 'bg-red-500/10 text-red-500'}">
|
||||
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
<a href="/command-trackers" class="text-xs text-[var(--color-primary)] hover:underline">
|
||||
{t('common.edit')}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('telegramBot.responseMode')}</label>
|
||||
<select bind:value={cmdConfig[bot.id].response_mode}
|
||||
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value="media">{t('telegramBot.modeMedia')}</option>
|
||||
<option value="text">{t('telegramBot.modeText')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('telegramBot.cmdLocale')}</label>
|
||||
<select bind:value={cmdConfig[bot.id].locale}
|
||||
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('telegramBot.defaultCount')}</label>
|
||||
<input type="number" bind:value={cmdConfig[bot.id].default_count} min="1" max="20"
|
||||
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs mb-1">{t('telegramBot.searchCooldown')}</label>
|
||||
<input type="number" bind:value={cmdConfig[bot.id].rate_limits.search} min="0" max="300"
|
||||
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 1: Config actions -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button onclick={() => saveCmdConfig(bot.id)} disabled={cmdSaving[bot.id]}
|
||||
class="px-3 py-1 text-xs bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
|
||||
{cmdSaving[bot.id] ? t('common.loading') : t('telegramBot.saveConfig')}
|
||||
</button>
|
||||
<button onclick={() => syncCommands(bot.id)} disabled={cmdSyncing[bot.id]}
|
||||
class="px-3 py-1 text-xs border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||
{cmdSyncing[bot.id] ? t('common.loading') : t('telegramBot.syncCommands')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Update mode -->
|
||||
<!-- Update mode -->
|
||||
<div class="border-t border-[var(--color-border)] pt-3">
|
||||
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
@@ -459,7 +400,6 @@
|
||||
class="px-2 py-1 text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
|
||||
{t('telegramBot.unregisterWebhook')}
|
||||
</button>
|
||||
<!-- Webhook status -->
|
||||
{#if webhookStatus[bot.id]}
|
||||
{@const ws = webhookStatus[bot.id]}
|
||||
<span class="text-xs font-mono {ws.url ? 'text-blue-500' : 'text-[var(--color-muted-foreground)]'}">
|
||||
|
||||
Reference in New Issue
Block a user