diff --git a/app.py b/app.py index ec1b4a3..60d99d5 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- """云服务器价格对比 - Flask 应用""" import io +import hashlib import json import os import re +import csv from time import monotonic -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from email.utils import format_datetime from urllib.parse import urlencode from xml.sax.saxutils import escape as xml_escape @@ -23,7 +25,7 @@ from flask import ( url_for, ) from werkzeug.middleware.proxy_fix import ProxyFix -from sqlalchemy import text, func, or_ +from sqlalchemy import text, func, or_, and_, case from sqlalchemy.orm import joinedload from markupsafe import Markup, escape try: @@ -57,6 +59,8 @@ from models import ( ForumNotification, ForumPostLike, ForumPostBookmark, + ForumTrackEvent, + ForumTrackDailySummary, ) # noqa: E402 @@ -152,6 +156,34 @@ def _ensure_forum_manage_columns(): pass +def _ensure_forum_track_columns(): + """为论坛埋点表补齐后续新增字段。""" + try: + engine = db.engine + dialect = engine.dialect.name + with engine.connect() as conn: + if dialect == "mysql": + alters = [ + "ALTER TABLE forum_track_events ADD COLUMN visitor_id VARCHAR(64) NULL", + "ALTER TABLE forum_track_events ADD COLUMN cta_variant VARCHAR(16) NULL", + "ALTER TABLE forum_track_events ADD COLUMN device_type VARCHAR(16) NULL", + ] + else: + alters = [ + "ALTER TABLE forum_track_events ADD COLUMN visitor_id TEXT", + "ALTER TABLE forum_track_events ADD COLUMN cta_variant TEXT", + "ALTER TABLE forum_track_events ADD COLUMN device_type TEXT", + ] + for sql in alters: + try: + conn.execute(text(sql)) + conn.commit() + except Exception: + conn.rollback() + except Exception: + pass + + DEFAULT_FORUM_CATEGORIES = [ "综合讨论", "VPS 评测", @@ -209,6 +241,7 @@ with app.app_context(): _ensure_mysql_columns() _ensure_forum_columns() _ensure_forum_manage_columns() + _ensure_forum_track_columns() _ensure_forum_categories_seed() _ensure_price_history_baseline() @@ -651,6 +684,78 @@ def _get_current_user(): return user +def _get_or_create_visitor_id(): + visitor_id = (session.get("visitor_id") or "").strip().lower() + if re.match(r"^[a-f0-9]{16,64}$", visitor_id): + return visitor_id + visitor_id = os.urandom(16).hex() + session["visitor_id"] = visitor_id + return visitor_id + + +def _stable_pick_variant(experiment_key, subject_key, variants): + safe_variants = [str(v).strip().lower() for v in (variants or []) if str(v).strip()] + if not safe_variants: + return "" + digest = hashlib.sha256("{}|{}".format(experiment_key, subject_key).encode("utf-8")).hexdigest() + idx = int(digest[:8], 16) % len(safe_variants) + return safe_variants[idx] + + +def _resolve_cta_variant(current_user=None, requested_variant=None): + allowed = ("control", "intent") + requested = (requested_variant or "").strip().lower() + if requested in allowed: + return requested + if current_user and getattr(current_user, "id", None): + subject_key = "u:{}".format(current_user.id) + else: + subject_key = "v:{}".format(_get_or_create_visitor_id()) + return _stable_pick_variant("forum_post_detail_cta_v1", subject_key, allowed) + + +def _normalize_cta_variant(value): + text_val = (value or "").strip().lower() + return text_val if text_val in {"control", "intent"} else "" + + +def _normalize_device_type(value): + text_val = (value or "").strip().lower() + return text_val if text_val in {"mobile", "desktop", "tablet"} else "" + + +def _guess_device_type_from_user_agent(raw_user_agent): + ua = (raw_user_agent or "").strip().lower() + if not ua: + return "" + if "ipad" in ua or "tablet" in ua or ("android" in ua and "mobile" not in ua): + return "tablet" + if any(x in ua for x in ("iphone", "ipod", "windows phone", "mobile")): + return "mobile" + return "desktop" + + +def _increment_track_daily_summary(event_name, cta_variant, event_dt=None): + if not event_name: + return + row_day = (event_dt or datetime.utcnow()).date() + row_variant = _normalize_cta_variant(cta_variant) or "unknown" + row = ( + ForumTrackDailySummary.query + .filter_by(event_day=row_day, cta_variant=row_variant, event_name=event_name) + .first() + ) + if row: + row.total = int(row.total or 0) + 1 + return + db.session.add(ForumTrackDailySummary( + event_day=row_day, + cta_variant=row_variant, + event_name=event_name, + total=1, + )) + + def _is_banned_user(user): return bool(user and bool(user.is_banned)) @@ -1186,6 +1291,131 @@ def _build_post_plan_recommendations(post, lang="zh", limit=5): return items, context_text +def _build_post_pricing_prefill(post, lang="zh"): + pricing_url = url_for("index", lang="en") if lang == "en" else url_for("index") + if not post or not getattr(post, "id", None): + return { + "url": pricing_url, + "hint_text": _pick_lang( + "可在价格页按厂商、地区、配置继续筛选。", + "Use provider/region/spec filters on the pricing page.", + lang, + ), + } + + source_title = (post.title or "").strip() + raw_text = "{}\n{}".format(source_title, post.content or "") + text_lower = raw_text.lower() + + provider_name = "" + provider_rows = Provider.query.order_by(Provider.id.asc()).limit(240).all() + for provider in provider_rows: + name = (provider.name or "").strip() + if name and name.lower() in text_lower: + provider_name = name + break + + region_name = "" + for region in COUNTRY_TAGS: + token = (region or "").strip() + if token and token in raw_text: + region_name = token + break + + memory_filter = 0 + mem_match = re.search(r"(?:内存|memory|ram)\s*[::]?\s*(\d+(?:\.\d+)?)\s*(?:g|gb|gib)", raw_text, flags=re.IGNORECASE) + if not mem_match: + mem_match = re.search(r"(\d+(?:\.\d+)?)\s*(?:g|gb|gib)\s*(?:内存|memory|ram)", raw_text, flags=re.IGNORECASE) + if mem_match: + try: + mem_gb = float(mem_match.group(1)) + except Exception: + mem_gb = 0.0 + if mem_gb >= 8: + memory_filter = 8 + elif mem_gb >= 4: + memory_filter = 4 + elif mem_gb >= 2: + memory_filter = 2 + elif mem_gb >= 1: + memory_filter = 1 + + price_filter = "0" + budget_match = re.search( + r"(?:预算|budget|price)\s*[::]?\s*(?:¥|¥|\$)?\s*(\d+(?:\.\d+)?)\s*(?:-|~|到|to)\s*(?:¥|¥|\$)?\s*(\d+(?:\.\d+)?)", + raw_text, + flags=re.IGNORECASE, + ) + if budget_match: + try: + budget_low = float(budget_match.group(1)) + budget_high = float(budget_match.group(2)) + if budget_high < budget_low: + budget_low, budget_high = budget_high, budget_low + if budget_high <= 50: + price_filter = "0-50" + elif budget_high <= 100: + price_filter = "50-100" + elif budget_high <= 300: + price_filter = "100-300" + elif budget_high <= 500: + price_filter = "300-500" + else: + price_filter = "500-99999" + except Exception: + price_filter = "0" + + source_title_short = source_title[:72] + params = { + "source_post": int(post.id), + "source_title": source_title_short, + } + if provider_name: + params["provider"] = provider_name + if region_name: + params["region"] = region_name + if memory_filter: + params["memory"] = str(memory_filter) + if price_filter != "0": + params["price"] = price_filter + if source_title_short: + params["search"] = source_title_short[:40] + if lang == "en": + params["lang"] = "en" + + hint_parts = [] + if provider_name: + hint_parts.append(_pick_lang("厂商 {}".format(provider_name), "provider {}".format(provider_name), lang)) + if region_name: + hint_parts.append(_pick_lang("地区 {}".format(region_name), "region {}".format(region_name), lang)) + if memory_filter: + hint_parts.append(_pick_lang("内存≥{}GB".format(memory_filter), "memory ≥{}GB".format(memory_filter), lang)) + if price_filter != "0": + hint_parts.append(_pick_lang("预算区间已预填", "budget range prefilled", lang)) + + if hint_parts: + hint_text = _pick_lang( + "已预填筛选:{}", + "Prefilled filters: {}", + lang, + ).format(" / ".join(hint_parts[:3])) + else: + hint_text = _pick_lang( + "已带入标题关键词,可在价格页继续微调筛选。", + "Title keywords were carried over. Fine-tune filters on the pricing page.", + lang, + ) + + return { + "url": url_for("index", **params), + "hint_text": hint_text, + "provider": provider_name, + "region": region_name, + "memory": memory_filter, + "price": price_filter, + } + + def _build_post_detail_url(post_id, lang="zh", comment_page=1): """构建帖子详情页 URL(用于评论分页链接)。""" page_num = 1 @@ -1277,6 +1507,108 @@ def _build_post_resource_links(post, lang="zh"): return deduped[:6] +def _build_post_requirement_draft(post, lang="zh", cta_variant=""): + if not post or not getattr(post, "id", None): + return {} + source_title = (post.title or _pick_lang("论坛主题", "Forum Topic", lang)).strip() + if len(source_title) > 86: + source_title = source_title[:86] + source_excerpt = _plain_excerpt(post.content or "", limit=180) + source_url = _build_post_detail_url(post.id, lang=lang) + + draft_title = _pick_lang( + "[需求补充] {}:预算/地区/用途".format(source_title), + "[Follow-up Need] {}: budget/region/workload".format(source_title), + lang, + )[:160] + if lang == "en": + draft_content = "\n".join([ + "## Context", + "- Source topic: {}".format(source_title), + "- Source URL: {}".format(source_url), + "- Planned launch window:", + "", + "## Constraints", + "- Budget range:", + "- Target region/routes:", + "- Workload type (web/api/db/proxy):", + "- Acceptable latency/loss:", + "", + "## Expected Spec", + "- CPU:", + "- Memory:", + "- Storage:", + "- Bandwidth/traffic:", + "", + "## Extra Notes", + source_excerpt or "Please continue from the source topic and add measurable constraints.", + ]) + intro_text = "Prefill a requirement template from this topic and publish directly." + action_text = "Prefill and Publish Need" + guest_text = "Login to Publish Need" + tips = [ + "Auto-carries source topic and context.", + "Fill budget/region/workload for better matching.", + "Keep quantitative constraints for faster replies.", + ] + else: + draft_content = "\n".join([ + "## 业务背景", + "- 参考帖子:{}".format(source_title), + "- 原帖链接:{}".format(source_url), + "- 预计上线时间:", + "", + "## 需求约束", + "- 预算区间:", + "- 目标地区/线路:", + "- 业务类型(Web/API/数据库/代理等):", + "- 可接受抖动与丢包:", + "", + "## 期望配置", + "- CPU:", + "- 内存:", + "- 存储:", + "- 流量/带宽:", + "", + "## 补充说明", + source_excerpt or "请基于原帖继续补充可量化的需求约束。", + ]) + intro_text = "基于本帖一键带入需求模板,补齐预算与约束后可直接发布。" + action_text = "一键带入并发布需求" + guest_text = "登录后发布需求" + tips = [ + "自动带入原帖标题和背景。", + "优先填写预算、地区和业务类型。", + "约束越量化,回复质量越高。", + ] + + params = { + "title": draft_title, + "content": draft_content[:6000], + "from_post": int(post.id), + } + safe_variant = _normalize_cta_variant(cta_variant) + if safe_variant: + params["cta_variant"] = safe_variant + post_category = (post.category or "").strip() + if post_category: + params["category"] = post_category + if lang == "en": + params["lang"] = "en" + new_topic_url = url_for("forum_post_new", **params) + pricing_url = url_for("index", lang="en") if lang == "en" else url_for("index") + return { + "new_topic_url": new_topic_url, + "source_post_id": int(post.id), + "preview_title": draft_title, + "intro_text": intro_text, + "action_text_member": action_text, + "action_text_guest": guest_text, + "tips": tips, + "pricing_url": pricing_url, + } + + def _build_post_faq_items(post, comments_count=0, read_minutes=1, plan_reco_context="", lang="zh"): if not post: return [] @@ -1796,12 +2128,22 @@ def api_event_track(): whitelist = { "post_detail_cta_pricing", "post_detail_cta_new_topic", + "post_detail_cta_impression", + "post_detail_mobile_bar_impression", "post_detail_jump_comments", "post_detail_related_click", "post_detail_plan_click", "post_detail_comment_submit", "post_detail_sidebar_compare", "post_detail_resource_click", + "post_detail_copy_link", + "post_detail_copy_link_success", + "post_detail_copy_link_failed", + "post_detail_outline_click", + "post_detail_inline_plan_click", + "post_detail_inline_plan_view_all", + "post_detail_requirement_template_click", + "post_detail_requirement_template_submit", } if event_name not in whitelist: return ("", 204) @@ -1809,6 +2151,10 @@ def api_event_track(): label = " ".join((payload.get("label") or "").strip().split())[:120] page_path = " ".join((payload.get("page_path") or "").strip().split())[:255] post_id = payload.get("post_id") + cta_variant = _normalize_cta_variant(payload.get("cta_variant")) + device_type = _normalize_device_type(payload.get("device_type")) + if not cta_variant and event_name == "post_detail_cta_impression": + cta_variant = _normalize_cta_variant(label) try: post_id = int(post_id) if post_id is not None else None except Exception: @@ -1817,17 +2163,45 @@ def api_event_track(): referer = (request.headers.get("Referer") or "").strip() page_path = referer[:255] user = _get_current_user() + visitor_id = _get_or_create_visitor_id() event_data = { "event_name": event_name, "label": label, "post_id": post_id, "user_id": user.id if user else None, + "visitor_id": visitor_id, + "cta_variant": cta_variant or None, + "device_type": device_type or None, "page_path": page_path, "endpoint_path": request.path, "referer": (request.headers.get("Referer") or "")[:255], "ip": (request.headers.get("X-Forwarded-For") or request.remote_addr or "")[:120], "at": _iso8601_utc(datetime.now(timezone.utc)), } + try: + now_dt = datetime.utcnow() + db.session.add(ForumTrackEvent( + event_name=event_name, + label=label or None, + post_id=post_id, + user_id=user.id if user else None, + visitor_id=visitor_id, + cta_variant=cta_variant or None, + device_type=device_type or None, + page_path=page_path or None, + endpoint_path=request.path, + referer=(request.headers.get("Referer") or "")[:255] or None, + ip=(request.headers.get("X-Forwarded-For") or request.remote_addr or "")[:120] or None, + created_at=now_dt, + )) + _increment_track_daily_summary( + event_name=event_name, + cta_variant=cta_variant, + event_dt=now_dt, + ) + db.session.commit() + except Exception: + db.session.rollback() app.logger.info("forum_track_event %s", json.dumps(event_data, ensure_ascii=False)) return ("", 204) @@ -2501,7 +2875,31 @@ def forum_post_new(): content = "" available_categories = _get_forum_category_names(active_only=True) category = available_categories[0] if available_categories else "综合讨论" + prefill_source_post_id = request.args.get("from_post", type=int) or 0 + if prefill_source_post_id < 1: + prefill_source_post_id = 0 + prefill_cta_variant = _normalize_cta_variant(request.args.get("cta_variant")) + prefill_applied = False + if request.method != "POST": + title = (request.args.get("title") or "").strip() + content = (request.args.get("content") or "").strip() + requested_category = (request.args.get("category") or "").strip() + if requested_category and requested_category in available_categories: + category = requested_category + if len(title) > 160: + title = title[:160] + if len(content) > 6000: + content = content[:6000] + prefill_applied = bool(title or content or prefill_source_post_id) if request.method == "POST": + posted_source_post_id = request.form.get("from_post", type=int) or 0 + if posted_source_post_id > 0: + prefill_source_post_id = posted_source_post_id + else: + prefill_source_post_id = 0 + posted_variant = _normalize_cta_variant(request.form.get("cta_variant")) + if posted_variant: + prefill_cta_variant = posted_variant title = (request.form.get("title") or "").strip() content = (request.form.get("content") or "").strip() category = (request.form.get("category") or "").strip() or category @@ -2522,6 +2920,31 @@ def forum_post_new(): ) db.session.add(post) db.session.commit() + if prefill_source_post_id > 0: + try: + now_dt = datetime.utcnow() + db.session.add(ForumTrackEvent( + event_name="post_detail_requirement_template_submit", + label="from_post_{}_to_post_{}".format(prefill_source_post_id, post.id), + post_id=prefill_source_post_id, + user_id=user.id if user else None, + visitor_id=_get_or_create_visitor_id(), + cta_variant=prefill_cta_variant or None, + device_type=_guess_device_type_from_user_agent(request.headers.get("User-Agent")) or None, + page_path=_build_post_detail_url(prefill_source_post_id, lang=lang)[:255], + endpoint_path=request.path, + referer=(request.headers.get("Referer") or "")[:255] or None, + ip=(request.headers.get("X-Forwarded-For") or request.remote_addr or "")[:120] or None, + created_at=now_dt, + )) + _increment_track_daily_summary( + event_name="post_detail_requirement_template_submit", + cta_variant=prefill_cta_variant, + event_dt=now_dt, + ) + db.session.commit() + except Exception: + db.session.rollback() return redirect(url_for("forum_post_detail", post_id=post.id)) return render_template( "forum/post_form.html", @@ -2535,6 +2958,9 @@ def forum_post_new(): action_url=url_for("forum_post_new"), cancel_url=url_for("forum_index"), form_mode="create", + prefill_source_post_id=prefill_source_post_id, + prefill_cta_variant=prefill_cta_variant, + prefill_applied=prefill_applied, ) @@ -2692,7 +3118,40 @@ def forum_post_detail(post_id): published_time = _iso8601_utc(post.created_at) modified_time = _iso8601_utc(post.updated_at or post.created_at) read_minutes = _estimate_reading_minutes(post.content or "", lang=lang) + cta_variant = _resolve_cta_variant( + current_user=current_user, + requested_variant=request.args.get("cv"), + ) + if cta_variant == "intent": + cta_copy = { + "headline": _pick_lang("30 秒筛出可落地 VPS 方案", "Shortlist Deployable VPS in 30 Seconds", lang), + "description": _pick_lang( + "用本帖结论作为筛选条件,先锁定预算与地区,再看稳定性和可交付能力。", + "Use this topic's conclusions as filters: lock budget and region first, then compare stability and deliverability.", + lang, + ), + "primary_button": _pick_lang("开始快速筛选", "Start Shortlisting", lang), + "secondary_button_member": _pick_lang("发布预算与需求", "Post Budget & Needs", lang), + "secondary_button_guest": _pick_lang("登录后发布需求", "Login to Post Needs", lang), + "sidebar_button": _pick_lang("30 秒筛选 VPS", "30s VPS Shortlist", lang), + } + else: + cta_copy = { + "headline": _pick_lang("准备选型或采购 VPS?", "Ready to shortlist or buy VPS?", lang), + "description": _pick_lang( + "结合本帖讨论,去价格页快速筛选可落地方案。", + "Use insights from this topic and shortlist actionable plans on the pricing page.", + lang, + ), + "primary_button": _pick_lang("去比价筛选", "Compare Plans", lang), + "secondary_button_member": _pick_lang("发布采购需求", "Post Requirement", lang), + "secondary_button_guest": _pick_lang("登录后发帖", "Login to Post", lang), + "sidebar_button": _pick_lang("立即筛选 VPS", "Shortlist VPS", lang), + } + cta_track_suffix = cta_variant detail_resource_links = _build_post_resource_links(post=post, lang=lang) + pricing_prefill = _build_post_pricing_prefill(post=post, lang=lang) + requirement_draft = _build_post_requirement_draft(post=post, lang=lang, cta_variant=cta_variant) detail_faq_items = _build_post_faq_items( post=post, comments_count=comments_count, @@ -2883,9 +3342,14 @@ def forum_post_detail(post_id): plan_recommendations=plan_recommendations, plan_reco_context=plan_reco_context, detail_resource_links=detail_resource_links, + pricing_prefill=pricing_prefill, + requirement_draft=requirement_draft, detail_faq_items=detail_faq_items, comments_count=comments_count, read_minutes=read_minutes, + cta_variant=cta_variant, + cta_copy=cta_copy, + cta_track_suffix=cta_track_suffix, comment_page=comment_page, comments_total_pages=comments_total_pages, comment_page_links=comment_page_links, @@ -3507,6 +3971,2080 @@ def admin_dashboard(): ) +def _admin_tracking_days_variant(): + days = request.args.get("days", type=int) or 14 + days = max(1, min(days, 90)) + selected_variant = (request.args.get("variant") or "all").strip().lower() + if selected_variant not in {"all", "control", "intent", "unknown"}: + selected_variant = "all" + return days, selected_variant + + +def _admin_tracking_selected_device(raw_value=None): + selected_device = ((raw_value if raw_value is not None else request.args.get("device")) or "all").strip().lower() + if selected_device not in {"all", "mobile", "desktop", "tablet", "unknown"}: + selected_device = "all" + return selected_device + + +def _admin_tracking_event_filters(start_at, selected_variant, selected_device="all"): + filters = [ForumTrackEvent.created_at >= start_at] + if selected_variant == "control": + filters.append(ForumTrackEvent.cta_variant == "control") + elif selected_variant == "intent": + filters.append(ForumTrackEvent.cta_variant == "intent") + elif selected_variant == "unknown": + filters.append(or_(ForumTrackEvent.cta_variant.is_(None), ForumTrackEvent.cta_variant == "")) + if selected_device == "mobile": + filters.append(ForumTrackEvent.device_type == "mobile") + elif selected_device == "desktop": + filters.append(ForumTrackEvent.device_type == "desktop") + elif selected_device == "tablet": + filters.append(ForumTrackEvent.device_type == "tablet") + elif selected_device == "unknown": + filters.append(or_( + ForumTrackEvent.device_type.is_(None), + ForumTrackEvent.device_type == "", + ForumTrackEvent.device_type == "unknown", + )) + return filters + + +def _admin_tracking_daily_filters(start_day, selected_variant): + filters = [ForumTrackDailySummary.event_day >= start_day] + if selected_variant == "control": + filters.append(ForumTrackDailySummary.cta_variant == "control") + elif selected_variant == "intent": + filters.append(ForumTrackDailySummary.cta_variant == "intent") + elif selected_variant == "unknown": + filters.append(ForumTrackDailySummary.cta_variant == "unknown") + return filters + + +def _admin_tracking_daily_filters_exact(day_value, selected_variant): + filters = [ForumTrackDailySummary.event_day == day_value] + if selected_variant == "control": + filters.append(ForumTrackDailySummary.cta_variant == "control") + elif selected_variant == "intent": + filters.append(ForumTrackDailySummary.cta_variant == "intent") + elif selected_variant == "unknown": + filters.append(ForumTrackDailySummary.cta_variant == "unknown") + return filters + + +def _admin_tracking_event_filters_exact(start_at, end_at, selected_variant, selected_device="all"): + filters = [ + ForumTrackEvent.created_at >= start_at, + ForumTrackEvent.created_at < end_at, + ] + if selected_variant == "control": + filters.append(ForumTrackEvent.cta_variant == "control") + elif selected_variant == "intent": + filters.append(ForumTrackEvent.cta_variant == "intent") + elif selected_variant == "unknown": + filters.append(or_(ForumTrackEvent.cta_variant.is_(None), ForumTrackEvent.cta_variant == "")) + if selected_device == "mobile": + filters.append(ForumTrackEvent.device_type == "mobile") + elif selected_device == "desktop": + filters.append(ForumTrackEvent.device_type == "desktop") + elif selected_device == "tablet": + filters.append(ForumTrackEvent.device_type == "tablet") + elif selected_device == "unknown": + filters.append(or_( + ForumTrackEvent.device_type.is_(None), + ForumTrackEvent.device_type == "", + ForumTrackEvent.device_type == "unknown", + )) + return filters + + +def _admin_tracking_day_value(): + today = datetime.utcnow().date() + default_day = today - timedelta(days=1) + raw_day = (request.args.get("day") or "").strip() + if not raw_day: + return default_day + try: + parsed_day = datetime.strptime(raw_day, "%Y-%m-%d").date() + except Exception: + return default_day + min_day = today - timedelta(days=365) + if parsed_day < min_day: + return min_day + if parsed_day > default_day: + return default_day + return parsed_day + + +def _admin_tracking_summary_from_event_map(event_map): + summary = { + "events": int(sum(int(v or 0) for v in event_map.values())), + "impressions": int(event_map.get("post_detail_cta_impression", 0) or 0), + "mobile_bar_impressions": int(event_map.get("post_detail_mobile_bar_impression", 0) or 0), + "mobile_pricing_clicks": 0, + "pricing_clicks": int(event_map.get("post_detail_cta_pricing", 0) or 0), + "new_topic_clicks": int(event_map.get("post_detail_cta_new_topic", 0) or 0), + "template_clicks": int(event_map.get("post_detail_requirement_template_click", 0) or 0), + "template_submits": int(event_map.get("post_detail_requirement_template_submit", 0) or 0), + "comment_submits": int(event_map.get("post_detail_comment_submit", 0) or 0), + "copy_success": int(event_map.get("post_detail_copy_link_success", 0) or 0), + "resource_clicks": int(event_map.get("post_detail_resource_click", 0) or 0), + "related_clicks": int(event_map.get("post_detail_related_click", 0) or 0), + "outline_clicks": int(event_map.get("post_detail_outline_click", 0) or 0), + } + impressions = summary["impressions"] or 0 + template_clicks = summary["template_clicks"] or 0 + rates = { + "pricing_ctr": round(summary["pricing_clicks"] * 100.0 / impressions, 2) if impressions else 0.0, + "mobile_pricing_rate": 0.0, + "new_topic_rate": round(summary["new_topic_clicks"] * 100.0 / impressions, 2) if impressions else 0.0, + "template_rate": round(summary["template_clicks"] * 100.0 / impressions, 2) if impressions else 0.0, + "template_submit_rate": round(summary["template_submits"] * 100.0 / impressions, 2) if impressions else 0.0, + "template_completion_rate": round(summary["template_submits"] * 100.0 / template_clicks, 2) if template_clicks else 0.0, + "comment_rate": round(summary["comment_submits"] * 100.0 / impressions, 2) if impressions else 0.0, + "copy_rate": round(summary["copy_success"] * 100.0 / impressions, 2) if impressions else 0.0, + } + return summary, rates + + +def _admin_tracking_event_map_for_day(day_value, selected_variant, selected_device="all"): + if selected_device == "all": + rows = ( + db.session.query( + ForumTrackDailySummary.event_name, + func.sum(ForumTrackDailySummary.total).label("total"), + ) + .filter(*_admin_tracking_daily_filters_exact(day_value, selected_variant)) + .group_by(ForumTrackDailySummary.event_name) + .all() + ) + return {row.event_name: int(row.total or 0) for row in rows} + + start_at = datetime(day_value.year, day_value.month, day_value.day) + end_at = start_at + timedelta(days=1) + return _admin_tracking_event_map_for_range( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + + +def _admin_tracking_event_map_for_range(start_at, end_at, selected_variant, selected_device="all"): + rows = ( + db.session.query( + ForumTrackEvent.event_name, + func.count(ForumTrackEvent.id).label("total"), + ) + .filter(*_admin_tracking_event_filters_exact( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + )) + .group_by(ForumTrackEvent.event_name) + .all() + ) + return {row.event_name: int(row.total or 0) for row in rows} + + +def _admin_tracking_variant_summary_for_day(day_value, selected_variant="all", selected_device="all"): + if selected_device == "all": + rows = ( + db.session.query( + ForumTrackDailySummary.cta_variant.label("variant"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_impression", ForumTrackDailySummary.total), else_=0)).label("impressions"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_pricing", ForumTrackDailySummary.total), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_new_topic", ForumTrackDailySummary.total), else_=0)).label("new_topic_clicks"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_requirement_template_click", ForumTrackDailySummary.total), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_requirement_template_submit", ForumTrackDailySummary.total), else_=0)).label("template_submits"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_comment_submit", ForumTrackDailySummary.total), else_=0)).label("comment_submits"), + ) + .filter(*_admin_tracking_daily_filters_exact(day_value, selected_variant)) + .group_by(ForumTrackDailySummary.cta_variant) + .order_by(ForumTrackDailySummary.cta_variant.asc()) + .all() + ) + else: + start_at = datetime(day_value.year, day_value.month, day_value.day) + end_at = start_at + timedelta(days=1) + variant_expr = func.coalesce(func.nullif(ForumTrackEvent.cta_variant, ""), "unknown") + rows = ( + db.session.query( + variant_expr.label("variant"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + ) + .filter(*_admin_tracking_event_filters_exact( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + )) + .group_by(variant_expr) + .order_by(variant_expr.asc()) + .all() + ) + items = [] + for row in rows: + impressions = int(row.impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + new_topic_clicks = int(row.new_topic_clicks or 0) + template_clicks = int(row.template_clicks or 0) + template_submits = int(row.template_submits or 0) + comment_submits = int(row.comment_submits or 0) + items.append({ + "variant": row.variant or "unknown", + "impressions": impressions, + "pricing_clicks": pricing_clicks, + "new_topic_clicks": new_topic_clicks, + "template_clicks": template_clicks, + "template_submits": template_submits, + "comment_submits": comment_submits, + "pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "new_topic_rate": round(new_topic_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0, + "template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0, + "comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0, + }) + return items + + +def _admin_tracking_top_posts_for_range(start_at, end_at, selected_variant, selected_device="all", limit=12, sort_mode="pricing"): + if sort_mode == "template": + order_fields = [ + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).desc(), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).desc(), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).desc(), + ForumTrackEvent.post_id.desc(), + ] + else: + order_fields = [ + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).desc(), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).desc(), + ForumTrackEvent.post_id.desc(), + ] + rows = ( + db.session.query( + ForumTrackEvent.post_id.label("post_id"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + ) + .filter(*_admin_tracking_event_filters_exact(start_at, end_at, selected_variant, selected_device), ForumTrackEvent.post_id.isnot(None)) + .group_by(ForumTrackEvent.post_id) + .having(func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)) > 0) + .order_by(*order_fields) + .limit(limit) + .all() + ) + post_ids = [int(row.post_id) for row in rows if row.post_id is not None] + title_map = {} + if post_ids: + title_map = { + pid: title + for pid, title in ( + db.session.query(ForumPost.id, ForumPost.title) + .filter(ForumPost.id.in_(post_ids)) + .all() + ) + } + items = [] + for row in rows: + impressions = int(row.impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + template_clicks = int(row.template_clicks or 0) + template_submits = int(row.template_submits or 0) + comment_submits = int(row.comment_submits or 0) + pid = int(row.post_id or 0) + items.append({ + "post_id": pid, + "title": title_map.get(pid, "帖子已删除或不可见"), + "impressions": impressions, + "pricing_clicks": pricing_clicks, + "template_clicks": template_clicks, + "template_submits": template_submits, + "comment_submits": comment_submits, + "pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0, + "template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0, + "comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0, + }) + return items + + +def _admin_tracking_top_posts_for_day(day_value, selected_variant, selected_device="all", limit=12, sort_mode="pricing"): + start_at = datetime(day_value.year, day_value.month, day_value.day) + end_at = start_at + timedelta(days=1) + return _admin_tracking_top_posts_for_range( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + limit=limit, + sort_mode=sort_mode, + ) + + +def _admin_tracking_top_labels_for_day(day_value, selected_variant, selected_device="all", limit=20): + start_at = datetime(day_value.year, day_value.month, day_value.day) + end_at = start_at + timedelta(days=1) + rows = ( + db.session.query( + ForumTrackEvent.event_name, + ForumTrackEvent.label, + func.count(ForumTrackEvent.id).label("total"), + ) + .filter( + *_admin_tracking_event_filters_exact(start_at, end_at, selected_variant, selected_device), + ForumTrackEvent.label.isnot(None), + ForumTrackEvent.label != "", + ) + .group_by(ForumTrackEvent.event_name, ForumTrackEvent.label) + .order_by(func.count(ForumTrackEvent.id).desc()) + .limit(limit) + .all() + ) + return [{ + "event_name": row.event_name, + "label": row.label, + "total": int(row.total or 0), + } for row in rows] + + +def _admin_tracking_mobile_pricing_clicks(start_at, end_at, selected_variant, selected_device="all"): + row = ( + db.session.query(func.count(ForumTrackEvent.id).label("total")) + .filter( + *_admin_tracking_event_filters_exact( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ), + ForumTrackEvent.event_name == "post_detail_cta_pricing", + ForumTrackEvent.label.isnot(None), + ForumTrackEvent.label != "", + ForumTrackEvent.label.like("mobile_%"), + ) + .first() + ) + return int(getattr(row, "total", 0) or 0) + + +@app.route("/admin/forum/tracking") +@admin_required +def admin_forum_tracking(): + days, selected_variant = _admin_tracking_days_variant() + selected_device = _admin_tracking_selected_device() + start_at = datetime.utcnow() - timedelta(days=days) + days_options = [3, 7, 14, 30, 60, 90] + device_options = ["all", "mobile", "desktop", "tablet", "unknown"] + + summary = { + "events": 0, + "impressions": 0, + "mobile_bar_impressions": 0, + "mobile_pricing_clicks": 0, + "pricing_clicks": 0, + "new_topic_clicks": 0, + "template_clicks": 0, + "template_submits": 0, + "comment_submits": 0, + "copy_success": 0, + "resource_clicks": 0, + "related_clicks": 0, + "outline_clicks": 0, + } + summary_rates = { + "pricing_ctr": 0.0, + "mobile_pricing_rate": 0.0, + "new_topic_rate": 0.0, + "template_rate": 0.0, + "template_submit_rate": 0.0, + "template_completion_rate": 0.0, + "comment_rate": 0.0, + "copy_rate": 0.0, + } + mobile_funnel = { + "mobile_impressions": 0, + "mobile_bar_impressions": 0, + "mobile_pricing_clicks": 0, + "mobile_pricing_rate": 0.0, + "mobile_traffic_share": 0.0, + "mobile_click_share": 0.0, + } + variant_summary = [] + device_summary = [] + daily_rows = [] + post_rows = [] + template_post_rows = [] + label_rows = [] + recent_rows = [] + error = "" + + base_filters = _admin_tracking_event_filters( + start_at=start_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + variant_expr = func.coalesce(func.nullif(ForumTrackEvent.cta_variant, ""), "unknown") + device_expr = func.coalesce(func.nullif(ForumTrackEvent.device_type, ""), "unknown") + + try: + agg_row = ( + db.session.query( + func.count(ForumTrackEvent.id).label("events"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_mobile_bar_impression", 1), else_=0)).label("mobile_bar_impressions"), + func.sum(case((and_(ForumTrackEvent.event_name == "post_detail_cta_pricing", ForumTrackEvent.label.like("mobile_%")), 1), else_=0)).label("mobile_pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_copy_link_success", 1), else_=0)).label("copy_success"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_resource_click", 1), else_=0)).label("resource_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_related_click", 1), else_=0)).label("related_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_outline_click", 1), else_=0)).label("outline_clicks"), + ) + .filter(*base_filters) + .first() + ) + if agg_row: + for key in summary.keys(): + summary[key] = int(getattr(agg_row, key, 0) or 0) + impressions = summary["impressions"] or 0 + if impressions > 0: + summary_rates["pricing_ctr"] = round(summary["pricing_clicks"] * 100.0 / impressions, 2) + summary_rates["new_topic_rate"] = round(summary["new_topic_clicks"] * 100.0 / impressions, 2) + summary_rates["template_rate"] = round(summary["template_clicks"] * 100.0 / impressions, 2) + summary_rates["template_submit_rate"] = round(summary["template_submits"] * 100.0 / impressions, 2) + summary_rates["comment_rate"] = round(summary["comment_submits"] * 100.0 / impressions, 2) + summary_rates["copy_rate"] = round(summary["copy_success"] * 100.0 / impressions, 2) + template_clicks = summary["template_clicks"] or 0 + if template_clicks > 0: + summary_rates["template_completion_rate"] = round(summary["template_submits"] * 100.0 / template_clicks, 2) + mobile_bar_impressions = summary["mobile_bar_impressions"] or 0 + if mobile_bar_impressions > 0: + summary_rates["mobile_pricing_rate"] = round(summary["mobile_pricing_clicks"] * 100.0 / mobile_bar_impressions, 2) + + variant_rows = ( + db.session.query( + variant_expr.label("variant"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_copy_link_success", 1), else_=0)).label("copy_success"), + ) + .filter(*base_filters) + .group_by(variant_expr) + .order_by(variant_expr.asc()) + .all() + ) + for row in variant_rows: + impression_count = int(row.impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + new_topic_clicks = int(row.new_topic_clicks or 0) + template_clicks = int(row.template_clicks or 0) + template_submits = int(row.template_submits or 0) + comment_submits = int(row.comment_submits or 0) + copy_success = int(row.copy_success or 0) + variant_summary.append({ + "variant": row.variant or "unknown", + "impressions": impression_count, + "pricing_clicks": pricing_clicks, + "new_topic_clicks": new_topic_clicks, + "template_clicks": template_clicks, + "template_submits": template_submits, + "comment_submits": comment_submits, + "copy_success": copy_success, + "pricing_ctr": round(pricing_clicks * 100.0 / impression_count, 2) if impression_count else 0.0, + "new_topic_rate": round(new_topic_clicks * 100.0 / impression_count, 2) if impression_count else 0.0, + "template_rate": round(template_clicks * 100.0 / impression_count, 2) if impression_count else 0.0, + "template_submit_rate": round(template_submits * 100.0 / impression_count, 2) if impression_count else 0.0, + "template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0, + "comment_rate": round(comment_submits * 100.0 / impression_count, 2) if impression_count else 0.0, + "copy_rate": round(copy_success * 100.0 / impression_count, 2) if impression_count else 0.0, + }) + + device_rows = ( + db.session.query( + device_expr.label("device_type"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_mobile_bar_impression", 1), else_=0)).label("mobile_bar_impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((and_(ForumTrackEvent.event_name == "post_detail_cta_pricing", ForumTrackEvent.label.like("mobile_%")), 1), else_=0)).label("mobile_pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + ) + .filter(*base_filters) + .group_by(device_expr) + .order_by(device_expr.asc()) + .all() + ) + for row in device_rows: + impression_count = int(row.impressions or 0) + mobile_bar_impressions = int(row.mobile_bar_impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + mobile_pricing_clicks = int(row.mobile_pricing_clicks or 0) + comment_submits = int(row.comment_submits or 0) + device_summary.append({ + "device_type": row.device_type or "unknown", + "impressions": impression_count, + "mobile_bar_impressions": mobile_bar_impressions, + "pricing_clicks": pricing_clicks, + "mobile_pricing_clicks": mobile_pricing_clicks, + "comment_submits": comment_submits, + "pricing_ctr": round(pricing_clicks * 100.0 / impression_count, 2) if impression_count else 0.0, + "mobile_pricing_rate": round(mobile_pricing_clicks * 100.0 / mobile_bar_impressions, 2) if mobile_bar_impressions else 0.0, + "comment_rate": round(comment_submits * 100.0 / impression_count, 2) if impression_count else 0.0, + }) + mobile_device_impressions = 0 + for row in device_summary: + if row.get("device_type") == "mobile": + mobile_device_impressions = int(row.get("impressions", 0) or 0) + break + mobile_funnel["mobile_impressions"] = mobile_device_impressions + mobile_funnel["mobile_bar_impressions"] = int(summary.get("mobile_bar_impressions", 0) or 0) + mobile_funnel["mobile_pricing_clicks"] = int(summary.get("mobile_pricing_clicks", 0) or 0) + mobile_funnel["mobile_pricing_rate"] = float(summary_rates.get("mobile_pricing_rate", 0.0) or 0.0) + if impressions > 0: + mobile_funnel["mobile_traffic_share"] = round(mobile_device_impressions * 100.0 / impressions, 2) + if summary["pricing_clicks"] > 0: + mobile_funnel["mobile_click_share"] = round(summary["mobile_pricing_clicks"] * 100.0 / summary["pricing_clicks"], 2) + + day_rows_raw = ( + db.session.query( + func.date(ForumTrackEvent.created_at).label("event_day"), + variant_expr.label("variant"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + ) + .filter(*base_filters) + .group_by(func.date(ForumTrackEvent.created_at), variant_expr) + .order_by(func.date(ForumTrackEvent.created_at).desc(), variant_expr.asc()) + .limit(100) + .all() + ) + for row in day_rows_raw: + impression_count = int(row.impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + comment_submits = int(row.comment_submits or 0) + daily_rows.append({ + "event_day": row.event_day, + "variant": row.variant or "unknown", + "impressions": impression_count, + "pricing_clicks": pricing_clicks, + "comment_submits": comment_submits, + "pricing_ctr": round(pricing_clicks * 100.0 / impression_count, 2) if impression_count else 0.0, + "comment_rate": round(comment_submits * 100.0 / impression_count, 2) if impression_count else 0.0, + }) + + post_rows_raw = ( + db.session.query( + ForumTrackEvent.post_id.label("post_id"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + ) + .filter(*base_filters, ForumTrackEvent.post_id.isnot(None)) + .group_by(ForumTrackEvent.post_id) + .having(func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)) > 0) + .order_by( + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).desc(), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).desc(), + ForumTrackEvent.post_id.desc(), + ) + .limit(30) + .all() + ) + post_id_list = [int(row.post_id) for row in post_rows_raw if row.post_id is not None] + post_title_map = {} + if post_id_list: + post_title_map = { + pid: title + for pid, title in ( + db.session.query(ForumPost.id, ForumPost.title) + .filter(ForumPost.id.in_(post_id_list)) + .all() + ) + } + for row in post_rows_raw: + impression_count = int(row.impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + template_clicks = int(row.template_clicks or 0) + template_submits = int(row.template_submits or 0) + comment_submits = int(row.comment_submits or 0) + post_rows.append({ + "post_id": int(row.post_id or 0), + "title": post_title_map.get(int(row.post_id or 0), "帖子已删除或不可见"), + "impressions": impression_count, + "pricing_clicks": pricing_clicks, + "template_clicks": template_clicks, + "template_submits": template_submits, + "comment_submits": comment_submits, + "pricing_ctr": round(pricing_clicks * 100.0 / impression_count, 2) if impression_count else 0.0, + "template_rate": round(template_clicks * 100.0 / impression_count, 2) if impression_count else 0.0, + "template_submit_rate": round(template_submits * 100.0 / impression_count, 2) if impression_count else 0.0, + "template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0, + "comment_rate": round(comment_submits * 100.0 / impression_count, 2) if impression_count else 0.0, + }) + template_post_rows = [ + row + for row in sorted( + post_rows, + key=lambda item: ( + int(item.get("template_submits", 0) or 0), + int(item.get("template_clicks", 0) or 0), + int(item.get("impressions", 0) or 0), + int(item.get("post_id", 0) or 0), + ), + reverse=True, + ) + if int(row.get("template_clicks", 0) or 0) > 0 or int(row.get("template_submits", 0) or 0) > 0 + ][:20] + + label_rows_raw = ( + db.session.query( + ForumTrackEvent.event_name, + ForumTrackEvent.label, + func.count(ForumTrackEvent.id).label("total"), + ) + .filter( + *base_filters, + ForumTrackEvent.event_name.in_({ + "post_detail_cta_pricing", + "post_detail_cta_new_topic", + "post_detail_sidebar_compare", + "post_detail_resource_click", + "post_detail_related_click", + "post_detail_plan_click", + "post_detail_inline_plan_click", + "post_detail_requirement_template_click", + "post_detail_requirement_template_submit", + }), + ForumTrackEvent.label.isnot(None), + ForumTrackEvent.label != "", + ) + .group_by(ForumTrackEvent.event_name, ForumTrackEvent.label) + .order_by(func.count(ForumTrackEvent.id).desc()) + .limit(40) + .all() + ) + label_rows = [ + { + "event_name": row.event_name, + "label": row.label, + "total": int(row.total or 0), + } + for row in label_rows_raw + ] + + recent_rows = ( + ForumTrackEvent.query + .filter(*base_filters) + .order_by(ForumTrackEvent.created_at.desc(), ForumTrackEvent.id.desc()) + .limit(80) + .all() + ) + except Exception: + db.session.rollback() + error = "埋点数据表尚未就绪或查询失败,请重启应用后重试。" + + return render_template( + "admin/forum_tracking.html", + days=days, + days_options=days_options, + selected_variant=selected_variant, + selected_device=selected_device, + device_options=device_options, + summary=summary, + summary_rates=summary_rates, + mobile_funnel=mobile_funnel, + variant_summary=variant_summary, + device_summary=device_summary, + daily_rows=daily_rows, + post_rows=post_rows, + template_post_rows=template_post_rows, + label_rows=label_rows, + recent_rows=recent_rows, + msg=request.args.get("msg", ""), + error=request.args.get("error", "") or error, + ) + + +@app.route("/admin/forum/tracking/daily") +@admin_required +def admin_forum_tracking_daily(): + selected_variant = (request.args.get("variant") or "all").strip().lower() + if selected_variant not in {"all", "control", "intent", "unknown"}: + selected_variant = "all" + selected_device = _admin_tracking_selected_device() + day_value = _admin_tracking_day_value() + prev_day = day_value - timedelta(days=1) + variant_options = ["all", "control", "intent", "unknown"] + device_options = ["all", "mobile", "desktop", "tablet", "unknown"] + error = "" + + summary = { + "events": 0, + "impressions": 0, + "mobile_bar_impressions": 0, + "mobile_pricing_clicks": 0, + "pricing_clicks": 0, + "new_topic_clicks": 0, + "template_clicks": 0, + "template_submits": 0, + "comment_submits": 0, + "copy_success": 0, + "resource_clicks": 0, + "related_clicks": 0, + "outline_clicks": 0, + } + summary_rates = { + "pricing_ctr": 0.0, + "mobile_pricing_rate": 0.0, + "new_topic_rate": 0.0, + "template_rate": 0.0, + "template_submit_rate": 0.0, + "template_completion_rate": 0.0, + "comment_rate": 0.0, + "copy_rate": 0.0, + } + prev_summary = dict(summary) + prev_summary_rates = dict(summary_rates) + delta_rows = [] + variant_rows = [] + top_posts = [] + template_top_posts = [] + top_labels = [] + + try: + curr_event_map = _admin_tracking_event_map_for_day( + day_value=day_value, + selected_variant=selected_variant, + selected_device=selected_device, + ) + prev_event_map = _admin_tracking_event_map_for_day( + day_value=prev_day, + selected_variant=selected_variant, + selected_device=selected_device, + ) + summary, summary_rates = _admin_tracking_summary_from_event_map(curr_event_map) + prev_summary, prev_summary_rates = _admin_tracking_summary_from_event_map(prev_event_map) + day_start_at = datetime(day_value.year, day_value.month, day_value.day) + day_end_at = day_start_at + timedelta(days=1) + prev_start_at = datetime(prev_day.year, prev_day.month, prev_day.day) + prev_end_at = prev_start_at + timedelta(days=1) + summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks( + start_at=day_start_at, + end_at=day_end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + prev_summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks( + start_at=prev_start_at, + end_at=prev_end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + curr_mobile_bar_impressions = int(summary.get("mobile_bar_impressions", 0) or 0) + prev_mobile_bar_impressions = int(prev_summary.get("mobile_bar_impressions", 0) or 0) + if curr_mobile_bar_impressions > 0: + summary_rates["mobile_pricing_rate"] = round(summary["mobile_pricing_clicks"] * 100.0 / curr_mobile_bar_impressions, 2) + if prev_mobile_bar_impressions > 0: + prev_summary_rates["mobile_pricing_rate"] = round(prev_summary["mobile_pricing_clicks"] * 100.0 / prev_mobile_bar_impressions, 2) + + for key, label in [ + ("impressions", "曝光"), + ("mobile_bar_impressions", "移动底栏曝光"), + ("mobile_pricing_clicks", "移动比价点击"), + ("pricing_clicks", "比价点击"), + ("comment_submits", "评论提交"), + ("new_topic_clicks", "发需求点击"), + ("template_clicks", "模板发帖点击"), + ("template_submits", "模板发帖提交"), + ("copy_success", "复制成功"), + ]: + current_val = int(summary.get(key, 0) or 0) + prev_val = int(prev_summary.get(key, 0) or 0) + delta_val = current_val - prev_val + if prev_val > 0: + delta_pct = round(delta_val * 100.0 / prev_val, 2) + elif current_val > 0: + delta_pct = 100.0 + else: + delta_pct = 0.0 + if delta_val > 0: + direction = "up" + elif delta_val < 0: + direction = "down" + else: + direction = "flat" + delta_rows.append({ + "label": label, + "current": current_val, + "previous": prev_val, + "delta": delta_val, + "delta_pct": delta_pct, + "direction": direction, + }) + + variant_rows = _admin_tracking_variant_summary_for_day( + day_value=day_value, + selected_variant=selected_variant, + selected_device=selected_device, + ) + top_posts = _admin_tracking_top_posts_for_day( + day_value=day_value, + selected_variant=selected_variant, + selected_device=selected_device, + limit=12, + sort_mode="pricing", + ) + template_top_posts = _admin_tracking_top_posts_for_day( + day_value=day_value, + selected_variant=selected_variant, + selected_device=selected_device, + limit=12, + sort_mode="template", + ) + top_labels = _admin_tracking_top_labels_for_day( + day_value=day_value, + selected_variant=selected_variant, + selected_device=selected_device, + limit=20, + ) + except Exception: + db.session.rollback() + error = "日报数据聚合失败,请检查埋点数据表是否已创建。" + + return render_template( + "admin/forum_tracking_daily.html", + day_value=day_value, + prev_day=prev_day, + selected_variant=selected_variant, + variant_options=variant_options, + selected_device=selected_device, + device_options=device_options, + summary=summary, + summary_rates=summary_rates, + prev_summary=prev_summary, + prev_summary_rates=prev_summary_rates, + delta_rows=delta_rows, + variant_rows=variant_rows, + top_posts=top_posts, + template_top_posts=template_top_posts, + top_labels=top_labels, + msg=request.args.get("msg", ""), + error=request.args.get("error", "") or error, + ) + + +@app.route("/admin/forum/tracking/daily/export.md") +@admin_required +def admin_forum_tracking_daily_export_markdown(): + selected_variant = (request.args.get("variant") or "all").strip().lower() + if selected_variant not in {"all", "control", "intent", "unknown"}: + selected_variant = "all" + selected_device = _admin_tracking_selected_device() + day_value = _admin_tracking_day_value() + prev_day = day_value - timedelta(days=1) + + curr_event_map = _admin_tracking_event_map_for_day( + day_value=day_value, + selected_variant=selected_variant, + selected_device=selected_device, + ) + prev_event_map = _admin_tracking_event_map_for_day( + day_value=prev_day, + selected_variant=selected_variant, + selected_device=selected_device, + ) + summary, summary_rates = _admin_tracking_summary_from_event_map(curr_event_map) + prev_summary, _ = _admin_tracking_summary_from_event_map(prev_event_map) + day_start_at = datetime(day_value.year, day_value.month, day_value.day) + day_end_at = day_start_at + timedelta(days=1) + prev_start_at = datetime(prev_day.year, prev_day.month, prev_day.day) + prev_end_at = prev_start_at + timedelta(days=1) + summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks( + start_at=day_start_at, + end_at=day_end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + prev_summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks( + start_at=prev_start_at, + end_at=prev_end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + mobile_bar_impressions = int(summary.get("mobile_bar_impressions", 0) or 0) + if mobile_bar_impressions > 0: + summary_rates["mobile_pricing_rate"] = round(summary["mobile_pricing_clicks"] * 100.0 / mobile_bar_impressions, 2) + top_posts = _admin_tracking_top_posts_for_day( + day_value=day_value, + selected_variant=selected_variant, + selected_device=selected_device, + limit=10, + sort_mode="pricing", + ) + template_top_posts = _admin_tracking_top_posts_for_day( + day_value=day_value, + selected_variant=selected_variant, + selected_device=selected_device, + limit=10, + sort_mode="template", + ) + + def _delta_str(current_val, prev_val): + delta_val = int(current_val or 0) - int(prev_val or 0) + if int(prev_val or 0) > 0: + delta_pct = round(delta_val * 100.0 / int(prev_val), 2) + elif int(current_val or 0) > 0: + delta_pct = 100.0 + else: + delta_pct = 0.0 + sign = "+" if delta_val > 0 else "" + return "{}{} ({:+.2f}%)".format(sign, delta_val, delta_pct) + + lines = [ + "# Forum Tracking Daily Report", + "", + "- Day: `{}`".format(day_value.isoformat()), + "- Variant filter: `{}`".format(selected_variant), + "- Device filter: `{}`".format(selected_device), + "", + "## Summary", + "", + "- Impressions: **{}** ({})".format(summary["impressions"], _delta_str(summary["impressions"], prev_summary["impressions"])), + "- Mobile bar impressions: **{}** ({})".format(summary["mobile_bar_impressions"], _delta_str(summary["mobile_bar_impressions"], prev_summary["mobile_bar_impressions"])), + "- Mobile pricing clicks: **{}** ({})".format(summary["mobile_pricing_clicks"], _delta_str(summary["mobile_pricing_clicks"], prev_summary["mobile_pricing_clicks"])), + "- Mobile bar click-through: **{}%**".format(summary_rates["mobile_pricing_rate"]), + "- Pricing clicks: **{}** ({})".format(summary["pricing_clicks"], _delta_str(summary["pricing_clicks"], prev_summary["pricing_clicks"])), + "- Pricing CTR: **{}%**".format(summary_rates["pricing_ctr"]), + "- New topic clicks: **{}** ({})".format(summary["new_topic_clicks"], _delta_str(summary["new_topic_clicks"], prev_summary["new_topic_clicks"])), + "- Requirement template clicks: **{}** ({})".format(summary["template_clicks"], _delta_str(summary["template_clicks"], prev_summary["template_clicks"])), + "- Template click rate: **{}%**".format(summary_rates["template_rate"]), + "- Requirement template submits: **{}** ({})".format(summary["template_submits"], _delta_str(summary["template_submits"], prev_summary["template_submits"])), + "- Template submit rate: **{}%**".format(summary_rates["template_submit_rate"]), + "- Template completion rate: **{}%**".format(summary_rates["template_completion_rate"]), + "- Comment submits: **{}** ({})".format(summary["comment_submits"], _delta_str(summary["comment_submits"], prev_summary["comment_submits"])), + "- Copy success: **{}** ({})".format(summary["copy_success"], _delta_str(summary["copy_success"], prev_summary["copy_success"])), + "", + "## Top Posts", + "", + ] + if top_posts: + for idx, row in enumerate(top_posts, start=1): + lines.append("{}. #{} {} | impressions={} pricing_clicks={} ctr={}%; template_clicks={} template_submits={} template_completion={}% ; comments={} comment_rate={}%".format( + idx, + row["post_id"], + row["title"], + row["impressions"], + row["pricing_clicks"], + row["pricing_ctr"], + row["template_clicks"], + row["template_submits"], + row["template_completion_rate"], + row["comment_submits"], + row["comment_rate"], + )) + else: + lines.append("- No post-level conversion data for this day.") + lines.extend([ + "", + "## Top Template Conversion Posts", + "", + ]) + if template_top_posts: + for idx, row in enumerate(template_top_posts, start=1): + lines.append("{}. #{} {} | template_clicks={} template_submits={} template_completion={}%; impressions={} template_rate={}%; template_submit_rate={}%".format( + idx, + row["post_id"], + row["title"], + row["template_clicks"], + row["template_submits"], + row["template_completion_rate"], + row["impressions"], + row["template_rate"], + row["template_submit_rate"], + )) + else: + lines.append("- No template conversion posts for this day.") + + body = "\n".join(lines) + "\n" + filename = "forum-tracking-daily-{}-{}-{}.md".format(day_value.isoformat(), selected_variant, selected_device) + resp = make_response(body) + resp.headers["Content-Type"] = "text/markdown; charset=utf-8" + resp.headers["Content-Disposition"] = "attachment; filename={}".format(filename) + return resp + + +@app.route("/admin/forum/tracking/weekly") +@admin_required +def admin_forum_tracking_weekly(): + selected_variant = (request.args.get("variant") or "all").strip().lower() + if selected_variant not in {"all", "control", "intent", "unknown"}: + selected_variant = "all" + selected_device = _admin_tracking_selected_device() + days = request.args.get("days", type=int) or 7 + days = max(3, min(days, 30)) + end_day = _admin_tracking_day_value() + end_at = datetime(end_day.year, end_day.month, end_day.day) + timedelta(days=1) + start_at = end_at - timedelta(days=days) + prev_end_at = start_at + prev_start_at = prev_end_at - timedelta(days=days) + days_options = [3, 7, 14, 21, 30] + variant_options = ["all", "control", "intent", "unknown"] + device_options = ["all", "mobile", "desktop", "tablet", "unknown"] + + summary = { + "events": 0, + "impressions": 0, + "mobile_bar_impressions": 0, + "mobile_pricing_clicks": 0, + "pricing_clicks": 0, + "new_topic_clicks": 0, + "template_clicks": 0, + "template_submits": 0, + "comment_submits": 0, + "copy_success": 0, + "resource_clicks": 0, + "related_clicks": 0, + "outline_clicks": 0, + } + summary_rates = { + "pricing_ctr": 0.0, + "mobile_pricing_rate": 0.0, + "new_topic_rate": 0.0, + "template_rate": 0.0, + "template_submit_rate": 0.0, + "template_completion_rate": 0.0, + "comment_rate": 0.0, + "copy_rate": 0.0, + } + prev_summary = dict(summary) + prev_summary_rates = dict(summary_rates) + delta_rows = [] + variant_rows = [] + device_rows = [] + device_variant_rows = [] + top_posts = [] + template_top_posts = [] + top_labels = [] + error = "" + + try: + curr_event_map = _admin_tracking_event_map_for_range( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + prev_event_map = _admin_tracking_event_map_for_range( + start_at=prev_start_at, + end_at=prev_end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + summary, summary_rates = _admin_tracking_summary_from_event_map(curr_event_map) + prev_summary, prev_summary_rates = _admin_tracking_summary_from_event_map(prev_event_map) + summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + prev_summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks( + start_at=prev_start_at, + end_at=prev_end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + curr_mobile_bar_impressions = int(summary.get("mobile_bar_impressions", 0) or 0) + prev_mobile_bar_impressions = int(prev_summary.get("mobile_bar_impressions", 0) or 0) + if curr_mobile_bar_impressions > 0: + summary_rates["mobile_pricing_rate"] = round(summary["mobile_pricing_clicks"] * 100.0 / curr_mobile_bar_impressions, 2) + if prev_mobile_bar_impressions > 0: + prev_summary_rates["mobile_pricing_rate"] = round(prev_summary["mobile_pricing_clicks"] * 100.0 / prev_mobile_bar_impressions, 2) + + for key, label in [ + ("impressions", "曝光"), + ("mobile_bar_impressions", "移动底栏曝光"), + ("mobile_pricing_clicks", "移动比价点击"), + ("pricing_clicks", "比价点击"), + ("new_topic_clicks", "发需求点击"), + ("template_clicks", "模板发帖点击"), + ("template_submits", "模板发帖提交"), + ("comment_submits", "评论提交"), + ("copy_success", "复制成功"), + ]: + current_val = int(summary.get(key, 0) or 0) + prev_val = int(prev_summary.get(key, 0) or 0) + delta_val = current_val - prev_val + if prev_val > 0: + delta_pct = round(delta_val * 100.0 / prev_val, 2) + elif current_val > 0: + delta_pct = 100.0 + else: + delta_pct = 0.0 + if delta_val > 0: + direction = "up" + elif delta_val < 0: + direction = "down" + else: + direction = "flat" + delta_rows.append({ + "label": label, + "current": current_val, + "previous": prev_val, + "delta": delta_val, + "delta_pct": delta_pct, + "direction": direction, + }) + + variant_expr = func.coalesce(func.nullif(ForumTrackEvent.cta_variant, ""), "unknown") + variant_rows_raw = ( + db.session.query( + variant_expr.label("variant"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_copy_link_success", 1), else_=0)).label("copy_success"), + ) + .filter(*_admin_tracking_event_filters_exact( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + )) + .group_by(variant_expr) + .order_by(variant_expr.asc()) + .all() + ) + for row in variant_rows_raw: + impressions = int(row.impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + new_topic_clicks = int(row.new_topic_clicks or 0) + template_clicks = int(row.template_clicks or 0) + template_submits = int(row.template_submits or 0) + comment_submits = int(row.comment_submits or 0) + copy_success = int(row.copy_success or 0) + variant_rows.append({ + "variant": row.variant or "unknown", + "impressions": impressions, + "pricing_clicks": pricing_clicks, + "new_topic_clicks": new_topic_clicks, + "template_clicks": template_clicks, + "template_submits": template_submits, + "comment_submits": comment_submits, + "copy_success": copy_success, + "pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "new_topic_rate": round(new_topic_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0, + "template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0, + "comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0, + "copy_rate": round(copy_success * 100.0 / impressions, 2) if impressions else 0.0, + }) + + device_expr = func.coalesce(func.nullif(ForumTrackEvent.device_type, ""), "unknown") + device_rows_raw = ( + db.session.query( + device_expr.label("device_type"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_mobile_bar_impression", 1), else_=0)).label("mobile_bar_impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((and_(ForumTrackEvent.event_name == "post_detail_cta_pricing", ForumTrackEvent.label.like("mobile_%")), 1), else_=0)).label("mobile_pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + ) + .filter(*_admin_tracking_event_filters_exact( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + )) + .group_by(device_expr) + .order_by(device_expr.asc()) + .all() + ) + for row in device_rows_raw: + impressions = int(row.impressions or 0) + mobile_bar_impressions = int(row.mobile_bar_impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + mobile_pricing_clicks = int(row.mobile_pricing_clicks or 0) + template_clicks = int(row.template_clicks or 0) + template_submits = int(row.template_submits or 0) + comment_submits = int(row.comment_submits or 0) + device_rows.append({ + "device_type": row.device_type or "unknown", + "impressions": impressions, + "mobile_bar_impressions": mobile_bar_impressions, + "pricing_clicks": pricing_clicks, + "mobile_pricing_clicks": mobile_pricing_clicks, + "template_clicks": template_clicks, + "template_submits": template_submits, + "comment_submits": comment_submits, + "pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "mobile_pricing_rate": round(mobile_pricing_clicks * 100.0 / mobile_bar_impressions, 2) if mobile_bar_impressions else 0.0, + "template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0, + "template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0, + "comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0, + }) + + device_variant_rows_raw = ( + db.session.query( + device_expr.label("device_type"), + variant_expr.label("variant"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_mobile_bar_impression", 1), else_=0)).label("mobile_bar_impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((and_(ForumTrackEvent.event_name == "post_detail_cta_pricing", ForumTrackEvent.label.like("mobile_%")), 1), else_=0)).label("mobile_pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + ) + .filter(*_admin_tracking_event_filters_exact( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + )) + .group_by(device_expr, variant_expr) + .order_by(device_expr.asc(), variant_expr.asc()) + .all() + ) + for row in device_variant_rows_raw: + impressions = int(row.impressions or 0) + mobile_bar_impressions = int(row.mobile_bar_impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + mobile_pricing_clicks = int(row.mobile_pricing_clicks or 0) + template_clicks = int(row.template_clicks or 0) + template_submits = int(row.template_submits or 0) + comment_submits = int(row.comment_submits or 0) + device_variant_rows.append({ + "device_type": row.device_type or "unknown", + "variant": row.variant or "unknown", + "impressions": impressions, + "mobile_bar_impressions": mobile_bar_impressions, + "pricing_clicks": pricing_clicks, + "mobile_pricing_clicks": mobile_pricing_clicks, + "template_clicks": template_clicks, + "template_submits": template_submits, + "comment_submits": comment_submits, + "pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "mobile_pricing_rate": round(mobile_pricing_clicks * 100.0 / mobile_bar_impressions, 2) if mobile_bar_impressions else 0.0, + "template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0, + "template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0, + "comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0, + }) + + top_posts = _admin_tracking_top_posts_for_range( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + limit=12, + sort_mode="pricing", + ) + template_top_posts = _admin_tracking_top_posts_for_range( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + limit=12, + sort_mode="template", + ) + top_labels_raw = ( + db.session.query( + ForumTrackEvent.event_name, + ForumTrackEvent.label, + func.count(ForumTrackEvent.id).label("total"), + ) + .filter( + *_admin_tracking_event_filters_exact( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ), + ForumTrackEvent.event_name.in_({ + "post_detail_cta_pricing", + "post_detail_cta_new_topic", + "post_detail_requirement_template_click", + "post_detail_requirement_template_submit", + "post_detail_sidebar_compare", + "post_detail_resource_click", + "post_detail_related_click", + "post_detail_plan_click", + "post_detail_inline_plan_click", + }), + ForumTrackEvent.label.isnot(None), + ForumTrackEvent.label != "", + ) + .group_by(ForumTrackEvent.event_name, ForumTrackEvent.label) + .order_by(func.count(ForumTrackEvent.id).desc()) + .limit(20) + .all() + ) + top_labels = [{ + "event_name": row.event_name, + "label": row.label, + "total": int(row.total or 0), + } for row in top_labels_raw] + except Exception: + db.session.rollback() + error = "周报数据聚合失败,请检查埋点数据表是否已创建。" + + range_start_day = start_at.date() + range_end_day = (end_at - timedelta(days=1)).date() + prev_start_day = prev_start_at.date() + prev_end_day = (prev_end_at - timedelta(days=1)).date() + + return render_template( + "admin/forum_tracking_weekly.html", + days=days, + days_options=days_options, + selected_variant=selected_variant, + variant_options=variant_options, + selected_device=selected_device, + device_options=device_options, + end_day=end_day, + range_start_day=range_start_day, + range_end_day=range_end_day, + prev_start_day=prev_start_day, + prev_end_day=prev_end_day, + summary=summary, + summary_rates=summary_rates, + prev_summary=prev_summary, + prev_summary_rates=prev_summary_rates, + delta_rows=delta_rows, + variant_rows=variant_rows, + device_rows=device_rows, + device_variant_rows=device_variant_rows, + top_posts=top_posts, + template_top_posts=template_top_posts, + top_labels=top_labels, + msg=request.args.get("msg", ""), + error=request.args.get("error", "") or error, + ) + + +@app.route("/admin/forum/tracking/weekly/export.md") +@admin_required +def admin_forum_tracking_weekly_export_markdown(): + selected_variant = (request.args.get("variant") or "all").strip().lower() + if selected_variant not in {"all", "control", "intent", "unknown"}: + selected_variant = "all" + selected_device = _admin_tracking_selected_device() + days = request.args.get("days", type=int) or 7 + days = max(3, min(days, 30)) + end_day = _admin_tracking_day_value() + end_at = datetime(end_day.year, end_day.month, end_day.day) + timedelta(days=1) + start_at = end_at - timedelta(days=days) + prev_end_at = start_at + prev_start_at = prev_end_at - timedelta(days=days) + + curr_event_map = _admin_tracking_event_map_for_range( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + prev_event_map = _admin_tracking_event_map_for_range( + start_at=prev_start_at, + end_at=prev_end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + summary, summary_rates = _admin_tracking_summary_from_event_map(curr_event_map) + prev_summary, _ = _admin_tracking_summary_from_event_map(prev_event_map) + summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + prev_summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks( + start_at=prev_start_at, + end_at=prev_end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + mobile_bar_impressions = int(summary.get("mobile_bar_impressions", 0) or 0) + if mobile_bar_impressions > 0: + summary_rates["mobile_pricing_rate"] = round(summary["mobile_pricing_clicks"] * 100.0 / mobile_bar_impressions, 2) + + variant_expr = func.coalesce(func.nullif(ForumTrackEvent.cta_variant, ""), "unknown") + variant_rows_raw = ( + db.session.query( + variant_expr.label("variant"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_copy_link_success", 1), else_=0)).label("copy_success"), + ) + .filter(*_admin_tracking_event_filters_exact( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + )) + .group_by(variant_expr) + .order_by(variant_expr.asc()) + .all() + ) + variant_rows = [] + for row in variant_rows_raw: + impressions = int(row.impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + new_topic_clicks = int(row.new_topic_clicks or 0) + template_clicks = int(row.template_clicks or 0) + template_submits = int(row.template_submits or 0) + comment_submits = int(row.comment_submits or 0) + copy_success = int(row.copy_success or 0) + variant_rows.append({ + "variant": row.variant or "unknown", + "impressions": impressions, + "pricing_clicks": pricing_clicks, + "new_topic_clicks": new_topic_clicks, + "template_clicks": template_clicks, + "template_submits": template_submits, + "comment_submits": comment_submits, + "copy_success": copy_success, + "pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "new_topic_rate": round(new_topic_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0, + "template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0, + "comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0, + "copy_rate": round(copy_success * 100.0 / impressions, 2) if impressions else 0.0, + }) + + device_expr = func.coalesce(func.nullif(ForumTrackEvent.device_type, ""), "unknown") + device_variant_rows_raw = ( + db.session.query( + device_expr.label("device_type"), + variant_expr.label("variant"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_mobile_bar_impression", 1), else_=0)).label("mobile_bar_impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((and_(ForumTrackEvent.event_name == "post_detail_cta_pricing", ForumTrackEvent.label.like("mobile_%")), 1), else_=0)).label("mobile_pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + ) + .filter(*_admin_tracking_event_filters_exact( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + )) + .group_by(device_expr, variant_expr) + .order_by(device_expr.asc(), variant_expr.asc()) + .all() + ) + device_variant_rows = [] + for row in device_variant_rows_raw: + impressions = int(row.impressions or 0) + mobile_bar_impressions = int(row.mobile_bar_impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + mobile_pricing_clicks = int(row.mobile_pricing_clicks or 0) + template_clicks = int(row.template_clicks or 0) + template_submits = int(row.template_submits or 0) + comment_submits = int(row.comment_submits or 0) + device_variant_rows.append({ + "device_type": row.device_type or "unknown", + "variant": row.variant or "unknown", + "impressions": impressions, + "mobile_bar_impressions": mobile_bar_impressions, + "pricing_clicks": pricing_clicks, + "mobile_pricing_clicks": mobile_pricing_clicks, + "template_clicks": template_clicks, + "template_submits": template_submits, + "comment_submits": comment_submits, + "pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "mobile_pricing_rate": round(mobile_pricing_clicks * 100.0 / mobile_bar_impressions, 2) if mobile_bar_impressions else 0.0, + "template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0, + "template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0, + "template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0, + "comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0, + }) + + top_posts = _admin_tracking_top_posts_for_range( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + limit=12, + sort_mode="pricing", + ) + template_top_posts = _admin_tracking_top_posts_for_range( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + limit=12, + sort_mode="template", + ) + top_labels_raw = ( + db.session.query( + ForumTrackEvent.event_name, + ForumTrackEvent.label, + func.count(ForumTrackEvent.id).label("total"), + ) + .filter( + *_admin_tracking_event_filters_exact( + start_at=start_at, + end_at=end_at, + selected_variant=selected_variant, + selected_device=selected_device, + ), + ForumTrackEvent.event_name.in_({ + "post_detail_cta_pricing", + "post_detail_cta_new_topic", + "post_detail_requirement_template_click", + "post_detail_requirement_template_submit", + "post_detail_sidebar_compare", + "post_detail_resource_click", + "post_detail_related_click", + "post_detail_plan_click", + "post_detail_inline_plan_click", + }), + ForumTrackEvent.label.isnot(None), + ForumTrackEvent.label != "", + ) + .group_by(ForumTrackEvent.event_name, ForumTrackEvent.label) + .order_by(func.count(ForumTrackEvent.id).desc()) + .limit(20) + .all() + ) + top_labels = [{ + "event_name": row.event_name, + "label": row.label, + "total": int(row.total or 0), + } for row in top_labels_raw] + + def _delta_str(current_val, prev_val): + delta_val = int(current_val or 0) - int(prev_val or 0) + if int(prev_val or 0) > 0: + delta_pct = round(delta_val * 100.0 / int(prev_val), 2) + elif int(current_val or 0) > 0: + delta_pct = 100.0 + else: + delta_pct = 0.0 + sign = "+" if delta_val > 0 else "" + return "{}{} ({:+.2f}%)".format(sign, delta_val, delta_pct) + + range_start_day = start_at.date().isoformat() + range_end_day = (end_at - timedelta(days=1)).date().isoformat() + prev_start_day = prev_start_at.date().isoformat() + prev_end_day = (prev_end_at - timedelta(days=1)).date().isoformat() + lines = [ + "# Forum Tracking Weekly Report", + "", + "- Window: `{}` ~ `{}` ({} days)".format(range_start_day, range_end_day, days), + "- Compare Window: `{}` ~ `{}` ({} days)".format(prev_start_day, prev_end_day, days), + "- Variant filter: `{}`".format(selected_variant), + "- Device filter: `{}`".format(selected_device), + "", + "## Summary", + "", + "- Impressions: **{}** ({})".format(summary["impressions"], _delta_str(summary["impressions"], prev_summary["impressions"])), + "- Mobile bar impressions: **{}** ({})".format(summary["mobile_bar_impressions"], _delta_str(summary["mobile_bar_impressions"], prev_summary["mobile_bar_impressions"])), + "- Mobile pricing clicks: **{}** ({})".format(summary["mobile_pricing_clicks"], _delta_str(summary["mobile_pricing_clicks"], prev_summary["mobile_pricing_clicks"])), + "- Mobile bar click-through: **{}%**".format(summary_rates["mobile_pricing_rate"]), + "- Pricing clicks: **{}** ({})".format(summary["pricing_clicks"], _delta_str(summary["pricing_clicks"], prev_summary["pricing_clicks"])), + "- Pricing CTR: **{}%**".format(summary_rates["pricing_ctr"]), + "- New topic clicks: **{}** ({})".format(summary["new_topic_clicks"], _delta_str(summary["new_topic_clicks"], prev_summary["new_topic_clicks"])), + "- Requirement template clicks: **{}** ({})".format(summary["template_clicks"], _delta_str(summary["template_clicks"], prev_summary["template_clicks"])), + "- Template click rate: **{}%**".format(summary_rates["template_rate"]), + "- Requirement template submits: **{}** ({})".format(summary["template_submits"], _delta_str(summary["template_submits"], prev_summary["template_submits"])), + "- Template submit rate: **{}%**".format(summary_rates["template_submit_rate"]), + "- Template completion rate: **{}%**".format(summary_rates["template_completion_rate"]), + "- Comment submits: **{}** ({})".format(summary["comment_submits"], _delta_str(summary["comment_submits"], prev_summary["comment_submits"])), + "- Copy success: **{}** ({})".format(summary["copy_success"], _delta_str(summary["copy_success"], prev_summary["copy_success"])), + "", + "## Variant Funnel", + "", + ] + if variant_rows: + for row in variant_rows: + lines.append("- `{}` | impressions={} | pricing_ctr={}% | new_topic_rate={}% | template_rate={}% | template_submit_rate={}% | template_completion={}% | comment_rate={}% | copy_rate={}%".format( + row["variant"], + row["impressions"], + row["pricing_ctr"], + row["new_topic_rate"], + row["template_rate"], + row["template_submit_rate"], + row["template_completion_rate"], + row["comment_rate"], + row["copy_rate"], + )) + else: + lines.append("- No variant funnel data.") + lines.extend([ + "", + "## Device x Variant Funnel", + "", + ]) + if device_variant_rows: + for row in device_variant_rows: + lines.append("- `{} / {}` | impressions={} | pricing_ctr={}% | mobile_bar_ctr={}% | template_rate={}% | template_submit_rate={}% | template_completion={}% | comment_rate={}%".format( + row["device_type"], + row["variant"], + row["impressions"], + row["pricing_ctr"], + row["mobile_pricing_rate"], + row["template_rate"], + row["template_submit_rate"], + row["template_completion_rate"], + row["comment_rate"], + )) + else: + lines.append("- No device x variant funnel data.") + lines.extend([ + "", + "## Top Pricing Conversion Posts", + "", + ]) + if top_posts: + for idx, row in enumerate(top_posts, start=1): + lines.append("{}. #{} {} | impressions={} pricing_clicks={} ctr={}%; template_clicks={} template_submits={} template_completion={}% ; comments={} comment_rate={}%".format( + idx, + row["post_id"], + row["title"], + row["impressions"], + row["pricing_clicks"], + row["pricing_ctr"], + row["template_clicks"], + row["template_submits"], + row["template_completion_rate"], + row["comment_submits"], + row["comment_rate"], + )) + else: + lines.append("- No post-level conversion data.") + lines.extend([ + "", + "## Top Template Conversion Posts", + "", + ]) + if template_top_posts: + for idx, row in enumerate(template_top_posts, start=1): + lines.append("{}. #{} {} | template_clicks={} template_submits={} template_completion={}%; impressions={} template_rate={}%; template_submit_rate={}%".format( + idx, + row["post_id"], + row["title"], + row["template_clicks"], + row["template_submits"], + row["template_completion_rate"], + row["impressions"], + row["template_rate"], + row["template_submit_rate"], + )) + else: + lines.append("- No template conversion posts.") + lines.extend([ + "", + "## Top Labels", + "", + ]) + if top_labels: + for idx, row in enumerate(top_labels, start=1): + lines.append("{}. `{}` | `{}` | {}".format( + idx, + row["event_name"], + row["label"], + row["total"], + )) + else: + lines.append("- No high-frequency labels.") + + body = "\n".join(lines) + "\n" + filename = "forum-tracking-weekly-{}-{}d-{}-{}.md".format( + range_end_day, + days, + selected_variant, + selected_device, + ) + resp = make_response(body) + resp.headers["Content-Type"] = "text/markdown; charset=utf-8" + resp.headers["Content-Disposition"] = "attachment; filename={}".format(filename) + return resp + + +@app.route("/admin/forum/tracking/export.csv") +@admin_required +def admin_forum_tracking_export(): + days, selected_variant = _admin_tracking_days_variant() + selected_device = _admin_tracking_selected_device() + mode = (request.args.get("mode") or "recent").strip().lower() + if mode not in {"recent", "daily", "variants", "variant_funnel", "device_variants", "posts", "labels"}: + mode = "recent" + start_at = datetime.utcnow() - timedelta(days=days) + start_day = start_at.date() + limit = request.args.get("limit", type=int) or 3000 + limit = max(100, min(limit, 10000)) + variant_expr = func.coalesce(func.nullif(ForumTrackEvent.cta_variant, ""), "unknown") + event_filters = _admin_tracking_event_filters( + start_at=start_at, + selected_variant=selected_variant, + selected_device=selected_device, + ) + daily_filters = _admin_tracking_daily_filters(start_day=start_day, selected_variant=selected_variant) + + csv_buf = io.StringIO() + writer = csv.writer(csv_buf) + + if mode == "daily": + writer.writerow(["event_day", "cta_variant", "event_name", "total"]) + if selected_device == "all": + rows = ( + ForumTrackDailySummary.query + .filter(*daily_filters) + .order_by( + ForumTrackDailySummary.event_day.desc(), + ForumTrackDailySummary.cta_variant.asc(), + ForumTrackDailySummary.event_name.asc(), + ) + .all() + ) + for row in rows: + writer.writerow([ + row.event_day.isoformat() if row.event_day else "", + row.cta_variant or "unknown", + row.event_name or "", + int(row.total or 0), + ]) + else: + rows = ( + db.session.query( + func.date(ForumTrackEvent.created_at).label("event_day"), + variant_expr.label("cta_variant"), + ForumTrackEvent.event_name, + func.count(ForumTrackEvent.id).label("total"), + ) + .filter(*event_filters) + .group_by(func.date(ForumTrackEvent.created_at), variant_expr, ForumTrackEvent.event_name) + .order_by(func.date(ForumTrackEvent.created_at).desc(), variant_expr.asc(), ForumTrackEvent.event_name.asc()) + .limit(limit) + .all() + ) + for row in rows: + day_val = row.event_day.isoformat() if hasattr(row.event_day, "isoformat") else str(row.event_day or "") + writer.writerow([ + day_val, + row.cta_variant or "unknown", + row.event_name or "", + int(row.total or 0), + ]) + elif mode == "variants": + writer.writerow(["cta_variant", "event_name", "total"]) + if selected_device == "all": + rows = ( + db.session.query( + ForumTrackDailySummary.cta_variant, + ForumTrackDailySummary.event_name, + func.sum(ForumTrackDailySummary.total).label("total"), + ) + .filter(*daily_filters) + .group_by(ForumTrackDailySummary.cta_variant, ForumTrackDailySummary.event_name) + .order_by(func.sum(ForumTrackDailySummary.total).desc()) + .all() + ) + else: + rows = ( + db.session.query( + variant_expr.label("cta_variant"), + ForumTrackEvent.event_name, + func.count(ForumTrackEvent.id).label("total"), + ) + .filter(*event_filters) + .group_by(variant_expr, ForumTrackEvent.event_name) + .order_by(func.count(ForumTrackEvent.id).desc()) + .limit(limit) + .all() + ) + for row in rows: + writer.writerow([ + row.cta_variant or "unknown", + row.event_name or "", + int(row.total or 0), + ]) + elif mode == "variant_funnel": + writer.writerow([ + "cta_variant", + "impressions", + "pricing_clicks", + "pricing_ctr_pct", + "new_topic_clicks", + "new_topic_rate_pct", + "template_clicks", + "template_rate_pct", + "template_submits", + "template_submit_rate_pct", + "template_completion_rate_pct", + "comment_submits", + "comment_rate_pct", + "copy_success", + "copy_rate_pct", + ]) + if selected_device == "all": + rows = ( + db.session.query( + ForumTrackDailySummary.cta_variant.label("cta_variant"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_impression", ForumTrackDailySummary.total), else_=0)).label("impressions"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_pricing", ForumTrackDailySummary.total), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_new_topic", ForumTrackDailySummary.total), else_=0)).label("new_topic_clicks"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_requirement_template_click", ForumTrackDailySummary.total), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_requirement_template_submit", ForumTrackDailySummary.total), else_=0)).label("template_submits"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_comment_submit", ForumTrackDailySummary.total), else_=0)).label("comment_submits"), + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_copy_link_success", ForumTrackDailySummary.total), else_=0)).label("copy_success"), + ) + .filter(*daily_filters) + .group_by(ForumTrackDailySummary.cta_variant) + .order_by( + func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_impression", ForumTrackDailySummary.total), else_=0)).desc(), + ForumTrackDailySummary.cta_variant.asc(), + ) + .all() + ) + else: + rows = ( + db.session.query( + variant_expr.label("cta_variant"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_copy_link_success", 1), else_=0)).label("copy_success"), + ) + .filter(*event_filters) + .group_by(variant_expr) + .order_by( + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).desc(), + variant_expr.asc(), + ) + .limit(limit) + .all() + ) + for row in rows: + impressions = int(row.impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + new_topic_clicks = int(row.new_topic_clicks or 0) + template_clicks = int(row.template_clicks or 0) + template_submits = int(row.template_submits or 0) + comment_submits = int(row.comment_submits or 0) + copy_success = int(row.copy_success or 0) + writer.writerow([ + row.cta_variant or "unknown", + impressions, + pricing_clicks, + round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0, + new_topic_clicks, + round(new_topic_clicks * 100.0 / impressions, 2) if impressions else 0.0, + template_clicks, + round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0, + template_submits, + round(template_submits * 100.0 / impressions, 2) if impressions else 0.0, + round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0, + comment_submits, + round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0, + copy_success, + round(copy_success * 100.0 / impressions, 2) if impressions else 0.0, + ]) + elif mode == "device_variants": + writer.writerow(["device_type", "cta_variant", "event_name", "total"]) + device_expr = func.coalesce(func.nullif(ForumTrackEvent.device_type, ""), "unknown") + rows = ( + db.session.query( + device_expr.label("device_type"), + variant_expr.label("cta_variant"), + ForumTrackEvent.event_name, + func.count(ForumTrackEvent.id).label("total"), + ) + .filter(*event_filters) + .group_by(device_expr, variant_expr, ForumTrackEvent.event_name) + .order_by(func.count(ForumTrackEvent.id).desc()) + .limit(limit) + .all() + ) + for row in rows: + writer.writerow([ + row.device_type or "unknown", + row.cta_variant or "unknown", + row.event_name or "", + int(row.total or 0), + ]) + elif mode == "posts": + writer.writerow([ + "post_id", + "title", + "impressions", + "pricing_clicks", + "pricing_ctr_pct", + "template_clicks", + "template_rate_pct", + "template_submits", + "template_submit_rate_pct", + "template_completion_rate_pct", + "comment_submits", + "comment_rate_pct", + ]) + rows = ( + db.session.query( + ForumTrackEvent.post_id.label("post_id"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"), + func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"), + ) + .filter(*event_filters, ForumTrackEvent.post_id.isnot(None)) + .group_by(ForumTrackEvent.post_id) + .having(func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)) > 0) + .order_by( + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).desc(), + func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).desc(), + ForumTrackEvent.post_id.desc(), + ) + .limit(limit) + .all() + ) + post_ids = [int(row.post_id) for row in rows if row.post_id is not None] + title_map = {} + if post_ids: + title_map = { + pid: title + for pid, title in ( + db.session.query(ForumPost.id, ForumPost.title) + .filter(ForumPost.id.in_(post_ids)) + .all() + ) + } + for row in rows: + impressions = int(row.impressions or 0) + pricing_clicks = int(row.pricing_clicks or 0) + template_clicks = int(row.template_clicks or 0) + template_submits = int(row.template_submits or 0) + comment_submits = int(row.comment_submits or 0) + writer.writerow([ + int(row.post_id or 0), + title_map.get(int(row.post_id or 0), "帖子已删除或不可见"), + impressions, + pricing_clicks, + round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0, + template_clicks, + round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0, + template_submits, + round(template_submits * 100.0 / impressions, 2) if impressions else 0.0, + round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0, + comment_submits, + round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0, + ]) + elif mode == "labels": + writer.writerow(["event_name", "label", "total"]) + rows = ( + db.session.query( + ForumTrackEvent.event_name, + ForumTrackEvent.label, + func.count(ForumTrackEvent.id).label("total"), + ) + .filter( + *event_filters, + ForumTrackEvent.event_name.in_({ + "post_detail_cta_pricing", + "post_detail_cta_new_topic", + "post_detail_sidebar_compare", + "post_detail_resource_click", + "post_detail_related_click", + "post_detail_plan_click", + "post_detail_inline_plan_click", + "post_detail_requirement_template_click", + "post_detail_requirement_template_submit", + }), + ForumTrackEvent.label.isnot(None), + ForumTrackEvent.label != "", + ) + .group_by(ForumTrackEvent.event_name, ForumTrackEvent.label) + .order_by(func.count(ForumTrackEvent.id).desc()) + .limit(limit) + .all() + ) + for row in rows: + writer.writerow([row.event_name or "", row.label or "", int(row.total or 0)]) + else: + writer.writerow([ + "created_at", + "event_name", + "label", + "cta_variant", + "device_type", + "post_id", + "user_id", + "visitor_id", + "page_path", + "endpoint_path", + "referer", + ]) + rows = ( + ForumTrackEvent.query + .filter(*event_filters) + .order_by(ForumTrackEvent.created_at.desc(), ForumTrackEvent.id.desc()) + .limit(limit) + .all() + ) + for row in rows: + writer.writerow([ + row.created_at.strftime("%Y-%m-%d %H:%M:%S") if row.created_at else "", + row.event_name or "", + row.label or "", + row.cta_variant or "unknown", + row.device_type or "unknown", + row.post_id or "", + row.user_id or "", + row.visitor_id or "", + row.page_path or "", + row.endpoint_path or "", + row.referer or "", + ]) + + stamp = datetime.utcnow().strftime("%Y%m%d%H%M%S") + filename = "forum-tracking-{}-{}d-{}-{}-{}.csv".format(mode, days, selected_variant, selected_device, stamp) + resp = make_response("\ufeff" + csv_buf.getvalue()) + resp.headers["Content-Type"] = "text/csv; charset=utf-8" + resp.headers["Content-Disposition"] = "attachment; filename={}".format(filename) + return resp + + # ---------- 厂商管理 ---------- @app.route("/admin/providers") @admin_required diff --git a/docs/forum-post-detail-funnel-sql.md b/docs/forum-post-detail-funnel-sql.md new file mode 100644 index 0000000..2c7d977 --- /dev/null +++ b/docs/forum-post-detail-funnel-sql.md @@ -0,0 +1,165 @@ +# Forum Post Detail Funnel SQL + +This project now stores tracking events in table `forum_track_events`. +Daily rollup is also available in `forum_track_daily_summary`. + +Key columns: + +- `event_name` +- `label` +- `post_id` +- `user_id` +- `visitor_id` +- `cta_variant` +- `created_at` + +Use the queries below as baseline funnel cuts. + +## 1) Daily impression -> pricing CTA conversion (MySQL) + +```sql +SELECT + DATE(created_at) AS d, + COALESCE(cta_variant, 'unknown') AS cta_variant, + COUNT(CASE WHEN event_name = 'post_detail_cta_impression' THEN 1 END) AS impressions, + COUNT(CASE WHEN event_name = 'post_detail_cta_pricing' THEN 1 END) AS pricing_clicks, + ROUND( + COUNT(CASE WHEN event_name = 'post_detail_cta_pricing' THEN 1 END) * 100.0 + / NULLIF(COUNT(CASE WHEN event_name = 'post_detail_cta_impression' THEN 1 END), 0), + 2 + ) AS ctr_pct +FROM forum_track_events +WHERE created_at >= NOW() - INTERVAL 30 DAY +GROUP BY DATE(created_at), COALESCE(cta_variant, 'unknown') +ORDER BY d DESC, cta_variant; +``` + +## 2) Unique visitor funnel by variant (MySQL) + +```sql +WITH pv AS ( + SELECT + COALESCE(NULLIF(visitor_id, ''), CONCAT('u:', user_id)) AS actor_id, + COALESCE(cta_variant, 'unknown') AS cta_variant, + MAX(event_name = 'post_detail_cta_impression') AS seen_impression, + MAX(event_name = 'post_detail_cta_pricing') AS clicked_pricing, + MAX(event_name = 'post_detail_comment_submit') AS submitted_comment + FROM forum_track_events + WHERE created_at >= NOW() - INTERVAL 30 DAY + GROUP BY COALESCE(NULLIF(visitor_id, ''), CONCAT('u:', user_id)), COALESCE(cta_variant, 'unknown') +) +SELECT + cta_variant, + COUNT(*) AS actors, + SUM(seen_impression) AS actors_with_impression, + SUM(clicked_pricing) AS actors_with_pricing_click, + SUM(submitted_comment) AS actors_with_comment, + ROUND(SUM(clicked_pricing) * 100.0 / NULLIF(SUM(seen_impression), 0), 2) AS actor_ctr_pct, + ROUND(SUM(submitted_comment) * 100.0 / NULLIF(SUM(seen_impression), 0), 2) AS actor_comment_rate_pct +FROM pv +GROUP BY cta_variant +ORDER BY cta_variant; +``` + +## 3) Top performing CTA labels (MySQL) + +```sql +SELECT + label, + COUNT(*) AS clicks +FROM forum_track_events +WHERE event_name IN ('post_detail_cta_pricing', 'post_detail_cta_new_topic', 'post_detail_sidebar_compare') + AND created_at >= NOW() - INTERVAL 30 DAY +GROUP BY label +ORDER BY clicks DESC +LIMIT 20; +``` + +## 4) Daily impression -> pricing CTA conversion (SQLite) + +```sql +SELECT + DATE(created_at) AS d, + COALESCE(cta_variant, 'unknown') AS cta_variant, + SUM(CASE WHEN event_name = 'post_detail_cta_impression' THEN 1 ELSE 0 END) AS impressions, + SUM(CASE WHEN event_name = 'post_detail_cta_pricing' THEN 1 ELSE 0 END) AS pricing_clicks, + ROUND( + SUM(CASE WHEN event_name = 'post_detail_cta_pricing' THEN 1 ELSE 0 END) * 100.0 + / NULLIF(SUM(CASE WHEN event_name = 'post_detail_cta_impression' THEN 1 ELSE 0 END), 0), + 2 + ) AS ctr_pct +FROM forum_track_events +WHERE created_at >= DATETIME('now', '-30 days') +GROUP BY DATE(created_at), COALESCE(cta_variant, 'unknown') +ORDER BY d DESC, cta_variant; +``` + +## 5) Post-level conversion ranking (SQLite/MySQL compatible style) + +```sql +SELECT + post_id, + COALESCE(cta_variant, 'unknown') AS cta_variant, + SUM(CASE WHEN event_name = 'post_detail_cta_impression' THEN 1 ELSE 0 END) AS impressions, + SUM(CASE WHEN event_name = 'post_detail_cta_pricing' THEN 1 ELSE 0 END) AS pricing_clicks, + ROUND( + SUM(CASE WHEN event_name = 'post_detail_cta_pricing' THEN 1 ELSE 0 END) * 100.0 + / NULLIF(SUM(CASE WHEN event_name = 'post_detail_cta_impression' THEN 1 ELSE 0 END), 0), + 2 + ) AS ctr_pct +FROM forum_track_events +WHERE post_id IS NOT NULL +GROUP BY post_id, COALESCE(cta_variant, 'unknown') +HAVING impressions >= 20 +ORDER BY ctr_pct DESC, impressions DESC +LIMIT 50; +``` + +## 6) Daily rollup table quick check (SQLite/MySQL compatible style) + +```sql +SELECT + event_day, + cta_variant, + event_name, + total +FROM forum_track_daily_summary +WHERE event_day >= DATE('now', '-30 day') -- MySQL: CURDATE() - INTERVAL 30 DAY +ORDER BY event_day DESC, cta_variant, event_name; +``` + +## 7) Mobile sticky-bar exposure rate (SQLite/MySQL compatible style) + +```sql +SELECT + DATE(created_at) AS d, + COALESCE(cta_variant, 'unknown') AS cta_variant, + SUM(CASE WHEN event_name = 'post_detail_cta_impression' THEN 1 ELSE 0 END) AS impressions, + SUM(CASE WHEN event_name = 'post_detail_mobile_bar_impression' THEN 1 ELSE 0 END) AS mobile_bar_impressions, + ROUND( + SUM(CASE WHEN event_name = 'post_detail_mobile_bar_impression' THEN 1 ELSE 0 END) * 100.0 + / NULLIF(SUM(CASE WHEN event_name = 'post_detail_cta_impression' THEN 1 ELSE 0 END), 0), + 2 + ) AS mobile_bar_exposure_pct +FROM forum_track_events +WHERE created_at >= DATETIME('now', '-30 days') +GROUP BY DATE(created_at), COALESCE(cta_variant, 'unknown') +ORDER BY d DESC, cta_variant; +``` + +## 8) Device split performance (SQLite/MySQL compatible style) + +```sql +SELECT + DATE(created_at) AS d, + COALESCE(device_type, 'unknown') AS device_type, + COALESCE(cta_variant, 'unknown') AS cta_variant, + SUM(CASE WHEN event_name = 'post_detail_cta_impression' THEN 1 ELSE 0 END) AS impressions, + SUM(CASE WHEN event_name = 'post_detail_mobile_bar_impression' THEN 1 ELSE 0 END) AS mobile_bar_impressions, + SUM(CASE WHEN event_name = 'post_detail_cta_pricing' THEN 1 ELSE 0 END) AS pricing_clicks, + SUM(CASE WHEN event_name = 'post_detail_cta_pricing' AND label LIKE 'mobile_%' THEN 1 ELSE 0 END) AS mobile_pricing_clicks +FROM forum_track_events +WHERE created_at >= DATETIME('now', '-30 days') +GROUP BY DATE(created_at), COALESCE(device_type, 'unknown'), COALESCE(cta_variant, 'unknown') +ORDER BY d DESC, device_type, cta_variant; +``` diff --git a/docs/forum-post-detail-tracking.md b/docs/forum-post-detail-tracking.md new file mode 100644 index 0000000..e59e113 --- /dev/null +++ b/docs/forum-post-detail-tracking.md @@ -0,0 +1,88 @@ +# Forum Post Detail Tracking Spec + +## Scope + +This document defines the tracking events emitted from `/forum/post/` and accepted by `/api/event/track`. + +Pricing CTA links on post detail may include prefill query params for `/`: + +- `provider`, `region`, `memory`, `price`, `search` +- `source_post`, `source_title` (used to show source context in pricing page UI) + +## CTA A/B Variant + +- Query override: `?cv=control` or `?cv=intent` +- Default behavior without `cv`: + - Stable assignment by hash of actor (`user_id` or anonymous `visitor_id`) + - Experiment key: `forum_post_detail_cta_v1` +- Variant is exposed to frontend via `data-cta-variant` on ``. + +## Common Payload + +All events are sent to `POST /api/event/track`: + +- `event_name`: snake_case event id +- `label`: short context string (max 120 chars server-side) +- `post_id`: current post id +- `page_path`: current pathname +- `cta_variant`: `control` or `intent` (when available) +- `device_type`: `mobile` / `desktop` / `tablet` (best effort) + +Server also appends `user_id`, `visitor_id`, `referer`, `ip`, timestamp in logs and persists rows into: + +- `forum_track_events` (event-level detail) +- `forum_track_daily_summary` (daily rollup by `event_day + cta_variant + event_name`) + +For SQL examples, see: `docs/forum-post-detail-funnel-sql.md`. + +## Admin Dashboard & Export + +- Dashboard: `/admin/forum/tracking` +- Weekly dashboard: `/admin/forum/tracking/weekly` +- CSV export endpoint: `/admin/forum/tracking/export.csv` +- Weekly Markdown endpoint: `/admin/forum/tracking/weekly/export.md` +- Export params: + - `days`: 1-90 + - `variant`: `all` / `control` / `intent` / `unknown` + - `device`: `all` / `mobile` / `desktop` / `tablet` / `unknown` + - `mode`: `recent` / `daily` / `variants` / `variant_funnel` / `device_variants` / `posts` / `labels` + - `mode=variant_funnel` includes variant-level CTR, template submit rate, and template completion rate + - `mode=posts` includes post-level `template_clicks`, `template_submits`, and `template_completion_rate_pct` +- Weekly Markdown params: + - `day`: report end day (inclusive, default yesterday UTC) + - `days`: window length (3-30, default 7) + - `variant` / `device`: same semantics as dashboard filters + +## Event Dictionary + +| event_name | Trigger | label examples | +|---|---|---| +| `post_detail_cta_impression` | Detail page loaded | `control`, `intent` | +| `post_detail_mobile_bar_impression` | Mobile sticky conversion bar first visible | `mobile_bar_visible` | +| `post_detail_cta_pricing` | Main CTA click to pricing page | `main_compare_plans_control`, `main_compare_plans_intent` | +| `post_detail_cta_new_topic` | CTA click to create topic / login | `main_post_requirement_control`, `sidebar_login_new_topic_intent` | +| `post_detail_requirement_template_click` | Requirement template shortcut click | `template_new_topic_control`, `template_login_new_topic_intent` | +| `post_detail_requirement_template_submit` | Requirement template submit succeeded on `/forum/post/new` | `from_post_12_to_post_98` | +| `post_detail_sidebar_compare` | Sidebar pricing CTA click | `sidebar_shortlist_control`, `sidebar_view_all_plans` | +| `post_detail_jump_comments` | Jump-to-comments click | `main_jump_comments_control`, `outline_jump_comments` | +| `post_detail_related_click` | Related topic click | post title | +| `post_detail_plan_click` | Sidebar recommended plan click | `Provider PlanName` | +| `post_detail_inline_plan_click` | Inline plan card click | `Provider PlanName` | +| `post_detail_inline_plan_view_all` | Inline panel “view all” click | `inline_view_all_plans` | +| `post_detail_resource_click` | Resource link click | resource track label | +| `post_detail_comment_submit` | Comment form submit | `comment_form` | +| `post_detail_copy_link` | Copy link button click | `copy_permalink` | +| `post_detail_copy_link_success` | Copy succeeded | `clipboard_api`, `clipboard_fallback`, `legacy_exec_command` | +| `post_detail_copy_link_failed` | Copy failed | `empty_url`, `clipboard_failed`, `legacy_failed` | +| `post_detail_outline_click` | Auto-generated outline item click | heading anchor id | + +## Suggested Dashboard Cuts + +- CTA conversion by variant: + - `post_detail_cta_impression` -> `post_detail_cta_pricing` +- Engagement by variant: + - comment submit rate + - copy link rate + - outline interaction rate +- Top content pathways: + - related click, resource click, plan click labels diff --git a/models.py b/models.py index 0a87851..68451cb 100644 --- a/models.py +++ b/models.py @@ -289,3 +289,37 @@ class ForumNotification(db.Model): message = db.Column(db.String(255), nullable=False) is_read = db.Column(db.Boolean, nullable=False, default=False, index=True) created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True) + + +class ForumTrackEvent(db.Model): + """论坛埋点事件(用于漏斗与转化分析)""" + __tablename__ = "forum_track_events" + + id = db.Column(db.Integer, primary_key=True) + event_name = db.Column(db.String(64), nullable=False, index=True) + label = db.Column(db.String(120), nullable=True) + post_id = db.Column(db.Integer, nullable=True, index=True) + user_id = db.Column(db.Integer, nullable=True, index=True) + visitor_id = db.Column(db.String(64), nullable=True, index=True) + cta_variant = db.Column(db.String(16), nullable=True, index=True) + device_type = db.Column(db.String(16), nullable=True, index=True) + page_path = db.Column(db.String(255), nullable=True) + endpoint_path = db.Column(db.String(64), nullable=True) + referer = db.Column(db.String(255), nullable=True) + ip = db.Column(db.String(120), nullable=True) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True) + + +class ForumTrackDailySummary(db.Model): + """论坛埋点按天汇总(用于看板与导出)""" + __tablename__ = "forum_track_daily_summary" + __table_args__ = ( + db.UniqueConstraint("event_day", "cta_variant", "event_name", name="uq_forum_track_daily"), + ) + + id = db.Column(db.Integer, primary_key=True) + event_day = db.Column(db.Date, nullable=False, index=True) + cta_variant = db.Column(db.String(16), nullable=False, default="unknown", index=True) + event_name = db.Column(db.String(64), nullable=False, index=True) + total = db.Column(db.Integer, nullable=False, default=0) + updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, index=True) diff --git a/static/css/admin.css b/static/css/admin.css index a191d31..22c8763 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -645,6 +645,77 @@ color: var(--text-muted); } +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 0.65rem; +} + +.metric-card { + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.7rem 0.8rem; + background: var(--bg-elevated); + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.metric-label { + color: var(--text-muted); + font-size: 0.78rem; +} + +.metric-value { + color: var(--text); + font-family: var(--font-mono); + font-size: 1.08rem; + line-height: 1.2; +} + +.metric-meta { + color: var(--accent); + font-size: 0.76rem; +} + +.tracking-export-row { + margin-top: 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.delta-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 68px; + border-radius: 999px; + padding: 0.14rem 0.48rem; + font-size: 0.76rem; + font-weight: 600; + line-height: 1.2; + border: 1px solid transparent; +} + +.delta-pill.delta-up { + color: var(--green-dim); + background: rgba(5, 150, 105, 0.14); + border-color: rgba(5, 150, 105, 0.3); +} + +.delta-pill.delta-down { + color: #b91c1c; + background: rgba(220, 38, 38, 0.12); + border-color: rgba(220, 38, 38, 0.28); +} + +.delta-pill.delta-flat { + color: var(--text-muted); + background: rgba(100, 116, 139, 0.12); + border-color: rgba(100, 116, 139, 0.24); +} + .admin-table .btn-delete:disabled { opacity: 0.5; cursor: not-allowed; diff --git a/static/css/forum.css b/static/css/forum.css index 5629e65..21d0785 100644 --- a/static/css/forum.css +++ b/static/css/forum.css @@ -165,6 +165,26 @@ width: 100%; } +.post-read-progress { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 3px; + z-index: 130; + pointer-events: none; + background: transparent; +} + +.post-read-progress-bar { + display: block; + width: 0; + height: 100%; + background: linear-gradient(90deg, #0f172a 0%, #0369a1 68%, #0284c7 100%); + box-shadow: 0 0 10px rgba(3, 105, 161, 0.4); + transition: width 0.12s linear; +} + .visually-hidden { position: absolute !important; width: 1px; @@ -795,6 +815,100 @@ textarea:focus-visible { font-size: 0.82rem; } +.side-profile-card { + border-style: solid; +} + +.side-profile-head { + display: flex; + align-items: center; + gap: 0.58rem; + margin-bottom: 0.65rem; +} + +.side-avatar { + width: 38px; + height: 38px; + border-radius: 999px; + border: 1px solid rgba(111, 124, 255, 0.28); + background: linear-gradient(145deg, rgba(111, 124, 255, 0.22), rgba(167, 139, 250, 0.18)); + color: #4f46e5; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono); + font-size: 0.88rem; + font-weight: 700; + flex-shrink: 0; +} + +.side-profile-meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.14rem; +} + +.side-profile-meta strong { + color: #1e293b; + font-size: 0.86rem; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.side-profile-meta span { + color: #64748b; + font-size: 0.75rem; + line-height: 1.45; +} + +.side-notice-list { + margin: 0; + padding-left: 1.05rem; + display: grid; + gap: 0.32rem; +} + +.side-notice-list li { + color: #475569; + font-size: 0.78rem; + line-height: 1.52; +} + +.side-tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 0.38rem; +} + +.side-tag { + display: inline-flex; + align-items: center; + gap: 0.3rem; + text-decoration: none; + color: #4f46e5; + border: 1px solid rgba(111, 124, 255, 0.24); + border-radius: 999px; + background: rgba(111, 124, 255, 0.08); + padding: 0.18rem 0.5rem; + font-size: 0.74rem; + line-height: 1.4; + transition: var(--transition); +} + +.side-tag strong { + font-family: var(--font-mono); + font-size: 0.72rem; + color: #4338ca; +} + +.side-tag:hover { + border-color: rgba(111, 124, 255, 0.45); + background: rgba(111, 124, 255, 0.15); +} + .side-cta p { margin: 0 0 0.7rem; color: var(--text-muted); @@ -802,6 +916,38 @@ textarea:focus-visible { line-height: 1.6; } +.side-cta-kpi { + display: grid; + gap: 0.45rem; + margin-bottom: 0.68rem; +} + +.side-cta-kpi > div { + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid var(--border); + border-radius: 9px; + padding: 0.44rem 0.5rem; + background: var(--bg); +} + +.side-cta-kpi span { + color: var(--text-muted); + font-size: 0.78rem; +} + +.side-cta-kpi strong { + color: var(--text); + font-family: var(--font-mono); + font-size: 0.86rem; +} + +.side-cta-sticky { + position: sticky; + top: 86px; +} + .related-post-list { list-style: none; margin: 0; @@ -817,8 +963,55 @@ textarea:focus-visible { background: var(--bg); } +.related-post-card { + position: relative; + overflow: hidden; + display: grid; + gap: 0.28rem; +} + +.related-post-card::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(120deg, rgba(129, 140, 248, 0.06), transparent 52%); + pointer-events: none; +} + +.related-post-card.tone-1::before { + background: linear-gradient(120deg, rgba(99, 102, 241, 0.09), transparent 54%); +} + +.related-post-card.tone-2::before { + background: linear-gradient(120deg, rgba(14, 165, 233, 0.09), transparent 54%); +} + +.related-post-card.tone-3::before { + background: linear-gradient(120deg, rgba(16, 185, 129, 0.09), transparent 54%); +} + +.related-post-card.tone-4::before { + background: linear-gradient(120deg, rgba(245, 158, 11, 0.1), transparent 56%); +} + +.related-post-kicker { + position: relative; + z-index: 1; + justify-self: start; + border: 1px solid rgba(129, 140, 248, 0.24); + border-radius: 999px; + padding: 0.1rem 0.4rem; + background: rgba(129, 140, 248, 0.11); + color: #4338ca; + font-size: 0.66rem; + line-height: 1.3; + font-weight: 700; +} + .related-post-list a { display: block; + position: relative; + z-index: 1; color: var(--text); text-decoration: none; font-size: 0.83rem; @@ -837,12 +1030,15 @@ textarea:focus-visible { } .related-post-meta { + position: relative; + z-index: 1; margin-top: 0.3rem; display: flex; align-items: center; gap: 0.5rem; color: var(--text-muted); font-size: 0.74rem; + flex-wrap: wrap; } .plan-reco-context { @@ -931,7 +1127,7 @@ textarea:focus-visible { .topic-post-author { color: var(--text-muted); font-size: 0.82rem; - margin-bottom: 0.74rem; + margin-bottom: 0.42rem; } .topic-detail-card { @@ -939,6 +1135,12 @@ textarea:focus-visible { overflow: hidden; } +.topic-detail-card h1 { + font-size: clamp(1.3rem, 2.4vw, 1.72rem); + line-height: 1.32; + letter-spacing: -0.01em; +} + .topic-detail-card::before { content: ''; position: absolute; @@ -950,6 +1152,364 @@ textarea:focus-visible { opacity: 0.85; } +.topic-detail-meta-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.38rem 0.55rem; + margin-bottom: 0.7rem; +} + +.topic-detail-meta-row span { + display: inline-flex; + align-items: center; + min-height: 28px; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.16rem 0.5rem; + color: #475569; + background: rgba(248, 250, 252, 0.75); + font-size: 0.74rem; + font-weight: 600; +} + +.post-mobile-quickjump { + display: none; +} + +.post-trust-action-hub { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr) minmax(0, 1fr); + gap: 0.52rem; + margin: 0 0 0.78rem; +} + +.post-trust-action-hub > article { + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 12px; + padding: 0.56rem 0.62rem; + background: rgba(255, 255, 255, 0.9); + display: grid; + gap: 0.42rem; + min-width: 0; + transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} + +.post-trust-action-hub > article:hover { + transform: translateY(-1px); + border-color: rgba(99, 102, 241, 0.36); + box-shadow: 0 9px 18px rgba(99, 102, 241, 0.12); +} + +.post-author-cred { + grid-template-columns: auto 1fr; + align-items: start; + gap: 0.44rem 0.52rem; +} + +.post-author-badge { + width: 38px; + height: 38px; + border-radius: 12px; + border: 1px solid rgba(99, 102, 241, 0.3); + background: linear-gradient(145deg, rgba(99, 102, 241, 0.22), rgba(167, 139, 250, 0.16)); + color: #4338ca; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.86rem; + font-family: var(--font-mono); + font-weight: 700; +} + +.post-author-copy { + min-width: 0; +} + +.post-author-copy h2, +.post-reading-rail h2, +.post-related-quick h2 { + margin: 0; + color: #1e293b; + font-size: 0.86rem; +} + +.post-author-copy p { + margin: 0.2rem 0 0; + color: #475569; + font-size: 0.76rem; + line-height: 1.52; +} + +.post-author-stats { + grid-column: 1 / -1; + margin: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.36rem; +} + +.post-author-stats > div { + border: 1px solid rgba(148, 163, 184, 0.32); + border-radius: 10px; + padding: 0.34rem 0.42rem; + background: rgba(248, 250, 252, 0.8); + display: grid; + gap: 0.1rem; +} + +.post-author-stats dt { + margin: 0; + color: #64748b; + font-size: 0.7rem; + line-height: 1.3; +} + +.post-author-stats dd { + margin: 0; + color: #1e293b; + font-size: 0.82rem; + line-height: 1.2; + font-family: var(--font-mono); +} + +.post-reading-rail-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.46rem; +} + +.post-reading-rail-head strong { + border: 1px solid rgba(99, 102, 241, 0.28); + border-radius: 999px; + padding: 0.14rem 0.42rem; + color: #4338ca; + background: rgba(99, 102, 241, 0.08); + font-size: 0.68rem; + line-height: 1.35; + font-weight: 700; + white-space: nowrap; + max-width: 14ch; + overflow: hidden; + text-overflow: ellipsis; +} + +.post-reading-inline { + display: grid; + gap: 0.28rem; +} + +.post-reading-inline-text { + color: #334155; + font-size: 0.72rem; + font-family: var(--font-mono); + line-height: 1.2; +} + +.post-reading-inline-bar { + width: 100%; + height: 6px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.3); + overflow: hidden; +} + +.post-reading-inline-fill { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #4f46e5 0%, #6366f1 100%); + transition: width 0.12s linear; +} + +.post-reading-rail-actions { + display: inline-flex; + align-items: center; + gap: 0.44rem; + flex-wrap: wrap; +} + +.post-related-quick-list, +.post-related-quick ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.36rem; +} + +.post-related-quick-card, +.post-related-quick li { + min-width: 0; + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: 10px; + padding: 0.36rem 0.44rem; + background: rgba(248, 250, 252, 0.78); + display: grid; + gap: 0.24rem; +} + +.post-related-quick-card.tone-1 { + border-color: rgba(99, 102, 241, 0.28); + background: linear-gradient(140deg, rgba(99, 102, 241, 0.09), rgba(248, 250, 252, 0.78)); +} + +.post-related-quick-card.tone-2 { + border-color: rgba(14, 165, 233, 0.28); + background: linear-gradient(140deg, rgba(14, 165, 233, 0.09), rgba(248, 250, 252, 0.78)); +} + +.post-related-quick-card.tone-3 { + border-color: rgba(16, 185, 129, 0.28); + background: linear-gradient(140deg, rgba(16, 185, 129, 0.09), rgba(248, 250, 252, 0.78)); +} + +.post-related-quick-card.tone-4 { + border-color: rgba(245, 158, 11, 0.3); + background: linear-gradient(140deg, rgba(245, 158, 11, 0.1), rgba(248, 250, 252, 0.78)); +} + +.post-related-kicker { + justify-self: start; + border: 1px solid rgba(99, 102, 241, 0.24); + border-radius: 999px; + padding: 0.08rem 0.36rem; + background: rgba(99, 102, 241, 0.1); + color: #4338ca; + font-size: 0.64rem; + line-height: 1.3; + font-weight: 700; +} + +.post-related-quick-link, +.post-related-quick a { + color: #0f172a; + text-decoration: none; + font-size: 0.77rem; + line-height: 1.5; + font-weight: 600; +} + +.post-related-quick-link:hover, +.post-related-quick a:hover { + color: #4f46e5; +} + +.post-related-quick-link:focus-visible, +.post-related-quick a:focus-visible { + outline: 2px solid rgba(79, 70, 229, 0.42); + outline-offset: 2px; + border-radius: 5px; +} + +.post-related-quick-meta { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + color: #64748b; + font-size: 0.68rem; + line-height: 1.4; +} + +.post-related-quick p { + margin: 0; + color: #64748b; + font-size: 0.75rem; + line-height: 1.5; +} + +.post-decision-strip { + border: 1px solid rgba(3, 105, 161, 0.24); + border-radius: 12px; + padding: 0.7rem 0.74rem; + margin: 0 0 0.78rem; + background: + linear-gradient(150deg, rgba(3, 105, 161, 0.1), rgba(15, 23, 42, 0.03)), + #ffffff; + display: grid; + gap: 0.62rem; +} + +.post-decision-copy h2 { + margin: 0 0 0.24rem; + font-size: 0.92rem; + letter-spacing: 0.01em; +} + +.post-decision-copy p { + margin: 0; + font-size: 0.81rem; + color: #475569; + line-height: 1.6; +} + +.post-trust-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.42rem; +} + +.post-trust-list li { + border: 1px solid rgba(15, 23, 42, 0.13); + border-radius: 999px; + padding: 0.18rem 0.5rem; + background: rgba(255, 255, 255, 0.88); + color: #334155; + font-size: 0.74rem; + font-weight: 600; + line-height: 1.45; +} + +.post-decision-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.44rem; +} + +.post-decision-prefill { + margin: 0; + color: #0369a1; + font-size: 0.74rem; + line-height: 1.45; + font-weight: 600; +} + +.post-decision-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.45rem; +} + +.post-decision-metrics article { + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: 10px; + padding: 0.44rem 0.5rem; + background: rgba(255, 255, 255, 0.86); + display: flex; + flex-direction: column; + gap: 0.16rem; +} + +.post-decision-metrics span { + color: #64748b; + font-size: 0.72rem; + line-height: 1.35; +} + +.post-decision-metrics strong { + color: #0f172a; + font-size: 0.86rem; + line-height: 1.2; + font-family: var(--font-mono); +} + .topic-metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -979,6 +1539,36 @@ textarea:focus-visible { font-family: var(--font-mono); } +.post-proof-band { + margin: 0 0 0.76rem; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; +} + +.post-proof-item { + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: 10px; + padding: 0.42rem 0.5rem; + background: rgba(255, 255, 255, 0.78); + display: flex; + flex-direction: column; + gap: 0.18rem; +} + +.post-proof-item strong { + color: #0f172a; + font-size: 0.9rem; + line-height: 1.1; + font-family: var(--font-mono); +} + +.post-proof-item span { + color: #475569; + font-size: 0.74rem; + line-height: 1.35; +} + .topic-action-bar { display: flex; align-items: center; @@ -987,6 +1577,21 @@ textarea:focus-visible { margin-bottom: 0.72rem; } +.topic-action-bar form { + margin: 0; +} + +.copy-link-feedback { + min-height: 20px; + color: #0369a1; + font-size: 0.76rem; + font-weight: 600; +} + +.copy-link-feedback.is-error { + color: var(--red); +} + .topic-post-content { white-space: normal; color: var(--text); @@ -994,6 +1599,12 @@ textarea:focus-visible { font-size: 0.95rem; } +.topic-post-content h2, +.topic-post-content h3, +#comments-panel { + scroll-margin-top: 92px; +} + .post-commercial-cta { margin-top: 0.9rem; border: 1px solid rgba(3, 105, 161, 0.26); @@ -1007,6 +1618,15 @@ textarea:focus-visible { flex-wrap: wrap; } +.post-commercial-cta-intent { + border-color: rgba(15, 23, 42, 0.3); + background: linear-gradient(145deg, rgba(15, 23, 42, 0.1), rgba(3, 105, 161, 0.07)); +} + +.post-commercial-cta-intent .post-commercial-copy h3 { + color: #020617; +} + .post-commercial-copy h3 { margin: 0 0 0.22rem; font-size: 0.95rem; @@ -1027,6 +1647,16 @@ textarea:focus-visible { flex-wrap: wrap; } +.forum-btn-primary.cta-emphasis { + background: linear-gradient(135deg, #0f172a 0%, #0369a1 100%); + border-color: #0f172a; +} + +.forum-btn-primary.cta-emphasis:hover { + background: linear-gradient(135deg, #020617 0%, #075985 100%); + border-color: #020617; +} + .post-resource-links { margin-top: 0.9rem; border: 1px solid rgba(148, 163, 184, 0.34); @@ -1118,6 +1748,250 @@ textarea:focus-visible { margin-bottom: 0; } +.post-outline-panel { + margin-top: 0.82rem; + border: 1px dashed rgba(51, 65, 85, 0.25); + border-radius: 11px; + padding: 0.68rem 0.74rem; + background: rgba(248, 250, 252, 0.75); +} + +.post-outline-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.44rem; +} + +.post-outline-head h2 { + margin: 0; + font-size: 0.9rem; +} + +.post-outline-list { + margin: 0; + padding-left: 1.1rem; + display: grid; + gap: 0.32rem; +} + +.post-outline-list li { + color: #475569; + font-size: 0.8rem; + line-height: 1.45; +} + +.post-outline-list li.outline-level-3 { + margin-left: 0.55rem; +} + +.post-outline-list a { + color: #0f172a; + text-decoration: none; + border-bottom: 1px dashed rgba(3, 105, 161, 0.35); + transition: var(--transition); +} + +.post-outline-list a:hover { + color: #0369a1; +} + +.post-outline-list a.is-active { + color: #4338ca; + border-bottom-color: rgba(79, 70, 229, 0.48); + font-weight: 700; +} + +.post-inline-plan-panel { + margin-top: 0.82rem; + border: 1px solid rgba(15, 23, 42, 0.16); + border-radius: 12px; + padding: 0.72rem 0.76rem; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.72)); +} + +.post-inline-plan-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.55rem; + flex-wrap: wrap; + margin-bottom: 0.54rem; +} + +.post-inline-plan-head h2 { + margin: 0; + font-size: 0.93rem; +} + +.post-inline-plan-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.48rem; +} + +.post-inline-plan-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.55rem; + border: 1px solid var(--border); + border-radius: 9px; + padding: 0.48rem 0.54rem; + background: #ffffff; +} + +.post-inline-plan-main { + min-width: 0; +} + +.post-inline-plan-main a { + display: block; + color: #020617; + text-decoration: none; + font-size: 0.84rem; + font-weight: 700; + line-height: 1.4; + transition: color 0.2s ease; +} + +.post-inline-plan-main a:hover { + color: #0369a1; +} + +.post-inline-plan-main p { + margin: 0.22rem 0 0; + color: #64748b; + font-size: 0.74rem; + line-height: 1.45; +} + +.post-inline-plan-list strong { + flex-shrink: 0; + color: #0f172a; + font-size: 0.82rem; + font-family: var(--font-mono); +} + +.post-requirement-brief { + margin-top: 0.84rem; + border: 1px solid rgba(15, 23, 42, 0.2); + border-radius: 12px; + padding: 0.72rem 0.76rem; + background: + linear-gradient(140deg, rgba(15, 23, 42, 0.06), rgba(3, 105, 161, 0.05)), + #ffffff; + display: grid; + gap: 0.7rem; +} + +.post-requirement-copy h2 { + margin: 0 0 0.34rem; + font-size: 0.93rem; + color: #0f172a; +} + +.post-requirement-copy p { + margin: 0; + color: #475569; + font-size: 0.79rem; + line-height: 1.58; +} + +.post-requirement-preview { + margin-top: 0.5rem; + border: 1px solid rgba(148, 163, 184, 0.38); + border-radius: 9px; + padding: 0.46rem 0.54rem; + background: rgba(248, 250, 252, 0.8); + display: grid; + gap: 0.2rem; +} + +.post-requirement-preview span { + color: #64748b; + font-size: 0.72rem; +} + +.post-requirement-preview strong { + color: #0f172a; + font-size: 0.8rem; + line-height: 1.45; +} + +.post-requirement-tips { + margin: 0.52rem 0 0; + padding-left: 1.05rem; + display: grid; + gap: 0.24rem; +} + +.post-requirement-tips li { + color: #334155; + font-size: 0.76rem; + line-height: 1.48; +} + +.post-requirement-actions { + display: inline-flex; + align-items: center; + gap: 0.46rem; + flex-wrap: wrap; +} + +.post-mobile-conversion-bar { + display: none; +} + +.post-mobile-conversion-main { + display: flex; + flex-direction: column; + gap: 0.14rem; + min-width: 0; +} + +.post-mobile-conversion-main strong { + color: #f8fafc; + font-size: 0.76rem; + line-height: 1.3; +} + +.post-mobile-conversion-main span { + color: rgba(226, 232, 240, 0.9); + font-size: 0.68rem; + line-height: 1.35; +} + +.post-mobile-conversion-actions { + display: flex; + align-items: center; + gap: 0.42rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.post-mobile-conversion-actions .forum-btn-primary, +.post-mobile-conversion-actions .forum-btn-muted { + min-height: 32px; + padding: 0.34rem 0.58rem; + font-size: 0.76rem; +} + +.post-mobile-conversion-actions .forum-link { + color: #e2e8f0; + font-size: 0.72rem; + padding: 0.18rem 0.28rem; +} + +.post-mobile-conversion-actions .topic-empty { + margin: 0; + color: #cbd5e1; + font-size: 0.7rem; +} + .comment-form .form-group, .post-form .form-group { margin-bottom: 0.76rem; @@ -1229,6 +2103,84 @@ textarea:focus-visible { color: var(--text); line-height: 1.58; font-size: 0.9rem; + transition: max-height 0.22s ease; +} + +.comment-content.is-collapsed { + max-height: 10.2rem; + overflow: hidden; + position: relative; +} + +.comment-content.is-collapsed::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2.2rem; + background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.98)); + pointer-events: none; +} + +.comment-toggle-btn { + display: none; + align-items: center; + border: none; + background: none; + color: #4f46e5; + font-size: 0.74rem; + line-height: 1.25; + padding: 0; + margin-top: 0.22rem; + cursor: pointer; +} + +.comment-toggle-btn.is-visible { + display: inline-flex; +} + +.comment-toggle-btn:hover { + text-decoration: underline; +} + +.comment-toggle-btn:focus-visible { + outline: 2px solid rgba(79, 70, 229, 0.42); + outline-offset: 2px; + border-radius: 4px; +} + +.md-content h1, +.md-content h2, +.md-content h3, +.md-content h4 { + margin: 1rem 0 0.56rem; + color: #1e293b; + letter-spacing: -0.01em; + line-height: 1.34; +} + +.md-content h1 { + font-size: 1.22rem; +} + +.md-content h2 { + font-size: 1.08rem; +} + +.md-content h3 { + font-size: 0.96rem; +} + +.md-content h4 { + font-size: 0.88rem; +} + +.md-content h1:first-child, +.md-content h2:first-child, +.md-content h3:first-child, +.md-content h4:first-child { + margin-top: 0; } .md-content p { @@ -1249,25 +2201,73 @@ textarea:focus-visible { margin-top: 0.22rem; } +.md-content strong { + color: #1e293b; +} + .md-content blockquote { margin: 0.75rem 0; padding: 0.45rem 0.8rem; - border-left: 3px solid var(--accent); - background: var(--accent-glow); - color: var(--text-muted); + border-left: 3px solid rgba(111, 124, 255, 0.55); + border-radius: 12px; + background: linear-gradient(140deg, rgba(111, 124, 255, 0.08), rgba(167, 139, 250, 0.06)); + color: #475569; } .md-content a { - color: var(--accent); + color: #4f46e5; text-decoration: underline; text-underline-offset: 2px; } +.md-content table { + width: 100%; + border-collapse: collapse; + margin: 0.8rem 0; + border-radius: 12px; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(255, 255, 255, 0.92); +} + +.md-content th, +.md-content td { + border-bottom: 1px solid rgba(148, 163, 184, 0.2); + padding: 0.45rem 0.56rem; + text-align: left; + font-size: 0.82rem; + line-height: 1.5; +} + +.md-content th { + background: rgba(247, 249, 255, 0.95); + color: #334155; + font-weight: 700; +} + +.md-content tr:last-child td { + border-bottom: none; +} + +.md-content hr { + border: 0; + height: 1px; + margin: 0.92rem 0; + background: linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.55), transparent); +} + +.md-content img { + max-width: 100%; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.24); + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08); +} + .md-content pre { margin: 0.75rem 0; padding: 0.72rem 0.82rem; - border-radius: 10px; - border: 1px solid var(--border); + border-radius: 14px; + border: 1px solid rgba(30, 41, 59, 0.16); background: #0f172a; color: #e2e8f0; overflow-x: auto; @@ -1278,8 +2278,8 @@ textarea:focus-visible { .md-content code { font-family: var(--font-mono); font-size: 0.86em; - background: rgba(2, 132, 199, 0.12); - border: 1px solid rgba(2, 132, 199, 0.18); + background: rgba(111, 124, 255, 0.12); + border: 1px solid rgba(111, 124, 255, 0.18); border-radius: 5px; padding: 0.08rem 0.32rem; } @@ -1569,6 +2569,10 @@ textarea:focus-visible { position: static; } + .side-cta-sticky { + position: static; + } + .forum-sidebar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1582,6 +2586,41 @@ textarea:focus-visible { .topic-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .post-trust-action-hub { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .post-related-quick { + grid-column: 1 / -1; + } + + body.forum-page[data-post-id] { + padding-bottom: 100px; + } + + .post-mobile-conversion-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 125; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.55rem; + padding: 0.5rem 0.75rem calc(0.5rem + env(safe-area-inset-bottom)); + border-top: 1px solid rgba(148, 163, 184, 0.35); + background: linear-gradient(145deg, rgba(2, 6, 23, 0.94), rgba(15, 23, 42, 0.92)); + box-shadow: 0 -10px 24px rgba(2, 6, 23, 0.28); + transition: transform 0.2s ease, opacity 0.2s ease; + } + + .post-mobile-conversion-bar.is-hidden { + transform: translateY(120%); + opacity: 0; + pointer-events: none; + } } @media (max-width: 768px) { @@ -1633,7 +2672,7 @@ textarea:focus-visible { } .forum-shell { - padding: 1.5rem 1rem; + padding: 0.92rem 0.9rem 1.2rem; } .forum-topline { @@ -1694,14 +2733,173 @@ textarea:focus-visible { grid-template-columns: 1fr; } - .topic-metric-grid { + .topic-detail-card { + display: flex; + flex-direction: column; + gap: 0.56rem; + } + + .topic-detail-card h1 { + margin-top: 0.16rem; + margin-bottom: 0.28rem; + font-size: clamp(1.08rem, 5.8vw, 1.34rem); + } + + .topic-post-author { + margin-bottom: 0.24rem; + } + + .post-mobile-quickjump { + display: inline-flex; + align-items: center; + gap: 0.36rem; + flex-wrap: wrap; + margin: 0; + } + + .post-mobile-quickjump .forum-btn-muted { + min-height: 30px; + padding: 0.28rem 0.48rem; + font-size: 0.74rem; + } + + .topic-detail-card > .post-mobile-quickjump { + order: 4; + } + + .topic-detail-card > .post-reading-layout { + order: 5; + margin-top: 0; + } + + .topic-detail-card > .post-trust-action-hub { + order: 10; + margin: 0; + } + + .topic-detail-card > .post-decision-strip { + order: 11; + margin: 0; + } + + .topic-detail-card > .topic-metric-grid { + order: 12; + margin: 0; grid-template-columns: 1fr; } + .topic-detail-card > .post-proof-band { + order: 13; + margin: 0; + } + + .topic-detail-card > .topic-action-bar { + order: 14; + margin: 0; + } + + .topic-detail-card > .post-resource-links { + order: 15; + margin-top: 0; + } + + .topic-detail-card > .post-faq-panel { + order: 16; + margin-top: 0; + } + + .topic-detail-card > .post-inline-plan-panel { + order: 17; + margin-top: 0; + } + + .topic-detail-card > .post-requirement-brief { + order: 18; + margin-top: 0; + } + + .topic-detail-card > .post-commercial-cta { + order: 19; + margin-top: 0; + } + + .post-reading-layout { + gap: 0.4rem; + } + + .post-trust-action-hub { + grid-template-columns: 1fr; + } + + .post-author-stats { + grid-template-columns: 1fr; + } + + .post-reading-rail-head { + align-items: flex-start; + flex-direction: column; + } + + .post-proof-band { + grid-template-columns: 1fr; + } + + .post-read-progress { + height: 2px; + } + + .topic-detail-meta-row { + gap: 0.34rem 0.45rem; + } + + .topic-detail-meta-row span { + min-height: 26px; + padding: 0.14rem 0.45rem; + } + + .post-decision-strip { + padding: 0.64rem 0.66rem; + } + + .post-decision-actions { + width: 100%; + } + + .post-decision-metrics { + grid-template-columns: 1fr; + } + + .post-inline-plan-list li { + flex-direction: column; + align-items: flex-start; + } + + .post-requirement-brief { + padding: 0.7rem 0.7rem; + } + + .post-requirement-actions { + width: 100%; + } + .post-commercial-cta { padding: 0.7rem 0.72rem; } + body.forum-page[data-post-id] { + padding-bottom: 120px; + } + + .post-mobile-conversion-bar { + grid-template-columns: 1fr; + gap: 0.38rem; + padding: 0.48rem 0.6rem calc(0.45rem + env(safe-area-inset-bottom)); + } + + .post-mobile-conversion-actions { + justify-content: flex-start; + } + .profile-stat-grid, .settings-grid { grid-template-columns: 1fr; @@ -1894,8 +3092,797 @@ textarea:focus-visible { } @media (prefers-reduced-motion: reduce) { + .post-read-progress-bar, + .post-reading-inline-fill { + transition: none !important; + } + * { transition: none !important; animation: none !important; } + + .post-mobile-conversion-bar { + transition: none !important; + } +} + +/* ========================================================================== + Fuwari-Inspired Forum Skin (2026-02) + ========================================================================== */ + +.forum-page { + --accent: #6f7cff; + --accent-dim: #5968f4; + --accent-glow: rgba(111, 124, 255, 0.14); + --border: rgba(148, 163, 184, 0.28); + --bg-card: rgba(255, 255, 255, 0.9); + --bg-elevated: rgba(248, 250, 255, 0.9); + font-family: "Plus Jakarta Sans", "Noto Sans SC", sans-serif; + color: #334155; + background: + radial-gradient(840px 320px at -4% -10%, rgba(111, 124, 255, 0.14), transparent 68%), + radial-gradient(780px 300px at 102% -8%, rgba(167, 139, 250, 0.15), transparent 70%), + linear-gradient(180deg, #f7f9ff 0%, #f3f6ff 100%); +} + +.forum-header { + background: rgba(247, 249, 255, 0.84); + backdrop-filter: blur(18px); + border-bottom: 1px solid rgba(148, 163, 184, 0.24); +} + +.forum-header::before { + height: 1px; + opacity: 0.65; + background: linear-gradient(90deg, rgba(111, 124, 255, 0.22), rgba(167, 139, 250, 0.26), rgba(111, 124, 255, 0.22)); +} + +.forum-logo span { + font-family: "Plus Jakarta Sans", "Noto Sans SC", sans-serif; + font-weight: 700; +} + +.forum-primary-nav a, +.forum-top-nav a, +.forum-link { + border: 1px solid transparent; + border-radius: 999px; +} + +.forum-primary-nav a:hover, +.forum-primary-nav a.active, +.forum-top-nav a:hover, +.forum-top-nav a.active, +.forum-link:hover { + border-color: rgba(111, 124, 255, 0.25); + background: rgba(111, 124, 255, 0.1); +} + +.forum-shell { + max-width: 1280px; +} + +.forum-layout { + grid-template-columns: 304px minmax(0, 1fr); + gap: 1.1rem; +} + +.forum-layout .forum-sidebar { + grid-column: 1; +} + +.forum-layout .topic-stream { + grid-column: 2; +} + +.topic-stream, +.side-card, +.topic-post-card, +.comment-form-card { + border-radius: 22px; + border: 1px solid rgba(148, 163, 184, 0.24); + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(8px); + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.08), + 0 18px 34px rgba(15, 23, 42, 0.06); +} + +.topic-head { + text-transform: none; + letter-spacing: 0.01em; + font-family: "Plus Jakarta Sans", "Noto Sans SC", sans-serif; + font-size: 0.77rem; + font-weight: 600; + color: #64748b; + border-bottom-color: rgba(148, 163, 184, 0.24); + background: linear-gradient(180deg, rgba(247, 249, 255, 0.98), rgba(247, 249, 255, 0.72)); +} + +.topic-row { + border-bottom-color: rgba(148, 163, 184, 0.2); +} + +.topic-row:hover { + background: rgba(245, 247, 255, 0.9); +} + +.topic-avatar { + border: 1px solid rgba(111, 124, 255, 0.24); + background: linear-gradient(145deg, rgba(111, 124, 255, 0.22), rgba(167, 139, 250, 0.15)); + color: #4f46e5; +} + +.topic-title { + color: #1e293b; + font-weight: 700; +} + +.topic-title:hover { + color: #4f46e5; +} + +.topic-category { + border-color: rgba(111, 124, 255, 0.22); + background: rgba(111, 124, 255, 0.1); + color: #4f46e5; +} + +.topic-flag.flag-featured { + color: #9a3412; + border-color: rgba(251, 146, 60, 0.32); + background: rgba(251, 146, 60, 0.13); +} + +.side-card { + padding: 0.86rem 0.9rem; +} + +.side-card h3 { + text-transform: none; + letter-spacing: 0; + font-family: "Plus Jakarta Sans", "Noto Sans SC", sans-serif; + font-size: 0.9rem; + color: #334155; +} + +.forum-btn-primary { + border: 1px solid #616ef8; + background: linear-gradient(135deg, #616ef8 0%, #8075ff 100%); +} + +.forum-btn-primary:hover { + border-color: #555fe6; + background: linear-gradient(135deg, #555fe6 0%, #7468f3 100%); + box-shadow: 0 8px 18px rgba(99, 102, 241, 0.3); +} + +.forum-btn-muted { + border-color: rgba(148, 163, 184, 0.34); + background: rgba(255, 255, 255, 0.88); +} + +.forum-btn-muted:hover, +.forum-btn-muted.active { + border-color: rgba(111, 124, 255, 0.36); + background: rgba(111, 124, 255, 0.1); + color: #4f46e5; +} + +.topic-post-card h1, +.topic-post-card h2 { + color: #1e293b; +} + +.topic-post-content { + line-height: 1.82; + color: #334155; +} + +.topic-post-content h2, +.topic-post-content h3 { + color: #1e293b; +} + +.topic-post-content blockquote { + margin: 0.92rem 0; + border-left: 3px solid rgba(111, 124, 255, 0.55); + border-radius: 12px; + padding: 0.58rem 0.72rem; + background: linear-gradient(140deg, rgba(111, 124, 255, 0.08), rgba(167, 139, 250, 0.06)); + color: #4338ca; +} + +.topic-post-content pre { + border: 1px solid rgba(30, 41, 59, 0.15); + border-radius: 14px; +} + +.post-read-progress-bar { + background: linear-gradient(90deg, #616ef8 0%, #8075ff 100%); + box-shadow: 0 0 10px rgba(111, 124, 255, 0.35); +} + +.post-decision-strip, +.post-trust-action-hub > article, +.post-commercial-cta, +.post-resource-links, +.post-faq-panel, +.post-inline-plan-panel, +.post-requirement-brief, +.post-outline-panel { + border-color: rgba(111, 124, 255, 0.22); + background: + linear-gradient(145deg, rgba(111, 124, 255, 0.08), rgba(167, 139, 250, 0.05)), + rgba(255, 255, 255, 0.9); +} + +.post-decision-prefill { + color: #4f46e5; +} + +.post-mobile-conversion-bar { + border-top-color: rgba(129, 140, 248, 0.35); + background: linear-gradient(145deg, rgba(67, 56, 202, 0.94), rgba(79, 70, 229, 0.92)); +} + +.post-mobile-conversion-actions .forum-link, +.post-mobile-conversion-main span { + color: rgba(224, 231, 255, 0.94); +} + +.post-mobile-conversion-main strong { + color: #f8fafc; +} + +.post-editor-layout { + grid-template-columns: 304px minmax(0, 1fr); +} + +.post-editor-layout .post-helper { + grid-column: 1; +} + +.post-editor-layout .post-editor { + grid-column: 2; +} + +.profile-layout { + grid-template-columns: 304px minmax(0, 1fr); +} + +.profile-layout .forum-sidebar { + grid-column: 1; +} + +.profile-layout .profile-main { + grid-column: 2; +} + +@media (max-width: 980px) { + .forum-layout .forum-sidebar, + .forum-layout .topic-stream, + .post-editor-layout .post-helper, + .post-editor-layout .post-editor, + .profile-layout .forum-sidebar, + .profile-layout .profile-main { + grid-column: auto; + } +} + +/* ========================================================================== + Fuwari Chronicle List Refinement + ========================================================================== */ + +.forum-topline, +.forum-tools { + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 16px; + padding: 0.72rem 0.78rem; + background: rgba(255, 255, 255, 0.88); + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.06), + 0 12px 22px rgba(15, 23, 42, 0.05); +} + +.forum-topline { + margin-bottom: 0.74rem; +} + +.forum-tools { + margin-bottom: 0.86rem; +} + +.topic-stream { + padding: 0.36rem; +} + +.topic-list-chronicle { + display: grid; + gap: 0.66rem; + padding: 0.18rem; +} + +.topic-row-chronicle { + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 16px; + background: rgba(255, 255, 255, 0.92); + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 8px 18px rgba(15, 23, 42, 0.05); + min-height: 0; + padding: 0.72rem 0.78rem; + align-items: flex-start; + gap: 0.46rem; + grid-template-columns: minmax(0, 1fr) auto auto auto; +} + +.topic-row-chronicle .topic-main { + padding: 0; + align-items: flex-start; +} + +.topic-visual { + width: 58px; + height: 58px; + border-radius: 14px; + border: 1px solid rgba(129, 140, 248, 0.25); + background: linear-gradient(145deg, rgba(129, 140, 248, 0.24), rgba(167, 139, 250, 0.15)); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + flex-shrink: 0; + transition: transform 0.22s ease, box-shadow 0.22s ease, border-color 0.22s ease; +} + +.topic-visual::before { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(120px 42px at 18% -14%, rgba(255, 255, 255, 0.58), transparent 58%), + linear-gradient(120deg, rgba(255, 255, 255, 0.18), transparent 42%); +} + +.topic-visual span { + position: relative; + z-index: 1; + color: #312e81; + font-family: var(--font-mono); + font-size: 1rem; + font-weight: 700; + line-height: 1; +} + +.topic-visual.tone-1 { + border-color: rgba(99, 102, 241, 0.28); + background: linear-gradient(145deg, rgba(99, 102, 241, 0.25), rgba(167, 139, 250, 0.18)); +} + +.topic-visual.tone-2 { + border-color: rgba(14, 165, 233, 0.28); + background: linear-gradient(145deg, rgba(14, 165, 233, 0.22), rgba(59, 130, 246, 0.14)); +} + +.topic-visual.tone-3 { + border-color: rgba(16, 185, 129, 0.28); + background: linear-gradient(145deg, rgba(16, 185, 129, 0.22), rgba(45, 212, 191, 0.14)); +} + +.topic-visual.tone-4 { + border-color: rgba(245, 158, 11, 0.3); + background: linear-gradient(145deg, rgba(245, 158, 11, 0.24), rgba(251, 146, 60, 0.16)); +} + +.topic-row-chronicle .topic-content { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.topic-cover { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.4rem; + border-radius: 12px; + border: 1px solid rgba(129, 140, 248, 0.22); + padding: 0.26rem 0.5rem; + margin-bottom: 0.22rem; + background: linear-gradient(140deg, rgba(129, 140, 248, 0.16), rgba(167, 139, 250, 0.1)); + transition: transform 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease; +} + +.topic-cover.tone-1 { + background: linear-gradient(140deg, rgba(99, 102, 241, 0.17), rgba(167, 139, 250, 0.11)); +} + +.topic-cover.tone-2 { + background: linear-gradient(140deg, rgba(14, 165, 233, 0.16), rgba(59, 130, 246, 0.1)); + border-color: rgba(14, 165, 233, 0.22); +} + +.topic-cover.tone-3 { + background: linear-gradient(140deg, rgba(16, 185, 129, 0.16), rgba(45, 212, 191, 0.1)); + border-color: rgba(16, 185, 129, 0.22); +} + +.topic-cover.tone-4 { + background: linear-gradient(140deg, rgba(245, 158, 11, 0.16), rgba(251, 146, 60, 0.1)); + border-color: rgba(245, 158, 11, 0.24); +} + +.topic-cover-kicker { + color: #1e293b; + font-size: 0.64rem; + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; + line-height: 1.2; +} + +.topic-cover-chip { + justify-self: start; + min-width: 0; + color: #4f46e5; + font-size: 0.68rem; + font-weight: 700; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.topic-cover-time { + color: #475569; + font-size: 0.66rem; + font-family: var(--font-mono); + line-height: 1.2; + white-space: nowrap; +} + +.topic-row-chronicle .topic-title { + margin-bottom: 0; +} + +.topic-row-chronicle .topic-meta { + gap: 0.3rem 0.4rem; +} + +.topic-row-chronicle .topic-stat { + min-width: 66px; + border: 1px solid rgba(148, 163, 184, 0.26); + border-radius: 12px; + background: rgba(247, 249, 255, 0.9); + padding: 0.22rem 0.44rem; + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + gap: 0.08rem; + text-align: right; + color: #475569; +} + +.topic-row-chronicle .topic-stat::before { + content: attr(data-label); + color: #64748b; + font-size: 0.63rem; + font-weight: 600; + line-height: 1; +} + +.topic-row-chronicle .topic-stat strong { + display: block; + font-family: var(--font-mono); + font-size: 0.76rem; + line-height: 1.2; + color: #334155; +} + +.topic-row-chronicle .topic-stat.topic-activity strong { + font-family: "Plus Jakarta Sans", "Noto Sans SC", sans-serif; + color: #4f46e5; + font-size: 0.72rem; + font-weight: 700; +} + +.topic-row-chronicle:hover { + border-color: rgba(129, 140, 248, 0.34); + background: rgba(244, 246, 255, 0.96); + transform: translateY(-1px); +} + +.topic-row-chronicle:hover .topic-visual { + border-color: rgba(129, 140, 248, 0.44); + transform: translateY(-2px) scale(1.02); + box-shadow: 0 10px 18px rgba(99, 102, 241, 0.2); +} + +.topic-row-chronicle:hover .topic-cover { + border-color: rgba(129, 140, 248, 0.34); + transform: translateY(-1px); + box-shadow: 0 8px 16px rgba(99, 102, 241, 0.14); +} + +.topic-head { + display: none; +} + +.topic-footer { + border-top: none; + background: transparent; + padding: 0.48rem 0.56rem 0.66rem; +} + +.post-reading-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 240px; + gap: 0.76rem; + align-items: start; +} + +.post-reading-layout .topic-post-content { + max-width: 76ch; + width: 100%; +} + +.post-outline-floating { + position: sticky; + top: 104px; + margin-top: 0; +} + +@media (max-width: 980px) { + .topic-row-chronicle { + grid-template-columns: minmax(0, 1fr) auto auto; + } + + .topic-row-chronicle .topic-stat.topic-activity { + grid-column: 2 / span 2; + } + + .post-reading-layout { + grid-template-columns: 1fr; + gap: 0.58rem; + } + + .post-reading-layout .topic-post-content { + max-width: none; + } + + .post-outline-floating { + position: static; + top: auto; + } +} + +@media (max-width: 768px) { + .topic-row-chronicle { + grid-template-columns: 1fr; + gap: 0.36rem; + } + + .topic-row-chronicle .topic-stat, + .topic-row-chronicle .topic-stat.topic-activity { + grid-column: auto; + min-width: 0; + width: 100%; + flex-direction: row; + align-items: center; + justify-content: space-between; + text-align: left; + } + + .topic-row-chronicle .topic-stat::before { + font-size: 0.66rem; + } + + .topic-visual { + width: 52px; + height: 52px; + border-radius: 12px; + } + + .topic-visual span { + font-size: 0.9rem; + } + + .topic-cover { + grid-template-columns: 1fr auto; + gap: 0.28rem 0.34rem; + align-items: start; + } + + .topic-cover-kicker { + grid-column: 1 / -1; + } + + .topic-cover-time { + justify-self: end; + } + + .forum-topline, + .forum-tools { + padding: 0.66rem 0.68rem; + border-radius: 14px; + } +} + +/* ========================================================================== + Mobile Reading Density Tuning + ========================================================================== */ + +@media (max-width: 768px) { + .topic-post-content { + font-size: 0.9rem; + line-height: 1.72; + letter-spacing: 0.002em; + } + + .topic-post-content h2 { + margin: 0.94rem 0 0.4rem; + font-size: 1rem; + line-height: 1.32; + } + + .topic-post-content h3 { + margin: 0.72rem 0 0.34rem; + font-size: 0.92rem; + line-height: 1.34; + } + + .md-content p { + margin: 0 0 0.58rem; + } + + .md-content ul, + .md-content ol { + margin: 0.42rem 0 0.62rem; + padding-left: 1.1rem; + } + + .md-content li + li { + margin-top: 0.2rem; + } + + .topic-post-content blockquote { + margin: 0.68rem 0; + padding: 0.48rem 0.56rem; + border-radius: 10px; + font-size: 0.84rem; + line-height: 1.55; + } + + .md-content pre { + margin: 0.62rem 0; + padding: 0.5rem 0.56rem; + border-radius: 12px; + font-size: 0.79rem; + line-height: 1.48; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .md-content code { + font-size: 0.8em; + } + + .post-outline-panel { + padding: 0.56rem 0.6rem; + } +} + +/* ========================================================================== + Mobile Comment Density Tuning + ========================================================================== */ + +@media (max-width: 768px) { + #comments-panel { + padding: 0.72rem 0.74rem; + } + + #comments-panel > h2 { + margin-bottom: 0.52rem; + font-size: 0.94rem; + } + + .comment-form .form-group { + margin-bottom: 0.56rem; + } + + .comment-form label { + margin-bottom: 0.24rem; + font-size: 0.78rem; + } + + .comment-form textarea { + min-height: 96px; + padding: 0.46rem 0.52rem; + font-size: 0.86rem; + line-height: 1.48; + } + + .comment-form .form-help { + margin-top: 0.24rem; + font-size: 0.72rem; + line-height: 1.35; + } + + .comment-form .forum-btn-primary { + min-height: 32px; + padding: 0.34rem 0.58rem; + font-size: 0.76rem; + } + + .comment-stream { + margin-top: 0.56rem; + } + + .comment-row { + gap: 0.5rem; + padding: 0.48rem 0; + } + + .comment-avatar { + width: 24px; + height: 24px; + font-size: 0.68rem; + } + + .comment-head { + gap: 0.4rem; + font-size: 0.72rem; + line-height: 1.3; + } + + .comment-author { + font-size: 0.76rem; + } + + .comment-content { + margin-top: 0.22rem; + font-size: 0.84rem; + line-height: 1.52; + } + + .comment-content.is-collapsed { + max-height: 8.4rem; + } + + .comment-content p { + margin: 0 0 0.5rem; + } + + .comment-content p:last-child { + margin-bottom: 0; + } + + .comment-actions { + margin-top: 0.3rem; + gap: 0.34rem; + } + + .comment-actions a, + .btn-link-delete { + font-size: 0.72rem; + } + + .comment-toggle-btn { + font-size: 0.72rem; + margin-top: 0.18rem; + } + + .report-form-inline { + gap: 0.3rem; + } + + .report-form-inline select { + min-height: 30px; + padding: 0.2rem 0.3rem; + font-size: 0.72rem; + } } diff --git a/static/css/style.css b/static/css/style.css index eefc5d0..33244e0 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2692,6 +2692,13 @@ html { flex-wrap: wrap; } +.filters-head-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.34rem; +} + .filters-title { margin: 0; font-size: 1.02rem; @@ -2716,6 +2723,30 @@ html { padding: 0.38rem 0.68rem; } +.filter-source-hint { + margin: 0; + color: #0369a1; + font-size: 0.74rem; + line-height: 1.42; + border-radius: 999px; + border: 1px solid rgba(3, 105, 161, 0.22); + background: rgba(3, 105, 161, 0.08); + padding: 0.24rem 0.58rem; + max-width: 460px; + text-align: right; +} + +.filter-source-hint a { + color: inherit; + font-weight: 700; + text-decoration: none; + border-bottom: 1px dashed rgba(3, 105, 161, 0.5); +} + +.filter-source-hint a:hover { + border-bottom-color: rgba(3, 105, 161, 0.85); +} + .filter-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); @@ -2909,6 +2940,14 @@ html { grid-column: span 2; justify-content: flex-start; } + + .filters-head-meta { + align-items: flex-start; + } + + .filter-source-hint { + text-align: left; + } } @media (max-width: 768px) { diff --git a/static/js/forum-post-detail.js b/static/js/forum-post-detail.js index 63b93e1..d66e5ef 100644 --- a/static/js/forum-post-detail.js +++ b/static/js/forum-post-detail.js @@ -14,6 +14,14 @@ var rawPostId = bodyEl.getAttribute('data-post-id'); var parsedPostId = Number(rawPostId); var postId = Number.isFinite(parsedPostId) ? parsedPostId : null; + var reducedMotionQuery = null; + if (typeof window.matchMedia === 'function') { + reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + } + var enableSmoothScroll = !(reducedMotionQuery && reducedMotionQuery.matches); + var rawCtaVariant = (bodyEl.getAttribute('data-cta-variant') || '').trim().toLowerCase(); + var ctaVariant = (rawCtaVariant === 'control' || rawCtaVariant === 'intent') ? rawCtaVariant : ''; + var deviceType = resolveDeviceType(); function normalizeLabel(value) { return String(value || '') @@ -32,7 +40,9 @@ event_name: normalizedName, label: normalizeLabel(label), post_id: postId, - page_path: (window.location && window.location.pathname) || '' + page_path: (window.location && window.location.pathname) || '', + cta_variant: ctaVariant, + device_type: deviceType }; var payloadText = JSON.stringify(payload); @@ -68,6 +78,526 @@ return target.closest('[data-track-event]'); } + function resolveDeviceType() { + var ua = ((window.navigator && window.navigator.userAgent) || '').toLowerCase(); + var width = window.innerWidth || document.documentElement.clientWidth || 0; + var hasTouch = Boolean(window.navigator && (window.navigator.maxTouchPoints || 0) > 0); + + if (/(ipad|tablet)/.test(ua) || (/android/.test(ua) && !/mobile/.test(ua))) { + return 'tablet'; + } + if (/(iphone|ipod|windows phone|mobile)/.test(ua)) { + return 'mobile'; + } + if (width > 0 && width <= 980) { + return 'mobile'; + } + if (hasTouch && width > 980 && width <= 1280) { + return 'tablet'; + } + return 'desktop'; + } + + function scrollToNode(target) { + if (!target || typeof target.scrollIntoView !== 'function') { + return; + } + target.scrollIntoView({ + behavior: enableSmoothScroll ? 'smooth' : 'auto', + block: 'start' + }); + } + + function fallbackCopy(text) { + var textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'readonly'); + textarea.style.position = 'fixed'; + textarea.style.top = '-9999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + var copied = false; + try { + copied = document.execCommand('copy'); + } catch (err) { + copied = false; + } + document.body.removeChild(textarea); + return copied; + } + + function toAnchorId(rawText, index) { + var safe = String(rawText || '') + .toLowerCase() + .trim() + .replace(/[^\w\u4e00-\u9fff-]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!safe) { + safe = 'section'; + } + return 'post-section-' + safe + '-' + index; + } + + function initCopyPermalink() { + var copyButton = document.querySelector('.js-copy-link'); + var feedbackNode = document.getElementById('copy-link-feedback'); + if (!copyButton || !feedbackNode) { + return; + } + + var successText = copyButton.getAttribute('data-copy-success') || 'Link copied'; + var failedText = copyButton.getAttribute('data-copy-failed') || 'Copy failed'; + var clearTimer = null; + + function setFeedback(text, isError) { + if (clearTimer) { + window.clearTimeout(clearTimer); + } + feedbackNode.textContent = text; + feedbackNode.classList.toggle('is-error', Boolean(isError)); + clearTimer = window.setTimeout(function () { + feedbackNode.textContent = ''; + feedbackNode.classList.remove('is-error'); + }, 2200); + } + + copyButton.addEventListener('click', function () { + var currentUrl = (window.location && window.location.href) || ''; + if (!currentUrl) { + setFeedback(failedText, true); + sendTrack('post_detail_copy_link_failed', 'empty_url'); + return; + } + + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + navigator.clipboard + .writeText(currentUrl) + .then(function () { + setFeedback(successText, false); + sendTrack('post_detail_copy_link_success', 'clipboard_api'); + }) + .catch(function () { + var copied = fallbackCopy(currentUrl); + setFeedback(copied ? successText : failedText, !copied); + sendTrack(copied ? 'post_detail_copy_link_success' : 'post_detail_copy_link_failed', copied ? 'clipboard_fallback' : 'clipboard_failed'); + }); + return; + } + + var copied = fallbackCopy(currentUrl); + setFeedback(copied ? successText : failedText, !copied); + sendTrack(copied ? 'post_detail_copy_link_success' : 'post_detail_copy_link_failed', copied ? 'legacy_exec_command' : 'legacy_failed'); + }); + } + + function initPostOutline() { + var contentRoot = document.querySelector('.topic-post-content'); + var outlinePanel = document.getElementById('post-outline-panel'); + var outlineList = document.getElementById('post-outline-list'); + var currentSectionNode = document.getElementById('post-current-section'); + var defaultSectionLabel = ''; + if (currentSectionNode) { + defaultSectionLabel = currentSectionNode.getAttribute('data-default-label') || currentSectionNode.textContent || ''; + } + if (!contentRoot || !outlinePanel || !outlineList) { + return; + } + + var headings = contentRoot.querySelectorAll('h2, h3'); + if (!headings.length) { + return; + } + + var outlineEntries = []; + + headings.forEach(function (heading, index) { + if (!heading.id) { + heading.id = toAnchorId(heading.textContent, index + 1); + } + var link = document.createElement('a'); + link.href = '#' + heading.id; + link.textContent = normalizeLabel(heading.textContent) || ('Section ' + (index + 1)); + link.setAttribute('data-track-event', 'post_detail_outline_click'); + link.setAttribute('data-track-label', heading.id); + link.setAttribute('data-outline-target', heading.id); + + var item = document.createElement('li'); + if (String(heading.tagName || '').toLowerCase() === 'h3') { + item.className = 'outline-level-3'; + } + item.appendChild(link); + outlineList.appendChild(item); + outlineEntries.push({ heading: heading, link: link }); + }); + + outlinePanel.hidden = false; + + if (!outlineEntries.length) { + return; + } + + function setActiveOutline(targetId) { + var activeLabel = ''; + outlineEntries.forEach(function (entry) { + var isActive = entry.heading.id === targetId; + entry.link.classList.toggle('is-active', isActive); + if (isActive) { + activeLabel = normalizeLabel(entry.heading.textContent); + } + }); + if (currentSectionNode) { + currentSectionNode.textContent = activeLabel || defaultSectionLabel || ''; + } + } + + function resolveActiveHeadingId() { + var doc = document.documentElement; + var scrollTop = window.pageYOffset || (doc && doc.scrollTop) || 0; + var anchorLine = scrollTop + 132; + var currentId = outlineEntries[0].heading.id; + + outlineEntries.forEach(function (entry) { + var top = entry.heading.getBoundingClientRect().top + scrollTop; + if (top <= anchorLine) { + currentId = entry.heading.id; + } + }); + + return currentId; + } + + var ticking = false; + function syncActiveOutline() { + ticking = false; + setActiveOutline(resolveActiveHeadingId()); + } + + function onViewportChange() { + if (ticking) { + return; + } + ticking = true; + window.requestAnimationFrame(syncActiveOutline); + } + + outlineEntries.forEach(function (entry) { + entry.link.addEventListener('click', function () { + setActiveOutline(entry.heading.id); + }); + }); + + syncActiveOutline(); + window.addEventListener('scroll', onViewportChange, { passive: true }); + window.addEventListener('resize', onViewportChange); + } + + function initProgressBar() { + var progressNode = document.getElementById('post-read-progress-bar'); + var inlineFillNode = document.getElementById('post-read-progress-inline-fill'); + var inlineTextNode = document.getElementById('post-read-progress-text'); + if (!progressNode && !inlineFillNode && !inlineTextNode) { + return; + } + var ticking = false; + + function updateProgress() { + ticking = false; + var doc = document.documentElement; + if (!doc) { + return; + } + var scrollTop = doc.scrollTop || document.body.scrollTop || 0; + var scrollHeight = doc.scrollHeight - doc.clientHeight; + if (scrollHeight <= 0) { + if (progressNode) { + progressNode.style.width = '0%'; + } + if (inlineFillNode) { + inlineFillNode.style.width = '0%'; + } + if (inlineTextNode) { + inlineTextNode.textContent = '0%'; + } + return; + } + var progress = Math.min(1, Math.max(0, scrollTop / scrollHeight)); + var progressPercent = (progress * 100).toFixed(2) + '%'; + if (progressNode) { + progressNode.style.width = progressPercent; + } + if (inlineFillNode) { + inlineFillNode.style.width = progressPercent; + } + if (inlineTextNode) { + inlineTextNode.textContent = Math.round(progress * 100) + '%'; + } + } + + function onScroll() { + if (ticking) { + return; + } + ticking = true; + window.requestAnimationFrame(updateProgress); + } + + updateProgress(); + window.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onScroll); + } + + function initCtaImpression() { + var variant = (bodyEl.getAttribute('data-cta-variant') || 'control').trim().toLowerCase(); + if (!variant) { + variant = 'control'; + } + sendTrack('post_detail_cta_impression', variant); + } + + function initMobileConversionBar() { + var mobileBar = document.querySelector('[data-mobile-conversion-bar]'); + var commentsPanel = document.getElementById('comments-panel'); + if (!mobileBar || !commentsPanel || typeof window.IntersectionObserver !== 'function') { + return; + } + var impressionSent = false; + + function isMobileViewport() { + if (typeof window.matchMedia === 'function') { + return window.matchMedia('(max-width: 980px)').matches; + } + return window.innerWidth <= 980; + } + + var observer = new window.IntersectionObserver(function (entries) { + var first = entries && entries[0]; + if (!first) { + return; + } + var shouldHide = Boolean(first.isIntersecting); + mobileBar.classList.toggle('is-hidden', shouldHide); + if (!shouldHide && !impressionSent && isMobileViewport()) { + impressionSent = true; + sendTrack('post_detail_mobile_bar_impression', 'mobile_bar_visible'); + } + }, { + threshold: 0.14 + }); + + observer.observe(commentsPanel); + } + + function initCommentCollapse() { + var commentNodes = document.querySelectorAll('.comment-content'); + if (!commentNodes.length) { + return; + } + + var docLang = ((document.documentElement && document.documentElement.lang) || '').toLowerCase(); + var isZh = docLang.indexOf('zh') === 0; + var expandText = isZh ? '展开全文' : 'Read more'; + var collapseText = isZh ? '收起' : 'Collapse'; + var collapseHeightMobile = 134; + var entries = []; + var mediaQuery = (typeof window.matchMedia === 'function') ? window.matchMedia('(max-width: 768px)') : null; + var storageKey = postId ? ('forum_post_comment_expanded_' + postId) : ''; + var expandedState = readExpandedState(); + + function readExpandedState() { + if (!storageKey || !window.localStorage) { + return Object.create(null); + } + try { + var raw = window.localStorage.getItem(storageKey); + if (!raw) { + return Object.create(null); + } + var parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + return Object.create(null); + } + return parsed; + } catch (err) { + return Object.create(null); + } + } + + function persistExpandedState() { + if (!storageKey || !window.localStorage) { + return; + } + try { + var keys = Object.keys(expandedState); + if (!keys.length) { + window.localStorage.removeItem(storageKey); + return; + } + window.localStorage.setItem(storageKey, JSON.stringify(expandedState)); + } catch (err) { + // Ignore persistence failures silently. + } + } + + function setExpandedState(label, expanded) { + if (!storageKey) { + return; + } + if (expanded) { + expandedState[label] = 1; + } else { + delete expandedState[label]; + } + persistExpandedState(); + } + + function isMobileViewport() { + if (mediaQuery) { + return mediaQuery.matches; + } + return window.innerWidth <= 768; + } + + function resolveCollapseHeight() { + return collapseHeightMobile; + } + + function setContentMaxHeight(node, maxHeightPx) { + if (!node || !node.style) { + return; + } + if (!Number.isFinite(maxHeightPx) || maxHeightPx <= 0) { + node.style.maxHeight = ''; + return; + } + node.style.maxHeight = String(Math.ceil(maxHeightPx)) + 'px'; + } + + function findCommentLabel(node, fallbackIndex) { + var row = node && node.closest ? node.closest('.comment-row') : null; + if (row && row.id) { + return row.id; + } + return 'comment-' + (fallbackIndex + 1); + } + + function ensureToggleNode(entry) { + var contentNode = entry.content; + var row = contentNode && contentNode.closest ? contentNode.closest('.comment-row') : null; + var parentNode = contentNode && contentNode.parentNode; + if (!parentNode) { + return; + } + + var toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'comment-toggle-btn'; + toggle.hidden = true; + + if (!contentNode.id) { + contentNode.id = entry.label + '-content'; + } + toggle.setAttribute('aria-controls', contentNode.id); + toggle.setAttribute('aria-expanded', 'false'); + + var actions = row ? row.querySelector('.comment-actions') : null; + if (actions && actions.parentNode === parentNode) { + parentNode.insertBefore(toggle, actions); + } else if (contentNode.nextSibling) { + parentNode.insertBefore(toggle, contentNode.nextSibling); + } else { + parentNode.appendChild(toggle); + } + + entry.toggle = toggle; + toggle.addEventListener('click', function () { + entry.expanded = !entry.expanded; + renderEntry(entry, true); + }); + } + + function measureEntry(entry) { + var node = entry.content; + node.classList.remove('is-collapsed'); + setContentMaxHeight(node, null); + entry.fullHeight = Math.ceil(node.scrollHeight || 0); + entry.longEnough = entry.fullHeight > resolveCollapseHeight(); + } + + function renderEntry(entry, fromUserAction) { + if (!entry.toggle || !entry.content) { + return; + } + + var canCollapse = isMobileViewport() && entry.longEnough; + if (!canCollapse) { + entry.content.classList.remove('is-collapsed'); + setContentMaxHeight(entry.content, null); + entry.toggle.classList.remove('is-visible'); + entry.toggle.hidden = true; + entry.toggle.setAttribute('aria-expanded', 'true'); + return; + } + + entry.toggle.hidden = false; + entry.toggle.classList.add('is-visible'); + var isCollapsed = !entry.expanded; + entry.content.classList.toggle('is-collapsed', isCollapsed); + setContentMaxHeight(entry.content, isCollapsed ? resolveCollapseHeight() : Math.max(resolveCollapseHeight(), entry.fullHeight)); + entry.toggle.textContent = isCollapsed ? expandText : collapseText; + entry.toggle.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true'); + + if (fromUserAction) { + setExpandedState(entry.label, entry.expanded); + sendTrack(isCollapsed ? 'post_detail_comment_collapse' : 'post_detail_comment_expand', entry.label); + } + } + + commentNodes.forEach(function (node, index) { + var label = findCommentLabel(node, index); + var entry = { + label: label, + content: node, + toggle: null, + expanded: Boolean(expandedState[label]), + longEnough: false, + fullHeight: 0 + }; + ensureToggleNode(entry); + entries.push(entry); + }); + + if (!entries.length) { + return; + } + + function syncCollapseState() { + entries.forEach(function (entry) { + measureEntry(entry); + renderEntry(entry, false); + }); + } + + var ticking = false; + function onViewportChange() { + if (ticking) { + return; + } + ticking = true; + window.requestAnimationFrame(function () { + ticking = false; + syncCollapseState(); + }); + } + + syncCollapseState(); + window.addEventListener('resize', onViewportChange); + window.addEventListener('load', onViewportChange); + if (mediaQuery && typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', onViewportChange); + } + } + document.addEventListener('click', function (evt) { var node = resolveTrackNode(evt.target); if (!node) { @@ -85,28 +615,34 @@ }); } - var reducedMotionQuery = null; - if (typeof window.matchMedia === 'function') { - reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); - } - var enableSmoothScroll = !(reducedMotionQuery && reducedMotionQuery.matches); - if (!enableSmoothScroll) { - return; - } + initCopyPermalink(); + initPostOutline(); + initProgressBar(); + initCtaImpression(); + initMobileConversionBar(); + initCommentCollapse(); - var jumpLinks = document.querySelectorAll('a[href="#comments-panel"]'); + var jumpLinks = document.querySelectorAll('a[href^="#"]'); if (!jumpLinks.length) { return; } jumpLinks.forEach(function (link) { - link.addEventListener('click', function () { - var panel = document.getElementById('comments-panel'); - if (!panel) { + link.addEventListener('click', function (evt) { + var href = link.getAttribute('href'); + if (!href || href.length < 2 || href.charAt(0) !== '#') { return; } + var target = document.getElementById(href.slice(1)); + if (!target) { + return; + } + evt.preventDefault(); window.setTimeout(function () { - panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + scrollToNode(target); + if (window.history && typeof window.history.replaceState === 'function') { + window.history.replaceState(null, '', href); + } }, 0); }); }); diff --git a/static/js/main-simple.js b/static/js/main-simple.js index 67e36af..8948239 100644 --- a/static/js/main-simple.js +++ b/static/js/main-simple.js @@ -61,6 +61,43 @@ return null; } + function getDisplayRegion(plan) { + return (plan.countries && String(plan.countries).trim()) ? String(plan.countries).trim() : (plan.region || ''); + } + + function getAllRegionOptions(plans) { + var regionSet = new Set(); + plans.forEach(function(plan) { + var countriesText = (plan.countries || '').trim(); + if (countriesText) { + countriesText.split(',').forEach(function(part) { + var regionName = part.trim(); + if (regionName) regionSet.add(regionName); + }); + } + var regionRaw = (plan.region || '').trim(); + if (regionRaw) regionSet.add(regionRaw); + }); + return Array.from(regionSet).sort(); + } + + function matchesRegion(plan, targetRegion) { + if (!targetRegion) return true; + var region = String(targetRegion).trim(); + if (!region) return true; + + var countriesText = (plan.countries || '').trim(); + if (countriesText === region) return true; + if (countriesText) { + var segments = countriesText.split(','); + for (var i = 0; i < segments.length; i++) { + if (segments[i].trim() === region) return true; + } + } + var regionRaw = (plan.region || '').trim(); + return regionRaw === region; + } + // ==================== 初始化 ==================== function init() { fetchData(); @@ -74,6 +111,7 @@ allPlans = window.__INITIAL_PLANS__; updateSummaryMetrics(allPlans); populateFilters(); + applyUrlPrefillFromQuery(); renderTable(); return; } @@ -86,6 +124,7 @@ allPlans = data; updateSummaryMetrics(allPlans); populateFilters(); + applyUrlPrefillFromQuery(); renderTable(); }) .catch(function(error) { @@ -118,15 +157,133 @@ // ==================== 筛选器填充 ==================== function populateFilters() { var providers = new Set(); - var regions = new Set(); allPlans.forEach(function(plan) { providers.add(plan.provider); - regions.add(plan.countries); }); populateSelect('filter-provider', Array.from(providers).sort()); - populateSelect('filter-region', Array.from(regions).sort()); + populateSelect('filter-region', getAllRegionOptions(allPlans)); + } + + function getQueryParams() { + try { + return new URLSearchParams(window.location.search || ''); + } catch (err) { + return new URLSearchParams(''); + } + } + + function hasSelectOption(select, value) { + if (!select || !value) return false; + for (var i = 0; i < select.options.length; i++) { + if (String(select.options[i].value) === String(value)) { + return true; + } + } + return false; + } + + function normalizeMemoryValue(raw) { + var value = parseInt(raw || '0', 10); + if (isNaN(value) || value <= 0) return '0'; + if (value >= 8) return '8'; + if (value >= 4) return '4'; + if (value >= 2) return '2'; + return '1'; + } + + function syncFiltersFromControls() { + var providerEl = document.getElementById('filter-provider'); + var regionEl = document.getElementById('filter-region'); + var memoryEl = document.getElementById('filter-memory'); + var priceEl = document.getElementById('filter-price'); + var currencyEl = document.getElementById('filter-currency'); + var searchEl = document.getElementById('search-input'); + + filters.provider = (providerEl && providerEl.value) || ''; + filters.region = (regionEl && regionEl.value) || ''; + filters.memory = parseFloat((memoryEl && memoryEl.value) || '0') || 0; + filters.price = (priceEl && priceEl.value) || '0'; + filters.currency = (currencyEl && currencyEl.value) || 'CNY'; + filters.search = ((searchEl && searchEl.value) || '').toLowerCase().trim(); + } + + function renderSourceHint(params) { + var hintEl = document.getElementById('filter-source-hint'); + if (!hintEl) return; + + var sourcePostRaw = (params.get('source_post') || '').trim(); + var sourcePost = parseInt(sourcePostRaw, 10); + if (isNaN(sourcePost) || sourcePost <= 0) { + hintEl.hidden = true; + hintEl.textContent = ''; + return; + } + + var sourceTitle = (params.get('source_title') || '').trim(); + if (sourceTitle.length > 56) { + sourceTitle = sourceTitle.slice(0, 56); + } + var sourceHref = '/forum/post/' + sourcePost + (isEnglish ? '?lang=en' : ''); + var summaryParts = []; + if (params.get('provider')) summaryParts.push((isEnglish ? 'provider ' : '厂商 ') + params.get('provider')); + if (params.get('region')) summaryParts.push((isEnglish ? 'region ' : '地区 ') + params.get('region')); + if (params.get('memory')) summaryParts.push((isEnglish ? 'memory ≥' : '内存≥') + normalizeMemoryValue(params.get('memory')) + 'GB'); + if (params.get('price')) summaryParts.push(isEnglish ? 'budget range' : '预算区间'); + + hintEl.textContent = ''; + hintEl.appendChild(document.createTextNode(isEnglish ? 'From ' : '来源:')); + var link = document.createElement('a'); + link.href = sourceHref; + link.target = '_blank'; + link.rel = 'noopener'; + link.textContent = '#' + sourcePost + ' ' + (sourceTitle || (isEnglish ? 'source topic' : '原帖')); + hintEl.appendChild(link); + if (summaryParts.length) { + hintEl.appendChild(document.createTextNode(isEnglish ? ' | Prefill: ' : ' | 预填:')); + hintEl.appendChild(document.createTextNode(summaryParts.slice(0, 3).join(' / '))); + } + hintEl.hidden = false; + } + + function applyUrlPrefillFromQuery() { + var params = getQueryParams(); + var providerEl = document.getElementById('filter-provider'); + var regionEl = document.getElementById('filter-region'); + var memoryEl = document.getElementById('filter-memory'); + var priceEl = document.getElementById('filter-price'); + var currencyEl = document.getElementById('filter-currency'); + var searchEl = document.getElementById('search-input'); + + var provider = (params.get('provider') || '').trim(); + var region = (params.get('region') || '').trim(); + var memory = normalizeMemoryValue(params.get('memory')); + var price = (params.get('price') || '0').trim(); + var currency = (params.get('currency') || '').trim().toUpperCase(); + var search = (params.get('search') || '').trim(); + + if (provider && hasSelectOption(providerEl, provider)) { + providerEl.value = provider; + } + if (region && hasSelectOption(regionEl, region)) { + regionEl.value = region; + } + if (memoryEl && hasSelectOption(memoryEl, memory)) { + memoryEl.value = memory; + } + if (priceEl && hasSelectOption(priceEl, price)) { + priceEl.value = price; + } + if (currencyEl && (currency === 'CNY' || currency === 'USD')) { + currencyEl.value = currency; + } + if (searchEl && search) { + searchEl.value = search; + } + + renderSourceHint(params); + syncFiltersFromControls(); } function populateSelect(id, options) { @@ -244,7 +401,7 @@ if (filters.provider && plan.provider !== filters.provider) return false; // 区域筛选 - if (filters.region && plan.countries !== filters.region) return false; + if (filters.region && !matchesRegion(plan, filters.region)) return false; // 内存筛选 if (filters.memory > 0 && plan.memory_gb < filters.memory) return false; @@ -304,8 +461,12 @@ var tr = document.createElement('tr'); var currentPrice = getPriceValue(plan, filters.currency); var displayPrice = currentPrice ? currentPrice.symbol + currentPrice.value.toFixed(2) : '—'; + var officialUrl = (plan.official_url || '').trim(); var btnText = (window.I18N_JS && window.I18N_JS.btn_visit) || '访问'; + var linkCell = officialUrl + ? '' + btnText + '' + : '-'; tr.innerHTML = '' + escapeHtml(plan.provider) + '' + @@ -317,13 +478,18 @@ '' + (plan.bandwidth_mbps ? plan.bandwidth_mbps + ' Mbps' : '-') + '' + '' + plan.traffic + '' + '' + displayPrice + '' + - '' + - '' + btnText + '' + - ''; + '' + linkCell + ''; return tr; } + function escapeAttr(text) { + if (text == null || text === '') return ''; + var div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML.replace(/"/g, '"'); + } + function escapeHtml(text) { var div = document.createElement('div'); div.textContent = text; @@ -363,7 +529,13 @@ plans.forEach(function(plan) { if (plan.provider) providerSet.add(plan.provider); - if (plan.countries) regionSet.add(plan.countries); + var regionText = getDisplayRegion(plan); + if (regionText) { + regionText.split(',').forEach(function(part) { + var token = part.trim(); + if (token) regionSet.add(token); + }); + } }); setText('metric-total-plans', formatCount(plans.length)); diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index b35b9d3..b9503d4 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -17,6 +17,7 @@ 评论管理 论坛分类 举报审核 + 埋点看板 + 添加配置 导出 Excel 导入 Excel @@ -37,6 +38,7 @@ 评论管理 分类管理 举报审核 + 埋点看板
diff --git a/templates/admin/forum_categories.html b/templates/admin/forum_categories.html index d32ad61..74acfaa 100644 --- a/templates/admin/forum_categories.html +++ b/templates/admin/forum_categories.html @@ -16,6 +16,7 @@ 帖子管理 评论管理 举报审核 + 埋点看板 返回总览 厂商管理 查看前台 diff --git a/templates/admin/forum_category_form.html b/templates/admin/forum_category_form.html index 5a286cf..1ee33e9 100644 --- a/templates/admin/forum_category_form.html +++ b/templates/admin/forum_category_form.html @@ -16,6 +16,7 @@ 帖子管理 评论管理 举报审核 + 埋点看板 返回总览 退出 diff --git a/templates/admin/forum_comment_form.html b/templates/admin/forum_comment_form.html index cdccb95..da28e7c 100644 --- a/templates/admin/forum_comment_form.html +++ b/templates/admin/forum_comment_form.html @@ -14,6 +14,7 @@ ← 评论列表 帖子管理 用户管理 + 埋点看板 总览 退出 diff --git a/templates/admin/forum_comments.html b/templates/admin/forum_comments.html index 0ce330b..4174097 100644 --- a/templates/admin/forum_comments.html +++ b/templates/admin/forum_comments.html @@ -15,6 +15,7 @@ 帖子管理 用户管理 举报审核 + 埋点看板 返回总览 退出 diff --git a/templates/admin/forum_post_form.html b/templates/admin/forum_post_form.html index 12efd22..775e9e2 100644 --- a/templates/admin/forum_post_form.html +++ b/templates/admin/forum_post_form.html @@ -14,6 +14,7 @@ ← 帖子列表 评论管理 用户管理 + 埋点看板 总览 退出 diff --git a/templates/admin/forum_posts.html b/templates/admin/forum_posts.html index 6eff12f..2402181 100644 --- a/templates/admin/forum_posts.html +++ b/templates/admin/forum_posts.html @@ -16,6 +16,7 @@ 用户管理 论坛分类 举报审核 + 埋点看板 返回总览 退出 diff --git a/templates/admin/forum_reports.html b/templates/admin/forum_reports.html index 6482d7d..eb0ea57 100644 --- a/templates/admin/forum_reports.html +++ b/templates/admin/forum_reports.html @@ -16,6 +16,7 @@ 帖子管理 评论管理 论坛分类 + 埋点看板 返回总览 查看前台 退出 diff --git a/templates/admin/forum_tracking.html b/templates/admin/forum_tracking.html new file mode 100644 index 0000000..fdd39e0 --- /dev/null +++ b/templates/admin/forum_tracking.html @@ -0,0 +1,394 @@ + + + + + + 论坛埋点看板 - 后台 + + + + +
+

