This commit is contained in:
ddrwode
2026-02-11 17:38:06 +08:00
parent 41be920f59
commit d454699f50
24 changed files with 7158 additions and 78 deletions

2542
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -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;
```

View File

@@ -0,0 +1,88 @@
# Forum Post Detail Tracking Spec
## Scope
This document defines the tracking events emitted from `/forum/post/<post_id>` 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 `<body>`.
## 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

View File

@@ -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)

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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) {

View File

@@ -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);
});
});

View File

@@ -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
? '<a href="' + escapeAttr(officialUrl) + '" target="_blank" rel="noopener noreferrer nofollow" class="btn-link">' + btnText + '</a>'
: '-';
tr.innerHTML =
'<td>' + escapeHtml(plan.provider) + '</td>' +
@@ -317,13 +478,18 @@
'<td>' + (plan.bandwidth_mbps ? plan.bandwidth_mbps + ' Mbps' : '-') + '</td>' +
'<td>' + plan.traffic + '</td>' +
'<td class="col-price">' + displayPrice + '</td>' +
'<td class="col-link">' +
'<a href="' + escapeHtml(plan.official_url) + '" target="_blank" rel="noopener noreferrer nofollow" class="btn-link">' + btnText + '</a>' +
'</td>';
'<td class="col-link">' + linkCell + '</td>';
return tr;
}
function escapeAttr(text) {
if (text == null || text === '') return '';
var div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;');
}
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));

View File

@@ -17,6 +17,7 @@
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
<a href="{{ url_for('admin_forum_categories') }}">论坛分类</a>
<a href="{{ url_for('admin_forum_reports') }}">举报审核</a>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
<a href="#plan-form" id="nav-add-plan">+ 添加配置</a>
<a href="{{ url_for('admin_export_excel') }}">导出 Excel</a>
<a href="{{ url_for('admin_import') }}">导入 Excel</a>
@@ -37,6 +38,7 @@
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
<a href="{{ url_for('admin_forum_categories') }}">分类管理</a>
<a href="{{ url_for('admin_forum_reports') }}">举报审核</a>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
</div>
</section>
<section class="dashboard-section">

View File

@@ -16,6 +16,7 @@
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
<a href="{{ url_for('admin_forum_reports') }}">举报审核</a>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
<a href="{{ url_for('admin_dashboard') }}">返回总览</a>
<a href="{{ url_for('admin_providers') }}">厂商管理</a>
<a href="{{ url_for('index') }}">查看前台</a>

View File

@@ -16,6 +16,7 @@
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
<a href="{{ url_for('admin_forum_reports') }}">举报审核</a>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
<a href="{{ url_for('admin_dashboard') }}">返回总览</a>
<a href="{{ url_for('admin_logout') }}">退出</a>
</nav>

View File

@@ -14,6 +14,7 @@
<a href="{{ cancel_url }}">← 评论列表</a>
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
<a href="{{ url_for('admin_users') }}">用户管理</a>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
<a href="{{ url_for('admin_dashboard') }}">总览</a>
<a href="{{ url_for('admin_logout') }}">退出</a>
</nav>

View File

@@ -15,6 +15,7 @@
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
<a href="{{ url_for('admin_users') }}">用户管理</a>
<a href="{{ url_for('admin_forum_reports') }}">举报审核</a>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
<a href="{{ url_for('admin_dashboard') }}">返回总览</a>
<a href="{{ url_for('admin_logout') }}">退出</a>
</nav>

View File

@@ -14,6 +14,7 @@
<a href="{{ url_for('admin_forum_posts') }}">← 帖子列表</a>
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
<a href="{{ url_for('admin_users') }}">用户管理</a>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
<a href="{{ url_for('admin_dashboard') }}">总览</a>
<a href="{{ url_for('admin_logout') }}">退出</a>
</nav>

View File

@@ -16,6 +16,7 @@
<a href="{{ url_for('admin_users') }}">用户管理</a>
<a href="{{ url_for('admin_forum_categories') }}">论坛分类</a>
<a href="{{ url_for('admin_forum_reports') }}">举报审核</a>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
<a href="{{ url_for('admin_dashboard') }}">返回总览</a>
<a href="{{ url_for('admin_logout') }}">退出</a>
</nav>

View File

@@ -16,6 +16,7 @@
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
<a href="{{ url_for('admin_forum_categories') }}">论坛分类</a>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
<a href="{{ url_for('admin_dashboard') }}">返回总览</a>
<a href="{{ url_for('index') }}">查看前台</a>
<a href="{{ url_for('admin_logout') }}">退出</a>

View File

@@ -0,0 +1,394 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>论坛埋点看板 - 后台</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body class="admin-page">
<header class="admin-header">
<h1>论坛埋点看板</h1>
<nav>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
<a href="{{ url_for('admin_forum_tracking_daily') }}">埋点日报</a>
<a href="{{ url_for('admin_forum_tracking_weekly', days=days, variant=selected_variant, device=selected_device) }}">埋点周报</a>
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
<a href="{{ url_for('admin_forum_reports') }}">举报审核</a>
<a href="{{ url_for('admin_dashboard') }}">返回总览</a>
<a href="{{ url_for('admin_logout') }}">退出</a>
</nav>
</header>
<main class="admin-main">
{% if msg %}
<p class="hint success-msg">{{ msg }}</p>
{% endif %}
{% if error %}
<p class="error-msg">{{ error }}</p>
{% endif %}
<section class="dashboard-section">
<div class="admin-topline">
<h2>筛选条件</h2>
</div>
<form method="get" class="admin-filter-form">
<div class="filter-row">
<div class="filter-item">
<label for="days">时间窗口</label>
<select id="days" name="days">
{% for opt in days_options %}
<option value="{{ opt }}" {{ 'selected' if days == opt else '' }}>最近 {{ opt }} 天</option>
{% endfor %}
</select>
</div>
<div class="filter-item">
<label for="variant">CTA 变体</label>
<select id="variant" name="variant">
<option value="all" {{ 'selected' if selected_variant == 'all' else '' }}>全部</option>
<option value="control" {{ 'selected' if selected_variant == 'control' else '' }}>control</option>
<option value="intent" {{ 'selected' if selected_variant == 'intent' else '' }}>intent</option>
<option value="unknown" {{ 'selected' if selected_variant == 'unknown' else '' }}>unknown</option>
</select>
</div>
<div class="filter-item">
<label for="device">设备类型</label>
<select id="device" name="device">
{% for opt in device_options %}
<option value="{{ opt }}" {{ 'selected' if selected_device == opt else '' }}>{{ opt }}</option>
{% endfor %}
</select>
</div>
<div class="filter-actions">
<button type="submit">应用筛选</button>
<a href="{{ url_for('admin_forum_tracking') }}">重置</a>
</div>
</div>
</form>
<div class="tracking-export-row">
<a href="{{ url_for('admin_forum_tracking_daily') }}" class="admin-btn-link">查看埋点日报</a>
<a href="{{ url_for('admin_forum_tracking_weekly', days=days, variant=selected_variant, device=selected_device) }}" class="admin-btn-link">查看埋点周报</a>
<a href="{{ url_for('admin_forum_tracking_export', days=days, variant=selected_variant, device=selected_device, mode='recent') }}" class="admin-btn-link">导出事件明细 CSV</a>
<a href="{{ url_for('admin_forum_tracking_export', days=days, variant=selected_variant, device=selected_device, mode='daily') }}" class="admin-btn-link">导出日汇总 CSV</a>
<a href="{{ url_for('admin_forum_tracking_export', days=days, variant=selected_variant, device=selected_device, mode='variants') }}" class="admin-btn-link">导出变体汇总 CSV</a>
<a href="{{ url_for('admin_forum_tracking_export', days=days, variant=selected_variant, device=selected_device, mode='variant_funnel') }}" class="admin-btn-link">导出变体漏斗 CSV</a>
<a href="{{ url_for('admin_forum_tracking_weekly_export_markdown', days=days, variant=selected_variant, device=selected_device) }}" class="admin-btn-link">导出周期周报 Markdown</a>
<a href="{{ url_for('admin_forum_tracking_export', days=days, variant=selected_variant, device=selected_device, mode='device_variants') }}" class="admin-btn-link">导出设备×变体 CSV</a>
<a href="{{ url_for('admin_forum_tracking_export', days=days, variant=selected_variant, device=selected_device, mode='posts') }}" class="admin-btn-link">导出帖子转化 CSV</a>
<a href="{{ url_for('admin_forum_tracking_export', days=days, variant=selected_variant, device=selected_device, mode='labels') }}" class="admin-btn-link">导出标签排行 CSV</a>
</div>
</section>
<section class="dashboard-section">
<h2>核心指标(当前筛选)</h2>
<div class="metric-grid">
<article class="metric-card">
<span class="metric-label">总事件</span>
<strong class="metric-value">{{ summary.events }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">曝光</span>
<strong class="metric-value">{{ summary.impressions }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">移动底栏曝光</span>
<strong class="metric-value">{{ summary.mobile_bar_impressions }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">移动比价点击</span>
<strong class="metric-value">{{ summary.mobile_pricing_clicks }}</strong>
<small class="metric-meta">Rate {{ summary_rates.mobile_pricing_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">点击比价</span>
<strong class="metric-value">{{ summary.pricing_clicks }}</strong>
<small class="metric-meta">CTR {{ summary_rates.pricing_ctr }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">点击发需求</span>
<strong class="metric-value">{{ summary.new_topic_clicks }}</strong>
<small class="metric-meta">Rate {{ summary_rates.new_topic_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">模板发帖点击</span>
<strong class="metric-value">{{ summary.template_clicks }}</strong>
<small class="metric-meta">Rate {{ summary_rates.template_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">模板发帖提交</span>
<strong class="metric-value">{{ summary.template_submits }}</strong>
<small class="metric-meta">Submit {{ summary_rates.template_submit_rate }}% · Completion {{ summary_rates.template_completion_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">评论提交</span>
<strong class="metric-value">{{ summary.comment_submits }}</strong>
<small class="metric-meta">Rate {{ summary_rates.comment_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">复制链接成功</span>
<strong class="metric-value">{{ summary.copy_success }}</strong>
<small class="metric-meta">Rate {{ summary_rates.copy_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">资源链接点击</span>
<strong class="metric-value">{{ summary.resource_clicks }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">相关推荐点击</span>
<strong class="metric-value">{{ summary.related_clicks }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">目录点击</span>
<strong class="metric-value">{{ summary.outline_clicks }}</strong>
</article>
</div>
</section>
<section class="dashboard-section">
<h2>移动端漏斗(当前筛选)</h2>
<div class="metric-grid">
<article class="metric-card">
<span class="metric-label">移动端曝光</span>
<strong class="metric-value">{{ mobile_funnel.mobile_impressions }}</strong>
<small class="metric-meta">Share {{ mobile_funnel.mobile_traffic_share }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">移动底栏曝光</span>
<strong class="metric-value">{{ mobile_funnel.mobile_bar_impressions }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">移动比价点击</span>
<strong class="metric-value">{{ mobile_funnel.mobile_pricing_clicks }}</strong>
<small class="metric-meta">CTR {{ mobile_funnel.mobile_pricing_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">移动点击占比</span>
<strong class="metric-value">{{ mobile_funnel.mobile_click_share }}%</strong>
<small class="metric-meta">占全部比价点击</small>
</article>
</div>
<p class="admin-note">移动比价点击率 = 移动比价点击 / 移动底栏曝光;移动点击占比 = 移动比价点击 / 全部比价点击。</p>
</section>
<section class="dashboard-section">
<h2>变体表现(最近 {{ days }} 天)</h2>
<table class="admin-table">
<thead>
<tr>
<th>变体</th>
<th>曝光</th>
<th>比价点击</th>
<th>CTR</th>
<th>发需求点击</th>
<th>需求率</th>
<th>模板发帖点击</th>
<th>模板率</th>
<th>模板发帖提交</th>
<th>模板完成率</th>
<th>评论提交</th>
<th>评论率</th>
<th>复制成功</th>
<th>复制率</th>
</tr>
</thead>
<tbody>
{% for row in variant_summary %}
<tr>
<td><span class="status-pill {{ 'active' if row.variant in ['control', 'intent'] else 'inactive' }}">{{ row.variant }}</span></td>
<td>{{ row.impressions }}</td>
<td>{{ row.pricing_clicks }}</td>
<td>{{ row.pricing_ctr }}%</td>
<td>{{ row.new_topic_clicks }}</td>
<td>{{ row.new_topic_rate }}%</td>
<td>{{ row.template_clicks }}</td>
<td>{{ row.template_rate }}%</td>
<td>{{ row.template_submits }}</td>
<td>{{ row.template_completion_rate }}%</td>
<td>{{ row.comment_submits }}</td>
<td>{{ row.comment_rate }}%</td>
<td>{{ row.copy_success }}</td>
<td>{{ row.copy_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="14">暂无变体统计数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>设备表现(最近 {{ days }} 天)</h2>
<table class="admin-table">
<thead>
<tr>
<th>设备</th>
<th>曝光</th>
<th>移动底栏曝光</th>
<th>比价点击</th>
<th>CTR</th>
<th>移动比价点击</th>
<th>移动底栏点击率</th>
<th>评论提交</th>
<th>评论率</th>
</tr>
</thead>
<tbody>
{% for row in device_summary %}
<tr>
<td><span class="status-pill {{ 'active' if row.device_type in ['mobile', 'desktop', 'tablet'] else 'inactive' }}">{{ row.device_type }}</span></td>
<td>{{ row.impressions }}</td>
<td>{{ row.mobile_bar_impressions }}</td>
<td>{{ row.pricing_clicks }}</td>
<td>{{ row.pricing_ctr }}%</td>
<td>{{ row.mobile_pricing_clicks }}</td>
<td>{{ row.mobile_pricing_rate }}%</td>
<td>{{ row.comment_submits }}</td>
<td>{{ row.comment_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="9">暂无设备分组统计。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>按帖子转化排行(当前筛选)</h2>
<table class="admin-table">
<thead>
<tr>
<th>帖子</th>
<th>曝光</th>
<th>比价点击</th>
<th>CTR</th>
<th>模板点击</th>
<th>模板提交</th>
<th>模板完成率</th>
<th>评论提交</th>
<th>评论率</th>
</tr>
</thead>
<tbody>
{% for row in post_rows %}
<tr>
<td>
<div class="table-title"><a href="{{ url_for('forum_post_detail', post_id=row.post_id) }}" target="_blank" rel="noopener">#{{ row.post_id }} {{ row.title }}</a></div>
</td>
<td>{{ row.impressions }}</td>
<td>{{ row.pricing_clicks }}</td>
<td>{{ row.pricing_ctr }}%</td>
<td>{{ row.template_clicks }}</td>
<td>{{ row.template_submits }}</td>
<td>{{ row.template_completion_rate }}%</td>
<td>{{ row.comment_submits }}</td>
<td>{{ row.comment_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="9">暂无帖子级转化数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>模板转化排行(当前筛选)</h2>
<table class="admin-table">
<thead>
<tr>
<th>帖子</th>
<th>模板点击</th>
<th>模板提交</th>
<th>模板完成率</th>
<th>模板点击率</th>
<th>模板提交率</th>
</tr>
</thead>
<tbody>
{% for row in template_post_rows %}
<tr>
<td><div class="table-title"><a href="{{ url_for('forum_post_detail', post_id=row.post_id) }}" target="_blank" rel="noopener">#{{ row.post_id }} {{ row.title }}</a></div></td>
<td>{{ row.template_clicks }}</td>
<td>{{ row.template_submits }}</td>
<td>{{ row.template_completion_rate }}%</td>
<td>{{ row.template_rate }}%</td>
<td>{{ row.template_submit_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="6">暂无模板转化数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>高频标签(当前筛选)</h2>
<table class="admin-table">
<thead>
<tr>
<th>事件</th>
<th>标签</th>
<th>次数</th>
</tr>
</thead>
<tbody>
{% for row in label_rows %}
<tr>
<td><code>{{ row.event_name }}</code></td>
<td>{{ row.label }}</td>
<td>{{ row.total }}</td>
</tr>
{% else %}
<tr><td colspan="3">暂无标签统计。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>最近事件(当前筛选)</h2>
<table class="admin-table">
<thead>
<tr>
<th>时间</th>
<th>事件</th>
<th>标签</th>
<th>变体</th>
<th>设备</th>
<th>帖子</th>
<th>用户</th>
<th>访客</th>
</tr>
</thead>
<tbody>
{% for e in recent_rows %}
<tr>
<td>{{ e.created_at.strftime('%Y-%m-%d %H:%M:%S') if e.created_at else '' }}</td>
<td><code>{{ e.event_name }}</code></td>
<td>{{ e.label or '—' }}</td>
<td>{{ e.cta_variant or 'unknown' }}</td>
<td>{{ e.device_type or 'unknown' }}</td>
<td>
{% if e.post_id %}
<a href="{{ url_for('forum_post_detail', post_id=e.post_id) }}" target="_blank" rel="noopener">#{{ e.post_id }}</a>
{% else %}
{% endif %}
</td>
<td>{{ e.user_id or '—' }}</td>
<td>{{ e.visitor_id or '—' }}</td>
</tr>
{% else %}
<tr><td colspan="8">暂无事件数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>查询说明</h2>
<p class="admin-note">本页基于数据库表 <code>forum_track_events</code> 聚合。SQL 示例可见 <code>/Users/ddrwode/code/vps_price/docs/forum-post-detail-funnel-sql.md</code></p>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>论坛埋点日报 - 后台</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body class="admin-page">
<header class="admin-header">
<h1>论坛埋点日报</h1>
<nav>
<a href="{{ url_for('admin_forum_tracking_daily') }}">埋点日报</a>
<a href="{{ url_for('admin_forum_tracking_weekly', day=day_value.isoformat() if day_value else '', days=7, variant=selected_variant, device=selected_device) }}">埋点周报</a>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
<a href="{{ url_for('admin_dashboard') }}">返回总览</a>
<a href="{{ url_for('admin_logout') }}">退出</a>
</nav>
</header>
<main class="admin-main">
{% if msg %}
<p class="hint success-msg">{{ msg }}</p>
{% endif %}
{% if error %}
<p class="error-msg">{{ error }}</p>
{% endif %}
<section class="dashboard-section">
<div class="admin-topline">
<h2>日报筛选</h2>
</div>
<form method="get" class="admin-filter-form">
<div class="filter-row">
<div class="filter-item">
<label for="day">日期</label>
<input id="day" name="day" type="date" value="{{ day_value.isoformat() if day_value else '' }}">
</div>
<div class="filter-item">
<label for="variant">CTA 变体</label>
<select id="variant" name="variant">
{% for opt in variant_options %}
<option value="{{ opt }}" {{ 'selected' if selected_variant == opt else '' }}>{{ opt }}</option>
{% endfor %}
</select>
</div>
<div class="filter-item">
<label for="device">设备类型</label>
<select id="device" name="device">
{% for opt in device_options %}
<option value="{{ opt }}" {{ 'selected' if selected_device == opt else '' }}>{{ opt }}</option>
{% endfor %}
</select>
</div>
<div class="filter-actions">
<button type="submit">查看日报</button>
<a href="{{ url_for('admin_forum_tracking_daily') }}">重置</a>
</div>
</div>
</form>
<div class="tracking-export-row">
<a href="{{ url_for('admin_forum_tracking_weekly', day=day_value.isoformat() if day_value else '', days=7, variant=selected_variant, device=selected_device) }}" class="admin-btn-link">查看近 7 天周报页</a>
<a href="{{ url_for('admin_forum_tracking_daily_export_markdown', day=day_value.isoformat() if day_value else '', variant=selected_variant, device=selected_device) }}" class="admin-btn-link">导出 Markdown 报告</a>
<a href="{{ url_for('admin_forum_tracking_export', mode='daily', days=2, variant=selected_variant, device=selected_device) }}" class="admin-btn-link">导出最近两天汇总 CSV</a>
<a href="{{ url_for('admin_forum_tracking_export', mode='variant_funnel', days=7, variant=selected_variant, device=selected_device) }}" class="admin-btn-link">导出近 7 天变体漏斗 CSV</a>
<a href="{{ url_for('admin_forum_tracking_weekly_export_markdown', day=day_value.isoformat() if day_value else '', days=7, variant=selected_variant, device=selected_device) }}" class="admin-btn-link">导出近 7 天周报 Markdown</a>
</div>
</section>
<section class="dashboard-section">
<h2>核心指标({{ day_value }}</h2>
<p class="admin-note">对比日期:{{ prev_day }}</p>
<div class="metric-grid">
<article class="metric-card">
<span class="metric-label">曝光</span>
<strong class="metric-value">{{ summary.impressions }}</strong>
<small class="metric-meta">CTR 基数</small>
</article>
<article class="metric-card">
<span class="metric-label">移动底栏曝光</span>
<strong class="metric-value">{{ summary.mobile_bar_impressions }}</strong>
<small class="metric-meta">移动端转化入口曝光</small>
</article>
<article class="metric-card">
<span class="metric-label">移动比价点击</span>
<strong class="metric-value">{{ summary.mobile_pricing_clicks }}</strong>
<small class="metric-meta">Rate {{ summary_rates.mobile_pricing_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">比价点击</span>
<strong class="metric-value">{{ summary.pricing_clicks }}</strong>
<small class="metric-meta">CTR {{ summary_rates.pricing_ctr }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">发需求点击</span>
<strong class="metric-value">{{ summary.new_topic_clicks }}</strong>
<small class="metric-meta">Rate {{ summary_rates.new_topic_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">模板发帖点击</span>
<strong class="metric-value">{{ summary.template_clicks }}</strong>
<small class="metric-meta">Rate {{ summary_rates.template_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">模板发帖提交</span>
<strong class="metric-value">{{ summary.template_submits }}</strong>
<small class="metric-meta">Submit {{ summary_rates.template_submit_rate }}% · Completion {{ summary_rates.template_completion_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">评论提交</span>
<strong class="metric-value">{{ summary.comment_submits }}</strong>
<small class="metric-meta">Rate {{ summary_rates.comment_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">复制链接成功</span>
<strong class="metric-value">{{ summary.copy_success }}</strong>
<small class="metric-meta">Rate {{ summary_rates.copy_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">总事件</span>
<strong class="metric-value">{{ summary.events }}</strong>
</article>
</div>
</section>
<section class="dashboard-section">
<h2>环比变化(较 {{ prev_day }}</h2>
<table class="admin-table">
<thead>
<tr>
<th>指标</th>
<th>当日</th>
<th>前日</th>
<th>变化值</th>
<th>变化率</th>
</tr>
</thead>
<tbody>
{% for row in delta_rows %}
<tr>
<td>{{ row.label }}</td>
<td>{{ row.current }}</td>
<td>{{ row.previous }}</td>
<td><span class="delta-pill delta-{{ row.direction }}">{{ '+' if row.delta > 0 else '' }}{{ row.delta }}</span></td>
<td>{{ '+' if row.delta_pct > 0 else '' }}{{ row.delta_pct }}%</td>
</tr>
{% else %}
<tr><td colspan="5">暂无环比数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>按变体对比({{ day_value }}</h2>
<table class="admin-table">
<thead>
<tr>
<th>变体</th>
<th>曝光</th>
<th>比价点击</th>
<th>CTR</th>
<th>发需求点击</th>
<th>需求率</th>
<th>模板发帖点击</th>
<th>模板率</th>
<th>模板发帖提交</th>
<th>模板完成率</th>
<th>评论提交</th>
<th>评论率</th>
</tr>
</thead>
<tbody>
{% for row in variant_rows %}
<tr>
<td><span class="status-pill {{ 'active' if row.variant in ['control', 'intent'] else 'inactive' }}">{{ row.variant }}</span></td>
<td>{{ row.impressions }}</td>
<td>{{ row.pricing_clicks }}</td>
<td>{{ row.pricing_ctr }}%</td>
<td>{{ row.new_topic_clicks }}</td>
<td>{{ row.new_topic_rate }}%</td>
<td>{{ row.template_clicks }}</td>
<td>{{ row.template_rate }}%</td>
<td>{{ row.template_submits }}</td>
<td>{{ row.template_completion_rate }}%</td>
<td>{{ row.comment_submits }}</td>
<td>{{ row.comment_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="12">暂无变体日报数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>Top 帖子({{ day_value }}</h2>
<table class="admin-table">
<thead>
<tr>
<th>帖子</th>
<th>曝光</th>
<th>比价点击</th>
<th>CTR</th>
<th>模板点击</th>
<th>模板提交</th>
<th>模板完成率</th>
<th>评论提交</th>
<th>评论率</th>
</tr>
</thead>
<tbody>
{% for row in top_posts %}
<tr>
<td><a href="{{ url_for('forum_post_detail', post_id=row.post_id) }}" target="_blank" rel="noopener">#{{ row.post_id }} {{ row.title }}</a></td>
<td>{{ row.impressions }}</td>
<td>{{ row.pricing_clicks }}</td>
<td>{{ row.pricing_ctr }}%</td>
<td>{{ row.template_clicks }}</td>
<td>{{ row.template_submits }}</td>
<td>{{ row.template_completion_rate }}%</td>
<td>{{ row.comment_submits }}</td>
<td>{{ row.comment_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="9">暂无帖子日报数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>Top 模板转化帖子({{ day_value }}</h2>
<table class="admin-table">
<thead>
<tr>
<th>帖子</th>
<th>模板点击</th>
<th>模板提交</th>
<th>模板完成率</th>
<th>模板点击率</th>
<th>模板提交率</th>
</tr>
</thead>
<tbody>
{% for row in template_top_posts %}
<tr>
<td><a href="{{ url_for('forum_post_detail', post_id=row.post_id) }}" target="_blank" rel="noopener">#{{ row.post_id }} {{ row.title }}</a></td>
<td>{{ row.template_clicks }}</td>
<td>{{ row.template_submits }}</td>
<td>{{ row.template_completion_rate }}%</td>
<td>{{ row.template_rate }}%</td>
<td>{{ row.template_submit_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="6">暂无模板转化帖子数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>Top 标签({{ day_value }}</h2>
<table class="admin-table">
<thead>
<tr>
<th>事件</th>
<th>标签</th>
<th>次数</th>
</tr>
</thead>
<tbody>
{% for row in top_labels %}
<tr>
<td><code>{{ row.event_name }}</code></td>
<td>{{ row.label }}</td>
<td>{{ row.total }}</td>
</tr>
{% else %}
<tr><td colspan="3">暂无标签日报数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,382 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>论坛埋点周报 - 后台</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body class="admin-page">
<header class="admin-header">
<h1>论坛埋点周报</h1>
<nav>
<a href="{{ url_for('admin_forum_tracking_weekly') }}">埋点周报</a>
<a href="{{ url_for('admin_forum_tracking_daily') }}">埋点日报</a>
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
<a href="{{ url_for('admin_dashboard') }}">返回总览</a>
<a href="{{ url_for('admin_logout') }}">退出</a>
</nav>
</header>
<main class="admin-main">
{% if msg %}
<p class="hint success-msg">{{ msg }}</p>
{% endif %}
{% if error %}
<p class="error-msg">{{ error }}</p>
{% endif %}
<section class="dashboard-section">
<div class="admin-topline">
<h2>周报筛选</h2>
</div>
<form method="get" class="admin-filter-form">
<div class="filter-row">
<div class="filter-item">
<label for="day">结束日期</label>
<input id="day" name="day" type="date" value="{{ end_day.isoformat() if end_day else '' }}">
</div>
<div class="filter-item">
<label for="days">窗口天数</label>
<select id="days" name="days">
{% for opt in days_options %}
<option value="{{ opt }}" {{ 'selected' if days == opt else '' }}>最近 {{ opt }} 天</option>
{% endfor %}
</select>
</div>
<div class="filter-item">
<label for="variant">CTA 变体</label>
<select id="variant" name="variant">
{% for opt in variant_options %}
<option value="{{ opt }}" {{ 'selected' if selected_variant == opt else '' }}>{{ opt }}</option>
{% endfor %}
</select>
</div>
<div class="filter-item">
<label for="device">设备类型</label>
<select id="device" name="device">
{% for opt in device_options %}
<option value="{{ opt }}" {{ 'selected' if selected_device == opt else '' }}>{{ opt }}</option>
{% endfor %}
</select>
</div>
<div class="filter-actions">
<button type="submit">查看周报</button>
<a href="{{ url_for('admin_forum_tracking_weekly') }}">重置</a>
</div>
</div>
</form>
<div class="tracking-export-row">
<a href="{{ url_for('admin_forum_tracking_weekly_export_markdown', day=end_day.isoformat() if end_day else '', days=days, variant=selected_variant, device=selected_device) }}" class="admin-btn-link">导出周报 Markdown</a>
<a href="{{ url_for('admin_forum_tracking_export', mode='variant_funnel', days=days, variant=selected_variant, device=selected_device) }}" class="admin-btn-link">导出变体漏斗 CSV</a>
<a href="{{ url_for('admin_forum_tracking_export', mode='device_variants', days=days, variant=selected_variant, device=selected_device) }}" class="admin-btn-link">导出设备×变体 CSV</a>
<a href="{{ url_for('admin_forum_tracking_export', mode='posts', days=days, variant=selected_variant, device=selected_device) }}" class="admin-btn-link">导出帖子转化 CSV</a>
<a href="{{ url_for('admin_forum_tracking_daily', day=end_day.isoformat() if end_day else '', variant=selected_variant, device=selected_device) }}" class="admin-btn-link">查看结束日日报</a>
</div>
</section>
<section class="dashboard-section">
<h2>核心指标({{ range_start_day }} ~ {{ range_end_day }}</h2>
<p class="admin-note">对比窗口:{{ prev_start_day }} ~ {{ prev_end_day }}{{ days }} 天)</p>
<div class="metric-grid">
<article class="metric-card">
<span class="metric-label">曝光</span>
<strong class="metric-value">{{ summary.impressions }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">移动底栏曝光</span>
<strong class="metric-value">{{ summary.mobile_bar_impressions }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">移动比价点击</span>
<strong class="metric-value">{{ summary.mobile_pricing_clicks }}</strong>
<small class="metric-meta">Rate {{ summary_rates.mobile_pricing_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">比价点击</span>
<strong class="metric-value">{{ summary.pricing_clicks }}</strong>
<small class="metric-meta">CTR {{ summary_rates.pricing_ctr }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">发需求点击</span>
<strong class="metric-value">{{ summary.new_topic_clicks }}</strong>
<small class="metric-meta">Rate {{ summary_rates.new_topic_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">模板发帖点击</span>
<strong class="metric-value">{{ summary.template_clicks }}</strong>
<small class="metric-meta">Rate {{ summary_rates.template_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">模板发帖提交</span>
<strong class="metric-value">{{ summary.template_submits }}</strong>
<small class="metric-meta">Submit {{ summary_rates.template_submit_rate }}% · Completion {{ summary_rates.template_completion_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">评论提交</span>
<strong class="metric-value">{{ summary.comment_submits }}</strong>
<small class="metric-meta">Rate {{ summary_rates.comment_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">复制成功</span>
<strong class="metric-value">{{ summary.copy_success }}</strong>
<small class="metric-meta">Rate {{ summary_rates.copy_rate }}%</small>
</article>
<article class="metric-card">
<span class="metric-label">总事件</span>
<strong class="metric-value">{{ summary.events }}</strong>
</article>
</div>
</section>
<section class="dashboard-section">
<h2>环比变化(上一窗口)</h2>
<table class="admin-table">
<thead>
<tr>
<th>指标</th>
<th>当前窗口</th>
<th>上一窗口</th>
<th>变化值</th>
<th>变化率</th>
</tr>
</thead>
<tbody>
{% for row in delta_rows %}
<tr>
<td>{{ row.label }}</td>
<td>{{ row.current }}</td>
<td>{{ row.previous }}</td>
<td><span class="delta-pill delta-{{ row.direction }}">{{ '+' if row.delta > 0 else '' }}{{ row.delta }}</span></td>
<td>{{ '+' if row.delta_pct > 0 else '' }}{{ row.delta_pct }}%</td>
</tr>
{% else %}
<tr><td colspan="5">暂无环比数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>变体漏斗({{ range_start_day }} ~ {{ range_end_day }}</h2>
<table class="admin-table">
<thead>
<tr>
<th>变体</th>
<th>曝光</th>
<th>比价点击</th>
<th>CTR</th>
<th>发需求点击</th>
<th>需求率</th>
<th>模板发帖点击</th>
<th>模板率</th>
<th>模板发帖提交</th>
<th>模板完成率</th>
<th>评论提交</th>
<th>评论率</th>
<th>复制成功</th>
<th>复制率</th>
</tr>
</thead>
<tbody>
{% for row in variant_rows %}
<tr>
<td><span class="status-pill {{ 'active' if row.variant in ['control', 'intent'] else 'inactive' }}">{{ row.variant }}</span></td>
<td>{{ row.impressions }}</td>
<td>{{ row.pricing_clicks }}</td>
<td>{{ row.pricing_ctr }}%</td>
<td>{{ row.new_topic_clicks }}</td>
<td>{{ row.new_topic_rate }}%</td>
<td>{{ row.template_clicks }}</td>
<td>{{ row.template_rate }}%</td>
<td>{{ row.template_submits }}</td>
<td>{{ row.template_completion_rate }}%</td>
<td>{{ row.comment_submits }}</td>
<td>{{ row.comment_rate }}%</td>
<td>{{ row.copy_success }}</td>
<td>{{ row.copy_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="14">暂无变体漏斗数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>设备漏斗({{ range_start_day }} ~ {{ range_end_day }}</h2>
<table class="admin-table">
<thead>
<tr>
<th>设备</th>
<th>曝光</th>
<th>移动底栏曝光</th>
<th>比价点击</th>
<th>CTR</th>
<th>移动比价点击</th>
<th>移动底栏点击率</th>
<th>模板发帖点击</th>
<th>模板发帖提交</th>
<th>模板完成率</th>
<th>评论提交</th>
<th>评论率</th>
</tr>
</thead>
<tbody>
{% for row in device_rows %}
<tr>
<td><span class="status-pill {{ 'active' if row.device_type in ['mobile', 'desktop', 'tablet'] else 'inactive' }}">{{ row.device_type }}</span></td>
<td>{{ row.impressions }}</td>
<td>{{ row.mobile_bar_impressions }}</td>
<td>{{ row.pricing_clicks }}</td>
<td>{{ row.pricing_ctr }}%</td>
<td>{{ row.mobile_pricing_clicks }}</td>
<td>{{ row.mobile_pricing_rate }}%</td>
<td>{{ row.template_clicks }}</td>
<td>{{ row.template_submits }}</td>
<td>{{ row.template_completion_rate }}%</td>
<td>{{ row.comment_submits }}</td>
<td>{{ row.comment_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="12">暂无设备漏斗数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>设备 × 变体漏斗矩阵({{ range_start_day }} ~ {{ range_end_day }}</h2>
<table class="admin-table">
<thead>
<tr>
<th>设备</th>
<th>变体</th>
<th>曝光</th>
<th>比价点击</th>
<th>CTR</th>
<th>移动比价点击</th>
<th>移动底栏点击率</th>
<th>模板发帖点击</th>
<th>模板发帖提交</th>
<th>模板完成率</th>
<th>评论提交</th>
<th>评论率</th>
</tr>
</thead>
<tbody>
{% for row in device_variant_rows %}
<tr>
<td><span class="status-pill {{ 'active' if row.device_type in ['mobile', 'desktop', 'tablet'] else 'inactive' }}">{{ row.device_type }}</span></td>
<td><span class="status-pill {{ 'active' if row.variant in ['control', 'intent'] else 'inactive' }}">{{ row.variant }}</span></td>
<td>{{ row.impressions }}</td>
<td>{{ row.pricing_clicks }}</td>
<td>{{ row.pricing_ctr }}%</td>
<td>{{ row.mobile_pricing_clicks }}</td>
<td>{{ row.mobile_pricing_rate }}%</td>
<td>{{ row.template_clicks }}</td>
<td>{{ row.template_submits }}</td>
<td>{{ row.template_completion_rate }}%</td>
<td>{{ row.comment_submits }}</td>
<td>{{ row.comment_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="12">暂无设备×变体漏斗数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>Top 比价转化帖子</h2>
<table class="admin-table">
<thead>
<tr>
<th>帖子</th>
<th>曝光</th>
<th>比价点击</th>
<th>CTR</th>
<th>模板点击</th>
<th>模板提交</th>
<th>模板完成率</th>
<th>评论提交</th>
<th>评论率</th>
</tr>
</thead>
<tbody>
{% for row in top_posts %}
<tr>
<td><a href="{{ url_for('forum_post_detail', post_id=row.post_id) }}" target="_blank" rel="noopener">#{{ row.post_id }} {{ row.title }}</a></td>
<td>{{ row.impressions }}</td>
<td>{{ row.pricing_clicks }}</td>
<td>{{ row.pricing_ctr }}%</td>
<td>{{ row.template_clicks }}</td>
<td>{{ row.template_submits }}</td>
<td>{{ row.template_completion_rate }}%</td>
<td>{{ row.comment_submits }}</td>
<td>{{ row.comment_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="9">暂无帖子周报数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>Top 模板转化帖子</h2>
<table class="admin-table">
<thead>
<tr>
<th>帖子</th>
<th>模板点击</th>
<th>模板提交</th>
<th>模板完成率</th>
<th>模板点击率</th>
<th>模板提交率</th>
</tr>
</thead>
<tbody>
{% for row in template_top_posts %}
<tr>
<td><a href="{{ url_for('forum_post_detail', post_id=row.post_id) }}" target="_blank" rel="noopener">#{{ row.post_id }} {{ row.title }}</a></td>
<td>{{ row.template_clicks }}</td>
<td>{{ row.template_submits }}</td>
<td>{{ row.template_completion_rate }}%</td>
<td>{{ row.template_rate }}%</td>
<td>{{ row.template_submit_rate }}%</td>
</tr>
{% else %}
<tr><td colspan="6">暂无模板转化帖子数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="dashboard-section">
<h2>Top 标签({{ range_start_day }} ~ {{ range_end_day }}</h2>
<table class="admin-table">
<thead>
<tr>
<th>事件</th>
<th>标签</th>
<th>次数</th>
</tr>
</thead>
<tbody>
{% for row in top_labels %}
<tr>
<td><code>{{ row.event_name }}</code></td>
<td>{{ row.label }}</td>
<td>{{ row.total }}</td>
</tr>
{% else %}
<tr><td colspan="3">暂无标签周报数据。</td></tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</body>
</html>

View File

@@ -144,13 +144,21 @@
</div>
{% if cards %}
<ul class="topic-list">
<ul class="topic-list topic-list-chronicle">
{% for item in cards %}
{% set post = item.post %}
<li class="topic-row">
{% set cover_tone = (loop.index0 % 4) + 1 %}
<li class="topic-row topic-row-chronicle" data-tone="{{ cover_tone }}">
<div class="topic-main">
<div class="topic-avatar">{{ item.author_initial }}</div>
<div class="topic-visual tone-{{ cover_tone }}" aria-hidden="true">
<span>{{ item.author_initial }}</span>
</div>
<div class="topic-content">
<div class="topic-cover tone-{{ cover_tone }}" aria-hidden="true">
<span class="topic-cover-kicker">{{ l('讨论精选', 'Featured Thread') }}</span>
<span class="topic-cover-chip">{{ post.category or l('综合讨论', 'General') }}</span>
<span class="topic-cover-time">{{ item.latest_activity_text }}</span>
</div>
<a href="{{ url_for('forum_post_detail', post_id=post.id) }}" rel="bookmark" class="topic-title">
{% if post.is_pinned %}<span class="topic-flag flag-pinned">{{ l('置顶', 'Pinned') }}</span>{% endif %}
{% if post.is_featured %}<span class="topic-flag flag-featured">{{ l('精华', 'Featured') }}</span>{% endif %}
@@ -168,9 +176,9 @@
</div>
</div>
</div>
<div class="topic-stat">{{ item.reply_count }}</div>
<div class="topic-stat">{{ item.view_count }}</div>
<div class="topic-stat topic-activity" title="{{ item.latest_activity.strftime('%Y-%m-%d %H:%M') if item.latest_activity else '' }}">{{ item.latest_activity_text }}</div>
<div class="topic-stat" data-label="{{ l('回复', 'Replies') }}"><strong>{{ item.reply_count }}</strong></div>
<div class="topic-stat" data-label="{{ l('浏览', 'Views') }}"><strong>{{ item.view_count }}</strong></div>
<div class="topic-stat topic-activity" data-label="{{ l('活动', 'Activity') }}" title="{{ item.latest_activity.strftime('%Y-%m-%d %H:%M') if item.latest_activity else '' }}"><strong>{{ item.latest_activity_text }}</strong></div>
</li>
{% endfor %}
</ul>
@@ -227,6 +235,69 @@
</div>
<aside class="forum-sidebar">
<div class="side-card side-profile-card">
<div class="side-profile-head">
<div class="side-avatar">
{% if current_user and current_user.username %}
{{ current_user.username[0]|upper }}
{% else %}
V
{% endif %}
</div>
<div class="side-profile-meta">
<strong>
{% if current_user and current_user.username %}
{{ current_user.username }}
{% else %}
{{ l('访客', 'Guest') }}
{% endif %}
</strong>
<span>
{% if current_user and not current_user.is_banned %}
{{ l('欢迎回来,继续你的 VPS 讨论。', 'Welcome back. Continue your VPS discussions.') }}
{% elif current_user and current_user.is_banned %}
{{ l('账号封禁中,仅可阅读内容。', 'Account banned. Read-only mode.') }}
{% else %}
{{ l('登录后可发布主题、评论与收藏。', 'Login to post topics, comment, and bookmark.') }}
{% endif %}
</span>
</div>
</div>
<div class="form-actions">
{% if current_user and not current_user.is_banned %}
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-primary">{{ l('发布主题', 'New Topic') }}</a>
<a href="{{ url_for('user_profile') }}" class="forum-btn-muted">{{ l('个人中心', 'Profile') }}</a>
{% elif current_user and current_user.is_banned %}
<a href="{{ url_for('user_profile') }}" class="forum-btn-muted">{{ l('查看封禁说明', 'View Account Status') }}</a>
{% else %}
<a href="{{ url_for('user_login', next='/forum') }}" class="forum-btn-primary">{{ l('登录社区', 'Login to Forum') }}</a>
<a href="{{ url_for('user_register', next='/forum') }}" class="forum-btn-muted">{{ l('立即注册', 'Create Account') }}</a>
{% endif %}
</div>
</div>
<div class="side-card side-notice-card">
<h3>{{ l('社区公告', 'Community Notes') }}</h3>
<ul class="side-notice-list">
<li>{{ l('优先阅读“精华”主题,减少试错成本。', 'Prioritize featured topics to reduce trial-and-error.') }}</li>
<li>{{ l('发帖建议写明预算、地区和业务类型。', 'Include budget, region, and workload when posting.') }}</li>
<li>{{ l('涉及采购请最终以厂商官方条款为准。', 'For purchasing decisions, always verify official provider terms.') }}</li>
</ul>
</div>
<div class="side-card">
<h3>{{ l('分类标签云', 'Category Tags') }}</h3>
{% if sb.category_counts %}
<div class="side-tag-cloud">
{% for name, count in sb.category_counts %}
<a href="{{ url_for('forum_index', category=name) }}" class="side-tag">{{ name }}<strong>{{ count }}</strong></a>
{% endfor %}
</div>
{% else %}
<p class="side-empty">{{ l('暂无分类数据', 'No category data') }}</p>
{% endif %}
</div>
<div class="side-card">
<h3>{{ l('社区统计', 'Community Stats') }}</h3>
<div class="side-stats">
@@ -235,18 +306,6 @@
<div><span>{{ l('评论', 'Comments') }}</span><strong>{{ sb.total_comments }}</strong></div>
</div>
</div>
<div class="side-card">
<h3>{{ l('分类热度', 'Category Heat') }}</h3>
{% if sb.category_counts %}
<ul class="side-list">
{% for name, count in sb.category_counts %}
<li><span>{{ name }}</span><strong>{{ count }}</strong></li>
{% endfor %}
</ul>
{% else %}
<p class="side-empty">{{ l('暂无分类数据', 'No category data') }}</p>
{% endif %}
</div>
<div class="side-card">
<h3>{{ l('活跃作者', 'Active Authors') }}</h3>
{% if sb.active_users %}

View File

@@ -42,12 +42,17 @@
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/forum.css">
</head>
<body class="forum-page" data-post-id="{{ post.id }}">
<body class="forum-page" data-post-id="{{ post.id }}" data-cta-variant="{{ cta_variant or 'control' }}">
{% set sb = sidebar if sidebar is defined else {'total_users': 0, 'total_posts': 0, 'total_comments': 0, 'category_counts': []} %}
{% set related = related_cards if related_cards is defined else [] %}
{% set plan_recos = plan_recommendations if plan_recommendations is defined else [] %}
{% set resource_links = detail_resource_links if detail_resource_links is defined else [] %}
{% set post_faq = detail_faq_items if detail_faq_items is defined else [] %}
{% set pricing_prefill_obj = pricing_prefill if pricing_prefill is defined and pricing_prefill else {} %}
{% set pricing_target_url = pricing_prefill_obj.url if pricing_prefill_obj and pricing_prefill_obj.url else (url_for('index', lang='en') if lang == 'en' else url_for('index')) %}
{% set cta_primary_label = cta_copy.primary_button if cta_copy and cta_copy.primary_button else l('去比价页筛选方案', 'Compare Plans') %}
{% set cta_secondary_member_label = cta_copy.secondary_button_member if cta_copy and cta_copy.secondary_button_member else l('发布需求主题', 'Post Requirement Topic') %}
{% set cta_secondary_guest_label = cta_copy.secondary_button_guest if cta_copy and cta_copy.secondary_button_guest else l('登录后发布需求', 'Login to Post Requirement') %}
<header class="forum-header">
<div class="forum-header-inner">
<div class="forum-header-left">
@@ -83,6 +88,9 @@
</header>
<main class="forum-shell">
<div class="post-read-progress" aria-hidden="true">
<span class="post-read-progress-bar" id="post-read-progress-bar"></span>
</div>
<nav class="forum-breadcrumb" aria-label="{{ l('面包屑导航', 'Breadcrumb') }}">
<a href="{{ url_for('index') }}">{{ l('首页', 'Home') }}</a>
<span>/</span>
@@ -108,6 +116,135 @@
</header>
<h1>{{ post.title }}</h1>
<div class="topic-post-author">{{ l('作者:', 'Author: ') }}{{ post.author_rel.username if post.author_rel else l('已注销用户', 'Deleted user') }}</div>
<div class="topic-detail-meta-row">
{% if post.updated_at and post.created_at and post.updated_at != post.created_at %}
<span>{{ l('最近更新', 'Last updated') }} {{ post.updated_at.strftime('%Y-%m-%d %H:%M') }}</span>
{% endif %}
<span>{{ l('收藏', 'Bookmarks') }} {{ bookmark_count or 0 }}</span>
<span>{{ l('评论', 'Comments') }} {{ comments_count or comments|length }}</span>
<span>{{ l('阅读时长', 'Read time') }} {{ read_minutes }} {{ l('分钟', 'min') }}</span>
</div>
<nav class="post-mobile-quickjump" aria-label="{{ l('正文快捷入口', 'Quick Reading Entry') }}">
<a href="#post-content-start" class="forum-btn-muted" data-track-event="post_detail_jump_content" data-track-label="{{ 'quickjump_content_' ~ cta_track_suffix }}">{{ l('开始阅读', 'Start Reading') }}</a>
<a href="#post-outline-panel" class="forum-btn-muted" data-track-event="post_detail_jump_outline" data-track-label="{{ 'quickjump_outline_' ~ cta_track_suffix }}">{{ l('查看目录', 'Open Outline') }}</a>
</nav>
<section class="post-trust-action-hub" aria-label="{{ l('作者信誉与阅读行动', 'Author Trust and Reading Actions') }}">
<article class="post-author-cred">
<div class="post-author-badge" aria-hidden="true">
{% if post.author_rel and post.author_rel.username %}
{{ post.author_rel.username[0]|upper }}
{% else %}
?
{% endif %}
</div>
<div class="post-author-copy">
<h2>{{ l('作者信誉', 'Author Trust') }}</h2>
<p>{{ l('基于该主题的公开互动数据,帮助你快速判断参考价值。', 'Use topic interaction signals to quickly judge trust and relevance.') }}</p>
</div>
<dl class="post-author-stats">
<div>
<dt>{{ l('浏览', 'Views') }}</dt>
<dd>{{ post.view_count or 0 }}</dd>
</div>
<div>
<dt>{{ l('互动', 'Engagement') }}</dt>
<dd>{{ (comments_count or comments|length) + (like_count or 0) + (bookmark_count or 0) }}</dd>
</div>
</dl>
</article>
<article class="post-reading-rail">
<div class="post-reading-rail-head">
<h2>{{ l('阅读导航', 'Reading Navigation') }}</h2>
<strong id="post-current-section" data-default-label="{{ l('正文', 'Content') }}">{{ l('正文', 'Content') }}</strong>
</div>
<div class="post-reading-inline">
<span class="post-reading-inline-text" id="post-read-progress-text">0%</span>
<span class="post-reading-inline-bar" aria-hidden="true">
<span class="post-reading-inline-fill" id="post-read-progress-inline-fill"></span>
</span>
</div>
<div class="post-reading-rail-actions">
<a href="#post-outline-panel" class="forum-link" data-track-event="post_detail_jump_outline" data-track-label="{{ 'hub_jump_outline_' ~ cta_track_suffix }}">{{ l('打开目录', 'Open Outline') }}</a>
<a href="#comments-panel" class="forum-link" data-track-event="post_detail_jump_comments" data-track-label="{{ 'hub_jump_comments_' ~ cta_track_suffix }}">{{ l('跳至评论', 'Jump to Comments') }}</a>
</div>
</article>
<article class="post-related-quick">
<h2>{{ l('延伸话题', 'Related Threads') }}</h2>
{% if related %}
<ul class="post-related-quick-list">
{% for item in related[:2] %}
{% set rp = item.post %}
{% set related_tone = (loop.index0 % 4) + 1 %}
<li class="post-related-quick-card tone-{{ related_tone }}">
<span class="post-related-kicker">{{ rp.category or l('综合讨论', 'General') }}</span>
<a href="{{ url_for('forum_post_detail', post_id=rp.id) }}" class="post-related-quick-link" data-track-event="post_detail_related_click" data-track-label="{{ 'hub_' ~ rp.title }}">{{ rp.title }}</a>
<div class="post-related-quick-meta">
<span>{{ l('回复', 'Replies') }} {{ item.reply_count }}</span>
<span>{{ l('浏览', 'Views') }} {{ item.view_count }}</span>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ l('暂无延伸话题,建议先看评论区争议点。', 'No related threads yet. Check comments for key disagreements.') }}</p>
{% endif %}
</article>
</section>
<section class="post-decision-strip" aria-label="{{ l('采购决策速览', 'Procurement Snapshot') }}">
<div class="post-decision-copy">
<h2>{{ l('采购决策速览', 'Procurement Snapshot') }}</h2>
<p>{{ l('本帖已沉淀讨论结论与可执行方案,建议先快速浏览要点,再进入比价页筛选。', 'This topic already contains discussion outcomes and executable options. Review the highlights first, then shortlist plans on the comparison page.') }}</p>
</div>
<ul class="post-trust-list">
<li>{{ l('官方链接可直达', 'Official links available') }}</li>
<li>{{ l('结合社区经验筛选', 'Filtered with community insights') }}</li>
<li>{{ l('支持二次发帖补充需求', 'Post follow-up requirements') }}</li>
</ul>
<div class="post-decision-actions">
<a href="{{ pricing_target_url }}" class="forum-btn-primary {{ 'cta-emphasis' if cta_variant == 'intent' else '' }}" data-track-event="post_detail_cta_pricing" data-track-label="{{ 'decision_compare_plans_' ~ cta_track_suffix }}">{{ cta_primary_label }}</a>
{% if requirement_draft %}
{% if current_user and not current_user.is_banned %}
<a href="{{ requirement_draft.new_topic_url }}" class="forum-btn-muted" data-track-event="post_detail_requirement_template_click" data-track-label="{{ 'decision_template_new_topic_' ~ cta_track_suffix }}">{{ requirement_draft.action_text_member }}</a>
{% elif current_user and current_user.is_banned %}
<span class="topic-empty">{{ l('账号封禁中,暂不可发帖', 'Account banned. Posting is disabled.') }}</span>
{% else %}
<a href="{{ url_for('user_login', next=requirement_draft.new_topic_url) }}" class="forum-btn-muted" data-track-event="post_detail_requirement_template_click" data-track-label="{{ 'decision_template_login_' ~ cta_track_suffix }}">{{ requirement_draft.action_text_guest }}</a>
{% endif %}
{% else %}
{% if current_user and not current_user.is_banned %}
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="{{ 'decision_new_topic_' ~ cta_track_suffix }}">{{ cta_secondary_member_label }}</a>
{% elif current_user and current_user.is_banned %}
<span class="topic-empty">{{ l('账号封禁中,暂不可发帖', 'Account banned. Posting is disabled.') }}</span>
{% else %}
<a href="{{ url_for('user_login', next='/forum/post/new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="{{ 'decision_login_new_topic_' ~ cta_track_suffix }}">{{ cta_secondary_guest_label }}</a>
{% endif %}
{% endif %}
{% if plan_recos %}
<a href="#post-inline-plans" class="forum-link" data-track-event="post_detail_inline_plan_view_all" data-track-label="{{ 'decision_jump_inline_plans_' ~ cta_track_suffix }}">{{ l('查看关联方案', 'Jump to Related Plans') }}</a>
{% else %}
<a href="{{ pricing_target_url }}" class="forum-link" data-track-event="post_detail_inline_plan_view_all" data-track-label="{{ 'decision_view_all_plans_' ~ cta_track_suffix }}">{{ l('查看全部方案', 'View All Plans') }}</a>
{% endif %}
</div>
{% if pricing_prefill_obj and pricing_prefill_obj.hint_text %}
<p class="post-decision-prefill">{{ pricing_prefill_obj.hint_text }}</p>
{% endif %}
<div class="post-decision-metrics" aria-label="{{ l('行动速览', 'Action Snapshot') }}">
<article>
<span>{{ l('推荐方案', 'Recommended Plans') }}</span>
<strong>{{ plan_recos|length }}</strong>
</article>
<article>
<span>{{ l('评论互动', 'Comment Signals') }}</span>
<strong>{{ comments_count or comments|length }}</strong>
</article>
<article>
<span>{{ l('决策阅读', 'Decision Read') }}</span>
<strong>{{ read_minutes }} {{ l('分钟', 'min') }}</strong>
</article>
</div>
</section>
<div class="topic-metric-grid">
<div><span>{{ l('浏览', 'Views') }}</span><strong>{{ post.view_count or 0 }}</strong></div>
@@ -115,6 +252,20 @@
<div><span>{{ l('点赞', 'Likes') }}</span><strong>{{ like_count or 0 }}</strong></div>
<div><span>{{ l('预计阅读', 'Read Time') }}</span><strong>{{ read_minutes }} {{ l('分钟', 'min') }}</strong></div>
</div>
<section class="post-proof-band" aria-label="{{ l('社区信号', 'Community Signals') }}">
<article class="post-proof-item">
<strong>{{ sb.total_users or 0 }}</strong>
<span>{{ l('活跃用户', 'Active Members') }}</span>
</article>
<article class="post-proof-item">
<strong>{{ sb.total_posts or 0 }}</strong>
<span>{{ l('社区主题', 'Topics') }}</span>
</article>
<article class="post-proof-item">
<strong>{{ sb.total_comments or 0 }}</strong>
<span>{{ l('累计评论', 'Comments') }}</span>
</article>
</section>
<div class="topic-action-bar">
{% if current_user and can_interact %}
@@ -129,6 +280,8 @@
{% else %}
<a href="{{ url_for('user_login', next=request.path) }}" class="forum-btn-muted">{{ l('登录后点赞/收藏', 'Login to like/bookmark') }}</a>
{% endif %}
<button type="button" class="forum-btn-muted js-copy-link" data-track-event="post_detail_copy_link" data-track-label="copy_permalink" data-copy-success="{{ l('链接已复制', 'Link copied') }}" data-copy-failed="{{ l('复制失败,请手动复制地址栏链接', 'Copy failed. Please copy the URL manually.') }}">{{ l('复制链接', 'Copy Link') }}</button>
<span class="copy-link-feedback" id="copy-link-feedback" aria-live="polite"></span>
{% if current_user and current_user.id == post.user_id and can_interact %}
<a href="{{ url_for('forum_post_edit', post_id=post.id) }}" class="forum-btn-muted">{{ l('编辑帖子', 'Edit Topic') }}</a>
<form method="post" action="{{ url_for('forum_post_delete', post_id=post.id) }}" onsubmit="return confirm('{{ l('确定删除该帖子?删除后不可恢复。', 'Delete this topic permanently?') }}');">
@@ -150,7 +303,16 @@
{% endif %}
</div>
<div class="topic-post-content md-content">{{ post.content|markdown_html }}</div>
<div class="post-reading-layout">
<div id="post-content-start" class="topic-post-content md-content">{{ post.content|markdown_html }}</div>
<section id="post-outline-panel" class="post-outline-panel post-outline-floating" aria-label="{{ l('帖子目录', 'Topic Outline') }}" hidden>
<div class="post-outline-head">
<h2>{{ l('快速导航', 'Quick Navigation') }}</h2>
<a href="#comments-panel" class="forum-link" data-track-event="post_detail_jump_comments" data-track-label="outline_jump_comments">{{ l('跳至评论', 'Jump to comments') }}</a>
</div>
<ol id="post-outline-list" class="post-outline-list"></ol>
</section>
</div>
{% if resource_links %}
<section class="post-resource-links" aria-label="{{ l('延伸阅读与采购路径', 'Extended Reading and Buying Paths') }}">
<h2>{{ l('延伸阅读与采购路径', 'Extended Reading and Buying Paths') }}</h2>
@@ -177,19 +339,80 @@
</section>
{% endif %}
<div class="post-commercial-cta">
{% if plan_recos %}
<section id="post-inline-plans" class="post-inline-plan-panel" aria-label="{{ l('本帖关联方案速览', 'Plans Mentioned in This Topic') }}">
<div class="post-inline-plan-head">
<h2>{{ l('本帖关联方案速览', 'Plans Mentioned in This Topic') }}</h2>
<a href="{{ pricing_target_url }}" class="forum-link" data-track-event="post_detail_inline_plan_view_all" data-track-label="inline_view_all_plans">{{ l('查看全部方案', 'View all plans') }}</a>
</div>
<ul class="post-inline-plan-list">
{% for plan in plan_recos[:3] %}
{% set inline_plan_href = plan.official_url or pricing_target_url %}
<li>
<div class="post-inline-plan-main">
<a href="{{ inline_plan_href }}" {% if plan.official_url %}target="_blank" rel="noopener nofollow"{% endif %} data-track-event="post_detail_inline_plan_click" data-track-label="{{ plan.provider ~ ' ' ~ plan.name }}">{{ plan.provider }} · {{ plan.name }}</a>
<p>{{ plan.region }}</p>
</div>
<strong>{{ plan.price_label }}</strong>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if requirement_draft %}
<section class="post-requirement-brief" aria-label="{{ l('需求模板快捷入口', 'Requirement Template Shortcut') }}">
<div class="post-requirement-copy">
<h2>{{ l('需求模板快捷入口', 'Requirement Template Shortcut') }}</h2>
<p>{{ requirement_draft.intro_text }}</p>
<div class="post-requirement-preview">
<span>{{ l('预填标题', 'Prefilled title') }}</span>
<strong>{{ requirement_draft.preview_title }}</strong>
</div>
<ul class="post-requirement-tips">
{% for tip in requirement_draft.tips %}
<li>{{ tip }}</li>
{% endfor %}
</ul>
</div>
<div class="post-requirement-actions">
{% if current_user and not current_user.is_banned %}
<a href="{{ requirement_draft.new_topic_url }}" class="forum-btn-primary" data-track-event="post_detail_requirement_template_click" data-track-label="{{ 'template_new_topic_' ~ cta_track_suffix }}">{{ requirement_draft.action_text_member }}</a>
{% elif current_user and current_user.is_banned %}
<span class="topic-empty">{{ l('账号封禁中,暂不可发帖', 'Account banned. Posting is disabled.') }}</span>
{% else %}
<a href="{{ url_for('user_login', next=requirement_draft.new_topic_url) }}" class="forum-btn-primary" data-track-event="post_detail_requirement_template_click" data-track-label="{{ 'template_login_new_topic_' ~ cta_track_suffix }}">{{ requirement_draft.action_text_guest }}</a>
{% endif %}
<a href="{{ pricing_target_url }}" class="forum-btn-muted" data-track-event="post_detail_cta_pricing" data-track-label="{{ 'template_compare_plans_' ~ cta_track_suffix }}">{{ l('对照价格页筛选', 'Compare on Pricing Page') }}</a>
</div>
</section>
{% endif %}
<div class="post-commercial-cta post-commercial-cta-{{ cta_variant or 'control' }}">
<div class="post-commercial-copy">
<h3>{{ l('准备选型或采购 VPS', 'Ready to shortlist or buy VPS?') }}</h3>
<p>{{ l('结合本帖讨论,去价格页快速筛选可落地方案。', 'Use insights from this topic and shortlist actionable plans on the pricing page.') }}</p>
<h3>{{ cta_copy.headline }}</h3>
<p>{{ cta_copy.description }}</p>
</div>
<div class="post-commercial-actions">
<a href="{{ url_for('index') }}" class="forum-btn-primary" data-track-event="post_detail_cta_pricing" data-track-label="main_compare_plans">{{ l('去比价筛选', 'Compare Plans') }}</a>
{% if current_user and not current_user.is_banned %}
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="main_post_requirement">{{ l('发布采购需求', 'Post Requirement') }}</a>
<a href="{{ pricing_target_url }}" class="forum-btn-primary {{ 'cta-emphasis' if cta_variant == 'intent' else '' }}" data-track-event="post_detail_cta_pricing" data-track-label="{{ 'main_compare_plans_' ~ cta_track_suffix }}">{{ cta_primary_label }}</a>
{% if requirement_draft %}
{% if current_user and not current_user.is_banned %}
<a href="{{ requirement_draft.new_topic_url }}" class="forum-btn-muted" data-track-event="post_detail_requirement_template_click" data-track-label="{{ 'main_template_new_topic_' ~ cta_track_suffix }}">{{ requirement_draft.action_text_member }}</a>
{% elif current_user and current_user.is_banned %}
<span class="topic-empty">{{ l('账号封禁中,暂不可发帖', 'Account banned. Posting is disabled.') }}</span>
{% else %}
<a href="{{ url_for('user_login', next=requirement_draft.new_topic_url) }}" class="forum-btn-muted" data-track-event="post_detail_requirement_template_click" data-track-label="{{ 'main_template_login_' ~ cta_track_suffix }}">{{ requirement_draft.action_text_guest }}</a>
{% endif %}
{% else %}
<a href="{{ url_for('user_login', next='/forum/post/new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="main_login_post_requirement">{{ l('登录后发帖', 'Login to Post') }}</a>
{% if current_user and not current_user.is_banned %}
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="{{ 'main_post_requirement_' ~ cta_track_suffix }}">{{ cta_secondary_member_label }}</a>
{% elif current_user and current_user.is_banned %}
<span class="topic-empty">{{ l('账号封禁中,暂不可发帖', 'Account banned. Posting is disabled.') }}</span>
{% else %}
<a href="{{ url_for('user_login', next='/forum/post/new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="{{ 'main_login_post_requirement_' ~ cta_track_suffix }}">{{ cta_secondary_guest_label }}</a>
{% endif %}
{% endif %}
<a href="#comments-panel" class="forum-btn-muted" data-track-event="post_detail_jump_comments" data-track-label="main_jump_comments">{{ l('查看评论', 'Jump to Comments') }}</a>
<a href="#comments-panel" class="forum-btn-muted" data-track-event="post_detail_jump_comments" data-track-label="{{ 'main_jump_comments_' ~ cta_track_suffix }}">{{ l('查看评论', 'Jump to Comments') }}</a>
</div>
</div>
</article>
@@ -287,15 +510,61 @@
</div>
<aside class="forum-sidebar">
<div class="side-card side-cta">
<h3>{{ l('商业化入口', 'Commercial Actions') }}</h3>
<p>{{ l('你可以基于本帖结论直接筛选方案,或发布更具体的业务需求。', 'Shortlist plans directly from this topic, or publish a more specific workload requirement.') }}</p>
<div class="side-card side-profile-card">
<div class="side-profile-head">
<div class="side-avatar">
{% if post.author_rel and post.author_rel.username %}
{{ post.author_rel.username[0]|upper }}
{% else %}
?
{% endif %}
</div>
<div class="side-profile-meta">
<strong>{{ post.author_rel.username if post.author_rel else l('已注销用户', 'Deleted user') }}</strong>
<span>{{ l('主题作者', 'Topic author') }} · {{ post.category or l('综合讨论', 'General') }}</span>
</div>
</div>
<ul class="side-list">
<li><span>{{ l('发帖时间', 'Posted') }}</span><strong>{{ post.created_at.strftime('%Y-%m-%d') if post.created_at else '—' }}</strong></li>
<li><span>{{ l('阅读时长', 'Read time') }}</span><strong>{{ read_minutes }} {{ l('分钟', 'min') }}</strong></li>
<li><span>{{ l('评论数', 'Comments') }}</span><strong>{{ comments_count or comments|length }}</strong></li>
</ul>
</div>
<div class="side-card side-notice-card">
<h3>{{ l('阅读建议', 'Reading Tips') }}</h3>
<ul class="side-notice-list">
<li>{{ l('先看目录和 FAQ再进评论区看争议点。', 'Review outline and FAQ first, then inspect comments for tradeoffs.') }}</li>
<li>{{ l('比价前先确认预算和线路约束。', 'Confirm budget and route constraints before shortlisting.') }}</li>
<li>{{ l('需求不清晰时,建议用模板补充发帖。', 'If requirements are unclear, publish a follow-up with template prefill.') }}</li>
</ul>
</div>
<div class="side-card side-cta side-cta-sticky">
<h3>{{ l('下一步行动', 'Next Action') }}</h3>
<p>{{ cta_copy.description }}</p>
<div class="side-cta-kpi">
<div><span>{{ l('本帖浏览', 'Views') }}</span><strong>{{ post.view_count or 0 }}</strong></div>
<div><span>{{ l('互动总量', 'Engagement') }}</span><strong>{{ (comments_count or comments|length) + (like_count or 0) + (bookmark_count or 0) }}</strong></div>
</div>
<div class="form-actions">
<a href="{{ url_for('index') }}" class="forum-btn-primary" data-track-event="post_detail_sidebar_compare" data-track-label="sidebar_shortlist">{{ l('立即筛选 VPS', 'Shortlist VPS') }}</a>
{% if current_user and not current_user.is_banned %}
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="sidebar_new_topic">{{ l('发布新主题', 'New Topic') }}</a>
<a href="{{ pricing_target_url }}" class="forum-btn-primary {{ 'cta-emphasis' if cta_variant == 'intent' else '' }}" data-track-event="post_detail_sidebar_compare" data-track-label="{{ 'sidebar_shortlist_' ~ cta_track_suffix }}">{{ cta_primary_label }}</a>
{% if requirement_draft %}
{% if current_user and not current_user.is_banned %}
<a href="{{ requirement_draft.new_topic_url }}" class="forum-btn-muted" data-track-event="post_detail_requirement_template_click" data-track-label="{{ 'sidebar_template_new_topic_' ~ cta_track_suffix }}">{{ requirement_draft.action_text_member }}</a>
{% elif current_user and current_user.is_banned %}
<span class="topic-empty">{{ l('账号封禁中,暂不可发帖', 'Account banned. Posting is disabled.') }}</span>
{% else %}
<a href="{{ url_for('user_login', next=requirement_draft.new_topic_url) }}" class="forum-btn-muted" data-track-event="post_detail_requirement_template_click" data-track-label="{{ 'sidebar_template_login_' ~ cta_track_suffix }}">{{ requirement_draft.action_text_guest }}</a>
{% endif %}
{% else %}
<a href="{{ url_for('user_login', next='/forum/post/new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="sidebar_login_new_topic">{{ l('登录后发帖', 'Login to Post') }}</a>
{% if current_user and not current_user.is_banned %}
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="{{ 'sidebar_new_topic_' ~ cta_track_suffix }}">{{ l('发布新主题', 'New Topic') }}</a>
{% elif current_user and current_user.is_banned %}
<span class="topic-empty">{{ l('账号封禁中,暂不可发帖', 'Account banned. Posting is disabled.') }}</span>
{% else %}
<a href="{{ url_for('user_login', next='/forum/post/new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="{{ 'sidebar_login_new_topic_' ~ cta_track_suffix }}">{{ l('登录后发帖', 'Login to Post') }}</a>
{% endif %}
{% endif %}
</div>
</div>
@@ -308,13 +577,13 @@
</div>
</div>
<div class="side-card">
<h3>{{ l('分类热度', 'Category Heat') }}</h3>
<h3>{{ l('分类标签云', 'Category Tags') }}</h3>
{% if sb.category_counts %}
<ul class="side-list">
<div class="side-tag-cloud">
{% for name, count in sb.category_counts %}
<li><span>{{ name }}</span><strong>{{ count }}</strong></li>
<a href="{{ url_for('forum_index', category=name) }}" class="side-tag">{{ name }}<strong>{{ count }}</strong></a>
{% endfor %}
</ul>
</div>
{% else %}
<p class="side-empty">{{ l('暂无分类数据', 'No category data') }}</p>
{% endif %}
@@ -325,11 +594,16 @@
<ul class="related-post-list">
{% for item in related %}
{% set rp = item.post %}
<li>
{% set related_tone = (loop.index0 % 4) + 1 %}
<li class="related-post-card tone-{{ related_tone }}">
<span class="related-post-kicker">{{ rp.category or l('综合讨论', 'General') }}</span>
<a href="{{ url_for('forum_post_detail', post_id=rp.id) }}" data-track-event="post_detail_related_click" data-track-label="{{ rp.title }}">{{ rp.title }}</a>
<div class="related-post-meta">
<span>{{ l('回复', 'Replies') }} {{ item.reply_count }}</span>
<span>{{ l('浏览', 'Views') }} {{ item.view_count }}</span>
{% if rp.created_at %}
<span>{{ rp.created_at.strftime('%m-%d') }}</span>
{% endif %}
</div>
</li>
{% endfor %}
@@ -345,7 +619,7 @@
<ul class="plan-reco-list">
{% for plan in plan_recos %}
<li>
{% set plan_href = plan.official_url or url_for('index') %}
{% set plan_href = plan.official_url or pricing_target_url %}
<a href="{{ plan_href }}" {% if plan.official_url %}target="_blank" rel="noopener nofollow"{% endif %} data-track-event="post_detail_plan_click" data-track-label="{{ plan.provider ~ ' ' ~ plan.name }}">{{ plan.provider }} · {{ plan.name }}</a>
<div class="plan-reco-meta">
<span>{{ plan.region }}</span>
@@ -358,11 +632,38 @@
<p class="side-empty">{{ l('暂无可推荐方案', 'No plan recommendations yet') }}</p>
{% endif %}
<div class="form-actions">
<a href="{{ url_for('index') }}" class="forum-btn-muted" data-track-event="post_detail_sidebar_compare" data-track-label="sidebar_view_all_plans">{{ l('查看全部方案', 'View All Plans') }}</a>
<a href="{{ pricing_target_url }}" class="forum-btn-muted" data-track-event="post_detail_sidebar_compare" data-track-label="sidebar_view_all_plans">{{ l('查看全部方案', 'View All Plans') }}</a>
</div>
</div>
</aside>
</section>
<section class="post-mobile-conversion-bar" data-mobile-conversion-bar aria-label="{{ l('移动端快捷转化操作', 'Mobile Conversion Actions') }}">
<div class="post-mobile-conversion-main">
<strong>{{ cta_copy.headline }}</strong>
<span>{{ l('基于本帖结论快速行动', 'Take action from this topic now') }}</span>
</div>
<div class="post-mobile-conversion-actions">
<a href="{{ pricing_target_url }}" class="forum-btn-primary {{ 'cta-emphasis' if cta_variant == 'intent' else '' }}" data-track-event="post_detail_cta_pricing" data-track-label="{{ 'mobile_compare_plans_' ~ cta_track_suffix }}">{{ cta_primary_label }}</a>
{% if requirement_draft %}
{% if current_user and not current_user.is_banned %}
<a href="{{ requirement_draft.new_topic_url }}" class="forum-btn-muted" data-track-event="post_detail_requirement_template_click" data-track-label="{{ 'mobile_template_new_topic_' ~ cta_track_suffix }}">{{ l('一键发需求', 'Prefill Need') }}</a>
{% elif current_user and current_user.is_banned %}
<span class="topic-empty">{{ l('账号封禁中', 'Account banned') }}</span>
{% else %}
<a href="{{ url_for('user_login', next=requirement_draft.new_topic_url) }}" class="forum-btn-muted" data-track-event="post_detail_requirement_template_click" data-track-label="{{ 'mobile_template_login_' ~ cta_track_suffix }}">{{ l('登录后发需求', 'Login to Prefill') }}</a>
{% endif %}
{% else %}
{% if current_user and not current_user.is_banned %}
<a href="{{ url_for('forum_post_new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="{{ 'mobile_new_topic_' ~ cta_track_suffix }}">{{ l('发布需求', 'Post Need') }}</a>
{% elif current_user and current_user.is_banned %}
<span class="topic-empty">{{ l('账号封禁中', 'Account banned') }}</span>
{% else %}
<a href="{{ url_for('user_login', next='/forum/post/new') }}" class="forum-btn-muted" data-track-event="post_detail_cta_new_topic" data-track-label="{{ 'mobile_login_new_topic_' ~ cta_track_suffix }}">{{ l('登录后发帖', 'Login to Post') }}</a>
{% endif %}
{% endif %}
<a href="#comments-panel" class="forum-link" data-track-event="post_detail_jump_comments" data-track-label="{{ 'mobile_jump_comments_' ~ cta_track_suffix }}">{{ l('看评论', 'Comments') }}</a>
</div>
</section>
</main>
<script src="{{ url_for('static', filename='js/forum-post-detail.js') }}" defer></script>
</body>

View File

@@ -53,10 +53,24 @@
{{ l('描述你的问题、评测或优惠信息,方便其他用户快速理解。', 'Describe your question, review, or deal details for other users.') }}
{% endif %}
</p>
{% if p_mode == 'create' and prefill_applied %}
<p class="form-success">
{{ l('已带入需求模板,可直接补充后发布。', 'Requirement template prefilled. Add details and publish directly.') }}
{% if prefill_source_post_id %}
{{ l('来源帖子:', 'Source topic: ') }}<a href="{{ url_for('forum_post_detail', post_id=prefill_source_post_id) }}" target="_blank" rel="noopener">#{{ prefill_source_post_id }}</a>
{% endif %}
</p>
{% endif %}
{% if error %}
<p class="form-error">{{ error }}</p>
{% endif %}
<form method="post" action="{{ p_action }}" class="post-form">
{% if p_mode == 'create' and prefill_source_post_id %}
<input type="hidden" name="from_post" value="{{ prefill_source_post_id }}">
{% if prefill_cta_variant %}
<input type="hidden" name="cta_variant" value="{{ prefill_cta_variant }}">
{% endif %}
{% endif %}
<div class="form-group">
<label for="category">{{ l('分类', 'Category') }}</label>
<select id="category" name="category" required>

View File

@@ -105,7 +105,10 @@
<h2 class="filters-title">{{ t.filters_title }}</h2>
<p class="filters-subtitle">{{ t.filters_subtitle }}</p>
</div>
<p class="result-count" id="result-count">--</p>
<div class="filters-head-meta">
<p class="result-count" id="result-count">--</p>
<p class="filter-source-hint" id="filter-source-hint" hidden></p>
</div>
</div>
<div class="filter-grid">
<div class="filter-group">