new web ract

This commit is contained in:
jze9
2026-05-15 03:31:28 +05:00
parent de78624495
commit 2335497226
58 changed files with 7397 additions and 406 deletions

View File

@@ -42,15 +42,18 @@ class DashboardView:
)
async def load(self):
import asyncio
token = self.page.session.get("token")
data = await api.admin_list_articles(token, limit=100)
if not data:
return
published_data, draft_data, recent_data = await asyncio.gather(
api.admin_list_articles(token, limit=1, status="published"),
api.admin_list_articles(token, limit=1, status="draft"),
api.admin_list_articles(token, limit=10),
)
items = data.get("items", [])
total = data.get("total", 0)
published = sum(1 for a in items if a.get("status") == "published")
drafts = total - published
published = (published_data or {}).get("total", 0)
drafts = (draft_data or {}).get("total", 0)
total = published + drafts
items = (recent_data or {}).get("items", [])
self._stats_row.controls = [
d.stat_card("Всего статей", str(total), ft.icons.ARTICLE, d.PRIMARY),
@@ -58,7 +61,7 @@ class DashboardView:
d.stat_card("Черновики", str(drafts), ft.icons.EDIT_NOTE, d.WARNING),
]
self._recent.controls = [_recent_item(a, self.page) for a in items[:10]]
self._recent.controls = [_recent_item(a, self.page) for a in items]
self.page.update()
@@ -118,6 +121,7 @@ def _sidebar(page: ft.Page, active: str = "") -> ft.Container:
(ft.icons.ARTICLE, "Статьи", "/admin/articles"),
(ft.icons.CATEGORY, "Категории", "/admin/categories"),
(ft.icons.PERM_MEDIA, "Медиа", "/admin/media"),
(ft.icons.RSS_FEED, "ВКонтакте", "/admin/vk"),
]
def nav_item(icon, label, route):

View File

