From 5bc413da5013fe19ef95d197cfa0591989ddfc22 Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Fri, 19 Jun 2026 10:53:00 +0300 Subject: [PATCH] feat(zero): admin UI for Zero notes management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds new admin section 'Заметки Зеро' (☕) with: - manual 'Generate' button with channel/bucket/allow-dup form - status filter tabs with counters (draft/approved/published/failed/skipped) - per-note actions: approve / edit inline / regenerate with bucket pick / skip - status-colored cards with bucket icon, pose, scheduled time MSK - error display with attempt counter - tokens & model footer Files: app/api/admin/zero/[...path]/route.js catch-all proxy → engine components/admin/AdminZero.js main component components/AdminPanel.js +section in sidebar --- app/api/admin/zero/[...path]/route.js | 46 +++ components/AdminPanel.js | 5 +- components/admin/AdminZero.js | 396 ++++++++++++++++++++++++++ 3 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/zero/[...path]/route.js create mode 100644 components/admin/AdminZero.js diff --git a/app/api/admin/zero/[...path]/route.js b/app/api/admin/zero/[...path]/route.js new file mode 100644 index 0000000..a401b98 --- /dev/null +++ b/app/api/admin/zero/[...path]/route.js @@ -0,0 +1,46 @@ +/** + * Catch-all proxy для /api/admin/zero/* → engine /api/admin/zero/* + * Принимает любой метод и любой путь. Auth: session cookie → user.isAdmin. + */ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; + +const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030'; +const ENGINE_SECRET = process.env.ENGINE_SECRET || ''; + +async function proxy(req, { params }) { + const user = await requireUser(); + if (!user?.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const tail = (params?.path || []).join('/'); + const qs = req.url.split('?')[1]; + const url = `${ENGINE_URL}/api/admin/zero${tail ? '/' + tail : ''}${qs ? '?' + qs : ''}`; + + const headers = { + 'x-internal-secret': ENGINE_SECRET, + 'x-user-id': String(user.id), + }; + + let body; + if (req.method !== 'GET' && req.method !== 'HEAD') { + const ct = req.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + headers['Content-Type'] = 'application/json'; + const raw = await req.text(); + body = raw || undefined; + } else { + body = await req.text(); + } + } + + const res = await fetch(url, { method: req.method, headers, body, cache: 'no-store' }); + const data = await res.json().catch(() => ({ error: 'invalid engine response' })); + return NextResponse.json(data, { status: res.status }); +} + +export const GET = proxy; +export const POST = proxy; +export const PATCH = proxy; +export const PUT = proxy; +export const DELETE = proxy; diff --git a/components/AdminPanel.js b/components/AdminPanel.js index dcd144b..68ccd26 100644 --- a/components/AdminPanel.js +++ b/components/AdminPanel.js @@ -1,6 +1,6 @@ 'use client'; import { useState, useEffect } from 'react'; -import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen, Sliders, Mail } from 'lucide-react'; +import { Coffee, Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen, Sliders, Mail } from 'lucide-react'; import Link from 'next/link'; import AdminBilling from './admin/AdminBilling'; import AdminUsers from './admin/AdminUsers'; @@ -10,6 +10,7 @@ import AdminLogs from './admin/AdminLogs'; import AdminAutogen from './admin/AdminAutogen'; import AdminContent from './admin/AdminContent'; import AdminTopicBank from './admin/AdminTopicBank'; +import AdminZero from './admin/AdminZero'; // ────────────────────────────────────────────────────────────── // Sidebar navigation @@ -25,6 +26,7 @@ const SECTIONS = [ { id: 'autogen', label: 'Автогенерация', icon: BookOpen, desc: 'Расписание статей блога' }, { id: 'content', label: 'Контент-дефолты', icon: Sliders, desc: 'Настройки для новых каналов' }, { id: 'topicbank', label: 'Банк тем блога', icon: BookOpen, desc: 'Темы для zeropost.ru' }, + { id: 'zero', label: 'Заметки Зеро', icon: Coffee, desc: 'AI-персонаж в @zeropostru' }, { id: 'smtp', label: 'Email / SMTP', icon: Mail, desc: 'Уведомления пользователям' }, { id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' }, { id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' }, @@ -81,6 +83,7 @@ export default function AdminPanel({ initialSection = 'settings' }) { {section === 'autogen' && } {section === 'content' && } {section === 'topicbank' && } + {section === 'zero' && } {section === 'smtp' && } />} {section === 'plans' && } {section === 'promos' && } diff --git a/components/admin/AdminZero.js b/components/admin/AdminZero.js new file mode 100644 index 0000000..6521a4d --- /dev/null +++ b/components/admin/AdminZero.js @@ -0,0 +1,396 @@ +'use client'; +import { useState, useEffect, useCallback } from 'react'; +import { + Loader2, RefreshCw, Plus, Check, X, Edit3, Save, Zap, Send, + Coffee, Bug, Wrench, MessageCircle, Sparkles, ChevronDown, Trash2, +} from 'lucide-react'; + +// ────────────────────────────────────────────────────────────── +// Метаданные для UI +// ────────────────────────────────────────────────────────────── +const STATUS_META = { + draft: { label: 'Черновик', color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30' }, + approved: { label: 'Одобрено', color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30' }, + sending: { label: 'Отправка…', color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/30' }, + published: { label: 'Опубликовано', color: 'text-emerald-400',bg: 'bg-emerald-500/10',border: 'border-emerald-500/30' }, + failed: { label: 'Ошибка', color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30' }, + skipped: { label: 'Пропущено', color: 'text-gray-500', bg: 'bg-surface2', border: 'border-border' }, +}; + +const BUCKET_ICON = { + bug_story: Bug, + tools: Wrench, + coffee_thoughts: Coffee, + ai_industry: Sparkles, +}; + +function bucketIcon(key) { return BUCKET_ICON[key] || MessageCircle; } + +function mskTime(iso) { + if (!iso) return '—'; + const d = new Date(iso); + return d.toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); +} + +function wordCount(s) { return s ? s.trim().split(/\s+/).length : 0; } + +// ────────────────────────────────────────────────────────────── +// Main component +// ────────────────────────────────────────────────────────────── +export default function AdminZero() { + const [notes, setNotes] = useState([]); + const [buckets, setBuckets] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState('all'); + const [msg, setMsg] = useState(''); + + // Генерация + const [genOpen, setGenOpen] = useState(false); + const [genForm, setGenForm] = useState({ channel_id: 1, force_bucket: '', allow_today_dup: false }); + const [generating, setGen] = useState(false); + + // Edit + const [editId, setEditId] = useState(null); + const [editText, setEditText] = useState(''); + const [saving, setSaving] = useState(false); + + // Regenerate + const [regenId, setRegenId] = useState(null); + const [regenBucket, setRegenBucket] = useState(''); + + // ────── загрузка ────── + const load = useCallback(async () => { + setLoading(true); + try { + const r = await fetch('/api/admin/zero/notes?limit=100').then(r => r.json()); + setNotes(r.items || []); + } catch (e) { + setMsg('Ошибка загрузки: ' + e.message); + } + setLoading(false); + }, []); + + useEffect(() => { + load(); + fetch('/api/admin/zero/buckets').then(r => r.json()).then(r => setBuckets(r.buckets || [])); + }, [load]); + + const flash = (text) => { + setMsg(text); + setTimeout(() => setMsg(''), 3000); + }; + + // ────── actions ────── + async function generate() { + setGen(true); + try { + const r = await fetch('/api/admin/zero/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channel_id: Number(genForm.channel_id), + force_bucket: genForm.force_bucket || undefined, + allow_today_dup: genForm.allow_today_dup, + }), + }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || 'Ошибка генерации'); + flash(`✓ Черновик #${data.note.id} создан · ведро ${data.note.theme_bucket}`); + setGenOpen(false); + await load(); + } catch (e) { + flash('✗ ' + e.message); + } + setGen(false); + } + + async function approve(id) { + const r = await fetch(`/api/admin/zero/notes/${id}/approve`, { method: 'POST' }); + if (r.ok) { flash(`✓ Заметка #${id} одобрена`); load(); } + else { const d = await r.json(); flash('✗ ' + (d.error || 'fail')); } + } + + async function skip(id) { + if (!confirm('Пропустить эту заметку? Она не будет опубликована.')) return; + const r = await fetch(`/api/admin/zero/notes/${id}/skip`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: 'skipped by admin via UI' }), + }); + if (r.ok) { flash(`Заметка #${id} пропущена`); load(); } + } + + async function regenerate(id) { + const r = await fetch(`/api/admin/zero/notes/${id}/regenerate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force_bucket: regenBucket || undefined }), + }); + const data = await r.json(); + if (r.ok) { flash(`✓ Регенерация #${id} → #${data.note?.id}`); setRegenId(null); setRegenBucket(''); load(); } + else flash('✗ ' + data.error); + } + + function startEdit(note) { + setEditId(note.id); + setEditText(note.content); + } + + async function saveEdit(id) { + setSaving(true); + try { + const r = await fetch(`/api/admin/zero/notes/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: editText }), + }); + if (r.ok) { flash('✓ Сохранено'); setEditId(null); load(); } + else { const d = await r.json(); flash('✗ ' + d.error); } + } catch (e) { flash('✗ ' + e.message); } + setSaving(false); + } + + // ────── filter + counts ────── + const counts = notes.reduce((acc, n) => { acc[n.status] = (acc[n.status] || 0) + 1; return acc; }, {}); + const filtered = filter === 'all' ? notes : notes.filter(n => n.status === filter); + + return ( +
+ {/* HEADER */} +
+
+

Заметки от Зеро

+

Короткие посты в @zeropostru от AI-персонажа · 13:00 МСК ежедневно

+
+
+ {msg && {msg}} + + +
+
+ + {/* GENERATE FORM */} + {genOpen && ( +
+

+ Новый черновик +

+
+
+ + setGenForm(f => ({ ...f, channel_id: e.target.value }))} + className="input text-sm py-1.5" /> +
+
+ + +
+
+ +
+
+
+ + +
+
+ )} + + {/* FILTER TABS */} +
+ setFilter('all')} label={`Все · ${notes.length}`} /> + {Object.entries(STATUS_META).map(([key, meta]) => { + const cnt = counts[key] || 0; + if (cnt === 0 && key !== 'draft' && key !== 'approved') return null; + return setFilter(key)} + label={`${meta.label} · ${cnt}`} colorClass={meta.color} />; + })} +
+ + {/* LIST */} + {loading && notes.length === 0 && ( +
+ )} + {!loading && filtered.length === 0 && ( +
+ Заметок в этом разделе нет. Жми «Сгенерировать» чтобы создать черновик. +
+ )} + +
+ {filtered.map(note => ( + startEdit(note)} + onCancelEdit={() => setEditId(null)} + onSaveEdit={() => saveEdit(note.id)} + onApprove={() => approve(note.id)} + onSkip={() => skip(note.id)} + isRegen={regenId === note.id} + regenBucket={regenBucket} + setRegenBucket={setRegenBucket} + onRegenStart={() => { setRegenId(note.id); setRegenBucket(''); }} + onRegenCancel={() => setRegenId(null)} + onRegenConfirm={() => regenerate(note.id)} /> + ))} +
+
+ ); +} + +// ────────────────────────────────────────────────────────────── +// Sub-components +// ────────────────────────────────────────────────────────────── +function FilterTab({ active, onClick, label, colorClass }) { + return ( + + ); +} + +function NoteCard({ + note, buckets, + isEditing, editText, setEditText, saving, onStartEdit, onCancelEdit, onSaveEdit, + onApprove, onSkip, + isRegen, regenBucket, setRegenBucket, onRegenStart, onRegenCancel, onRegenConfirm, +}) { + const meta = STATUS_META[note.status] || STATUS_META.draft; + const Icon = bucketIcon(note.theme_bucket); + const bucketLabel = buckets.find(b => b.key === note.theme_bucket)?.label || note.theme_bucket; + const canApprove = note.status === 'draft'; + const canEdit = ['draft', 'approved'].includes(note.status); + const canRegen = ['draft', 'failed'].includes(note.status); + const canSkip = ['draft', 'approved'].includes(note.status); + + return ( +
+ {/* HEADER */} +
+ +
+
+ #{note.id} + {meta.label} + · {bucketLabel} + {note.pose && · поза: {note.pose}} + · {note.status === 'published' + ? `опубликовано ${mskTime(note.published_at)}` + : `на ${mskTime(note.scheduled_at)} МСК`} +
+ {note.theme &&
тема: {note.theme}
} +
+
+ + {/* CONTENT or EDIT */} + {isEditing ? ( +
+