feat: use floating customer service widget in pro
Deploy olons-server production / deploy (push) Successful in 3m1s
Deploy olons-server production / deploy (push) Successful in 3m1s
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user