feat: use floating customer service widget in pro
Deploy olons-server production / deploy (push) Successful in 3m1s

This commit is contained in:
richjohnrambo
2026-10-21 13:15:15 +08:00
parent 79c502cb20
commit 9bb479b506
4 changed files with 144 additions and 6 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
run: |
set -euo pipefail
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
docker build -t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
docker build --build-arg VITE_CUSTOMER_SERVICE_PROVIDER=third_party -t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
docker push "$IMAGE:pro"
docker push "$IMAGE:$SHORT_SHA"
+2
View File
@@ -6,7 +6,9 @@ RUN corepack enable
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY web/ ./
ARG VITE_CUSTOMER_SERVICE_PROVIDER=
ENV VITE_PUBLIC_PATH=/
ENV VITE_CUSTOMER_SERVICE_PROVIDER=${VITE_CUSTOMER_SERVICE_PROVIDER}
RUN pnpm run build
# ---- Stage 2: build the Go backend ----
+6 -1
View File
@@ -21,6 +21,12 @@ class DeployWorkflowContractTest(unittest.TestCase):
self.assertIn("git.olns.shop/pro/olons_server", self.workflow)
self.assertIn("docker push", self.workflow)
self.assertIn(":pro", self.workflow)
self.assertIn("--build-arg VITE_CUSTOMER_SERVICE_PROVIDER=third_party", self.workflow)
def test_production_image_enables_customer_service_widget(self):
dockerfile = (WORKFLOW.parents[2] / "Dockerfile").read_text(encoding="utf-8")
self.assertIn("ARG VITE_CUSTOMER_SERVICE_PROVIDER=", dockerfile)
self.assertIn("ENV VITE_CUSTOMER_SERVICE_PROVIDER=${VITE_CUSTOMER_SERVICE_PROVIDER}", dockerfile)
def test_deploys_compose_service_with_pinned_host_key(self):
self.assertIn("DEPLOY_SSH_KEY", self.workflow)
@@ -53,4 +59,3 @@ class DeployWorkflowContractTest(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
+135 -4
View File
@@ -16,7 +16,7 @@
v-for="item in services"
:key="item.id"
class="m-service-card"
@click="$router.push(`/app/support/${item.id}`)"
@click="openService(item)"
>
<div class="m-service-avatar"
><img
@@ -35,6 +35,15 @@
</div>
</section>
<section v-else-if="useThirdPartyChat" class="m-third-party-launch">
<PageHeader title="在线客服" />
<div class="m-third-party-launch__content">
<h2>在线客服</h2>
<p>聊天窗口将在当前页面内打开。</p>
<button type="button" class="m-primary-btn" @click="openThirdPartyChat">打开在线客服</button>
</div>
</section>
<section v-else class="m-chat">
<PageHeader :title="current?.name || '在线客服'" />
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
@@ -144,7 +153,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import {
HappyOutline,
HeadsetOutline,
@@ -161,8 +170,77 @@
type CustomerServiceConnectionState,
type CustomerServiceSession,
} from '../im';
type ThirdPartyChatWidget = {
initialized: boolean;
configReady: boolean;
init: (config: { channelId: string; useCustomButton: boolean }) => void;
open: () => void;
close: () => void;
};
declare global {
interface Window {
ChatWidget?: ThirdPartyChatWidget;
__olonsChatWidgetScriptPromise?: Promise<void>;
__olonsChatWidgetConfigPromise?: Promise<void>;
}
}
const route = useRoute();
const router = useRouter();
const isChat = computed(() => Boolean(route.params.id));
const useThirdPartyChat = import.meta.env.VITE_CUSTOMER_SERVICE_PROVIDER === 'third_party';
const thirdPartyChatChannelId = 'ebdf4b92054645d992ec9ede4ac19161';
const thirdPartyChatScriptUrl = 'https://47.115.208.214:1582/chat/widget.js';
function openService(item: any) {
if (useThirdPartyChat) {
void openThirdPartyChat();
return;
}
void router.push(`/app/support/${item.id}`);
}
function loadThirdPartyChatWidget() {
if (window.ChatWidget?.initialized) return Promise.resolve();
if (!window.__olonsChatWidgetScriptPromise) {
window.__olonsChatWidgetScriptPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = thirdPartyChatScriptUrl;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('客服插件加载失败,请稍后重试'));
document.head.appendChild(script);
});
}
return window.__olonsChatWidgetScriptPromise;
}
async function initializeThirdPartyChatWidget() {
if (!useThirdPartyChat) return;
try {
await loadThirdPartyChatWidget();
const widget = window.ChatWidget;
if (!widget) throw new Error('客服插件初始化失败,请刷新页面重试');
if (!widget.initialized) {
window.__olonsChatWidgetConfigPromise = new Promise<void>((resolve) => {
window.addEventListener('chatWidgetReady', () => resolve(), { once: true });
});
widget.init({ channelId: thirdPartyChatChannelId, useCustomButton: true });
}
if (widget.configReady) return;
await Promise.race([
window.__olonsChatWidgetConfigPromise,
new Promise<void>((_, reject) => window.setTimeout(() => reject(new Error('客服配置加载超时,请稍后重试')), 10000)),
]);
} catch (err) {
window.$message?.error(errorText(err));
throw err;
}
}
async function openThirdPartyChat() {
try {
await initializeThirdPartyChatWidget();
window.ChatWidget?.open();
} catch {
// initializeThirdPartyChatWidget already displays a user-facing error.
}
}
const loading = ref(true);
const error = ref('');
const services = ref<any[]>([]);
@@ -418,9 +496,62 @@
}).format(new Date(value));
}
watch(() => route.params.id, (serviceID) => {
if (useThirdPartyChat) {
if (!serviceID) void load();
return;
}
if (serviceID) void connectChat();
else disconnectChat();
}, { immediate: true, flush: 'sync' });
onMounted(load);
onBeforeUnmount(disconnectChat);
onMounted(() => {
if (!isChat.value) void load();
if (useThirdPartyChat) void initializeThirdPartyChatWidget().catch(() => undefined);
});
onBeforeUnmount(() => {
disconnectChat();
if (useThirdPartyChat && window.ChatWidget?.initialized) window.ChatWidget.close();
});
</script>
<style scoped>
.m-third-party-launch {
display: flex;
width: 100%;
min-height: 100dvh;
flex-direction: column;
background: #fff;
}
.m-third-party-launch__content {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
text-align: center;
}
.m-third-party-launch__content h2,
.m-third-party-launch__content p {
margin: 0;
}
:global(.chat-widget-iframe-container.mobile) {
width: min(92vw, 380px) !important;
height: min(72dvh, 560px) !important;
max-height: calc(100dvh - 150px) !important;
top: auto !important;
right: 12px !important;
bottom: calc(84px + env(safe-area-inset-bottom)) !important;
left: auto !important;
border-radius: 14px !important;
box-shadow: 0 8px 28px rgb(0 0 0 / 20%) !important;
overflow: hidden !important;
}
:global(.chat-widget-iframe-container.mobile .chat-widget-iframe) {
border-radius: 14px !important;
}
</style>