修复访客浏览轨迹延迟与排序,增加演示站
保留 SPA hash 以正确区分页面;延迟采集 title 避免慢一页;轨迹改为最新在上;补充 demo-shop 测试页与 Vite 静态路由。
This commit is contained in:
@@ -67,8 +67,12 @@ func sanitizePageURL(raw string) string {
|
|||||||
if changed {
|
if changed {
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
}
|
}
|
||||||
// 去掉 fragment
|
// 保留 hash:大量 SPA(含演示站 demo-shop)靠 #/path 区分页面;
|
||||||
u.Fragment = ""
|
// 若去掉 fragment,换页后 URL 相同会被服务端当成重复而丢弃轨迹。
|
||||||
|
// 仅截断过长 hash,避免异常数据撑爆字段。
|
||||||
|
if utf8.RuneCountInString(u.Fragment) > 200 {
|
||||||
|
u.Fragment = string([]rune(u.Fragment)[:200])
|
||||||
|
}
|
||||||
out := u.String()
|
out := u.String()
|
||||||
return truncateRunes(out, maxPageURLLen)
|
return truncateRunes(out, maxPageURLLen)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSanitizePageURL(t *testing.T) {
|
func TestSanitizePageURL(t *testing.T) {
|
||||||
got := sanitizePageURL("https://shop.example.com/p/1?token=secret&ok=1#hash")
|
got := sanitizePageURL("https://shop.example.com/p/1?token=secret&ok=1#/cart")
|
||||||
if got == "" || strings.Contains(got, "token=") || strings.Contains(got, "#") {
|
if got == "" || strings.Contains(got, "token=") {
|
||||||
t.Fatalf("sanitize failed: %q", got)
|
t.Fatalf("sanitize failed: %q", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "ok=1") {
|
if !strings.Contains(got, "ok=1") {
|
||||||
t.Fatalf("should keep ok param: %q", got)
|
t.Fatalf("should keep ok param: %q", got)
|
||||||
}
|
}
|
||||||
|
// SPA hash 路由必须保留,否则换页轨迹会被去重丢掉
|
||||||
|
if !strings.Contains(got, "#/cart") {
|
||||||
|
t.Fatalf("should keep hash fragment for SPA: %q", got)
|
||||||
|
}
|
||||||
|
home := sanitizePageURL("http://localhost:5173/demo-shop/index.html#/")
|
||||||
|
cart := sanitizePageURL("http://localhost:5173/demo-shop/index.html#/cart")
|
||||||
|
if home == "" || cart == "" || home == cart {
|
||||||
|
t.Fatalf("hash pages must differ: home=%q cart=%q", home, cart)
|
||||||
|
}
|
||||||
if sanitizePageURL("javascript:alert(1)") != "" {
|
if sanitizePageURL("javascript:alert(1)") != "" {
|
||||||
t.Fatal("reject javascript")
|
t.Fatal("reject javascript")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,7 +353,8 @@ func (h *SessionHandler) Get(c *gin.Context) {
|
|||||||
var events []model.SessionEvent
|
var events []model.SessionEvent
|
||||||
model.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&events)
|
model.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&events)
|
||||||
var pageViews []model.VisitorPageView
|
var pageViews []model.VisitorPageView
|
||||||
model.DB.Where("session_id = ?", session.ID).Order("entered_at asc").Limit(maxPageViewsPerSession).Find(&pageViews)
|
// 最新在前,坐席侧无需滚到底
|
||||||
|
model.DB.Where("session_id = ?", session.ID).Order("entered_at desc").Limit(maxPageViewsPerSession).Find(&pageViews)
|
||||||
var pendingCount int64
|
var pendingCount int64
|
||||||
model.DB.Model(&model.Session{}).Where("customer_id = ? AND tenant_id = ? AND status = ?", session.CustomerID, session.TenantID, "waiting").Count(&pendingCount)
|
model.DB.Model(&model.Session{}).Where("customer_id = ? AND tenant_id = ? AND status = ?", session.CustomerID, session.TenantID, "waiting").Count(&pendingCount)
|
||||||
|
|
||||||
|
|||||||
@@ -274,13 +274,34 @@ func (h *WidgetHandler) PageView(c *gin.Context) {
|
|||||||
pageTitle := sanitizePageTitle(req.PageTitle)
|
pageTitle := sanitizePageTitle(req.PageTitle)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// 与当前页相同则只刷新 last_seen,不重复插轨迹
|
// 与当前页相同:只刷新活跃时间/标题,不插重复轨迹
|
||||||
if strings.TrimSpace(session.CurrentURL) == pageURL {
|
if strings.TrimSpace(session.CurrentURL) == pageURL {
|
||||||
_ = model.DB.Model(session).Updates(map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"last_seen_at": now,
|
"last_seen_at": now,
|
||||||
"current_title": pageTitle,
|
}
|
||||||
}).Error
|
titleFixed := false
|
||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"deduped": true}})
|
if pageTitle != "" && pageTitle != strings.TrimSpace(session.CurrentTitle) {
|
||||||
|
updates["current_title"] = pageTitle
|
||||||
|
// 同步最新一条轨迹的标题(纠正「慢一页」时写入的旧 title)
|
||||||
|
var last model.VisitorPageView
|
||||||
|
if err := model.DB.Where("session_id = ? AND url = ?", session.ID, pageURL).
|
||||||
|
Order("entered_at desc").First(&last).Error; err == nil {
|
||||||
|
_ = model.DB.Model(&last).Update("title", pageTitle).Error
|
||||||
|
titleFixed = true
|
||||||
|
if payload, err := ws.NewEvent("page_view", session.ID, gin.H{
|
||||||
|
"id": last.ID,
|
||||||
|
"session_id": last.SessionID,
|
||||||
|
"url": pageURL,
|
||||||
|
"title": pageTitle,
|
||||||
|
"entered_at": last.EnteredAt,
|
||||||
|
"title_fix": true,
|
||||||
|
}); err == nil {
|
||||||
|
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = model.DB.Model(session).Updates(updates).Error
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"deduped": true, "title_updated": titleFixed}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +345,9 @@ func (h *WidgetHandler) PageView(c *gin.Context) {
|
|||||||
"title": pv.Title,
|
"title": pv.Title,
|
||||||
"entered_at": pv.EnteredAt,
|
"entered_at": pv.EnteredAt,
|
||||||
}); err == nil {
|
}); err == nil {
|
||||||
ws.DefaultHub.BroadcastToSessionStaff(session.TenantID, session.AgentID, payload)
|
// 排队中 AgentID 为空时,BroadcastToSessionStaff 只会推给 admin/supervisor,
|
||||||
|
// 一线坐席看不到实时轨迹;轨迹对租户内工作人员统一广播。
|
||||||
|
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": pv})
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": pv})
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>演示商城 · 首页</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--brand: #2563eb;
|
||||||
|
--bg: #f8fafc;
|
||||||
|
--card: #fff;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
header.site {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.site-inner {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--brand);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
nav a:hover { background: #f1f5f9; color: var(--text); }
|
||||||
|
nav a.active {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: var(--brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 20px 120px;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.hero h1 { margin: 0 0 8px; font-size: 24px; }
|
||||||
|
.hero p { margin: 0; opacity: 0.9; font-size: 14px; line-height: 1.6; }
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.card h2 { margin: 0 0 8px; font-size: 18px; }
|
||||||
|
.card p { margin: 0 0 12px; color: var(--muted); font-size: 14px; line-height: 1.6; }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.product {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.product:hover {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.08);
|
||||||
|
}
|
||||||
|
.product .thumb {
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #dbeafe, #eff6ff);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.product .name { font-weight: 600; font-size: 14px; }
|
||||||
|
.product .price { color: var(--brand); font-weight: 700; margin-top: 4px; font-size: 15px; }
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn.secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn + .btn { margin-left: 8px; }
|
||||||
|
.muted { color: var(--muted); font-size: 13px; }
|
||||||
|
.steps {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
counter-reset: step;
|
||||||
|
}
|
||||||
|
.steps li {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px 12px 10px 40px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.steps li::before {
|
||||||
|
counter-increment: step;
|
||||||
|
content: counter(step);
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 12px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.status-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 1000;
|
||||||
|
max-width: min(420px, calc(100vw - 100px));
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
.status-bar strong { color: #fff; }
|
||||||
|
.status-bar code {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(255,255,255,.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #93c5fd;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.status-bar .hint { color: #94a3b8; margin-top: 6px; font-size: 11px; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
label.field { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||||
|
input.field {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site">
|
||||||
|
<div class="site-inner">
|
||||||
|
<a class="logo" href="#/" data-nav>演示商城</a>
|
||||||
|
<nav id="nav">
|
||||||
|
<a href="#/" data-nav data-path="/">首页</a>
|
||||||
|
<a href="#/product/iphone" data-nav data-path="/product/iphone">商品详情</a>
|
||||||
|
<a href="#/cart" data-nav data-path="/cart">购物车</a>
|
||||||
|
<a href="#/checkout" data-nav data-path="/checkout">结算</a>
|
||||||
|
<a href="#/help" data-nav data-path="/help">帮助中心</a>
|
||||||
|
</nav>
|
||||||
|
<span class="badge">访客轨迹测试站</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="app"></main>
|
||||||
|
|
||||||
|
<div class="status-bar" id="statusBar">
|
||||||
|
<div><strong>当前宿主页(会上报给客服)</strong></div>
|
||||||
|
<code id="statusUrl">—</code>
|
||||||
|
<div class="hint" id="statusTitle">title: —</div>
|
||||||
|
<div class="hint">
|
||||||
|
点右下角蓝钮打开客服 → 再点上方导航换页 → 在坐席工作台右侧看「落地页 / 浏览轨迹 / 在线时长」
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// 渠道 key:?channel=WK_xxx ,默认种子渠道
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
var channelKey = params.get('channel') || params.get('channel_key') || 'WK_8a3f2e';
|
||||||
|
|
||||||
|
var pages = {
|
||||||
|
'/': {
|
||||||
|
title: '演示商城 · 首页',
|
||||||
|
html: function () {
|
||||||
|
return (
|
||||||
|
'<div class="hero">' +
|
||||||
|
'<h1>欢迎来到演示商城</h1>' +
|
||||||
|
'<p>这是<strong>模拟客户官网</strong>。请打开右下角客服,再切换导航,验证:落地页、换页轨迹、在线读秒。</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card">' +
|
||||||
|
'<h2>测试步骤</h2>' +
|
||||||
|
'<ol class="steps">' +
|
||||||
|
'<li>确认后端 API 已启动(默认 :8080),前端 <code>npm run dev</code>(:5173)</li>' +
|
||||||
|
'<li>点击右下角蓝色客服按钮,发送一句「你好」</li>' +
|
||||||
|
'<li>保持聊天窗口打开,依次点:商品详情 → 购物车 → 结算 → 帮助中心</li>' +
|
||||||
|
'<li>坐席登录工作台,打开该会话,右侧查看落地页 / 浏览轨迹 / 在线时长</li>' +
|
||||||
|
'</ol>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card">' +
|
||||||
|
'<h2>热门商品</h2>' +
|
||||||
|
'<div class="grid">' +
|
||||||
|
productCard('iphone', '云客服专业版', '¥299/月') +
|
||||||
|
productCard('seat', '坐席扩容包', '¥99/席') +
|
||||||
|
productCard('api', '开放 API', '联系销售') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/product/iphone': {
|
||||||
|
title: '演示商城 · 商品详情 · 云客服专业版',
|
||||||
|
html: function () {
|
||||||
|
return (
|
||||||
|
'<div class="card">' +
|
||||||
|
'<div class="thumb" style="height:160px;border-radius:12px;background:linear-gradient(135deg,#93c5fd,#2563eb);margin-bottom:16px"></div>' +
|
||||||
|
'<h2>云客服专业版</h2>' +
|
||||||
|
'<p class="muted">多渠道接待 · 知识库 · 数据统计 · 适合中小团队</p>' +
|
||||||
|
'<p style="font-size:22px;font-weight:700;color:#2563eb;margin:8px 0 16px">¥299 / 月</p>' +
|
||||||
|
'<button class="btn" type="button" data-goto="/cart">加入购物车</button>' +
|
||||||
|
'<button class="btn secondary" type="button" data-goto="/">返回首页</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card"><h2>规格说明</h2><p>本页用于模拟访客浏览商品详情。切换到此页应在客服端轨迹中新增一条记录。</p></div>'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/product/seat': {
|
||||||
|
title: '演示商城 · 商品详情 · 坐席扩容包',
|
||||||
|
html: function () {
|
||||||
|
return (
|
||||||
|
'<div class="card">' +
|
||||||
|
'<h2>坐席扩容包</h2>' +
|
||||||
|
'<p>按需增加在线坐席数量。</p>' +
|
||||||
|
'<p style="font-size:22px;font-weight:700;color:#2563eb">¥99 / 席 / 月</p>' +
|
||||||
|
'<button class="btn" type="button" data-goto="/cart">加入购物车</button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/product/api': {
|
||||||
|
title: '演示商城 · 商品详情 · 开放 API',
|
||||||
|
html: function () {
|
||||||
|
return (
|
||||||
|
'<div class="card">' +
|
||||||
|
'<h2>开放 API</h2>' +
|
||||||
|
'<p>企业集成方案,请通过在线客服联系销售。</p>' +
|
||||||
|
'<button class="btn" type="button" onclick="window.KefuWidget&&KefuWidget.open()">咨询客服</button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/cart': {
|
||||||
|
title: '演示商城 · 购物车',
|
||||||
|
html: function () {
|
||||||
|
return (
|
||||||
|
'<div class="card">' +
|
||||||
|
'<h2>购物车</h2>' +
|
||||||
|
'<p>云客服专业版 × 1</p>' +
|
||||||
|
'<p class="muted">小计 ¥299</p>' +
|
||||||
|
'<button class="btn" type="button" data-goto="/checkout">去结算</button>' +
|
||||||
|
'<button class="btn secondary" type="button" data-goto="/product/iphone">继续浏览</button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/checkout': {
|
||||||
|
title: '演示商城 · 结算页',
|
||||||
|
html: function () {
|
||||||
|
return (
|
||||||
|
'<div class="card">' +
|
||||||
|
'<h2>确认订单</h2>' +
|
||||||
|
'<p>收货信息、支付方式等(示意)。访客停在结算页时,客服应能看到「当前页」为本页。</p>' +
|
||||||
|
'<button class="btn" type="button" onclick="alert(\'演示完成:请到坐席工作台查看浏览轨迹\')">提交订单(演示)</button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/help': {
|
||||||
|
title: '演示商城 · 帮助中心',
|
||||||
|
html: function () {
|
||||||
|
return (
|
||||||
|
'<div class="card">' +
|
||||||
|
'<h2>帮助中心</h2>' +
|
||||||
|
'<p>常见问题:如何退款?如何开通坐席?可点右下角咨询人工客服。</p>' +
|
||||||
|
'<button class="btn" type="button" onclick="window.KefuWidget&&KefuWidget.open()">联系客服</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="card">' +
|
||||||
|
'<h2>渠道 Key</h2>' +
|
||||||
|
'<label class="field">当前 data-id / channel_key</label>' +
|
||||||
|
'<div class="toolbar">' +
|
||||||
|
'<input class="field" id="channelInput" value="' + escapeHtml(channelKey) + '" />' +
|
||||||
|
'<button class="btn secondary" type="button" id="applyChannel">切换并刷新</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p class="muted" style="margin-top:10px">须与系统设置 → 渠道管理中的网页渠道 data-id 一致。种子默认为 <code>WK_8a3f2e</code>。</p>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function productCard(id, name, price) {
|
||||||
|
return (
|
||||||
|
'<div class="product" data-goto="/product/' + id + '">' +
|
||||||
|
'<div class="thumb"></div>' +
|
||||||
|
'<div class="name">' + name + '</div>' +
|
||||||
|
'<div class="price">' + price + '</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathFromHash() {
|
||||||
|
var h = location.hash.replace(/^#/, '') || '/';
|
||||||
|
if (!h.startsWith('/')) h = '/' + h;
|
||||||
|
// strip query in hash
|
||||||
|
return h.split('?')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
var elUrl = document.getElementById('statusUrl');
|
||||||
|
var elTitle = document.getElementById('statusTitle');
|
||||||
|
if (elUrl) elUrl.textContent = location.href;
|
||||||
|
if (elTitle) elTitle.textContent = 'title: ' + document.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveNav(path) {
|
||||||
|
document.querySelectorAll('#nav a').forEach(function (a) {
|
||||||
|
var p = a.getAttribute('data-path');
|
||||||
|
a.classList.toggle('active', p === path || (path.indexOf('/product/') === 0 && p === '/product/iphone' && path === '/product/iphone'));
|
||||||
|
if (path.indexOf('/product/') === 0) {
|
||||||
|
a.classList.toggle('active', p === path || (p === '/product/iphone' && path === '/product/iphone'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// simpler: exact match
|
||||||
|
document.querySelectorAll('#nav a').forEach(function (a) {
|
||||||
|
a.classList.toggle('active', a.getAttribute('data-path') === path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var path = pathFromHash();
|
||||||
|
var page = pages[path] || pages['/'];
|
||||||
|
if (!pages[path]) {
|
||||||
|
// unknown product path fallback
|
||||||
|
if (path.indexOf('/product/') === 0) {
|
||||||
|
page = {
|
||||||
|
title: '演示商城 · 商品 ' + path,
|
||||||
|
html: function () {
|
||||||
|
return '<div class="card"><h2>商品 ' + escapeHtml(path) + '</h2><p>示意详情页</p><button class="btn" data-goto="/cart">加入购物车</button></div>';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
path = '/';
|
||||||
|
page = pages['/'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.title = page.title;
|
||||||
|
document.getElementById('app').innerHTML = page.html();
|
||||||
|
setActiveNav(path);
|
||||||
|
updateStatus();
|
||||||
|
|
||||||
|
// 绑定站内跳转(触发 history / hash,widget.js 会监听到)
|
||||||
|
document.querySelectorAll('[data-goto]').forEach(function (el) {
|
||||||
|
el.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
go(el.getAttribute('data-goto'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var applyBtn = document.getElementById('applyChannel');
|
||||||
|
if (applyBtn) {
|
||||||
|
applyBtn.addEventListener('click', function () {
|
||||||
|
var v = (document.getElementById('channelInput').value || '').trim();
|
||||||
|
if (!v) return;
|
||||||
|
var u = new URL(location.href);
|
||||||
|
u.searchParams.set('channel', v);
|
||||||
|
location.href = u.pathname + u.search + '#/help';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(path) {
|
||||||
|
if (!path) return;
|
||||||
|
if (path.charAt(0) !== '/') path = '/' + path;
|
||||||
|
// 使用 pushState + hash,URL 变化更接近真实站点路径展示
|
||||||
|
// 最终 href 形如 /demo-shop/?channel=xx#/cart
|
||||||
|
var next = '#' + path;
|
||||||
|
if (location.hash !== next) {
|
||||||
|
location.hash = path;
|
||||||
|
} else {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', render);
|
||||||
|
// 兼容直接点 nav 的 a[href="#/..."]
|
||||||
|
document.querySelectorAll('a[data-nav]').forEach(function (a) {
|
||||||
|
a.addEventListener('click', function () {
|
||||||
|
setTimeout(updateStatus, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
// 动态插入 widget.js(与渠道 key 一致)
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = '/widget.js';
|
||||||
|
s.setAttribute('data-id', channelKey);
|
||||||
|
s.async = true;
|
||||||
|
s.onerror = function () {
|
||||||
|
alert('加载 /widget.js 失败。请用前端开发服务打开本页,例如:\nhttp://localhost:5173/demo-shop/');
|
||||||
|
};
|
||||||
|
document.body.appendChild(s);
|
||||||
|
|
||||||
|
// 标题/状态栏同步
|
||||||
|
setInterval(updateStatus, 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+25
-7
@@ -30,8 +30,9 @@
|
|||||||
|
|
||||||
var open = false;
|
var open = false;
|
||||||
var iframe = null;
|
var iframe = null;
|
||||||
var lastSentURL = '';
|
var lastSentKey = '';
|
||||||
var lastSentAt = 0;
|
var lastSentAt = 0;
|
||||||
|
var routeTimer = null;
|
||||||
|
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
@@ -82,8 +83,10 @@
|
|||||||
if (!iframe || !iframe.contentWindow) return;
|
if (!iframe || !iframe.contentWindow) return;
|
||||||
var info = hostPageInfo();
|
var info = hostPageInfo();
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
if (!force && info.url === lastSentURL && now - lastSentAt < 800) return;
|
// url + title 一起去重:SPA 常先改 hash 再改 title,只比 url 会丢掉正确标题
|
||||||
lastSentURL = info.url;
|
var key = info.url + '\0' + info.title;
|
||||||
|
if (!force && key === lastSentKey && now - lastSentAt < 500) return;
|
||||||
|
lastSentKey = key;
|
||||||
lastSentAt = now;
|
lastSentAt = now;
|
||||||
try {
|
try {
|
||||||
iframe.contentWindow.postMessage({
|
iframe.contentWindow.postMessage({
|
||||||
@@ -95,6 +98,21 @@
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由变化后延迟采集:让宿主 SPA(含 demo-shop)先跑完 render、更新 document.title,
|
||||||
|
* 避免轨迹标题永远慢一页(URL 已是新页、title 仍是上一页)。
|
||||||
|
*/
|
||||||
|
function schedulePostPage(force) {
|
||||||
|
if (!open && !force) return;
|
||||||
|
if (routeTimer) clearTimeout(routeTimer);
|
||||||
|
routeTimer = setTimeout(function () {
|
||||||
|
routeTimer = null;
|
||||||
|
postPageToIframe(!!force);
|
||||||
|
// 再补一帧:部分框架 title 在 paint 后才写
|
||||||
|
setTimeout(function () { postPageToIframe(false); }, 50);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureIframe() {
|
function ensureIframe() {
|
||||||
if (iframe) return;
|
if (iframe) return;
|
||||||
iframe = document.createElement('iframe');
|
iframe = document.createElement('iframe');
|
||||||
@@ -103,7 +121,7 @@
|
|||||||
iframe.style.cssText = 'width:100%;height:100%;border:0;display:block;background:#fff;';
|
iframe.style.cssText = 'width:100%;height:100%;border:0;display:block;background:#fff;';
|
||||||
iframe.src = buildEmbedURL();
|
iframe.src = buildEmbedURL();
|
||||||
iframe.addEventListener('load', function () {
|
iframe.addEventListener('load', function () {
|
||||||
postPageToIframe(true);
|
schedulePostPage(true);
|
||||||
});
|
});
|
||||||
panel.appendChild(iframe);
|
panel.appendChild(iframe);
|
||||||
}
|
}
|
||||||
@@ -114,7 +132,7 @@
|
|||||||
ensureIframe();
|
ensureIframe();
|
||||||
panel.style.display = 'block';
|
panel.style.display = 'block';
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
postPageToIframe(true);
|
schedulePostPage(true);
|
||||||
} else {
|
} else {
|
||||||
panel.style.display = 'none';
|
panel.style.display = 'none';
|
||||||
btn.style.display = 'flex';
|
btn.style.display = 'flex';
|
||||||
@@ -130,14 +148,14 @@
|
|||||||
}
|
}
|
||||||
// iframe 就绪后可再次同步宿主页
|
// iframe 就绪后可再次同步宿主页
|
||||||
if (event.data.type === 'kefu-widget-ready') {
|
if (event.data.type === 'kefu-widget-ready') {
|
||||||
postPageToIframe(true);
|
schedulePostPage(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// —— SPA / 浏览器导航监听 ——
|
// —— SPA / 浏览器导航监听 ——
|
||||||
function onRouteMaybeChanged() {
|
function onRouteMaybeChanged() {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
postPageToIframe(false);
|
schedulePostPage(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -136,10 +136,11 @@ function shortPagePath(url?: string): string {
|
|||||||
if (!url) return '—'
|
if (!url) return '—'
|
||||||
try {
|
try {
|
||||||
const u = new URL(url)
|
const u = new URL(url)
|
||||||
const path = u.pathname + (u.search || '')
|
// 必须带 hash:demo-shop 等 SPA 靠 #/cart 区分页面
|
||||||
return path.length > 48 ? `${path.slice(0, 46)}…` : path || '/'
|
const path = u.pathname + (u.search || '') + (u.hash || '')
|
||||||
|
return path.length > 56 ? `${path.slice(0, 54)}…` : path || '/'
|
||||||
} catch {
|
} catch {
|
||||||
return url.length > 48 ? `${url.slice(0, 46)}…` : url
|
return url.length > 56 ? `${url.slice(0, 54)}…` : url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +260,10 @@ const Dashboard = () => {
|
|||||||
setDetail({
|
setDetail({
|
||||||
messages,
|
messages,
|
||||||
events: data.events || [],
|
events: data.events || [],
|
||||||
pageViews: Array.isArray(data.page_views) ? data.page_views : [],
|
// 接口已 desc;再保险按时间新→旧
|
||||||
|
pageViews: (Array.isArray(data.page_views) ? data.page_views : [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => new Date(b.entered_at).getTime() - new Date(a.entered_at).getTime()),
|
||||||
pendingCount: data.pending_count || 0,
|
pendingCount: data.pending_count || 0,
|
||||||
})
|
})
|
||||||
rememberMessagesSeq(id, messages)
|
rememberMessagesSeq(id, messages)
|
||||||
@@ -464,20 +468,29 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === 'page_view' && sameSession && payload.data) {
|
if (payload.type === 'page_view' && sameSession && payload.data) {
|
||||||
const pv = payload.data as unknown as VisitorPageView
|
const raw = payload.data as unknown as VisitorPageView & { title_fix?: boolean }
|
||||||
if (pv?.url) {
|
if (raw?.url) {
|
||||||
setDetail(prev => {
|
setDetail(prev => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
if (prev.pageViews.some(p => p.id === pv.id)) return prev
|
const idx = prev.pageViews.findIndex(p => p.id === raw.id)
|
||||||
return { ...prev, pageViews: [...prev.pageViews, pv] }
|
if (idx >= 0) {
|
||||||
|
// 标题纠正:更新已有条目,并移到最前(仍是当前页)
|
||||||
|
const next = prev.pageViews.slice()
|
||||||
|
const item = { ...next[idx], title: raw.title || next[idx].title, url: raw.url }
|
||||||
|
next.splice(idx, 1)
|
||||||
|
return { ...prev, pageViews: [item, ...next] }
|
||||||
|
}
|
||||||
|
if (prev.pageViews.some(p => p.url === raw.url && !raw.id)) return prev
|
||||||
|
// 最新轨迹插到最上方
|
||||||
|
return { ...prev, pageViews: [raw, ...prev.pageViews] }
|
||||||
})
|
})
|
||||||
setSessions(previous => previous.map(session =>
|
setSessions(previous => previous.map(session =>
|
||||||
session.id === sid
|
session.id === sid
|
||||||
? {
|
? {
|
||||||
...session,
|
...session,
|
||||||
current_url: pv.url,
|
current_url: raw.url,
|
||||||
current_title: pv.title || session.current_title,
|
current_title: raw.title || session.current_title,
|
||||||
last_seen_at: pv.entered_at || session.last_seen_at,
|
last_seen_at: raw.entered_at || session.last_seen_at,
|
||||||
}
|
}
|
||||||
: session,
|
: session,
|
||||||
))
|
))
|
||||||
@@ -602,11 +615,19 @@ const Dashboard = () => {
|
|||||||
const selectedCustomer = selected ? customers[selected.customer_id] : null
|
const selectedCustomer = selected ? customers[selected.customer_id] : null
|
||||||
const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active')
|
const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active')
|
||||||
|
|
||||||
// 进行中会话:在线时长每秒刷新
|
// 进行中会话:在线时长每秒刷新;轨迹每 8s 静默拉一次(防 WS 漏推)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected || selected.status === 'ended' || selected.status === 'archived') return
|
if (!selected || selected.status === 'ended' || selected.status === 'archived') return
|
||||||
const t = window.setInterval(() => setClockTick(n => n + 1), 1000)
|
const t = window.setInterval(() => setClockTick(n => n + 1), 1000)
|
||||||
return () => clearInterval(t)
|
const poll = window.setInterval(() => {
|
||||||
|
if (selectedIdRef.current != null) {
|
||||||
|
void loadDetailRef.current(selectedIdRef.current, false, { silent: true })
|
||||||
|
}
|
||||||
|
}, 8000)
|
||||||
|
return () => {
|
||||||
|
clearInterval(t)
|
||||||
|
clearInterval(poll)
|
||||||
|
}
|
||||||
}, [selected?.id, selected?.status])
|
}, [selected?.id, selected?.status])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1491,14 +1512,14 @@ const Dashboard = () => {
|
|||||||
<div
|
<div
|
||||||
key={pv.id || `${pv.url}-${pv.entered_at}`}
|
key={pv.id || `${pv.url}-${pv.entered_at}`}
|
||||||
className={`px-2.5 py-2 text-xs ${idx > 0 ? 'border-t border-neutral-50' : ''} ${
|
className={`px-2.5 py-2 text-xs ${idx > 0 ? 'border-t border-neutral-50' : ''} ${
|
||||||
idx === detail.pageViews.length - 1 ? 'bg-blue-50/50' : 'bg-white'
|
idx === 0 ? 'bg-blue-50/50' : 'bg-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||||
<span className="text-neutral-400 tabular-nums shrink-0">
|
<span className="text-neutral-400 tabular-nums shrink-0">
|
||||||
{new Date(pv.entered_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
{new Date(pv.entered_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
{idx === detail.pageViews.length - 1 && (
|
{idx === 0 && (
|
||||||
<span className="text-[10px] text-[#2563eb] font-medium">当前</span>
|
<span className="text-[10px] text-[#2563eb] font-medium">当前</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const VisitorChat = ({
|
|||||||
title: initialPage?.title || (typeof document !== 'undefined' ? document.title : ''),
|
title: initialPage?.title || (typeof document !== 'undefined' ? document.title : ''),
|
||||||
referrer: initialPage?.referrer || (typeof document !== 'undefined' ? document.referrer : ''),
|
referrer: initialPage?.referrer || (typeof document !== 'undefined' ? document.referrer : ''),
|
||||||
})
|
})
|
||||||
const lastPageURLRef = useRef('')
|
const lastPageKeyRef = useRef('') // url + title,避免同 URL 标题更新被吞
|
||||||
/** 本地已同步到的最大 seq(从缓存消息初始化) */
|
/** 本地已同步到的最大 seq(从缓存消息初始化) */
|
||||||
const lastSeqRef = useRef((() => {
|
const lastSeqRef = useRef((() => {
|
||||||
try {
|
try {
|
||||||
@@ -315,11 +315,23 @@ const VisitorChat = ({
|
|||||||
const reportPageView = useCallback(async (url: string, title: string) => {
|
const reportPageView = useCallback(async (url: string, title: string) => {
|
||||||
const sid = sessionIdRef.current
|
const sid = sessionIdRef.current
|
||||||
const token = visitorTokenRef.current
|
const token = visitorTokenRef.current
|
||||||
if (!sid || !token || !url || sessionEnded) return
|
if (!url) return
|
||||||
if (url === lastPageURLRef.current) return
|
// 会话尚未 Init 完成:只缓存宿主页,避免丢掉打开前/初始化中的换页
|
||||||
lastPageURLRef.current = url
|
if (!sid || !token) {
|
||||||
|
hostPageRef.current = {
|
||||||
|
...hostPageRef.current,
|
||||||
|
url,
|
||||||
|
title: title || hostPageRef.current.title,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (sessionEnded) return
|
||||||
|
const key = `${url}\0${title || ''}`
|
||||||
|
// 同 URL 但标题变了仍要上报(服务端会更新 current_title;新 URL 会插轨迹)
|
||||||
|
if (key === lastPageKeyRef.current) return
|
||||||
|
lastPageKeyRef.current = key
|
||||||
try {
|
try {
|
||||||
await fetch('/api/widget/pageview', {
|
const res = await fetch('/api/widget/pageview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -332,8 +344,11 @@ const VisitorChat = ({
|
|||||||
page_title: title || '',
|
page_title: title || '',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
lastPageKeyRef.current = ''
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* 轨迹失败不影响会话 */
|
lastPageKeyRef.current = ''
|
||||||
}
|
}
|
||||||
}, [sessionEnded])
|
}, [sessionEnded])
|
||||||
|
|
||||||
@@ -378,9 +393,15 @@ const VisitorChat = ({
|
|||||||
localStorage.setItem(tokenKey, token)
|
localStorage.setItem(tokenKey, token)
|
||||||
localStorage.removeItem(msgsKey)
|
localStorage.removeItem(msgsKey)
|
||||||
lastSeqRef.current = 0
|
lastSeqRef.current = 0
|
||||||
if (page.url) lastPageURLRef.current = page.url
|
// init 已写入落地页;标记已上报,避免立刻重复;宿主若已换页则补报
|
||||||
|
const latest = hostPageRef.current
|
||||||
|
if (page.url) lastPageKeyRef.current = `${page.url}\0${page.title || ''}`
|
||||||
await loadMessages(sid, token, { full: true })
|
await loadMessages(sid, token, { full: true })
|
||||||
}, [channelKey, loadMessages, storageKey, tokenKey, msgsKey])
|
if (latest.url && (latest.url !== page.url || (latest.title || '') !== (page.title || ''))) {
|
||||||
|
lastPageKeyRef.current = ''
|
||||||
|
void reportPageView(latest.url, latest.title || '')
|
||||||
|
}
|
||||||
|
}, [channelKey, loadMessages, storageKey, tokenKey, msgsKey, reportPageView])
|
||||||
|
|
||||||
const closeSocket = useCallback(() => {
|
const closeSocket = useCallback(() => {
|
||||||
const sock = socketRef.current
|
const sock = socketRef.current
|
||||||
|
|||||||
+21
-2
@@ -1,10 +1,29 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, type Plugin } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
|
/** 避免 /demo-shop/ 被 SPA 回退成 React,导致 404;改为提供 public 静态页 */
|
||||||
|
function demoShopStatic(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'demo-shop-static',
|
||||||
|
configureServer(server) {
|
||||||
|
// 必须挂在内部中间件之前,否则会先进 React SPA
|
||||||
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
const raw = req.url || ''
|
||||||
|
const pathname = raw.split('?')[0]
|
||||||
|
if (pathname === '/demo-shop' || pathname === '/demo-shop/') {
|
||||||
|
const qs = raw.includes('?') ? raw.slice(raw.indexOf('?')) : ''
|
||||||
|
req.url = `/demo-shop/index.html${qs}`
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss(), demoShopStatic()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
|||||||
Reference in New Issue
Block a user