哈哈
This commit is contained in:
165
docs/forum-post-detail-funnel-sql.md
Normal file
165
docs/forum-post-detail-funnel-sql.md
Normal 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;
|
||||||
|
```
|
||||||
88
docs/forum-post-detail-tracking.md
Normal file
88
docs/forum-post-detail-tracking.md
Normal 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
|
||||||
34
models.py
34
models.py
@@ -289,3 +289,37 @@ class ForumNotification(db.Model):
|
|||||||
message = db.Column(db.String(255), nullable=False)
|
message = db.Column(db.String(255), nullable=False)
|
||||||
is_read = db.Column(db.Boolean, nullable=False, default=False, index=True)
|
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)
|
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)
|
||||||
|
|||||||
@@ -645,6 +645,77 @@
|
|||||||
color: var(--text-muted);
|
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 {
|
.admin-table .btn-delete:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
2009
static/css/forum.css
2009
static/css/forum.css
File diff suppressed because it is too large
Load Diff
@@ -2692,6 +2692,13 @@ html {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filters-head-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.34rem;
|
||||||
|
}
|
||||||
|
|
||||||
.filters-title {
|
.filters-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.02rem;
|
font-size: 1.02rem;
|
||||||
@@ -2716,6 +2723,30 @@ html {
|
|||||||
padding: 0.38rem 0.68rem;
|
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 {
|
.filter-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
@@ -2909,6 +2940,14 @@ html {
|
|||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filters-head-meta {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-source-hint {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -14,6 +14,14 @@
|
|||||||
var rawPostId = bodyEl.getAttribute('data-post-id');
|
var rawPostId = bodyEl.getAttribute('data-post-id');
|
||||||
var parsedPostId = Number(rawPostId);
|
var parsedPostId = Number(rawPostId);
|
||||||
var postId = Number.isFinite(parsedPostId) ? parsedPostId : null;
|
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) {
|
function normalizeLabel(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
@@ -32,7 +40,9 @@
|
|||||||
event_name: normalizedName,
|
event_name: normalizedName,
|
||||||
label: normalizeLabel(label),
|
label: normalizeLabel(label),
|
||||||
post_id: postId,
|
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);
|
var payloadText = JSON.stringify(payload);
|
||||||
|
|
||||||
@@ -68,6 +78,526 @@
|
|||||||
return target.closest('[data-track-event]');
|
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) {
|
document.addEventListener('click', function (evt) {
|
||||||
var node = resolveTrackNode(evt.target);
|
var node = resolveTrackNode(evt.target);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
@@ -85,28 +615,34 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var reducedMotionQuery = null;
|
initCopyPermalink();
|
||||||
if (typeof window.matchMedia === 'function') {
|
initPostOutline();
|
||||||
reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
initProgressBar();
|
||||||
}
|
initCtaImpression();
|
||||||
var enableSmoothScroll = !(reducedMotionQuery && reducedMotionQuery.matches);
|
initMobileConversionBar();
|
||||||
if (!enableSmoothScroll) {
|
initCommentCollapse();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var jumpLinks = document.querySelectorAll('a[href="#comments-panel"]');
|
var jumpLinks = document.querySelectorAll('a[href^="#"]');
|
||||||
if (!jumpLinks.length) {
|
if (!jumpLinks.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
jumpLinks.forEach(function (link) {
|
jumpLinks.forEach(function (link) {
|
||||||
link.addEventListener('click', function () {
|
link.addEventListener('click', function (evt) {
|
||||||
var panel = document.getElementById('comments-panel');
|
var href = link.getAttribute('href');
|
||||||
if (!panel) {
|
if (!href || href.length < 2 || href.charAt(0) !== '#') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var target = document.getElementById(href.slice(1));
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
evt.preventDefault();
|
||||||
window.setTimeout(function () {
|
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);
|
}, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,43 @@
|
|||||||
return null;
|
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() {
|
function init() {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -74,6 +111,7 @@
|
|||||||
allPlans = window.__INITIAL_PLANS__;
|
allPlans = window.__INITIAL_PLANS__;
|
||||||
updateSummaryMetrics(allPlans);
|
updateSummaryMetrics(allPlans);
|
||||||
populateFilters();
|
populateFilters();
|
||||||
|
applyUrlPrefillFromQuery();
|
||||||
renderTable();
|
renderTable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -86,6 +124,7 @@
|
|||||||
allPlans = data;
|
allPlans = data;
|
||||||
updateSummaryMetrics(allPlans);
|
updateSummaryMetrics(allPlans);
|
||||||
populateFilters();
|
populateFilters();
|
||||||
|
applyUrlPrefillFromQuery();
|
||||||
renderTable();
|
renderTable();
|
||||||
})
|
})
|
||||||
.catch(function(error) {
|
.catch(function(error) {
|
||||||
@@ -118,15 +157,133 @@
|
|||||||
// ==================== 筛选器填充 ====================
|
// ==================== 筛选器填充 ====================
|
||||||
function populateFilters() {
|
function populateFilters() {
|
||||||
var providers = new Set();
|
var providers = new Set();
|
||||||
var regions = new Set();
|
|
||||||
|
|
||||||
allPlans.forEach(function(plan) {
|
allPlans.forEach(function(plan) {
|
||||||
providers.add(plan.provider);
|
providers.add(plan.provider);
|
||||||
regions.add(plan.countries);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
populateSelect('filter-provider', Array.from(providers).sort());
|
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) {
|
function populateSelect(id, options) {
|
||||||
@@ -244,7 +401,7 @@
|
|||||||
if (filters.provider && plan.provider !== filters.provider) return false;
|
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;
|
if (filters.memory > 0 && plan.memory_gb < filters.memory) return false;
|
||||||
@@ -304,8 +461,12 @@
|
|||||||
var tr = document.createElement('tr');
|
var tr = document.createElement('tr');
|
||||||
var currentPrice = getPriceValue(plan, filters.currency);
|
var currentPrice = getPriceValue(plan, filters.currency);
|
||||||
var displayPrice = currentPrice ? currentPrice.symbol + currentPrice.value.toFixed(2) : '—';
|
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 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 =
|
tr.innerHTML =
|
||||||
'<td>' + escapeHtml(plan.provider) + '</td>' +
|
'<td>' + escapeHtml(plan.provider) + '</td>' +
|
||||||
@@ -317,13 +478,18 @@
|
|||||||
'<td>' + (plan.bandwidth_mbps ? plan.bandwidth_mbps + ' Mbps' : '-') + '</td>' +
|
'<td>' + (plan.bandwidth_mbps ? plan.bandwidth_mbps + ' Mbps' : '-') + '</td>' +
|
||||||
'<td>' + plan.traffic + '</td>' +
|
'<td>' + plan.traffic + '</td>' +
|
||||||
'<td class="col-price">' + displayPrice + '</td>' +
|
'<td class="col-price">' + displayPrice + '</td>' +
|
||||||
'<td class="col-link">' +
|
'<td class="col-link">' + linkCell + '</td>';
|
||||||
'<a href="' + escapeHtml(plan.official_url) + '" target="_blank" rel="noopener noreferrer nofollow" class="btn-link">' + btnText + '</a>' +
|
|
||||||
'</td>';
|
|
||||||
|
|
||||||
return tr;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAttr(text) {
|
||||||
|
if (text == null || text === '') return '';
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = String(text);
|
||||||
|
return div.innerHTML.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
var div = document.createElement('div');
|
var div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
@@ -363,7 +529,13 @@
|
|||||||
|
|
||||||
plans.forEach(function(plan) {
|
plans.forEach(function(plan) {
|
||||||
if (plan.provider) providerSet.add(plan.provider);
|
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));
|
setText('metric-total-plans', formatCount(plans.length));
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
|
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
|
||||||
<a href="{{ url_for('admin_forum_categories') }}">论坛分类</a>
|
<a href="{{ url_for('admin_forum_categories') }}">论坛分类</a>
|
||||||
<a href="{{ url_for('admin_forum_reports') }}">举报审核</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="#plan-form" id="nav-add-plan">+ 添加配置</a>
|
||||||
<a href="{{ url_for('admin_export_excel') }}">导出 Excel</a>
|
<a href="{{ url_for('admin_export_excel') }}">导出 Excel</a>
|
||||||
<a href="{{ url_for('admin_import') }}">导入 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_comments') }}">评论管理</a>
|
||||||
<a href="{{ url_for('admin_forum_categories') }}">分类管理</a>
|
<a href="{{ url_for('admin_forum_categories') }}">分类管理</a>
|
||||||
<a href="{{ url_for('admin_forum_reports') }}">举报审核</a>
|
<a href="{{ url_for('admin_forum_reports') }}">举报审核</a>
|
||||||
|
<a href="{{ url_for('admin_forum_tracking') }}">埋点看板</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="dashboard-section">
|
<section class="dashboard-section">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
|
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
|
||||||
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
|
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
|
||||||
<a href="{{ url_for('admin_forum_reports') }}">举报审核</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_dashboard') }}">返回总览</a>
|
||||||
<a href="{{ url_for('admin_providers') }}">厂商管理</a>
|
<a href="{{ url_for('admin_providers') }}">厂商管理</a>
|
||||||
<a href="{{ url_for('index') }}">查看前台</a>
|
<a href="{{ url_for('index') }}">查看前台</a>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
|
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
|
||||||
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
|
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
|
||||||
<a href="{{ url_for('admin_forum_reports') }}">举报审核</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_dashboard') }}">返回总览</a>
|
||||||
<a href="{{ url_for('admin_logout') }}">退出</a>
|
<a href="{{ url_for('admin_logout') }}">退出</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<a href="{{ cancel_url }}">← 评论列表</a>
|
<a href="{{ cancel_url }}">← 评论列表</a>
|
||||||
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
|
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
|
||||||
<a href="{{ url_for('admin_users') }}">用户管理</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_dashboard') }}">总览</a>
|
||||||
<a href="{{ url_for('admin_logout') }}">退出</a>
|
<a href="{{ url_for('admin_logout') }}">退出</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
|
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
|
||||||
<a href="{{ url_for('admin_users') }}">用户管理</a>
|
<a href="{{ url_for('admin_users') }}">用户管理</a>
|
||||||
<a href="{{ url_for('admin_forum_reports') }}">举报审核</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_dashboard') }}">返回总览</a>
|
||||||
<a href="{{ url_for('admin_logout') }}">退出</a>
|
<a href="{{ url_for('admin_logout') }}">退出</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<a href="{{ url_for('admin_forum_posts') }}">← 帖子列表</a>
|
<a href="{{ url_for('admin_forum_posts') }}">← 帖子列表</a>
|
||||||
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
|
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
|
||||||
<a href="{{ url_for('admin_users') }}">用户管理</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_dashboard') }}">总览</a>
|
||||||
<a href="{{ url_for('admin_logout') }}">退出</a>
|
<a href="{{ url_for('admin_logout') }}">退出</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<a href="{{ url_for('admin_users') }}">用户管理</a>
|
<a href="{{ url_for('admin_users') }}">用户管理</a>
|
||||||
<a href="{{ url_for('admin_forum_categories') }}">论坛分类</a>
|
<a href="{{ url_for('admin_forum_categories') }}">论坛分类</a>
|
||||||
<a href="{{ url_for('admin_forum_reports') }}">举报审核</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_dashboard') }}">返回总览</a>
|
||||||
<a href="{{ url_for('admin_logout') }}">退出</a>
|
<a href="{{ url_for('admin_logout') }}">退出</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
|
<a href="{{ url_for('admin_forum_posts') }}">帖子管理</a>
|
||||||
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
|
<a href="{{ url_for('admin_forum_comments') }}">评论管理</a>
|
||||||
<a href="{{ url_for('admin_forum_categories') }}">论坛分类</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('admin_dashboard') }}">返回总览</a>
|
||||||
<a href="{{ url_for('index') }}">查看前台</a>
|
<a href="{{ url_for('index') }}">查看前台</a>
|
||||||
<a href="{{ url_for('admin_logout') }}">退出</a>
|
<a href="{{ url_for('admin_logout') }}">退出</a>
|
||||||
|
|||||||
394
templates/admin/forum_tracking.html
Normal file
394
templates/admin/forum_tracking.html
Normal 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>
|
||||||
288
templates/admin/forum_tracking_daily.html
Normal file
288
templates/admin/forum_tracking_daily.html
Normal 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>
|
||||||
382
templates/admin/forum_tracking_weekly.html
Normal file
382
templates/admin/forum_tracking_weekly.html
Normal 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>
|
||||||
@@ -144,13 +144,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if cards %}
|
{% if cards %}
|
||||||
<ul class="topic-list">
|
<ul class="topic-list topic-list-chronicle">
|
||||||
{% for item in cards %}
|
{% for item in cards %}
|
||||||
{% set post = item.post %}
|
{% 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-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-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">
|
<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_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 %}
|
{% if post.is_featured %}<span class="topic-flag flag-featured">{{ l('精华', 'Featured') }}</span>{% endif %}
|
||||||
@@ -168,9 +176,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topic-stat">{{ item.reply_count }}</div>
|
<div class="topic-stat" data-label="{{ l('回复', 'Replies') }}"><strong>{{ item.reply_count }}</strong></div>
|
||||||
<div class="topic-stat">{{ item.view_count }}</div>
|
<div class="topic-stat" data-label="{{ l('浏览', 'Views') }}"><strong>{{ item.view_count }}</strong></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 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>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -227,6 +235,69 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="forum-sidebar">
|
<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">
|
<div class="side-card">
|
||||||
<h3>{{ l('社区统计', 'Community Stats') }}</h3>
|
<h3>{{ l('社区统计', 'Community Stats') }}</h3>
|
||||||
<div class="side-stats">
|
<div class="side-stats">
|
||||||
@@ -235,18 +306,6 @@
|
|||||||
<div><span>{{ l('评论', 'Comments') }}</span><strong>{{ sb.total_comments }}</strong></div>
|
<div><span>{{ l('评论', 'Comments') }}</span><strong>{{ sb.total_comments }}</strong></div>
|
||||||
</div>
|
</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">
|
<div class="side-card">
|
||||||
<h3>{{ l('活跃作者', 'Active Authors') }}</h3>
|
<h3>{{ l('活跃作者', 'Active Authors') }}</h3>
|
||||||
{% if sb.active_users %}
|
{% if sb.active_users %}
|
||||||
|
|||||||
@@ -42,12 +42,17 @@
|
|||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
<link rel="stylesheet" href="/static/css/forum.css">
|
<link rel="stylesheet" href="/static/css/forum.css">
|
||||||
</head>
|
</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 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 related = related_cards if related_cards is defined else [] %}
|
||||||
{% set plan_recos = plan_recommendations if plan_recommendations 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 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 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">
|
<header class="forum-header">
|
||||||
<div class="forum-header-inner">
|
<div class="forum-header-inner">
|
||||||
<div class="forum-header-left">
|
<div class="forum-header-left">
|
||||||
@@ -83,6 +88,9 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="forum-shell">
|
<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') }}">
|
<nav class="forum-breadcrumb" aria-label="{{ l('面包屑导航', 'Breadcrumb') }}">
|
||||||
<a href="{{ url_for('index') }}">{{ l('首页', 'Home') }}</a>
|
<a href="{{ url_for('index') }}">{{ l('首页', 'Home') }}</a>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
@@ -108,6 +116,135 @@
|
|||||||
</header>
|
</header>
|
||||||
<h1>{{ post.title }}</h1>
|
<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-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 class="topic-metric-grid">
|
||||||
<div><span>{{ l('浏览', 'Views') }}</span><strong>{{ post.view_count or 0 }}</strong></div>
|
<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('点赞', 'Likes') }}</span><strong>{{ like_count or 0 }}</strong></div>
|
||||||
<div><span>{{ l('预计阅读', 'Read Time') }}</span><strong>{{ read_minutes }} {{ l('分钟', 'min') }}</strong></div>
|
<div><span>{{ l('预计阅读', 'Read Time') }}</span><strong>{{ read_minutes }} {{ l('分钟', 'min') }}</strong></div>
|
||||||
</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">
|
<div class="topic-action-bar">
|
||||||
{% if current_user and can_interact %}
|
{% if current_user and can_interact %}
|
||||||
@@ -129,6 +280,8 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('user_login', next=request.path) }}" class="forum-btn-muted">{{ l('登录后点赞/收藏', 'Login to like/bookmark') }}</a>
|
<a href="{{ url_for('user_login', next=request.path) }}" class="forum-btn-muted">{{ l('登录后点赞/收藏', 'Login to like/bookmark') }}</a>
|
||||||
{% endif %}
|
{% 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 %}
|
{% 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>
|
<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?') }}');">
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</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 %}
|
{% if resource_links %}
|
||||||
<section class="post-resource-links" aria-label="{{ l('延伸阅读与采购路径', 'Extended Reading and Buying Paths') }}">
|
<section class="post-resource-links" aria-label="{{ l('延伸阅读与采购路径', 'Extended Reading and Buying Paths') }}">
|
||||||
<h2>{{ l('延伸阅读与采购路径', 'Extended Reading and Buying Paths') }}</h2>
|
<h2>{{ l('延伸阅读与采购路径', 'Extended Reading and Buying Paths') }}</h2>
|
||||||
@@ -177,19 +339,80 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% 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">
|
<div class="post-commercial-copy">
|
||||||
<h3>{{ l('准备选型或采购 VPS?', 'Ready to shortlist or buy VPS?') }}</h3>
|
<h3>{{ cta_copy.headline }}</h3>
|
||||||
<p>{{ l('结合本帖讨论,去价格页快速筛选可落地方案。', 'Use insights from this topic and shortlist actionable plans on the pricing page.') }}</p>
|
<p>{{ cta_copy.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-commercial-actions">
|
<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>
|
<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 current_user and not current_user.is_banned %}
|
{% if requirement_draft %}
|
||||||
<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>
|
{% 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 %}
|
{% 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 %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -287,15 +510,61 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="forum-sidebar">
|
<aside class="forum-sidebar">
|
||||||
<div class="side-card side-cta">
|
<div class="side-card side-profile-card">
|
||||||
<h3>{{ l('商业化入口', 'Commercial Actions') }}</h3>
|
<div class="side-profile-head">
|
||||||
<p>{{ l('你可以基于本帖结论直接筛选方案,或发布更具体的业务需求。', 'Shortlist plans directly from this topic, or publish a more specific workload requirement.') }}</p>
|
<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">
|
<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>
|
<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 current_user and not current_user.is_banned %}
|
{% if requirement_draft %}
|
||||||
<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>
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,13 +577,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="side-card">
|
<div class="side-card">
|
||||||
<h3>{{ l('分类热度', 'Category Heat') }}</h3>
|
<h3>{{ l('分类标签云', 'Category Tags') }}</h3>
|
||||||
{% if sb.category_counts %}
|
{% if sb.category_counts %}
|
||||||
<ul class="side-list">
|
<div class="side-tag-cloud">
|
||||||
{% for name, count in sb.category_counts %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</ul>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="side-empty">{{ l('暂无分类数据', 'No category data') }}</p>
|
<p class="side-empty">{{ l('暂无分类数据', 'No category data') }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -325,11 +594,16 @@
|
|||||||
<ul class="related-post-list">
|
<ul class="related-post-list">
|
||||||
{% for item in related %}
|
{% for item in related %}
|
||||||
{% set rp = item.post %}
|
{% 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>
|
<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">
|
<div class="related-post-meta">
|
||||||
<span>{{ l('回复', 'Replies') }} {{ item.reply_count }}</span>
|
<span>{{ l('回复', 'Replies') }} {{ item.reply_count }}</span>
|
||||||
<span>{{ l('浏览', 'Views') }} {{ item.view_count }}</span>
|
<span>{{ l('浏览', 'Views') }} {{ item.view_count }}</span>
|
||||||
|
{% if rp.created_at %}
|
||||||
|
<span>{{ rp.created_at.strftime('%m-%d') }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -345,7 +619,7 @@
|
|||||||
<ul class="plan-reco-list">
|
<ul class="plan-reco-list">
|
||||||
{% for plan in plan_recos %}
|
{% for plan in plan_recos %}
|
||||||
<li>
|
<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>
|
<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">
|
<div class="plan-reco-meta">
|
||||||
<span>{{ plan.region }}</span>
|
<span>{{ plan.region }}</span>
|
||||||
@@ -358,11 +632,38 @@
|
|||||||
<p class="side-empty">{{ l('暂无可推荐方案', 'No plan recommendations yet') }}</p>
|
<p class="side-empty">{{ l('暂无可推荐方案', 'No plan recommendations yet') }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-actions">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
<script src="{{ url_for('static', filename='js/forum-post-detail.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='js/forum-post-detail.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -53,10 +53,24 @@
|
|||||||
{{ l('描述你的问题、评测或优惠信息,方便其他用户快速理解。', 'Describe your question, review, or deal details for other users.') }}
|
{{ l('描述你的问题、评测或优惠信息,方便其他用户快速理解。', 'Describe your question, review, or deal details for other users.') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</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 %}
|
{% if error %}
|
||||||
<p class="form-error">{{ error }}</p>
|
<p class="form-error">{{ error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{{ p_action }}" class="post-form">
|
<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">
|
<div class="form-group">
|
||||||
<label for="category">{{ l('分类', 'Category') }}</label>
|
<label for="category">{{ l('分类', 'Category') }}</label>
|
||||||
<select id="category" name="category" required>
|
<select id="category" name="category" required>
|
||||||
|
|||||||
@@ -105,7 +105,10 @@
|
|||||||
<h2 class="filters-title">{{ t.filters_title }}</h2>
|
<h2 class="filters-title">{{ t.filters_title }}</h2>
|
||||||
<p class="filters-subtitle">{{ t.filters_subtitle }}</p>
|
<p class="filters-subtitle">{{ t.filters_subtitle }}</p>
|
||||||
</div>
|
</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>
|
||||||
<div class="filter-grid">
|
<div class="filter-grid">
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
|
|||||||
Reference in New Issue
Block a user