@@ -1,9 +1,41 @@
import re
import flet as ft
import designer as d
import api_client as api
import theme_manager
def _html_to_markdown(html: str) -> str:
"""Convert TipTap HTML output to Markdown for ft.Markdown rendering."""
t = html or ""
# Headings
t = re.sub(r"<h([1-6])[^>]*>(.*?)</h\1>",
lambda m: "#" * int(m[1]) + " " + m[2] + "\n\n",
t, flags=re.DOTALL | re.IGNORECASE)
# Bold / italic
t = re.sub(r"<(strong|b)[^>]*>(.*?)</\1>", r"**\2**", t, flags=re.DOTALL | re.IGNORECASE)
t = re.sub(r"<(em|i)[^>]*>(.*?)</\1>", r"*\2*", t, flags=re.DOTALL | re.IGNORECASE)
# Links
t = re.sub(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)</a>',
r"[\2](\1)", t, flags=re.DOTALL | re.IGNORECASE)
# Images
t = re.sub(r'<img[^>]+src=["\']([^"\']+)["\'][^>]*>', r"![](\1)", t, flags=re.IGNORECASE)
# Lists
t = re.sub(r"<li[^>]*>(.*?)</li>", r"- \1\n", t, flags=re.DOTALL | re.IGNORECASE)
t = re.sub(r"</?[uo]l[^>]*>", "\n", t, flags=re.IGNORECASE)
# Paragraphs / line breaks
t = re.sub(r"<br\s*/?>", "\n", t, flags=re.IGNORECASE)
t = re.sub(r"<p[^>]*>(.*?)</p>", r"\1\n\n", t, flags=re.DOTALL | re.IGNORECASE)
# Strip remaining tags
t = re.sub(r"<[^>]+>", "", t)
# HTML entities
t = (t.replace("&nbsp;", " ").replace("&amp;", "&")
.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", '"'))
# Collapse excessive blank lines
t = re.sub(r"\n{3,}", "\n\n", t)
return t.strip()
class NewsDetailView:
def __init__(self, page: ft.Page, slug: str):
self.page = page
@@ -159,7 +191,7 @@ class NewsDetailView:
controls.append(
ft.Markdown(
value=a.get("content", ""),
value=_html_to_markdown(a.get("content", "")),
selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
on_tap_link=lambda e: self.page.launch_url(e.data),
@@ -167,6 +199,26 @@ class NewsDetailView:
)
)
if a.get("source_url"):
source_url = a["source_url"]
controls.append(
ft.Container(
content=ft.Row([
ft.Icon(ft.icons.OPEN_IN_NEW, size=15, color=d.PRIMARY),
ft.Text("Источник:", size=13, color=d.TEXT_SECONDARY),
ft.TextButton(
source_url,
style=ft.ButtonStyle(color=d.PRIMARY),
on_click=lambda e, url=source_url: self.page.launch_url(url),
),
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER, wrap=True),
bgcolor=ft.colors.with_opacity(0.04, d.PRIMARY),
border_radius=8,
padding=12,
border=ft.border.all(1, ft.colors.with_opacity(0.15, d.PRIMARY)),
)
)
controls.append(ft.Divider(height=32, color="transparent"))
controls.append(
ft.TextButton(

View File

@@ -1,8 +1,34 @@
from datetime import datetime, timedelta
from urllib.parse import urlparse
import flet as ft
import designer as d
import api_client as api
_DATE_PRESETS = [
(None, "Всё время"),
("week", "Неделя"),
("month", "Месяц"),
("3months", "3 месяца"),
("year", "Год"),
]
def _preset_to_date_from(preset: str | None) -> str | None:
if preset is None:
return None
deltas = {
"week": timedelta(weeks=1),
"month": timedelta(days=30),
"3months": timedelta(days=90),
"year": timedelta(days=365),
}
if preset in deltas:
return (datetime.now() - deltas[preset]).isoformat()
return None
class NewsFeedView:
def __init__(self, page: ft.Page):
self.page = page
@@ -12,82 +38,103 @@ class NewsFeedView:
self._has_more = True
self._loading = False
self._category: str | None = None
self._tag: str | None = None
self._date_preset: str | None = None
self._categories: list = []
self._tags: list = []
self._list_col = ft.Column(spacing=16)
self._grid = ft.GridView(
max_extent=290,
child_aspect_ratio=0.72,
spacing=12,
run_spacing=12,
expand=True,
padding=0,
)
self._loader = ft.Container(
content=d.loader(36),
alignment=ft.alignment.center,
padding=20,
visible=True,
)
self._load_more_btn = ft.ElevatedButton(
"Загрузить ещё",
icon=ft.icons.EXPAND_MORE,
on_click=lambda e: self.page.run_task(self._load_more),
visible=False,
style=ft.ButtonStyle(
bgcolor=d.SURFACE,
color=d.PRIMARY,
shape=ft.RoundedRectangleBorder(radius=8),
self._load_more_btn = ft.Container(
content=ft.TextButton(
"Показать ещё",
icon=ft.icons.EXPAND_MORE,
on_click=lambda e: self.page.run_task(self._load_more),
style=ft.ButtonStyle(color=d.PRIMARY),
),
alignment=ft.alignment.center,
padding=ft.padding.only(bottom=24),
visible=False,
)
self._cat_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=8)
self._tag_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=6)
self._date_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=6)
def build_page_view(self) -> ft.View:
header = ft.Container(
content=ft.Row(
[
ft.Row([
ft.Icon(ft.icons.NEWSPAPER, color=ft.colors.WHITE, size=28),
ft.Text("Новости", size=20, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE),
], spacing=8),
ft.Row([
ft.TextButton(
"Войти",
icon=ft.icons.ADMIN_PANEL_SETTINGS_OUTLINED,
style=ft.ButtonStyle(color=ft.colors.WHITE70),
on_click=lambda e: self.page.go("/admin/login"),
)
]),
ft.Container(
content=ft.Icon(ft.icons.NEWSPAPER_ROUNDED, color=ft.colors.WHITE, size=22),
bgcolor=ft.colors.with_opacity(0.2, ft.colors.WHITE),
border_radius=8,
padding=6,
),
ft.Text(
"Новости колледжей",
size=18,
weight=ft.FontWeight.W_700,
color=ft.colors.WHITE,
font_family=d.FONT_UI,
),
], spacing=10),
ft.IconButton(
icon=ft.icons.MANAGE_ACCOUNTS_OUTLINED,
icon_color=ft.colors.WHITE70,
tooltip="Войти в панель управления",
on_click=lambda e: self.page.go("/admin/login"),
icon_size=22,
),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
bgcolor=d.ADMIN_SIDEBAR,
padding=ft.padding.symmetric(horizontal=24, vertical=14),
bgcolor=d.PRIMARY,
padding=ft.padding.symmetric(horizontal=20, vertical=12),
shadow=ft.BoxShadow(
blur_radius=8,
offset=ft.Offset(0, 2),
color=ft.colors.with_opacity(0.18, ft.colors.BLACK),
),
)
cat_section = ft.Container(
filters = ft.Container(
content=ft.Column([
ft.Container(content=self._cat_row, padding=ft.padding.symmetric(horizontal=24, vertical=8)),
]),
ft.Container(
content=self._cat_row,
padding=ft.padding.only(left=16, right=16, top=10, bottom=4),
),
ft.Container(
content=self._tag_row,
padding=ft.padding.symmetric(horizontal=16, vertical=4),
),
ft.Container(
content=self._date_row,
padding=ft.padding.only(left=16, right=16, top=4, bottom=10),
),
], spacing=0),
bgcolor=d.SURFACE,
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
)
main_content = ft.Container(
feed = ft.Container(
content=ft.Column(
[
ft.Container(
content=ft.Column(
[
self._list_col,
self._loader,
ft.Container(
content=self._load_more_btn,
alignment=ft.alignment.center,
padding=16,
),
],
spacing=16,
),
width=680,
),
],
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
[self._grid, self._loader, self._load_more_btn],
spacing=0,
),
padding=ft.padding.symmetric(horizontal=16, vertical=24),
alignment=ft.alignment.top_center,
padding=ft.padding.symmetric(horizontal=12, vertical=16),
expand=True,
)
@@ -98,10 +145,9 @@ class NewsFeedView:
ft.Column(
[
header,
cat_section,
filters,
ft.Container(
content=main_content,
alignment=ft.alignment.top_center,
content=feed,
expand=True,
),
],
@@ -113,27 +159,39 @@ class NewsFeedView:
)
async def load(self):
self._categories = await api.get_categories()
self._render_categories()
self._categories, self._tags = await _gather(
api.get_categories(),
api.get_tags(30),
)
# Все фильтры — один update вместо трёх
self._render_categories(update=False)
self._render_tags(update=False)
self._render_dates(update=False)
self.page.update()
await self._load_articles()
async def _load_more(self):
if self._loading or not self._has_more:
return
self._loading = True
self._load_more_btn.disabled = True
self._load_more_btn.visible = False
self.page.update()
self._offset += self._limit
await self._load_articles()
self._loading = False
self._load_more_btn.disabled = False
self.page.update()
async def _load_articles(self):
self._loader.visible = True
self.page.update()
data = await api.get_news(offset=self._offset, limit=self._limit, category=self._category)
date_from = _preset_to_date_from(self._date_preset)
data = await api.get_news(
offset=self._offset,
limit=self._limit,
category=self._category,
tag=self._tag,
date_from=date_from,
)
self._loader.visible = False
if not data:
@@ -145,120 +203,250 @@ class NewsFeedView:
self._has_more = data.get("has_more", False)
for a in items:
self._list_col.controls.append(self._build_card(a))
self._grid.controls.append(self._build_card(a))
self._load_more_btn.visible = self._has_more
self.page.update()
def _render_categories(self):
def _reset_feed(self):
self._articles = []
self._offset = 0
self._has_more = True
self._grid.controls.clear()
# ── Category filter ────────────────────────────────────────────────────────
def _render_categories(self, update: bool = True):
self._cat_row.controls = [
self._cat_chip(None, "Все")
] + [
self._cat_chip(c["slug"], c["name"]) for c in self._categories
]
self.page.update()
if update:
self.page.update()
def _cat_chip(self, slug: str | None, label: str) -> ft.Chip:
is_active = self._category == slug
return ft.Chip(
label=ft.Text(label, size=13),
label=ft.Text(label, size=12, font_family=d.FONT_UI),
bgcolor=d.PRIMARY if is_active else d.SURFACE,
label_style=ft.TextStyle(color=ft.colors.WHITE if is_active else d.TEXT_PRIMARY),
label_style=ft.TextStyle(
color=ft.colors.WHITE if is_active else d.TEXT_PRIMARY,
weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL,
),
on_click=lambda e, s=slug: self.page.run_task(self._filter_category, s),
padding=ft.padding.symmetric(horizontal=12, vertical=6),
padding=ft.padding.symmetric(horizontal=10, vertical=2),
border_side=ft.BorderSide(1, d.PRIMARY if is_active else d.DIVIDER),
elevation=0,
)
async def _filter_category(self, slug: str | None):
self._category = slug
self._articles = []
self._offset = 0
self._has_more = True
self._list_col.controls.clear()
self._reset_feed()
self._render_categories()
await self._load_articles()
# ── Tag filter ─────────────────────────────────────────────────────────────
def _render_tags(self, update: bool = True):
if not self._tags:
self._tag_row.controls = []
else:
self._tag_row.controls = [
ft.Container(
content=ft.Text("Теги:", size=11, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
padding=ft.padding.only(right=4),
)
] + [self._tag_chip(t["slug"], t["name"]) for t in self._tags]
if update:
self.page.update()
def _tag_chip(self, slug: str, label: str) -> ft.Container:
is_active = self._tag == slug
return ft.Container(
content=ft.Text(
f"#{label}",
size=11,
color=ft.colors.WHITE if is_active else d.PRIMARY,
font_family=d.FONT_UI,
weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL,
),
bgcolor=d.PRIMARY if is_active else ft.colors.with_opacity(0.08, d.PRIMARY),
border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=3),
on_click=lambda e, s=slug: self.page.run_task(self._filter_tag, s),
ink=True,
)
async def _filter_tag(self, slug: str):
self._tag = None if self._tag == slug else slug
self._reset_feed()
self._render_tags()
await self._load_articles()
# ── Date filter ────────────────────────────────────────────────────────────
def _render_dates(self, update: bool = True):
self._date_row.controls = [
ft.Container(
content=ft.Text("Период:", size=11, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
padding=ft.padding.only(right=4),
)
] + [self._date_btn(preset, label) for preset, label in _DATE_PRESETS]
if update:
self.page.update()
def _date_btn(self, preset: str | None, label: str) -> ft.Container:
is_active = self._date_preset == preset
return ft.Container(
content=ft.Text(
label,
size=11,
color=ft.colors.WHITE if is_active else d.TEXT_SECONDARY,
font_family=d.FONT_UI,
weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL,
),
bgcolor=d.PRIMARY if is_active else ft.colors.with_opacity(0.06, ft.colors.BLACK),
border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=3),
border=ft.border.all(1, d.PRIMARY if is_active else d.DIVIDER),
on_click=lambda e, p=preset: self.page.run_task(self._filter_date, p),
ink=True,
)
async def _filter_date(self, preset: str | None):
self._date_preset = preset
self._reset_feed()
self._render_dates()
await self._load_articles()
# ── Card ───────────────────────────────────────────────────────────────────
def _build_card(self, article: dict) -> ft.Container:
slug = article["slug"]
source_url = article.get("source_url", "") or ""
src_domain = ""
if source_url:
try:
src_domain = urlparse(source_url).netloc.replace("www.", "")
except Exception:
src_domain = "vk.com"
pub_date = ""
if article.get("published_at"):
pub_date = article["published_at"][:10]
slug = article["slug"]
try:
dt = datetime.fromisoformat(article["published_at"].replace("Z", "+00:00"))
pub_date = dt.strftime("%d.%m.%Y")
except Exception:
pub_date = article["published_at"][:10]
cat = article.get("category")
meta_row_controls = []
if cat:
meta_row_controls.append(ft.Container(
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE, weight=ft.FontWeight.W_600),
bgcolor=d.PRIMARY,
border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=3),
))
if pub_date:
meta_row_controls.append(ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY))
cover_section = ft.Container(
content=ft.Image(
src=article["cover_url"],
width=float("inf"),
height=200,
fit=ft.ImageFit.COVER,
),
height=200,
clip_behavior=ft.ClipBehavior.HARD_EDGE,
border_radius=ft.BorderRadius(12, 12, 0, 0),
) if article.get("cover_url") else ft.Container(visible=False)
# Cover
if article.get("cover_url"):
cover = ft.Container(
content=ft.Image(
src=article["cover_url"],
fit=ft.ImageFit.COVER,
width=float("inf"),
height=170,
error_content=ft.Container(
bgcolor=ft.colors.with_opacity(0.06, d.PRIMARY),
content=ft.Icon(ft.icons.IMAGE_NOT_SUPPORTED_OUTLINED, size=28, color=d.DIVIDER),
alignment=ft.alignment.center,
height=170,
),
),
height=170,
clip_behavior=ft.ClipBehavior.HARD_EDGE,
)
else:
cover = ft.Container(
height=120,
bgcolor=ft.colors.with_opacity(0.06, d.PRIMARY),
content=ft.Icon(ft.icons.NEWSPAPER_ROUNDED, size=32, color=ft.colors.with_opacity(0.25, d.PRIMARY)),
alignment=ft.alignment.center,
)
body = ft.Container(
content=ft.Column(
[
ft.Row(meta_row_controls, spacing=8) if meta_row_controls else ft.Container(visible=False),
ft.Text(
article["title"],
size=18,
weight=ft.FontWeight.BOLD,
color=d.TEXT_PRIMARY,
max_lines=3,
overflow=ft.TextOverflow.ELLIPSIS,
),
ft.Text(
article.get("excerpt", ""),
size=13,
color=d.TEXT_SECONDARY,
max_lines=3,
overflow=ft.TextOverflow.ELLIPSIS,
) if article.get("excerpt") else ft.Container(visible=False),
ft.Row(
[
ft.Row([
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
ft.Text(str(article.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
], spacing=4),
ft.TextButton(
"Читать →",
style=ft.ButtonStyle(color=d.PRIMARY),
on_click=lambda e, s=slug: self.page.launch_url(
f"/article/{s}", web_window_name="_self"
),
),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
],
spacing=8,
# Category + date row
meta_row = ft.Row([
ft.Text(
cat["name"] if cat else "",
size=10,
color=d.PRIMARY,
weight=ft.FontWeight.W_600,
font_family=d.FONT_UI,
max_lines=1,
overflow=ft.TextOverflow.ELLIPSIS,
expand=True,
),
padding=ft.padding.symmetric(horizontal=20, vertical=16),
ft.Text(pub_date, size=10, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
], spacing=4, vertical_alignment=ft.CrossAxisAlignment.CENTER)
title = ft.Text(
article["title"],
size=13,
weight=ft.FontWeight.W_700,
color=d.TEXT_PRIMARY,
max_lines=2,
overflow=ft.TextOverflow.ELLIPSIS,
font_family=d.FONT_UI,
)
tags = article.get("tags", [])
tag_row = ft.Row(
[
ft.Container(
content=ft.Text(f"#{t['name']}", size=10, color=d.PRIMARY, font_family=d.FONT_UI),
bgcolor=ft.colors.with_opacity(0.08, d.PRIMARY),
border_radius=4,
padding=ft.padding.symmetric(horizontal=6, vertical=2),
)
for t in tags[:3]
],
spacing=4,
wrap=True,
) if tags else ft.Container(height=2)
# Footer: source domain + "Читать →"
footer = ft.Row([
ft.Row([
ft.Icon(ft.icons.LINK, size=11, color=d.TEXT_SECONDARY),
ft.Text(src_domain, size=10, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
], spacing=2) if src_domain else ft.Container(),
ft.Container(expand=True),
ft.Text("Читать →", size=11, color=d.PRIMARY, weight=ft.FontWeight.W_600, font_family=d.FONT_UI),
], vertical_alignment=ft.CrossAxisAlignment.CENTER)
content_col = ft.Column(
[meta_row, title, tag_row, ft.Container(expand=True), footer],
spacing=6,
expand=True,
)
return ft.Container(
content=ft.Column([cover_section, body], spacing=0),
bgcolor=d.SURFACE,
content=ft.Column([
cover,
ft.Container(
content=content_col,
padding=ft.padding.symmetric(horizontal=10, vertical=8),
expand=True,
),
], spacing=0, expand=True),
bgcolor=d.CARD_BG,
border_radius=12,
border=ft.border.all(1, d.DIVIDER),
shadow=ft.BoxShadow(blur_radius=8, color=ft.colors.with_opacity(0.07, ft.colors.BLACK)),
clip_behavior=ft.ClipBehavior.HARD_EDGE,
on_click=lambda e, s=slug: self.page.launch_url(
f"/article/{s}", web_window_name="_self"
shadow=ft.BoxShadow(
blur_radius=6,
offset=ft.Offset(0, 1),
color=ft.colors.with_opacity(0.08, ft.colors.BLACK),
),
on_click=lambda e, s=slug: self.page.go(f"/news/{s}"),
ink=True,
clip_behavior=ft.ClipBehavior.HARD_EDGE,
)
async def _gather(*coros):
import asyncio
return await asyncio.gather(*coros)

359
web/views/vk_sources.py Normal file
View File

@@ -0,0 +1,359 @@
import flet as ft
import designer as d
import api_client as api
from views.dashboard import _admin_appbar, _sidebar
class VkSourcesView:
def __init__(self, page: ft.Page):
self.page = page
self._sources: list = []
self._api_ok = False
self._editing_id: str | None = None # ID источника в режиме редактирования
# Статус бейдж
self._status_row = ft.Row(spacing=6)
# Уведомление (добавляется в page.overlay при load)
self._snack = ft.SnackBar(content=ft.Text(""), open=False)
# Таблица источников
self._table = ft.DataTable(
columns=[
ft.DataColumn(ft.Text("Группа", weight=ft.FontWeight.W_600, size=13)),
ft.DataColumn(ft.Text("ID", weight=ft.FontWeight.W_600, size=13)),
ft.DataColumn(ft.Text("Последний импорт", weight=ft.FontWeight.W_600, size=13)),
ft.DataColumn(ft.Text("Статус", weight=ft.FontWeight.W_600, size=13)),
ft.DataColumn(ft.Text("Действия", weight=ft.FontWeight.W_600, size=13)),
],
rows=[],
border=ft.border.all(1, d.DIVIDER),
border_radius=8,
vertical_lines=ft.BorderSide(1, d.DIVIDER),
heading_row_color=ft.colors.with_opacity(0.04, ft.colors.BLACK),
data_row_max_height=56,
column_spacing=16,
)
# Форма добавления / редактирования
self._form_gid = ft.TextField(
label="ID группы (числовой)",
hint_text="95503797",
width=220,
dense=True,
)
self._form_gname = ft.TextField(
label="Название группы",
hint_text="ЮУрГК",
width=240,
dense=True,
)
self._form_enabled = ft.Checkbox(label="Активна", value=True)
self._form_save_btn = ft.ElevatedButton(
"Добавить",
icon=ft.icons.ADD,
bgcolor=d.PRIMARY,
color=ft.colors.WHITE,
on_click=lambda e: self.page.run_task(self._save_source),
)
self._form_cancel_btn = ft.TextButton(
"Отмена",
visible=False,
on_click=lambda e: self._cancel_edit(),
)
# ── Build ──────────────────────────────────────────────────────────────────
def build_page_view(self) -> ft.View:
token_info = ft.Container(
content=ft.Column([
self._status_row,
ft.Text(
"Без токена VK API — HTML-парсинг не работает (ВК перешёл на JS-рендеринг).\n"
"Получи бесплатный сервисный ключ: vk.com/dev → Мои приложения → Создать → "
"Standalone → вкладка «Настройки» → «Сервисный ключ доступа».\n"
"Затем добавь в .env: VK_ACCESS_TOKEN=<ключ>",
size=12,
color=d.TEXT_SECONDARY,
selectable=True,
),
], spacing=6),
bgcolor=ft.colors.with_opacity(0.04, ft.colors.BLACK),
border_radius=8,
padding=14,
border=ft.border.all(1, d.DIVIDER),
)
form = ft.Container(
content=ft.Column([
d.headline("Добавить / редактировать группу", size=15),
ft.Row(
[self._form_gid, self._form_gname, self._form_enabled],
spacing=10, wrap=True,
),
ft.Row(
[self._form_save_btn, self._form_cancel_btn],
spacing=10,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
], spacing=10),
bgcolor=d.SURFACE,
border_radius=10,
padding=16,
border=ft.border.all(1, d.DIVIDER),
)
import_btns = ft.Row([
ft.ElevatedButton(
"Импорт новых постов",
icon=ft.icons.SYNC,
bgcolor=d.PRIMARY,
color=ft.colors.WHITE,
tooltip="Загрузить новые посты со всех активных групп",
on_click=lambda e: self.page.run_task(self._import_recent),
),
ft.ElevatedButton(
"Импорт за год (все)",
icon=ft.icons.HISTORY,
bgcolor=d.WARNING,
color=ft.colors.WHITE,
tooltip="Пагинированный импорт за 365 дней. Запускается в фоне.",
on_click=lambda e: self.page.run_task(self._import_history_all),
),
], spacing=10, wrap=True)
content = ft.Column([
d.headline("ВКонтакте — источники", size=22),
token_info,
import_btns,
ft.Divider(height=4, color="transparent"),
form,
ft.Divider(height=4, color="transparent"),
d.headline("Группы в базе", size=15),
ft.Container(
content=self._table,
border_radius=8,
clip_behavior=ft.ClipBehavior.HARD_EDGE,
),
], scroll=ft.ScrollMode.AUTO, spacing=12, expand=True)
return ft.View(
route="/admin/vk",
bgcolor=d.BACKGROUND,
appbar=_admin_appbar(self.page, "ВКонтакте"),
controls=[
ft.Row([
_sidebar(self.page, active="/admin/vk"),
ft.Container(content=content, expand=True, padding=24),
], expand=True, spacing=0),
],
)
# ── Load ───────────────────────────────────────────────────────────────────
async def load(self):
self.page.overlay.append(self._snack)
token = self.page.session.get("token")
status = await api.admin_vk_status(token)
self._api_ok = status.get("configured", False)
self._status_row.controls = [
ft.Icon(
ft.icons.CHECK_CIRCLE if self._api_ok else ft.icons.WARNING_AMBER_ROUNDED,
color=d.SUCCESS if self._api_ok else d.WARNING,
size=18,
),
ft.Text(
"API-токен подключён — VK API активен" if self._api_ok
else "Токен не задан — парсинг недоступен",
size=13,
weight=ft.FontWeight.W_600,
color=d.SUCCESS if self._api_ok else d.WARNING,
),
]
self._sources = await api.admin_vk_list_sources(token)
self._render_table()
self.page.update()
# ── Table ──────────────────────────────────────────────────────────────────
def _render_table(self):
if not self._sources:
self._table.rows = [
ft.DataRow(cells=[
ft.DataCell(ft.Text("Групп пока нет", color=d.TEXT_SECONDARY, italic=True)),
ft.DataCell(ft.Text("")),
ft.DataCell(ft.Text("")),
ft.DataCell(ft.Text("")),
ft.DataCell(ft.Text("")),
])
]
return
self._table.rows = [self._build_row(s) for s in self._sources]
def _build_row(self, src: dict) -> ft.DataRow:
last_run = src.get("last_run")
if last_run:
try:
from datetime import datetime
dt = datetime.fromisoformat(last_run.replace("Z", "+00:00"))
last_run_str = dt.strftime("%d.%m.%Y %H:%M")
except Exception:
last_run_str = last_run[:16]
else:
last_run_str = "никогда"
enabled = src.get("enabled", True)
return ft.DataRow(
cells=[
ft.DataCell(ft.Text(src["group_name"], size=13, weight=ft.FontWeight.W_500)),
ft.DataCell(ft.Text(src["group_id"], size=13, color=d.TEXT_SECONDARY, selectable=True)),
ft.DataCell(ft.Text(last_run_str, size=12, color=d.TEXT_SECONDARY)),
ft.DataCell(
ft.Container(
content=ft.Text(
"Активна" if enabled else "Отключена",
size=11,
color=ft.colors.WHITE,
),
bgcolor=d.SUCCESS if enabled else d.TEXT_SECONDARY,
border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=3),
)
),
ft.DataCell(
ft.Row([
ft.IconButton(
ft.icons.EDIT_OUTLINED,
icon_size=18,
icon_color=d.PRIMARY,
tooltip="Редактировать",
on_click=lambda e, s=src: self._start_edit(s),
),
ft.IconButton(
ft.icons.TOGGLE_ON if enabled else ft.icons.TOGGLE_OFF,
icon_size=22,
icon_color=d.SUCCESS if enabled else d.TEXT_SECONDARY,
tooltip="Вкл / Выкл",
on_click=lambda e, s=src: self.page.run_task(self._toggle, s),
),
ft.IconButton(
ft.icons.HISTORY,
icon_size=18,
icon_color=d.WARNING,
tooltip="Импорт за год для этой группы",
on_click=lambda e, s=src: self.page.run_task(
self._import_history_one, s["id"], s["group_name"]
),
),
ft.IconButton(
ft.icons.DELETE_OUTLINE,
icon_size=18,
icon_color=d.ERROR,
tooltip="Удалить группу",
on_click=lambda e, s=src: self.page.run_task(self._delete, s["id"]),
),
], spacing=0),
),
]
)
# ── Edit form helpers ──────────────────────────────────────────────────────
def _start_edit(self, src: dict):
self._editing_id = src["id"]
self._form_gid.value = src["group_id"]
self._form_gname.value = src["group_name"]
self._form_enabled.value = src.get("enabled", True)
self._form_save_btn.text = "Сохранить"
self._form_save_btn.icon = ft.icons.SAVE
self._form_cancel_btn.visible = True
self.page.update()
def _cancel_edit(self):
self._editing_id = None
self._form_gid.value = ""
self._form_gname.value = ""
self._form_enabled.value = True
self._form_save_btn.text = "Добавить"
self._form_save_btn.icon = ft.icons.ADD
self._form_cancel_btn.visible = False
self.page.update()
# ── CRUD actions ───────────────────────────────────────────────────────────
async def _save_source(self):
gid = self._form_gid.value.strip()
gname = self._form_gname.value.strip()
if not gid or not gname:
self._notify("Заполни ID и название", d.ERROR)
return
token = self.page.session.get("token")
if self._editing_id:
result = await api.admin_vk_update_source(
token, self._editing_id, gid, gname, self._form_enabled.value
)
self._notify("Группа обновлена" if result else "Ошибка обновления",
d.SUCCESS if result else d.ERROR)
else:
result = await api.admin_vk_add_source(token, gid, gname, self._form_enabled.value)
if result and "error" not in result:
self._notify(f"Группа «{gname}» добавлена", d.SUCCESS)
else:
err = result.get("error") if result else "Ошибка запроса"
self._notify(str(err), d.ERROR)
return
self._cancel_edit()
self._sources = await api.admin_vk_list_sources(token)
self._render_table()
self.page.update()
async def _toggle(self, src: dict):
token = self.page.session.get("token")
result = await api.admin_vk_update_source(
token, src["id"], src["group_id"], src["group_name"], not src["enabled"]
)
if result:
self._sources = await api.admin_vk_list_sources(token)
self._render_table()
self.page.update()
async def _delete(self, source_id: str):
token = self.page.session.get("token")
ok = await api.admin_vk_delete_source(token, source_id)
self._notify("Группа удалена" if ok else "Ошибка удаления",
d.TEXT_SECONDARY if ok else d.ERROR)
self._sources = await api.admin_vk_list_sources(token)
self._render_table()
self.page.update()
async def _import_recent(self):
self._notify("Запускаю импорт новых постов…", d.PRIMARY)
token = self.page.session.get("token")
result = await api.admin_vk_import(token)
msg = result.get("message", "Запущен") if result else "Ошибка"
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
async def _import_history_all(self):
self._notify("Запускаю исторический импорт в фоне…", d.WARNING)
token = self.page.session.get("token")
result = await api.admin_vk_import_history(token)
msg = result.get("message", "Запущен") if result else "Ошибка"
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
async def _import_history_one(self, source_id: str, name: str):
self._notify(f"Запускаю год для «{name}»…", d.WARNING)
token = self.page.session.get("token")
result = await api.admin_vk_import_history(token, source_id=source_id)
msg = result.get("message", "Запущен") if result else "Ошибка"
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
def _notify(self, text: str, color: str = d.SUCCESS):
self._snack.content = ft.Text(text, color=ft.colors.WHITE)
self._snack.bgcolor = color
self._snack.open = True
self.page.update()