论坛埋点看板

+ +
+
+ {% if msg %} +

{{ msg }}

+ {% endif %} + {% if error %} +

{{ error }}

+ {% endif %} + +
+
+

筛选条件

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + 重置 +
+
+
+ +
+ +
+

核心指标(当前筛选)

+
+
+ 总事件 + {{ summary.events }} +
+
+ 曝光 + {{ summary.impressions }} +
+
+ 移动底栏曝光 + {{ summary.mobile_bar_impressions }} +
+
+ 移动比价点击 + {{ summary.mobile_pricing_clicks }} + Rate {{ summary_rates.mobile_pricing_rate }}% +
+
+ 点击比价 + {{ summary.pricing_clicks }} + CTR {{ summary_rates.pricing_ctr }}% +
+
+ 点击发需求 + {{ summary.new_topic_clicks }} + Rate {{ summary_rates.new_topic_rate }}% +
+
+ 模板发帖点击 + {{ summary.template_clicks }} + Rate {{ summary_rates.template_rate }}% +
+
+ 模板发帖提交 + {{ summary.template_submits }} + Submit {{ summary_rates.template_submit_rate }}% · Completion {{ summary_rates.template_completion_rate }}% +
+
+ 评论提交 + {{ summary.comment_submits }} + Rate {{ summary_rates.comment_rate }}% +
+
+ 复制链接成功 + {{ summary.copy_success }} + Rate {{ summary_rates.copy_rate }}% +
+
+ 资源链接点击 + {{ summary.resource_clicks }} +
+
+ 相关推荐点击 + {{ summary.related_clicks }} +
+
+ 目录点击 + {{ summary.outline_clicks }} +
+
+
+ +
+

