feat: autogen time picker, channel schedule tab with publish slots

This commit is contained in:
Alexey Pavlov
2026-05-31 16:45:16 +03:00
parent edc38d2318
commit 14e1fd14df
4 changed files with 207 additions and 21 deletions
@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { checkAdminAuth } from '@/lib/adminAuth';
const E = process.env.ENGINE_URL||'http://127.0.0.1:3030';
const S = process.env.ENGINE_SECRET||'zeropost_internal_2026';
export async function DELETE(req, { params }) {
if (!(await checkAdminAuth())) return NextResponse.json({error:'Unauthorized'},{status:401});
const { id, slotId } = await params;
await fetch(`${E}/api/channels/admin/${id}/slots/${slotId}`,{method:'DELETE',headers:{'x-internal-secret':S}});
return NextResponse.json({ok:true});
}
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { checkAdminAuth } from '@/lib/adminAuth';
const E = process.env.ENGINE_URL||'http://127.0.0.1:3030';
const S = process.env.ENGINE_SECRET||'zeropost_internal_2026';
const h = {'Content-Type':'application/json','x-internal-secret':S};
export async function GET(req, { params }) {
if (!(await checkAdminAuth())) return NextResponse.json({error:'Unauthorized'},{status:401});
const { id } = await params;
const res = await fetch(`${E}/api/channels/admin/${id}/slots`, { headers: h });
return NextResponse.json(await res.json());
}
export async function POST(req, { params }) {
if (!(await checkAdminAuth())) return NextResponse.json({error:'Unauthorized'},{status:401});
const { id } = await params;
const body = await req.json();
const res = await fetch(`${E}/api/channels/admin/${id}/slots`, {method:'POST',headers:h,body:JSON.stringify(body)});
return NextResponse.json(await res.json());
}
+45 -15
View File
@@ -68,6 +68,15 @@ export default function AutogenPanel({ status, queue, topics }) {
} }
} }
async function updateTime(category, run_hour, run_minute) {
await fetch('/admin/api/autogen/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category, run_hour: parseInt(run_hour), run_minute: parseInt(run_minute) }),
});
router.refresh();
}
async function toggleCategory(category, enabled) { async function toggleCategory(category, enabled) {
await fetch('/admin/api/autogen/settings', { await fetch('/admin/api/autogen/settings', {
method: 'PATCH', method: 'PATCH',
@@ -170,21 +179,42 @@ export default function AutogenPanel({ status, queue, topics }) {
</div> </div>
{/* Расписание */} {/* Расписание */}
<div className="flex items-center gap-3 mb-4"> <div className="space-y-2 mb-4">
<Clock className="w-3.5 h-3.5 opacity-60" /> <div className="flex items-center gap-3">
<span className="text-xs opacity-70">Статей в день:</span> <Clock className="w-3.5 h-3.5 opacity-60 shrink-0" />
<select <span className="text-xs opacity-70">Статей в день:</span>
value={s.per_day} <select value={s.per_day} onChange={e => updatePerDay(s.category, e.target.value)}
onChange={e => updatePerDay(s.category, e.target.value)} className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5">
className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5" {[1,2,3,4].map(n => <option key={n} value={n}>{n}</option>)}
> </select>
{[1,2,3,4].map(n => <option key={n} value={n}>{n}</option>)} </div>
</select> <div className="flex items-center gap-3">
{s.next_run_at && ( <Clock className="w-3.5 h-3.5 opacity-60 shrink-0" />
<span className="text-xs opacity-60 ml-auto"> <span className="text-xs opacity-70">Время запуска:</span>
след.: {new Date(s.next_run_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })} <select
</span> value={s.run_hour ?? 8}
)} onChange={e => updateTime(s.category, e.target.value, s.run_minute ?? 0)}
className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5"
>
{Array.from({length: 24}, (_,i) => (
<option key={i} value={i}>{String(i).padStart(2,'0')}:__</option>
))}
</select>
<select
value={s.run_minute ?? 0}
onChange={e => updateTime(s.category, s.run_hour ?? 8, e.target.value)}
className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5"
>
{[0,5,10,15,20,25,30,35,40,45,50,55].map(m => (
<option key={m} value={m}>__:{String(m).padStart(2,'0')}</option>
))}
</select>
{s.next_run_at && (
<span className="text-xs opacity-60 ml-auto">
след.: {new Date(s.next_run_at).toLocaleString('ru-RU', {day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})}
</span>
)}
</div>
</div> </div>
{/* Кнопка запуска */} {/* Кнопка запуска */}
+132 -6
View File
@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Save, Trash2, Send, Plus, ExternalLink } from 'lucide-react'; import { ArrowLeft, Save, Trash2, Send, Plus, Clock, X } from 'lucide-react';
const PLATFORMS = [ const PLATFORMS = [
{ value: 'telegram', label: 'Telegram', desc: 'Публикация через Bot API' }, { value: 'telegram', label: 'Telegram', desc: 'Публикация через Bot API' },
@@ -10,6 +10,12 @@ const PLATFORMS = [
{ value: 'max', label: 'Max', desc: 'Канал в мессенджере Max' }, { value: 'max', label: 'Max', desc: 'Канал в мессенджере Max' },
]; ];
const TABS = [
{ id: 'settings', label: 'Настройки' },
{ id: 'schedule', label: 'Расписание' },
{ id: 'publish', label: 'Публикация' },
];
export default function ChannelEditor({ channel, articles = [] }) { export default function ChannelEditor({ channel, articles = [] }) {
const router = useRouter(); const router = useRouter();
const isNew = !channel; const isNew = !channel;
@@ -34,6 +40,20 @@ export default function ChannelEditor({ channel, articles = [] }) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
const [activeTab, setActiveTab] = useState('settings');
// Слоты расписания
const [slots, setSlots] = useState([]);
const [newSlotH, setNewSlotH] = useState(8);
const [newSlotM, setNewSlotM] = useState(0);
const [addingSlot, setAddingSlot] = useState(false);
// Загружаем слоты
useEffect(() => {
if (!channel?.id) return;
fetch(`/admin/api/channels/${channel.id}/slots`)
.then(r => r.json()).then(setSlots).catch(() => {});
}, [channel?.id]);
function showToast(msg, type = 'success') { function showToast(msg, type = 'success') {
setToast({ msg, type }); setToast({ msg, type });
@@ -124,6 +144,28 @@ export default function ChannelEditor({ channel, articles = [] }) {
} }
} }
async function addSlot() {
if (!channel?.id) return;
setAddingSlot(true);
try {
const res = await fetch(`/admin/api/channels/${channel.id}/slots`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slot_hour: newSlotH, slot_minute: newSlotM }),
});
if (!res.ok) throw new Error(await res.text());
const slot = await res.json();
setSlots(s => [...s, slot].sort((a,b) => a.slot_hour*60+a.slot_minute - (b.slot_hour*60+b.slot_minute)));
showToast('Слот добавлен');
} catch (e) { showToast(e.message, 'error'); }
finally { setAddingSlot(false); }
}
async function removeSlot(slotId) {
await fetch(`/admin/api/channels/${channel.id}/slots/${slotId}`, { method: 'DELETE' });
setSlots(s => s.filter(sl => sl.id !== slotId));
}
return ( return (
<div className="space-y-6 max-w-3xl"> <div className="space-y-6 max-w-3xl">
{/* Toast */} {/* Toast */}
@@ -161,8 +203,25 @@ export default function ChannelEditor({ channel, articles = [] }) {
</div> </div>
</div> </div>
{/* Настройки */} {/* Вкладки — только для существующего канала */}
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 space-y-4"> {!isNew && (
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-800">
{TABS.map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
: 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}>
{tab.label}
</button>
))}
</div>
)}
{/* Вкладка: Настройки */}
{(isNew || activeTab === 'settings') && (
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Основное</h2> <h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Основное</h2>
{/* Платформа */} {/* Платформа */}
@@ -272,9 +331,76 @@ export default function ChannelEditor({ channel, articles = [] }) {
<label htmlFor="isActive" className="text-sm text-neutral-700 dark:text-neutral-300">Канал активен</label> <label htmlFor="isActive" className="text-sm text-neutral-700 dark:text-neutral-300">Канал активен</label>
</div> </div>
</div> </div>
)}
{/* Публикация — только для существующего канала */} {/* Вкладка: Расписание */}
{!isNew && ( {!isNew && activeTab === 'schedule' && (
<div className="space-y-4">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-1">Слоты публикации</h2>
<p className="text-xs text-neutral-400 mb-5">Время когда будут отправляться запланированные посты. Добавьте несколько слотов посты распределятся по ним автоматически.</p>
{/* Список слотов */}
<div className="space-y-2 mb-5">
{slots.length === 0 && (
<p className="text-sm text-neutral-400 text-center py-4">Слотов пока нет добавьте время публикации</p>
)}
{slots.map((slot, idx) => (
<div key={slot.id} className="flex items-center gap-3 px-4 py-3 rounded-lg bg-neutral-50 dark:bg-neutral-800">
<Clock className="w-4 h-4 text-neutral-400 shrink-0" />
<span className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 font-mono w-16">
{String(slot.slot_hour).padStart(2,'0')}:{String(slot.slot_minute).padStart(2,'0')}
</span>
<span className="text-xs text-neutral-400 flex-1">Слот {idx + 1}</span>
<button onClick={() => removeSlot(slot.id)}
className="p-1 rounded text-neutral-300 hover:text-red-500 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Добавить слот */}
<div className="border-t border-neutral-100 dark:border-neutral-800 pt-4">
<p className="text-xs font-medium text-neutral-500 mb-3">Добавить слот</p>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-neutral-50 dark:bg-neutral-800 rounded-lg px-4 py-2.5 border border-neutral-200 dark:border-neutral-700">
<select value={newSlotH} onChange={e => setNewSlotH(parseInt(e.target.value))}
className="bg-transparent text-lg font-bold font-mono text-neutral-900 dark:text-neutral-100 focus:outline-none w-12">
{Array.from({length:24},(_,i)=>(
<option key={i} value={i}>{String(i).padStart(2,'0')}</option>
))}
</select>
<span className="text-lg font-bold text-neutral-400">:</span>
<select value={newSlotM} onChange={e => setNewSlotM(parseInt(e.target.value))}
className="bg-transparent text-lg font-bold font-mono text-neutral-900 dark:text-neutral-100 focus:outline-none w-12">
{Array.from({length:60},(_,i)=>(
<option key={i} value={i}>{String(i).padStart(2,'0')}</option>
))}
</select>
</div>
<button onClick={addSlot} disabled={addingSlot}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors">
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
<p className="text-xs text-neutral-400 mt-2">
Совет: используй не круглые числа (8:06, 11:23) так посты выглядят органичнее в ленте
</p>
</div>
</div>
{/* Подсказка */}
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-xl p-4 text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium mb-1">Как работает расписание</p>
<p className="text-xs opacity-80">Когда ты ставишь пост в очередь на вкладке "Публикация" он автоматически назначается на ближайший свободный слот. Если добавить 4 слота, посты будут выходить 4 раза в день.</p>
</div>
</div>
)}
{/* Вкладка: Публикация */}
{!isNew && activeTab === 'publish' && (
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 space-y-4"> <div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Опубликовать</h2> <h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Опубликовать</h2>