优化官网案例库与站内统计页,新增文旅案例和电商双视频展示 (#109)

This commit is contained in:
keroly
2026-07-01 20:14:42 +08:00
committed by GitHub
parent 8087110620
commit d8bc28cea1
7 changed files with 659 additions and 51 deletions

View File

@@ -5,7 +5,7 @@ import hashlib
from datetime import datetime, timedelta, timezone
from html import escape
from pathlib import Path
from urllib.parse import urlparse
from urllib.parse import parse_qs, urlparse
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
@@ -19,10 +19,13 @@ DIST_DIR = ROOT / "dist"
GITHUB_REPO_OWNER = "datascale-ai"
GITHUB_REPO_NAME = "opentalking"
GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}"
GITHUB_STARGAZERS_API_URL = f"{GITHUB_API_URL}/stargazers"
GITHUB_FORKS_API_URL = f"{GITHUB_API_URL}/forks"
ANALYTICS_DB_PATH = Path(os.getenv("HOMEPAGE_ANALYTICS_DB", ROOT / ".analytics" / "homepage_analytics.sqlite3"))
ANALYTICS_HASH_SALT = os.getenv("HOMEPAGE_ANALYTICS_SALT", "opentalking-homepage")
MAX_FIELD_LENGTH = 500
BEIJING_TZ = timezone(timedelta(hours=8))
TREND_DAYS = 14
app = FastAPI(docs_url=None, redoc_url=None)
@@ -190,23 +193,44 @@ TRAFFIC_COPY = {
"language_href": "/en/traffic",
"empty": "暂无数据。",
"cards": {
"today": "今日访问",
"seven_day": "7 天访问",
"total": "累计访问",
"today": "今日浏览",
"seven_day": "14 天浏览",
"total": "累计浏览",
"video": "视频播放",
"visitors": "累计访客",
},
"deltas": {
"today": "较昨日",
"seven_day": "较前 14 天",
"total": "今日新增浏览",
"video": "今日新增播放",
"visitors": "今日新增访客",
"increase": "增加",
"decrease": "减少",
"flat": "持平",
},
"delta_help": {
"today": "今日浏览量减去昨日浏览量,用来观察当天页面浏览变化。",
"seven_day": "最近 14 天浏览量减去前一个 14 天窗口浏览量。",
"total": "累计浏览的今日增量,今天新增的页面浏览次数。",
"video": "视频播放总量的今日增量,今天新增的视频播放次数。",
"visitors": "累计访客的今日增量,今天首次出现的新访客数。",
},
"sections": {
"top_pages": "热门页面",
"top_referrers": "来源排行",
"top_videos": "视频播放排行",
"daily_views": "每日访问",
"daily_views": "每日浏览",
},
"charts": {
"views_title": "过去 7 天访问",
"unique_title": "过去 7 天独立访客",
"views_title": "过去 14 天浏览",
"unique_title": "过去 14 天独立访客",
"stars_title": "最近 14 天 Star 趋势",
"forks_title": "最近 14 天 Fork 趋势",
"views_total": "Views",
"unique_total": "Unique Visitors",
"stars_total": "Stars",
"forks_total": "Forks",
"view_table": "View as table",
"download_csv": "Download CSV",
"show_labels": "Show data labels",
@@ -216,6 +240,9 @@ TRAFFIC_COPY = {
"video_names": {
"hero-companion-character": "首页主视觉:陪伴类角色",
"case-ecommerce-livestream": "案例:电商带货",
"case-ecommerce-livestream-front": "案例:电商直播正视",
"case-ecommerce-livestream-angle": "案例:电商带货斜视",
"case-huangshan-tour-guide": "案例:黄山文旅导览",
"case-news-anchor": "案例:新闻主播",
"case-companion-character": "案例:陪伴类角色",
"case-anime-talk-show": "案例:动漫脱口秀",
@@ -224,13 +251,23 @@ TRAFFIC_COPY = {
},
"columns": {
"path": "路径",
"views": "访问",
"views": "浏览",
"uniques": "独立访客",
"source": "来源",
"video": "视频",
"plays": "播放",
"day": "日期",
},
"direct_unknown_help": {
"label": "Direct / Unknown 来源说明",
"title": "Direct / Unknown的路由来源",
"items": [
"用户直接输入网址、从书签或桌面快捷方式打开。",
"微信、QQ、飞书、邮件、文档等应用可能不传递来源。",
"浏览器隐私策略、插件或部分网站会隐藏 Referer。",
"GitHub About 等外链可能使用 noreferrer来源被清空。",
],
},
},
"en": {
"html_lang": "en",
@@ -243,11 +280,28 @@ TRAFFIC_COPY = {
"empty": "No data yet.",
"cards": {
"today": "Today views",
"seven_day": "7-day views",
"seven_day": "14-day views",
"total": "Total views",
"video": "Video plays",
"visitors": "Total visitors",
},
"deltas": {
"today": "vs yesterday",
"seven_day": "vs previous 14 days",
"total": "new views today",
"video": "new plays today",
"visitors": "new visitors today",
"increase": "up",
"decrease": "down",
"flat": "no change",
},
"delta_help": {
"today": "Todays page views minus yesterdays page views.",
"seven_day": "Page views from the latest 14-day window minus the previous 14-day window.",
"total": "The daily increase in total views, which equals todays new page views.",
"video": "The daily increase in total video plays.",
"visitors": "The daily increase in total visitors, counted as newly seen visitors today.",
},
"sections": {
"top_pages": "Top Pages",
"top_referrers": "Top Referrers",
@@ -255,10 +309,14 @@ TRAFFIC_COPY = {
"daily_views": "Daily Views",
},
"charts": {
"views_title": "Total views in last 7 days",
"unique_title": "Unique visitors in last 7 days",
"views_title": "Total views in last 14 days",
"unique_title": "Unique visitors in last 14 days",
"stars_title": "Stars in last 14 days",
"forks_title": "Forks in last 14 days",
"views_total": "Views",
"unique_total": "Unique Visitors",
"stars_total": "Stars",
"forks_total": "Forks",
"view_table": "View as table",
"download_csv": "Download CSV",
"show_labels": "Show data labels",
@@ -268,6 +326,9 @@ TRAFFIC_COPY = {
"video_names": {
"hero-companion-character": "Hero: Companion Character",
"case-ecommerce-livestream": "Case: E-commerce Livestream",
"case-ecommerce-livestream-front": "Case: E-commerce Front View",
"case-ecommerce-livestream-angle": "Case: E-commerce Angled View",
"case-huangshan-tour-guide": "Case: Huangshan Tourism Guide",
"case-news-anchor": "Case: News Anchor",
"case-companion-character": "Case: Companion Character",
"case-anime-talk-show": "Case: Anime Talk Show",
@@ -283,6 +344,16 @@ TRAFFIC_COPY = {
"plays": "Plays",
"day": "Day",
},
"direct_unknown_help": {
"label": "Direct / Unknown source details",
"title": "Why Direct / Unknown?",
"items": [
"Visitors typed the URL directly, used a bookmark, or opened a desktop shortcut.",
"Apps such as WeChat, QQ, Feishu, email clients, or documents may strip the referrer.",
"Browser privacy settings, extensions, or some websites may hide the Referer header.",
"External links such as GitHub About can use noreferrer, so the source is not available.",
],
},
},
}
@@ -305,7 +376,7 @@ def parse_event_datetime(value):
def build_seven_day_traffic(beijing_now):
today = beijing_now.date()
days = [today - timedelta(days=offset) for offset in reversed(range(7))]
days = [today - timedelta(days=offset) for offset in reversed(range(TREND_DAYS))]
day_keys = {day.isoformat(): {"label": day.strftime("%m/%d"), "views": 0, "visitors": set()} for day in days}
start_at = datetime(days[0].year, days[0].month, days[0].day, tzinfo=BEIJING_TZ).astimezone(timezone.utc).isoformat()
events = query_rows(
@@ -354,6 +425,152 @@ def build_daily_views(beijing_now):
]
def fetch_github_json(url, accept="application/vnd.github+json"):
request = Request(
url,
headers={
"Accept": accept,
"User-Agent": "opentalking-homepage",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urlopen(request, timeout=10) as response:
return json.loads(response.read().decode("utf-8")), response.headers
def get_last_github_page(link_header):
if not link_header:
return 1
for item in link_header.split(","):
if 'rel="last"' not in item:
continue
start = item.find("<")
end = item.find(">")
if start == -1 or end == -1:
continue
query = parse_qs(urlparse(item[start + 1:end]).query)
try:
return int(query.get("page", ["1"])[0])
except ValueError:
return 1
return 1
def collect_recent_github_datetimes(url, time_key, since_datetime, accept="application/vnd.github+json", newest_first=False):
recent_datetimes = []
first_url = f"{url}{'&' if '?' in url else '?'}per_page=100"
try:
first_page, headers = fetch_github_json(first_url, accept)
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, OSError):
return recent_datetimes
pages_to_scan = []
if newest_first:
pages_to_scan = [first_page]
else:
last_page = get_last_github_page(headers.get("Link"))
page_numbers = range(last_page, max(last_page - 5, 0), -1)
for page_number in page_numbers:
if page_number == 1:
pages_to_scan.append(first_page)
continue
try:
page, _ = fetch_github_json(f"{first_url}&page={page_number}", accept)
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, OSError):
continue
pages_to_scan.append(page)
should_stop = False
for page in pages_to_scan:
if not isinstance(page, list):
continue
iterable_page = page if newest_first else reversed(page)
for item in iterable_page:
event_time = parse_event_datetime(item.get(time_key, ""))
if event_time is None:
continue
if event_time < since_datetime:
should_stop = True
continue
recent_datetimes.append(event_time)
if should_stop:
break
return recent_datetimes
def build_cumulative_github_trend(beijing_now, current_total, event_datetimes):
today = beijing_now.date()
days = [today - timedelta(days=offset) for offset in reversed(range(TREND_DAYS))]
points = []
for day in days:
next_day_start = datetime(day.year, day.month, day.day, tzinfo=BEIJING_TZ) + timedelta(days=1)
next_day_start_utc = next_day_start.astimezone(timezone.utc)
later_events = sum(1 for event_time in event_datetimes if event_time >= next_day_start_utc)
points.append(
{
"date": day.isoformat(),
"label": day.strftime("%m/%d"),
"count": max(current_total - later_events, 0),
}
)
return points
def build_github_trends(beijing_now):
since_day = beijing_now.date() - timedelta(days=TREND_DAYS - 1)
since_datetime = datetime(since_day.year, since_day.month, since_day.day, tzinfo=BEIJING_TZ).astimezone(timezone.utc)
try:
repo_data, _ = fetch_github_json(GITHUB_API_URL)
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, OSError):
repo_data = {}
star_total = int(repo_data.get("stargazers_count") or 0)
fork_total = int(repo_data.get("forks_count") or 0)
star_datetimes = collect_recent_github_datetimes(
GITHUB_STARGAZERS_API_URL,
"starred_at",
since_datetime,
accept="application/vnd.github.star+json",
)
fork_datetimes = collect_recent_github_datetimes(
f"{GITHUB_FORKS_API_URL}?sort=newest",
"created_at",
since_datetime,
newest_first=True,
)
return {
"stars": build_cumulative_github_trend(beijing_now, star_total, star_datetimes),
"forks": build_cumulative_github_trend(beijing_now, fork_total, fork_datetimes),
"star_total": star_total,
"fork_total": fork_total,
}
def nice_chart_ceiling(value):
if value <= 4:
return 4
@@ -366,6 +583,10 @@ def nice_chart_ceiling(value):
return ((value + magnitude - 1) // magnitude) * magnitude
def beijing_day_start(day):
return datetime(day.year, day.month, day.day, tzinfo=BEIJING_TZ).astimezone(timezone.utc).isoformat()
@app.get("/traffic")
def traffic_dashboard_zh():
return render_traffic_dashboard("zh")
@@ -380,33 +601,56 @@ def render_traffic_dashboard(language):
copy = TRAFFIC_COPY[language]
now = datetime.now(timezone.utc)
beijing_now = now.astimezone(BEIJING_TZ)
today_start = datetime(
beijing_now.year,
beijing_now.month,
beijing_now.day,
tzinfo=BEIJING_TZ,
).astimezone(timezone.utc).isoformat()
seven_day_start_date = beijing_now.date() - timedelta(days=6)
seven_day_start = datetime(
seven_day_start_date.year,
seven_day_start_date.month,
seven_day_start_date.day,
tzinfo=BEIJING_TZ,
).astimezone(timezone.utc).isoformat()
today = beijing_now.date()
yesterday = today - timedelta(days=1)
previous_seven_day_start_date = today - timedelta(days=TREND_DAYS * 2 - 1)
seven_day_start_date = beijing_now.date() - timedelta(days=TREND_DAYS - 1)
today_start = beijing_day_start(today)
yesterday_start = beijing_day_start(yesterday)
seven_day_start = beijing_day_start(seven_day_start_date)
previous_seven_day_start = beijing_day_start(previous_seven_day_start_date)
total_page_views = query_value("SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'page_view'")
today_page_views = query_value(
"SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'page_view' AND created_at >= ?",
(today_start,),
)
yesterday_page_views = query_value(
"""
SELECT COUNT(*) AS count
FROM analytics_events
WHERE event_name = 'page_view' AND created_at >= ? AND created_at < ?
""",
(yesterday_start, today_start),
)
seven_day_page_views = query_value(
"SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'page_view' AND created_at >= ?",
(seven_day_start,),
)
previous_seven_day_page_views = query_value(
"""
SELECT COUNT(*) AS count
FROM analytics_events
WHERE event_name = 'page_view' AND created_at >= ? AND created_at < ?
""",
(previous_seven_day_start, seven_day_start),
)
video_plays = query_value("SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'video_play'")
today_video_plays = query_value(
"SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'video_play' AND created_at >= ?",
(today_start,),
)
unique_visitors = query_value(
"SELECT COUNT(DISTINCT ip_hash) AS count FROM analytics_events WHERE event_name = 'page_view' AND ip_hash != ''"
)
previous_unique_visitors = query_value(
"""
SELECT COUNT(DISTINCT ip_hash) AS count
FROM analytics_events
WHERE event_name = 'page_view' AND ip_hash != '' AND created_at < ?
""",
(today_start,),
)
seven_day_unique_visitors = query_value(
"""
SELECT COUNT(DISTINCT ip_hash) AS count
@@ -437,6 +681,7 @@ def render_traffic_dashboard(language):
COUNT(DISTINCT CASE WHEN ip_hash != '' THEN ip_hash END) AS uniques
FROM analytics_events
WHERE event_name = 'page_view'
AND referrer_host NOT IN ('opentalking.net', 'www.opentalking.net')
GROUP BY referrer_host
ORDER BY count DESC
LIMIT 10
@@ -460,8 +705,54 @@ def render_traffic_dashboard(language):
for row in top_videos
]
seven_day_traffic = build_seven_day_traffic(beijing_now)
github_trends = build_github_trends(beijing_now)
daily_views = build_daily_views(beijing_now)
card_deltas = {
"today": today_page_views - yesterday_page_views,
"seven_day": seven_day_page_views - previous_seven_day_page_views,
"total": today_page_views,
"video": today_video_plays,
"visitors": unique_visitors - previous_unique_visitors,
}
def render_delta(key, value):
delta_label = copy["deltas"][key]
if value > 0:
state = "positive"
sign = "+"
trend_word = copy["deltas"]["increase"]
elif value < 0:
state = "negative"
sign = "-"
trend_word = copy["deltas"]["decrease"]
else:
state = "neutral"
sign = ""
trend_word = copy["deltas"]["flat"]
title = f'{delta_label} {trend_word} {format_number(abs(value))}'
return (
f'<span class="delta delta-{state}" tabindex="0" role="button" '
f'title="{escape(title)}" aria-label="{escape(title)}" '
f'data-tooltip-title="{escape(title)}" '
f'data-tooltip-body="{escape(copy["delta_help"][key])}">'
f'{sign}{escape(format_number(abs(value)))}</span>'
)
def render_metric_card(key, value):
return (
f'<div class="card">'
f'<div class="label">{escape(copy["cards"][key])}</div>'
f'<div class="value-row">'
f'<span class="value">{escape(format_number(value))}</span>'
f'{render_delta(key, card_deltas[key])}'
f'</div>'
f'</div>'
)
def render_table(rows, columns):
if not rows:
return f'<p class="empty">{escape(copy["empty"])}</p>'
@@ -469,13 +760,29 @@ def render_traffic_dashboard(language):
head = "".join(f"<th>{escape(label)}</th>" for _, label in columns)
body_parts = []
def render_cell(key, value):
display_value = value or "-"
if key == "referrer_host" and display_value == "Direct / Unknown":
help_copy = copy["direct_unknown_help"]
help_items = "".join(f"<li>{escape(item)}</li>" for item in help_copy["items"])
return (
f'<td><span class="source-help-wrap">'
f'<span>{escape(display_value)}</span>'
f'<span class="source-help" tabindex="0" role="button" aria-label="{escape(help_copy["label"])}" '
f'data-tooltip-title="{escape(help_copy["title"])}" '
f'data-tooltip-items="{escape(json.dumps(help_copy["items"], ensure_ascii=False))}">i</span>'
f'</span></td>'
)
return f"<td>{escape(str(display_value))}</td>"
for row in rows:
cells = []
for key, _ in columns:
value = row.get(key, "") or "-"
cells.append(f"<td>{escape(str(value))}</td>")
cells.append(render_cell(key, row.get(key, "")))
body_parts.append("<tr>" + "".join(cells) + "</tr>")
@@ -518,9 +825,12 @@ def render_traffic_dashboard(language):
f'<line class="chart-grid-line" x1="{x:.1f}" y1="{top}" x2="{x:.1f}" y2="{top + chart_height}" />'
for x, _, _ in coords
]
x_label_indexes = set(range(len(coords))) if len(coords) <= 10 else set(range(0, len(coords), 2))
x_labels = [
f'<text class="chart-x-label" x="{x:.1f}" y="{height - 12}" text-anchor="middle">{escape(point["label"])}</text>'
for x, _, point in coords
for index, (x, _, point) in enumerate(coords)
if index in x_label_indexes
]
dots = [
f'<g class="chart-point" tabindex="0" data-label="{escape(point["label"])}" '
@@ -601,7 +911,16 @@ def render_traffic_dashboard(language):
.card, section {{ border: 1px solid #e2e8f0; background: rgba(255,255,255,.86); border-radius: 14px; box-shadow: 0 18px 50px rgba(15,23,42,.06); }}
.card {{ padding: 16px; }}
.label {{ color: #64748b; font-size: 12px; font-weight: 700; text-transform: uppercase; }}
.value {{ margin-top: 8px; font-size: 26px; font-weight: 750; }}
.value-row {{ display: flex; align-items: baseline; gap: 8px; margin-top: 8px; min-width: 0; }}
.value {{ font-size: 26px; font-weight: 750; letter-spacing: 0; }}
.delta {{ display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 999px; padding: 2px 6px; font-size: 12px; font-weight: 800; line-height: 1.35; white-space: nowrap; cursor: help; transition: transform .16s ease, box-shadow .16s ease; }}
.delta:hover, .delta:focus-visible {{ outline: none; transform: translateY(-1px); box-shadow: 0 8px 18px rgba(15,23,42,.08); }}
.delta-positive {{ background: #dcfce7; color: #15803d; }}
.delta-negative {{ background: #fee2e2; color: #dc2626; }}
.delta-neutral {{ background: #f1f5f9; color: #64748b; }}
.delta-help-card {{ position: fixed; z-index: 85; width: min(270px, calc(100vw - 40px)); border: 1px solid #dbe3ec; border-radius: 12px; background: rgba(255,255,255,.98); box-shadow: 0 18px 46px rgba(15,23,42,.16); padding: 11px 13px; color: #334155; font-size: 12px; line-height: 1.55; }}
.delta-help-card[hidden] {{ display: none; }}
.delta-help-card strong {{ display: block; color: #0f172a; font-size: 13px; margin-bottom: 5px; }}
.grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }}
.chart-grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 14px; }}
section {{ padding: 18px; overflow: hidden; }}
@@ -611,6 +930,14 @@ def render_traffic_dashboard(language):
th, td {{ padding: 10px 8px; border-bottom: 1px solid #eef2f7; text-align: left; vertical-align: top; }}
th {{ position: sticky; top: 0; background: rgba(255,255,255,.96); color: #64748b; font-size: 12px; font-weight: 700; }}
.empty {{ margin: 0; color: #94a3b8; font-size: 13px; }}
.source-help-wrap {{ display: inline-flex; align-items: center; gap: 6px; }}
.source-help {{ display: inline-flex; height: 15px; width: 15px; align-items: center; justify-content: center; border-radius: 999px; background: #e0f2fe; color: #0284c7; cursor: help; font-size: 10px; font-weight: 800; line-height: 1; }}
.source-help:hover, .source-help:focus-visible {{ background: #bae6fd; color: #0369a1; outline: none; }}
.source-help-card {{ position: fixed; z-index: 80; width: min(320px, calc(100vw - 40px)); border: 1px solid #dbe3ec; border-radius: 12px; background: rgba(255,255,255,.98); box-shadow: 0 18px 46px rgba(15,23,42,.16); padding: 12px 14px; color: #334155; font-size: 12px; line-height: 1.55; }}
.source-help-card[hidden] {{ display: none; }}
.source-help-card strong {{ display: block; color: #0f172a; font-size: 13px; margin-bottom: 6px; }}
.source-help-card ul {{ margin: 0; padding-left: 17px; }}
.source-help-card li + li {{ margin-top: 4px; }}
.chart-card {{ min-height: 336px; }}
.chart-head {{ display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 8px; }}
.chart-head h2 {{ margin-bottom: 8px; font-size: 20px; line-height: 1.25; }}
@@ -661,11 +988,11 @@ def render_traffic_dashboard(language):
</div>
</div>
<div class="cards">
<div class="card"><div class="label">{escape(copy["cards"]["today"])}</div><div class="value">{today_page_views}</div></div>
<div class="card"><div class="label">{escape(copy["cards"]["seven_day"])}</div><div class="value">{seven_day_page_views}</div></div>
<div class="card"><div class="label">{escape(copy["cards"]["total"])}</div><div class="value">{total_page_views}</div></div>
<div class="card"><div class="label">{escape(copy["cards"]["video"])}</div><div class="value">{video_plays}</div></div>
<div class="card"><div class="label">{escape(copy["cards"]["visitors"])}</div><div class="value">{unique_visitors}</div></div>
{render_metric_card("today", today_page_views)}
{render_metric_card("seven_day", seven_day_page_views)}
{render_metric_card("total", total_page_views)}
{render_metric_card("video", video_plays)}
{render_metric_card("visitors", unique_visitors)}
</div>
<div class="grid">
<section>
@@ -686,8 +1013,10 @@ def render_traffic_dashboard(language):
</section>
</div>
<div class="chart-grid">
{render_line_chart(copy["charts"]["views_title"], copy["charts"]["views_total"], seven_day_traffic, "views", "seven-day-views")}
{render_line_chart(copy["charts"]["unique_title"], copy["charts"]["unique_total"], seven_day_traffic, "uniques", "seven-day-uniques", seven_day_unique_visitors)}
{render_line_chart(copy["charts"]["views_title"], copy["charts"]["views_total"], seven_day_traffic, "views", "fourteen-day-views")}
{render_line_chart(copy["charts"]["unique_title"], copy["charts"]["unique_total"], seven_day_traffic, "uniques", "fourteen-day-uniques", seven_day_unique_visitors)}
{render_line_chart(copy["charts"]["stars_title"], copy["charts"]["stars_total"], github_trends["stars"], "count", "fourteen-day-stars", github_trends["star_total"])}
{render_line_chart(copy["charts"]["forks_title"], copy["charts"]["forks_total"], github_trends["forks"], "count", "fourteen-day-forks", github_trends["fork_total"])}
</div>
</main>
<script>
@@ -697,6 +1026,16 @@ def render_traffic_dashboard(language):
tooltip.hidden = true;
document.body.appendChild(tooltip);
const sourceTooltip = document.createElement("div");
sourceTooltip.className = "source-help-card";
sourceTooltip.hidden = true;
document.body.appendChild(sourceTooltip);
const deltaTooltip = document.createElement("div");
deltaTooltip.className = "delta-help-card";
deltaTooltip.hidden = true;
document.body.appendChild(deltaTooltip);
const closePopovers = (except) => {{
document.querySelectorAll(".chart-popover").forEach((popover) => {{
if (popover !== except) popover.hidden = true;
@@ -723,6 +1062,71 @@ def render_traffic_dashboard(language):
tooltip.hidden = true;
}};
const positionFloatingCard = (trigger, card) => {{
card.hidden = false;
const rect = trigger.getBoundingClientRect();
const tooltipRect = card.getBoundingClientRect();
const margin = 12;
const left = Math.min(
Math.max(rect.left + rect.width / 2 - tooltipRect.width / 2, margin),
window.innerWidth - tooltipRect.width - margin
);
const top = rect.bottom + tooltipRect.height + margin > window.innerHeight
? Math.max(rect.top - tooltipRect.height - 8, margin)
: rect.bottom + 8;
card.style.left = `${{left}}px`;
card.style.top = `${{top}}px`;
}};
const showDeltaTooltip = (trigger) => {{
const title = trigger.dataset.tooltipTitle || "";
const body = trigger.dataset.tooltipBody || "";
deltaTooltip.innerHTML = `<strong>${{title}}</strong><div>${{body}}</div>`;
positionFloatingCard(trigger, deltaTooltip);
}};
const hideDeltaTooltip = () => {{
deltaTooltip.hidden = true;
}};
const showSourceTooltip = (trigger) => {{
const title = trigger.dataset.tooltipTitle || "";
let items = [];
try {{
items = JSON.parse(trigger.dataset.tooltipItems || "[]");
}} catch {{
items = [];
}}
sourceTooltip.innerHTML = `
<strong>${{title}}</strong>
<ul>${{items.map((item) => `<li>${{item}}</li>`).join("")}}</ul>
`;
sourceTooltip.hidden = false;
const rect = trigger.getBoundingClientRect();
const tooltipRect = sourceTooltip.getBoundingClientRect();
const margin = 12;
const left = Math.min(
Math.max(rect.left, margin),
window.innerWidth - tooltipRect.width - margin
);
const top = rect.bottom + tooltipRect.height + margin > window.innerHeight
? Math.max(rect.top - tooltipRect.height - 8, margin)
: rect.bottom + 8;
sourceTooltip.style.left = `${{left}}px`;
sourceTooltip.style.top = `${{top}}px`;
}};
const hideSourceTooltip = () => {{
sourceTooltip.hidden = true;
}};
const renderDataTable = (card) => {{
const data = getChartData(card);
const container = card.querySelector(".chart-table-popover");
@@ -821,11 +1225,52 @@ def render_traffic_dashboard(language):
}});
}});
document.addEventListener("click", () => closePopovers());
document.querySelectorAll(".source-help").forEach((trigger) => {{
trigger.addEventListener("pointerenter", () => showSourceTooltip(trigger));
trigger.addEventListener("pointerleave", hideSourceTooltip);
trigger.addEventListener("mouseover", () => showSourceTooltip(trigger));
trigger.addEventListener("mouseout", hideSourceTooltip);
trigger.addEventListener("focus", () => showSourceTooltip(trigger));
trigger.addEventListener("blur", hideSourceTooltip);
trigger.addEventListener("click", (event) => {{
event.stopPropagation();
showSourceTooltip(trigger);
}});
}});
document.querySelectorAll(".delta").forEach((trigger) => {{
trigger.addEventListener("pointerenter", () => showDeltaTooltip(trigger));
trigger.addEventListener("pointerleave", hideDeltaTooltip);
trigger.addEventListener("mouseover", () => showDeltaTooltip(trigger));
trigger.addEventListener("mouseout", hideDeltaTooltip);
trigger.addEventListener("focus", () => showDeltaTooltip(trigger));
trigger.addEventListener("blur", hideDeltaTooltip);
trigger.addEventListener("click", (event) => {{
event.stopPropagation();
showDeltaTooltip(trigger);
}});
}});
sourceTooltip.addEventListener("pointerenter", () => {{
sourceTooltip.hidden = false;
}});
sourceTooltip.addEventListener("pointerleave", hideSourceTooltip);
deltaTooltip.addEventListener("pointerenter", () => {{
deltaTooltip.hidden = false;
}});
deltaTooltip.addEventListener("pointerleave", hideDeltaTooltip);
document.addEventListener("click", () => {{
closePopovers();
hideSourceTooltip();
hideDeltaTooltip();
}});
document.addEventListener("keydown", (event) => {{
if (event.key === "Escape") {{
closePopovers();
hideTooltip();
hideSourceTooltip();
hideDeltaTooltip();
}}
}});
}})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 KiB

View File

@@ -16,7 +16,7 @@ export type CaseStudy = {
slug: string;
title: string;
eyebrow: string;
category: "livestream" | "media" | "character" | "companion" | "experiment";
category: "livestream" | "media" | "tourism" | "character" | "companion" | "experiment";
categoryLabel: string;
description: string;
detailIntro: string;
@@ -27,6 +27,13 @@ export type CaseStudy = {
accent: "cyan" | "mint" | "amber" | "violet" | "rose" | "slate";
comingSoon?: boolean;
videoUrl?: string;
videoVariants?: Array<{
title: string;
description: string;
url: string;
poster?: string;
videoId?: string;
}>;
sections: Array<{
title: string;
body: string;
@@ -83,6 +90,7 @@ export const caseCategories = [
{ key: "all", label: "全部场景" },
{ key: "livestream", label: "直播带货" },
{ key: "media", label: "媒体播报" },
{ key: "tourism", label: "文旅导览" },
{ key: "character", label: "角色内容" },
{ key: "companion", label: "陪伴互动" },
{ key: "experiment", label: "创意实验" },
@@ -130,11 +138,27 @@ export const caseStudies: CaseStudy[] = [
"面向商品讲解、评论问答和直播间陪跑,把语音回复、字幕和实时视频渲染整合到同一链路。",
detailIntro:
"用 OpenTalking 搭建一个可互动的数字人直播间,让商品介绍、用户问题和优惠转化都能通过实时语音和画面完成。",
route: "Local GPU 或 OmniRT 高质量路线",
route: "双机位直播带货演示",
features: ["实时问答", "角色音色", "字幕同步"],
image: "/images/cases/live-sales.jpeg",
image: "/images/cases/ecommerce-live-front-preview.png",
accent: "amber",
videoUrl: "https://1441945933.vod-qcloud.com/0b66444dvodcq1441945933/d9d848c95001834806724661995/SaicQA0Ah7QA.mp4",
videoUrl: "https://1441945933.vod-qcloud.com/0b66444dvodcq1441945933/742bcb745001834809665869890/vkxsFysr5REA.mp4",
videoVariants: [
{
title: "正视直播间",
description: "正面机位展示商品讲解、优惠信息和直播间视觉元素,适合官网主案例展示。",
url: "https://1441945933.vod-qcloud.com/0b66444dvodcq1441945933/742bcb745001834809665869890/vkxsFysr5REA.mp4",
poster: "/images/cases/ecommerce-live-front-preview.png",
videoId: "case-ecommerce-livestream-front",
},
{
title: "斜视带货机位",
description: "加入真实拍摄机位感,突出桌面商品、导购动作和直播场景的空间层次。",
url: "https://1441945933.vod-qcloud.com/0b66444dvodcq1441945933/cca683435001834809670031995/aswhi0HNmYkA.mp4",
poster: "/images/cases/ecommerce-live-angle-preview.png",
videoId: "case-ecommerce-livestream-angle",
},
],
sections: [
{
title: "场景挑战",
@@ -151,6 +175,37 @@ export const caseStudies: CaseStudy[] = [
],
outcomes: ["商品讲解自动化", "评论问答实时响应", "字幕与视频同步展示"],
},
{
slug: "huangshan-tour-guide",
title: "黄山文旅导览",
eyebrow: "沉浸讲解",
category: "tourism",
categoryLabel: "文旅导览",
description:
"面向景区、城市展馆和文旅宣传,用数字人讲解自然景观、路线亮点和地域文化。",
detailIntro:
"以黄山介绍为例,把景区画面、导览口播和数字讲解员结合起来,适合游客中心、城市展厅和线上文旅内容展示。",
route: "QuickTalk / FlashTalk",
features: ["景区讲解", "沉浸画面", "多语言扩展"],
image: "/images/cases/huangshan-guide-preview.png",
accent: "mint",
videoUrl: "https://1441945933.vod-qcloud.com/0b66444dvodcq1441945933/742bcf735001834809665869970/T9jkqMRNCacA.mp4",
sections: [
{
title: "场景挑战",
body: "文旅内容既要讲清景点特色,也要保持画面感染力和讲解节奏,传统拍摄更新成本较高。",
},
{
title: "适合扩展",
body: "可以接入景区知识库、路线推荐、多语言导览和游客问答,把单条宣传片扩展成可互动的数字导览员。",
},
{
title: "推荐模型",
body: "推荐 QuickTalk / FlashTalk先快速验证导览脚本和画面融合再按展厅大屏或宣传片质量要求升级。",
},
],
outcomes: ["景区讲解视频化", "导览内容可复用", "支持后续多语言与问答扩展"],
},
{
slug: "news-anchor",
title: "新闻主播",

View File

@@ -150,6 +150,7 @@ const enCaseCategories = [
{ key: "all", label: "All" },
{ key: "livestream", label: "Live commerce" },
{ key: "media", label: "Media" },
{ key: "tourism", label: "Tourism" },
{ key: "character", label: "Character content" },
{ key: "companion", label: "Companion" },
{ key: "experiment", label: "Creative demos" },
@@ -197,11 +198,27 @@ const enCaseStudies: CaseStudy[] = [
"Combine product narration, audience Q&A, captions, speech, and real-time avatar video in one live commerce workflow.",
detailIntro:
"Build an interactive AI host that can follow product scripts, answer audience questions, and present offers with synchronized voice and video.",
route: "Local GPU or OmniRT quality route",
route: "Two-view live commerce demo",
features: ["Real-time Q&A", "Voice persona", "Caption sync"],
image: "/images/cases/live-sales.jpeg",
image: "/images/cases/ecommerce-live-front-preview.png",
accent: "amber",
videoUrl: "https://1441945933.vod-qcloud.com/0b66444dvodcq1441945933/d9d848c95001834806724661995/SaicQA0Ah7QA.mp4",
videoUrl: "https://1441945933.vod-qcloud.com/0b66444dvodcq1441945933/742bcb745001834809665869890/vkxsFysr5REA.mp4",
videoVariants: [
{
title: "Front-facing livestream",
description: "A direct host view for product explanation, offer highlights, and live commerce UI elements.",
url: "https://1441945933.vod-qcloud.com/0b66444dvodcq1441945933/742bcb745001834809665869890/vkxsFysr5REA.mp4",
poster: "/images/cases/ecommerce-live-front-preview.png",
videoId: "case-ecommerce-livestream-front",
},
{
title: "Angled selling setup",
description: "A more realistic camera angle with visible products, presenter movement, and livestream staging.",
url: "https://1441945933.vod-qcloud.com/0b66444dvodcq1441945933/cca683435001834809670031995/aswhi0HNmYkA.mp4",
poster: "/images/cases/ecommerce-live-angle-preview.png",
videoId: "case-ecommerce-livestream-angle",
},
],
sections: [
{
title: "Scenario Challenge",
@@ -218,6 +235,37 @@ const enCaseStudies: CaseStudy[] = [
],
outcomes: ["Automated product narration", "Real-time audience response", "Synchronized captions and video"],
},
{
slug: "huangshan-tour-guide",
title: "Huangshan Tourism Guide",
eyebrow: "Immersive narration",
category: "tourism",
categoryLabel: "Tourism",
description:
"Use an avatar guide to introduce scenic areas, travel routes, cultural highlights, and destination stories.",
detailIntro:
"This Huangshan example combines scenic footage, guide narration, and a digital presenter for visitor centers, city exhibitions, and online tourism campaigns.",
route: "QuickTalk / FlashTalk",
features: ["Scenic narration", "Immersive visuals", "Multilingual-ready"],
image: "/images/cases/huangshan-guide-preview.png",
accent: "mint",
videoUrl: "https://1441945933.vod-qcloud.com/0b66444dvodcq1441945933/742bcf735001834809665869970/T9jkqMRNCacA.mp4",
sections: [
{
title: "Scenario Challenge",
body: "Tourism videos need clear destination storytelling, strong visuals, and a consistent narration rhythm, while updates can be costly with traditional shoots.",
},
{
title: "Extension Path",
body: "Connect destination knowledge, route recommendations, multilingual narration, and visitor Q&A to turn a video into an interactive digital guide.",
},
{
title: "Recommended Model",
body: "Recommended: QuickTalk / FlashTalk. Start by validating narration and scene composition, then upgrade quality for exhibition screens or destination campaigns.",
},
],
outcomes: ["Turn scenic content into guided video", "Reuse destination scripts", "Extend to multilingual Q&A later"],
},
{
slug: "news-anchor",
title: "News Anchor",
@@ -479,7 +527,7 @@ export const siteContent: Record<Language, SiteContent> = {
capabilityDescription: "OpenTalking 把会话、语音、字幕、播放和模型服务串成完整的数字人产品链路。",
showcaseEyebrow: "Showcase",
showcaseTitle: "真实产品场景,为数字人服务而生",
showcaseDescription: "用同一套编排层覆盖直播、播报、陪伴、角色内容和端到端演示。",
showcaseDescription: "用同一套编排层覆盖直播、文旅导览、播报、陪伴、角色内容和端到端演示。",
allCasesCta: "全部案例",
deploymentEyebrow: "Deployment",
deploymentTitle: "按你的需求匹配不同部署方式",
@@ -603,7 +651,7 @@ export const siteContent: Record<Language, SiteContent> = {
capabilityDescription: "OpenTalking connects dialogue, voice, captions, playback, and model services into a complete AI avatar workflow.",
showcaseEyebrow: "Showcase",
showcaseTitle: "Built for real avatar use cases",
showcaseDescription: "Use the same orchestration layer for livestreaming, broadcast, companion experiences, character content, and end-to-end demos.",
showcaseDescription: "Use the same orchestration layer for livestreaming, tourism guides, broadcast, companion experiences, character content, and end-to-end demos.",
allCasesCta: "All cases",
deploymentEyebrow: "Deployment",
deploymentTitle: "Pick the right path for your stage",

View File

@@ -1,4 +1,5 @@
import { ArrowLeft, CheckCircle2, ExternalLink, PlayCircle } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { trackAnalyticsEvent } from "../analytics";
import { CaseCard } from "../components/CaseCard";
import type { CaseStudy } from "../content";
@@ -19,6 +20,32 @@ export function CaseDetailPage({
onBack,
onOpenCase,
}: CaseDetailPageProps) {
const videoItems = useMemo(() => {
if (item.videoVariants?.length) {
return item.videoVariants;
}
if (!item.videoUrl) {
return [];
}
return [
{
title: copy.videoTitle,
description: item.route,
url: item.videoUrl,
poster: item.image,
videoId: `case-${item.slug}`,
},
];
}, [copy.videoTitle, item]);
const [activeVideoIndex, setActiveVideoIndex] = useState(0);
const activeVideo = videoItems[Math.min(activeVideoIndex, Math.max(videoItems.length - 1, 0))];
useEffect(() => {
setActiveVideoIndex(0);
}, [item.slug]);
return (
<>
<section className="case-detail-hero">
@@ -51,17 +78,18 @@ export function CaseDetailPage({
<section className="section-container">
<div className="grid gap-8 lg:grid-cols-[1fr_340px]">
<article className="grid gap-6">
{item.videoUrl ? (
{activeVideo ? (
<div className="video-panel">
<div className="flex items-center gap-2 border-b border-white/14 px-5 py-4 text-white">
<PlayCircle className="h-5 w-5 text-indigo-200" />
<span className="font-semibold">{copy.videoTitle}</span>
</div>
<video
key={activeVideo.url}
className="aspect-video w-full bg-black"
src={item.videoUrl}
src={activeVideo.url}
controls
poster={item.image}
poster={activeVideo.poster ?? item.image}
onPlay={() =>
trackAnalyticsEvent({
eventName: "video_play",
@@ -69,10 +97,42 @@ export function CaseDetailPage({
language: window.location.pathname === "/en" || window.location.pathname.startsWith("/en/") ? "en" : "zh",
page: "caseDetail",
caseSlug: item.slug,
videoId: `case-${item.slug}`,
videoId: activeVideo.videoId ?? `case-${item.slug}`,
})
}
/>
{videoItems.length > 1 ? (
<div className="grid gap-3 border-t border-white/14 bg-indigo-950/95 p-4 md:grid-cols-2">
{videoItems.map((video, index) => {
const isActive = index === activeVideoIndex;
return (
<button
key={video.url}
type="button"
className={`group flex cursor-pointer gap-3 rounded-lg border p-2 text-left transition duration-300 ${
isActive
? "border-cyanline bg-white text-ink"
: "border-white/14 bg-white/10 text-white hover:border-white/30 hover:bg-white/20"
}`}
onClick={() => setActiveVideoIndex(index)}
>
<img
src={video.poster ?? item.image}
alt={`${video.title} preview`}
className="h-16 w-24 shrink-0 rounded-md object-cover"
/>
<span className="min-w-0">
<span className="block text-sm font-semibold">{video.title}</span>
<span className={`mt-1 block max-h-10 overflow-hidden text-xs leading-5 ${isActive ? "text-slate-600" : "text-white/70"}`}>
{video.description}
</span>
</span>
</button>
);
})}
</div>
) : null}
</div>
) : null}