移动端漏斗(当前筛选)

+
+
+ 移动端曝光 + {{ mobile_funnel.mobile_impressions }} + Share {{ mobile_funnel.mobile_traffic_share }}% +
+
+ 移动底栏曝光 + {{ mobile_funnel.mobile_bar_impressions }} +
+
+ 移动比价点击 + {{ mobile_funnel.mobile_pricing_clicks }} + CTR {{ mobile_funnel.mobile_pricing_rate }}% +
+
+ 移动点击占比 + {{ mobile_funnel.mobile_click_share }}% + 占全部比价点击 +
+
+

移动比价点击率 = 移动比价点击 / 移动底栏曝光;移动点击占比 = 移动比价点击 / 全部比价点击。

+
+ +
+

变体表现(最近 {{ days }} 天)

+ + + + + + + + + + + + + + + + + + + + + {% for row in variant_summary %} + + + + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
变体曝光比价点击CTR发需求点击需求率模板发帖点击模板率模板发帖提交模板完成率评论提交评论率复制成功复制率
{{ row.variant }}{{ row.impressions }}{{ row.pricing_clicks }}{{ row.pricing_ctr }}%{{ row.new_topic_clicks }}{{ row.new_topic_rate }}%{{ row.template_clicks }}{{ row.template_rate }}%{{ row.template_submits }}{{ row.template_completion_rate }}%{{ row.comment_submits }}{{ row.comment_rate }}%{{ row.copy_success }}{{ row.copy_rate }}%
暂无变体统计数据。
+
+ +
+

