feat(chat): Socket.IO 双端通道、消息持久化与幂等

访客与客服分别鉴权接入,消息带客户端 ID 去重,重发只产生一条业务记录。
This commit is contained in:
WangShen007 committed 2026-09-26 22:20:00 +08:00
1 parent 0cc6299d1c
commit ef9fee1585
23 files changed
+7057

No files matched your search

@@ -0,0 +1,73 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
// The channel's delivery receipt for a message we sent. 'failed' has its own
// "Not delivered" notice, so it shows no tick.
type ChannelStatus = 'sent' | 'delivered' | 'read'
const TICKS: Record<ChannelStatus, { icon: string; label: string }> = {
sent: { icon: 'fa-solid fa-check', label: '已发送' },
delivered: { icon: 'fa-solid fa-check-double', label: '已送达' },
read: { icon: 'fa-solid fa-check-double', label: '已读' },
}
const props = defineProps<{
status?: string | null
// On an accent-filled bubble no blue holds contrast in either theme, so
// read becomes an inverted badge in the bubble's own colour pair
onAccent?: boolean
}>()
const tick = computed(() =>
props.status && props.status in TICKS ? TICKS[props.status as ChannelStatus] : null
)
</script>
<template>
<span
v-if="tick"
class="status-ticks"
:class="[`status-ticks--${status}`, { 'status-ticks--on-accent': onAccent }]"
:title="tick.label"
:aria-label="tick.label"
role="img"
>
<font-awesome-icon :icon="tick.icon" />
</span>
</template>
<style scoped>
.status-ticks {
display: inline-flex;
margin-left: 4px;
font-size: var(--text-xs);
vertical-align: baseline;
}
.status-ticks--read {
color: var(--c-info);
}
.status-ticks--on-accent.status-ticks--read {
padding: 0 4px;
border-radius: var(--radius-full);
background: var(--bubble-agent-fg);
color: var(--bubble-agent-bg);
}
</style>
@@ -0,0 +1,62 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
// The choices a message went out with (the backend's record of what was
// actually sent). Read-only: they were tapped on the customer's phone.
interface SentChoices {
as_text?: boolean
choices?: Array<{ id: string; text: string }>
}
const props = defineProps<{
sent?: SentChoices | null
}>()
const choices = computed(() => props.sent?.choices ?? [])
const label = computed(() =>
props.sent?.as_text ? '已发送的选项(以编号列表发送)' : '已发送的选项'
)
</script>
<template>
<ul v-if="choices.length" class="quick-reply-chips" :aria-label="label" :title="label">
<li v-for="choice in choices" :key="choice.id" class="quick-reply-chip">{{ choice.text }}</li>
</ul>
</template>
<style scoped>
.quick-reply-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0 0;
padding: 0;
list-style: none;
}
.quick-reply-chip {
padding: 3px 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
background: var(--background-soft);
color: var(--text-color);
font-size: var(--text-xs);
line-height: 1.5;
}
</style>