设备表现(最近 {{ days }} 天)

+ + + + + + + + + + + + + + + + {% for row in device_summary %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
设备曝光移动底栏曝光比价点击CTR移动比价点击移动底栏点击率评论提交评论率
{{ row.device_type }}{{ row.impressions }}{{ row.mobile_bar_impressions }}{{ row.pricing_clicks }}{{ row.pricing_ctr }}%{{ row.mobile_pricing_clicks }}{{ row.mobile_pricing_rate }}%{{ row.comment_submits }}{{ row.comment_rate }}%
暂无设备分组统计。
+
+ +
+

按帖子转化排行(当前筛选)

+ + + + + + + + + + + + + + + + {% for row in post_rows %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
帖子曝光比价点击CTR模板点击模板提交模板完成率评论提交评论率
+ + {{ row.impressions }}{{ row.pricing_clicks }}{{ row.pricing_ctr }}%{{ row.template_clicks }}{{ row.template_submits }}{{ row.template_completion_rate }}%{{ row.comment_submits }}{{ row.comment_rate }}%
暂无帖子级转化数据。
+
+ +
+

模板转化排行(当前筛选)

+ + + + + + + + + + + + + {% for row in template_post_rows %} + + + + + + + + + {% else %} + + {% endfor %} + +
帖子模板点击模板提交模板完成率模板点击率模板提交率
{{ row.template_clicks }}{{ row.template_submits }}{{ row.template_completion_rate }}%{{ row.template_rate }}%{{ row.template_submit_rate }}%
暂无模板转化数据。
+
+ +
+

高频标签(当前筛选)

+ + + + + + + + + + {% for row in label_rows %} + + + + + + {% else %} + + {% endfor %} + +
事件标签次数
{{ row.event_name }}{{ row.label }}{{ row.total }}
暂无标签统计。
+
+ +
+

最近事件(当前筛选)

+ + + + + + + + + + + + + + + {% for e in recent_rows %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
时间事件标签变体设备帖子用户访客
{{ e.created_at.strftime('%Y-%m-%d %H:%M:%S') if e.created_at else '' }}{{ e.event_name }}{{ e.label or '—' }}{{ e.cta_variant or 'unknown' }}{{ e.device_type or 'unknown' }} + {% if e.post_id %} + #{{ e.post_id }} + {% else %} + — + {% endif %} + {{ e.user_id or '—' }}{{ e.visitor_id or '—' }}
暂无事件数据。
+
+ +
+

查询说明

+

本页基于数据库表 forum_track_events 聚合。SQL 示例可见 /Users/ddrwode/code/vps_price/docs/forum-post-detail-funnel-sql.md

+
+
+ + diff --git a/templates/admin/forum_tracking_daily.html b/templates/admin/forum_tracking_daily.html new file mode 100644 index 0000000..449457b --- /dev/null +++ b/templates/admin/forum_tracking_daily.html @@ -0,0 +1,288 @@ + + + + + + 论坛埋点日报 - 后台 + + + + +
+

论坛埋点日报

+ +
+
+ {% if msg %} +

{{ msg }}

+ {% endif %} + {% if error %} +

{{ error }}

+ {% endif %} + +
+
+

日报筛选

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + 重置 +
+
+
+ +
+ +
+

核心指标({{ day_value }})

+

对比日期:{{ prev_day }}

+
+
+ 曝光 + {{ summary.impressions }} + CTR 基数 +
+
+ 移动底栏曝光 + {{ summary.mobile_bar_impressions }} + 移动端转化入口曝光 +
+
+ 移动比价点击 + {{ summary.mobile_pricing_clicks }} + Rate {{ summary_rates.mobile_pricing_rate }}% +
+
+ 比价点击 + {{ summary.pricing_clicks }} + CTR {{ summary_rates.pricing_ctr }}% +
+
+ 发需求点击 + {{ summary.new_topic_clicks }} + Rate {{ summary_rates.new_topic_rate }}% +
+
+ 模板发帖点击 + {{ summary.template_clicks }} + Rate {{ summary_rates.template_rate }}% +
+
+ 模板发帖提交 + {{ summary.template_submits }} + Submit {{ summary_rates.template_submit_rate }}% · Completion {{ summary_rates.template_completion_rate }}% +
+
+ 评论提交 + {{ summary.comment_submits }} + Rate {{ summary_rates.comment_rate }}% +
+
+ 复制链接成功 + {{ summary.copy_success }} + Rate {{ summary_rates.copy_rate }}% +
+
+ 总事件 + {{ summary.events }} +
+
+
+ +
+

环比变化(较 {{ prev_day }})

+ + + + + + + + + + + + {% for row in delta_rows %} + + + + + + + + {% else %} + + {% endfor %} + +
指标当日前日变化值变化率
{{ row.label }}{{ row.current }}{{ row.previous }}{{ '+' if row.delta > 0 else '' }}{{ row.delta }}{{ '+' if row.delta_pct > 0 else '' }}{{ row.delta_pct }}%
暂无环比数据。
+
+ +
+

按变体对比({{ day_value }})

+ + + + + + + + + + + + + + + + + + + {% for row in variant_rows %} + + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
变体曝光比价点击CTR发需求点击需求率模板发帖点击模板率模板发帖提交模板完成率评论提交评论率
{{ row.variant }}{{ row.impressions }}{{ row.pricing_clicks }}{{ row.pricing_ctr }}%{{ row.new_topic_clicks }}{{ row.new_topic_rate }}%{{ row.template_clicks }}{{ row.template_rate }}%{{ row.template_submits }}{{ row.template_completion_rate }}%{{ row.comment_submits }}{{ row.comment_rate }}%
暂无变体日报数据。
+
+ +
+

Top 帖子({{ day_value }})

+ + + + + + + + + + + + + + + + {% for row in top_posts %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
帖子曝光比价点击CTR模板点击模板提交模板完成率评论提交评论率
#{{ row.post_id }} {{ row.title }}{{ row.impressions }}{{ row.pricing_clicks }}{{ row.pricing_ctr }}%{{ row.template_clicks }}{{ row.template_submits }}{{ row.template_completion_rate }}%{{ row.comment_submits }}{{ row.comment_rate }}%
暂无帖子日报数据。
+
+ +
+

Top 模板转化帖子({{ day_value }})

+ + + + + + + + + + + + + {% for row in template_top_posts %} + + + + + + + + + {% else %} + + {% endfor %} + +
帖子模板点击模板提交模板完成率模板点击率模板提交率
#{{ row.post_id }} {{ row.title }}{{ row.template_clicks }}{{ row.template_submits }}{{ row.template_completion_rate }}%{{ row.template_rate }}%{{ row.template_submit_rate }}%
暂无模板转化帖子数据。
+
+ +
+

Top 标签({{ day_value }})

+ + + + + + + + + + {% for row in top_labels %} + + + + + + {% else %} + + {% endfor %} + +
事件标签次数
{{ row.event_name }}{{ row.label }}{{ row.total }}
暂无标签日报数据。
+
+
+ + diff --git a/templates/admin/forum_tracking_weekly.html b/templates/admin/forum_tracking_weekly.html new file mode 100644 index 0000000..bc88844 --- /dev/null +++ b/templates/admin/forum_tracking_weekly.html @@ -0,0 +1,382 @@ + + + + + + 论坛埋点周报 - 后台 + + + + +
+

论坛埋点周报

+ +
+
+ {% if msg %} +

{{ msg }}

+ {% endif %} + {% if error %} +

{{ error }}

+ {% endif %} + +
+
+

周报筛选

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + 重置 +
+
+
+ +
+ +
+

核心指标({{ range_start_day }} ~ {{ range_end_day }})

+

对比窗口:{{ prev_start_day }} ~ {{ prev_end_day }}({{ days }} 天)

+
+
+ 曝光 + {{ summary.impressions }} +
+
+ 移动底栏曝光 + {{ summary.mobile_bar_impressions }} +
+
+ 移动比价点击 + {{ summary.mobile_pricing_clicks }} + Rate {{ summary_rates.mobile_pricing_rate }}% +
+
+ 比价点击 + {{ summary.pricing_clicks }} + CTR {{ summary_rates.pricing_ctr }}% +
+
+ 发需求点击 + {{ summary.new_topic_clicks }} + Rate {{ summary_rates.new_topic_rate }}% +
+
+ 模板发帖点击 + {{ summary.template_clicks }} + Rate {{ summary_rates.template_rate }}% +
+
+ 模板发帖提交 + {{ summary.template_submits }} + Submit {{ summary_rates.template_submit_rate }}% · Completion {{ summary_rates.template_completion_rate }}% +
+
+ 评论提交 + {{ summary.comment_submits }} + Rate {{ summary_rates.comment_rate }}% +
+
+ 复制成功 + {{ summary.copy_success }} + Rate {{ summary_rates.copy_rate }}% +
+
+ 总事件 + {{ summary.events }} +
+
+
+ +
+

环比变化(上一窗口)

+ + + + + + + + + + + + {% for row in delta_rows %} + + + + + + + + {% else %} + + {% endfor %} + +
指标当前窗口上一窗口变化值变化率
{{ row.label }}{{ row.current }}{{ row.previous }}{{ '+' if row.delta > 0 else '' }}{{ row.delta }}{{ '+' if row.delta_pct > 0 else '' }}{{ row.delta_pct }}%
暂无环比数据。
+
+ +
+

变体漏斗({{ range_start_day }} ~ {{ range_end_day }})

+ + + + + + + + + + + + + + + + + + + + + {% for row in variant_rows %} + + + + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
变体曝光比价点击CTR发需求点击需求率模板发帖点击模板率模板发帖提交模板完成率评论提交评论率复制成功复制率
{{ row.variant }}{{ row.impressions }}{{ row.pricing_clicks }}{{ row.pricing_ctr }}%{{ row.new_topic_clicks }}{{ row.new_topic_rate }}%{{ row.template_clicks }}{{ row.template_rate }}%{{ row.template_submits }}{{ row.template_completion_rate }}%{{ row.comment_submits }}{{ row.comment_rate }}%{{ row.copy_success }}{{ row.copy_rate }}%
暂无变体漏斗数据。
+
+ +
+

设备漏斗({{ range_start_day }} ~ {{ range_end_day }})

+ + + + + + + + + + + + + + + + + + + {% for row in device_rows %} + + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
设备曝光移动底栏曝光比价点击CTR移动比价点击移动底栏点击率模板发帖点击模板发帖提交模板完成率评论提交评论率
{{ row.device_type }}{{ row.impressions }}{{ row.mobile_bar_impressions }}{{ row.pricing_clicks }}{{ row.pricing_ctr }}%{{ row.mobile_pricing_clicks }}{{ row.mobile_pricing_rate }}%{{ row.template_clicks }}{{ row.template_submits }}{{ row.template_completion_rate }}%{{ row.comment_submits }}{{ row.comment_rate }}%
暂无设备漏斗数据。
+
+ +
+

设备 × 变体漏斗矩阵({{ range_start_day }} ~ {{ range_end_day }})

+ + + + + + + + + + + + + + + + + + + {% for row in device_variant_rows %} + + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
设备变体曝光比价点击CTR移动比价点击移动底栏点击率模板发帖点击模板发帖提交模板完成率评论提交评论率
{{ row.device_type }}{{ row.variant }}{{ row.impressions }}{{ row.pricing_clicks }}{{ row.pricing_ctr }}%{{ row.mobile_pricing_clicks }}{{ row.mobile_pricing_rate }}%{{ row.template_clicks }}{{ row.template_submits }}{{ row.template_completion_rate }}%{{ row.comment_submits }}{{ row.comment_rate }}%
暂无设备×变体漏斗数据。
+
+ +
+

Top 比价转化帖子

+ + + + + + + + + + + + + + + + {% for row in top_posts %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
帖子曝光比价点击CTR模板点击模板提交模板完成率评论提交评论率
#{{ row.post_id }} {{ row.title }}{{ row.impressions }}{{ row.pricing_clicks }}{{ row.pricing_ctr }}%{{ row.template_clicks }}{{ row.template_submits }}{{ row.template_completion_rate }}%{{ row.comment_submits }}{{ row.comment_rate }}%
暂无帖子周报数据。
+
+ +
+

Top 模板转化帖子

+ + + + + + + + + + + + + {% for row in template_top_posts %} + + + + + + + + + {% else %} + + {% endfor %} + +
帖子模板点击模板提交模板完成率模板点击率模板提交率
#{{ row.post_id }} {{ row.title }}{{ row.template_clicks }}{{ row.template_submits }}{{ row.template_completion_rate }}%{{ row.template_rate }}%{{ row.template_submit_rate }}%
暂无模板转化帖子数据。
+
+ +
+

Top 标签({{ range_start_day }} ~ {{ range_end_day }})

+ + + + + + + + + + {% for row in top_labels %} + + + + + + {% else %} + + {% endfor %} + +
事件标签次数
{{ row.event_name }}{{ row.label }}{{ row.total }}
暂无标签周报数据。
+
+
+ + diff --git a/templates/forum/index.html b/templates/forum/index.html index f4e2a63..8af1c7b 100644 --- a/templates/forum/index.html +++ b/templates/forum/index.html @@ -144,13 +144,21 @@ {% if cards %} -
+
+
+ {{ cta_copy.headline }} + {{ l('基于本帖结论快速行动', 'Take action from this topic now') }} +
+
+ {{ cta_primary_label }} + {% if requirement_draft %} + {% if current_user and not current_user.is_banned %} + {{ l('一键发需求', 'Prefill Need') }} + {% elif current_user and current_user.is_banned %} + {{ l('账号封禁中', 'Account banned') }} + {% else %} + {{ l('登录后发需求', 'Login to Prefill') }} + {% endif %} + {% else %} + {% if current_user and not current_user.is_banned %} + {{ l('发布需求', 'Post Need') }} + {% elif current_user and current_user.is_banned %} + {{ l('账号封禁中', 'Account banned') }} + {% else %} + {{ l('登录后发帖', 'Login to Post') }} + {% endif %} + {% endif %} + {{ l('看评论', 'Comments') }} +
+
diff --git a/templates/forum/post_form.html b/templates/forum/post_form.html index e111ecb..4f4ef66 100644 --- a/templates/forum/post_form.html +++ b/templates/forum/post_form.html @@ -53,10 +53,24 @@ {{ l('描述你的问题、评测或优惠信息,方便其他用户快速理解。', 'Describe your question, review, or deal details for other users.') }} {% endif %}

+ {% if p_mode == 'create' and prefill_applied %} +

+ {{ l('已带入需求模板,可直接补充后发布。', 'Requirement template prefilled. Add details and publish directly.') }} + {% if prefill_source_post_id %} + {{ l('来源帖子:', 'Source topic: ') }}#{{ prefill_source_post_id }} + {% endif %} +

+ {% endif %} {% if error %}

{{ error }}

{% endif %} + {% if p_mode == 'create' and prefill_source_post_id %} + + {% if prefill_cta_variant %} + + {% endif %} + {% endif %}