This commit is contained in:
Putoo
2026-04-25 18:11:21 +08:00
parent 5153a4e1e7
commit 8e1a6ff0ec
8 changed files with 836 additions and 14 deletions

588
Web/src/pages/home/im.vue Normal file
View File

@@ -0,0 +1,588 @@
<template>
<div class="im-page">
<section class="hero-card">
<div>
<p class="eyebrow">SignalR IM Demo</p>
<h1>即时通讯测试页</h1>
<p class="hero-text">
当前已对接服务端 `ChatHub`默认调用 `SendMessage(user, message)`监听 `ReceiveMessage`
`UserConnected` `UserDisconnected`
</p>
</div>
<div class="status-box">
<span class="status-label">连接状态</span>
<strong :class="['status-pill', `is-${status}`]">{{ statusText }}</strong>
<span v-if="connectionId" class="connection-id">连接ID{{ connectionId }}</span>
</div>
</section>
<section class="panel">
<div class="panel-title">连接配置</div>
<div class="field-grid">
<label class="field">
<span>Hub 地址</span>
<input v-model.trim="hubUrl" type="text" placeholder="https://localhost:7198/chatHub">
</label>
<label class="field">
<span>当前昵称</span>
<input v-model.trim="nickname" type="text" placeholder="请输入发送昵称">
</label>
</div>
<label class="field">
<span>Bearer Token</span>
<textarea
v-model.trim="accessToken"
rows="3"
placeholder="如后端开启鉴权,可在这里粘贴 token为空则匿名连接。"
/>
</label>
<div class="action-row">
<button class="primary-btn" :disabled="isConnecting || isConnected" @click="connectHub">连接</button>
<button class="ghost-btn" :disabled="!isConnected" @click="disconnectHub">断开</button>
<button class="ghost-btn" @click="resetToken">读取本地 Token</button>
</div>
</section>
<section class="panel">
<div class="panel-title">消息面板</div>
<div ref="messageListRef" class="message-list">
<div v-if="!messages.length" class="empty-state">还没有消息先连接然后发送一条试试</div>
<article
v-for="item in messages"
:key="item.id"
:class="['message-item', item.type === 'system' ? 'is-system' : item.user === nickname ? 'is-self' : '']"
>
<div class="message-meta">
<strong>{{ item.user }}</strong>
<span>{{ item.time }}</span>
</div>
<p>{{ item.message }}</p>
</article>
</div>
<label class="field">
<span>发送内容</span>
<textarea
v-model.trim="draftMessage"
rows="3"
placeholder="输入消息后点击发送"
@keydown.ctrl.enter.exact.prevent="sendMessage"
/>
</label>
<div class="action-row">
<button class="primary-btn" :disabled="!isConnected || !draftMessage" @click="sendMessage">发送消息</button>
<button class="ghost-btn" @click="clearMessages">清空消息</button>
</div>
</section>
<section class="panel">
<div class="panel-title">运行日志</div>
<div class="log-list">
<p v-if="!logs.length" class="empty-state">暂无日志</p>
<p v-for="item in logs" :key="item.id" class="log-item">{{ item.text }}</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import type { HubConnection } from '@microsoft/signalr'
import { BaseConfig } from '@/config/BaseConfig'
definePageMeta({
layout: layout.default
})
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
type MessageType = 'chat' | 'system'
interface ChatMessage {
id: string
user: string
message: string
time: string
type: MessageType
}
interface LogItem {
id: string
text: string
}
const userStore = useUserStore()
const defaultHubUrl = `${BaseConfig.BaseUrl.replace(/\/$/, '')}/chatHub`
const hubUrl = ref(defaultHubUrl)
const nickname = ref('')
const accessToken = ref('')
const draftMessage = ref('')
const status = ref<ConnectionStatus>('disconnected')
const connectionId = ref('')
const messages = ref<ChatMessage[]>([])
const logs = ref<LogItem[]>([])
const connection = shallowRef<HubConnection | null>(null)
const messageListRef = useTemplateRef<HTMLDivElement>('messageListRef')
const isConnected = computed(() => status.value === 'connected')
const isConnecting = computed(() => status.value === 'connecting')
const statusText = computed(() => {
switch (status.value) {
case 'connecting':
return '连接中'
case 'connected':
return '已连接'
case 'error':
return '连接异常'
default:
return '未连接'
}
})
const buildId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`
const getNowText = () =>
new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
const addLog = (text: string) => {
logs.value.unshift({
id: buildId(),
text: `[${getNowText()}] ${text}`
})
logs.value = logs.value.slice(0, 60)
}
const addMessage = (user: string, message: string, type: MessageType = 'chat') => {
messages.value.push({
id: buildId(),
user,
message,
time: getNowText(),
type
})
void nextTick(() => {
const target = messageListRef.value
if (target) {
target.scrollTop = target.scrollHeight
}
})
}
const resolveHubUrl = (value: string) => {
const finalValue = value.trim() || defaultHubUrl
if (/^https?:\/\//i.test(finalValue)) {
return finalValue
}
if (import.meta.client && finalValue.startsWith('/')) {
return `${window.location.origin}${finalValue}`
}
return finalValue
}
const registerConnectionEvents = (hub: HubConnection) => {
hub.on('ReceiveMessage', (user: string, message: string) => {
addMessage(user || '匿名用户', message || '')
})
hub.on('UserConnected', (id: string) => {
addMessage('系统', `用户已连接:${id}`, 'system')
addLog(`收到 UserConnected 事件:${id}`)
})
hub.on('UserDisconnected', (id: string) => {
addMessage('系统', `用户已断开:${id}`, 'system')
addLog(`收到 UserDisconnected 事件:${id}`)
})
hub.onreconnecting((error) => {
status.value = 'connecting'
addLog(`连接重试中:${error?.message || '网络波动'}`)
})
hub.onreconnected((id) => {
status.value = 'connected'
connectionId.value = id || hub.connectionId || ''
addLog(`重连成功:${connectionId.value || '无连接ID'}`)
})
hub.onclose((error) => {
status.value = error ? 'error' : 'disconnected'
connectionId.value = ''
addLog(`连接关闭:${error?.message || '已主动断开'}`)
})
}
const createConnection = async () => {
const signalR = await import('@microsoft/signalr')
const hub = new signalR.HubConnectionBuilder()
.withUrl(resolveHubUrl(hubUrl.value), {
withCredentials: false,
accessTokenFactory: () => accessToken.value.trim()
})
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Information)
.build()
registerConnectionEvents(hub)
return hub
}
const connectHub = async () => {
if (isConnecting.value || isConnected.value) {
return
}
if (!nickname.value.trim()) {
nickname.value = `游客${Math.floor(Math.random() * 1000)}`
}
try {
status.value = 'connecting'
addLog(`开始连接:${resolveHubUrl(hubUrl.value)}`)
if (connection.value) {
await connection.value.stop()
connection.value = null
}
const hub = await createConnection()
await hub.start()
connection.value = hub
connectionId.value = hub.connectionId || ''
status.value = 'connected'
addLog(`连接成功:${connectionId.value || '未返回连接ID'}`)
addMessage('系统', 'SignalR 已连接,可以开始发送消息。', 'system')
} catch (error) {
status.value = 'error'
connectionId.value = ''
connection.value = null
const message = error instanceof Error ? error.message : '未知错误'
addLog(`连接失败:${message}`)
addMessage('系统', `连接失败:${message}`, 'system')
}
}
const disconnectHub = async () => {
if (!connection.value) {
status.value = 'disconnected'
return
}
await connection.value.stop()
connection.value = null
status.value = 'disconnected'
connectionId.value = ''
addMessage('系统', '已主动断开连接。', 'system')
}
const sendMessage = async () => {
const message = draftMessage.value.trim()
if (!connection.value || !isConnected.value || !message) {
return
}
try {
await connection.value.invoke('SendMessage', nickname.value.trim(), message)
addLog(`发送成功:${message}`)
draftMessage.value = ''
} catch (error) {
const text = error instanceof Error ? error.message : '未知错误'
addLog(`发送失败:${text}`)
addMessage('系统', `发送失败:${text}`, 'system')
}
}
const clearMessages = () => {
messages.value = []
}
const resetToken = () => {
if (!import.meta.client) {
return
}
accessToken.value = userStore.token || localStorage.getItem('token') || ''
addLog(accessToken.value ? '已读取本地 token。' : '未读取到本地 token。')
}
onMounted(() => {
nickname.value = userStore.userNickname || `游客${Math.floor(Math.random() * 1000)}`
resetToken()
})
onBeforeUnmount(() => {
if (connection.value) {
void connection.value.stop()
}
})
</script>
<style scoped>
.im-page,
.im-page div,
.im-page section,
.im-page article,
.im-page p,
.im-page h1,
.im-page span,
.im-page strong {
margin: 0;
}
.im-page {
min-height: 100vh;
padding: 20px 16px 40px;
background:
radial-gradient(circle at top right, rgba(217, 83, 79, 0.14), transparent 32%),
linear-gradient(180deg, #f7efe4 0%, #f4f0ea 100%);
color: #2a221d;
}
.hero-card,
.panel {
width: min(960px, 100%);
margin: 0 auto 16px;
border: 1px solid rgba(71, 50, 38, 0.14);
border-radius: 20px;
background: rgba(255, 250, 244, 0.92);
box-shadow: 0 16px 36px rgba(54, 38, 29, 0.08);
}
.hero-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
padding: 22px;
}
.eyebrow {
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #8d5d48;
}
h1 {
margin-top: 8px;
font-size: 28px;
line-height: 1.2;
}
.hero-text {
margin-top: 10px;
max-width: 640px;
color: #665246;
font-size: 14px;
}
.status-box {
display: flex;
min-width: 180px;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 8px;
}
.status-label,
.connection-id,
.field span,
.panel-title {
color: #7d6557;
font-size: 13px;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 84px;
padding: 8px 14px;
border-radius: 999px;
font-size: 14px;
}
.status-pill.is-disconnected,
.status-pill.is-error {
background: rgba(217, 83, 79, 0.14);
color: #b53d38;
}
.status-pill.is-connecting {
background: rgba(254, 94, 8, 0.14);
color: #d46514;
}
.status-pill.is-connected {
background: rgba(25, 135, 84, 0.14);
color: #198754;
}
.panel {
padding: 18px;
}
.panel-title {
margin-bottom: 14px;
font-weight: 700;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.field input,
.field textarea {
width: 100%;
border: 1px solid rgba(98, 77, 65, 0.18);
border-radius: 14px;
padding: 12px 14px;
background: #fffdfa;
color: #2a221d;
font-size: 14px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.field input:focus,
.field textarea:focus {
border-color: #d46514;
box-shadow: 0 0 0 3px rgba(254, 94, 8, 0.14);
}
.field textarea {
resize: vertical;
min-height: 88px;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.primary-btn,
.ghost-btn {
border: 0;
border-radius: 12px;
padding: 10px 16px;
font-size: 14px;
cursor: pointer;
transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease;
}
.primary-btn {
background: linear-gradient(135deg, #fe5e08 0%, #d9534f 100%);
color: #fff;
}
.ghost-btn {
background: rgba(89, 70, 59, 0.08);
color: #4a3a31;
}
.primary-btn:hover,
.ghost-btn:hover {
transform: translateY(-1px);
}
.primary-btn:disabled,
.ghost-btn:disabled {
cursor: not-allowed;
opacity: 0.45;
transform: none;
}
.message-list,
.log-list {
max-height: 360px;
overflow-y: auto;
border: 1px solid rgba(98, 77, 65, 0.14);
border-radius: 16px;
background: rgba(255, 255, 255, 0.76);
padding: 12px;
margin-bottom: 14px;
}
.message-item {
padding: 12px;
border-radius: 14px;
background: #fff;
border: 1px solid rgba(98, 77, 65, 0.1);
}
.message-item + .message-item {
margin-top: 10px;
}
.message-item.is-self {
border-color: rgba(25, 135, 84, 0.22);
background: rgba(25, 135, 84, 0.06);
}
.message-item.is-system {
border-style: dashed;
background: rgba(254, 94, 8, 0.06);
}
.message-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
font-size: 13px;
color: #7d6557;
}
.message-item p,
.log-item,
.empty-state {
font-size: 14px;
color: #45352d;
word-break: break-word;
}
.log-item + .log-item {
margin-top: 8px;
}
@media (max-width: 768px) {
.hero-card,
.field-grid {
grid-template-columns: 1fr;
}
.status-box {
align-items: flex-start;
}
h1 {
font-size: 24px;
}
}
</style>

View File

@@ -1,19 +1,88 @@
<template>
<div class="page-home"></div>
</template>
<template>
<div class="page-home">
<section class="home-card">
<p class="page-tag">Home</p>
<h1>前端调试入口</h1>
<p class="page-desc">
这里先放一个即时通讯测试页入口方便直接验证 SignalR 连接收发消息和连接状态
</p>
<NuxtLink class="entry-link" to="/home/im">
进入 IM Demo
</NuxtLink>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: layout.default
})
</script>
<style scoped>
.page-home,
.page-home div,
.page-home section,
.page-home p,
.page-home h1 {
margin: 0;
}
.page-home {
min-height: 100vh;
padding: 24px 16px;
background:
linear-gradient(135deg, rgba(254, 94, 8, 0.1), transparent 38%),
linear-gradient(180deg, #f5f1ea 0%, #f8f6f2 100%);
}
.home-card {
width: min(720px, 100%);
margin: 0 auto;
padding: 24px;
border-radius: 24px;
background: rgba(255, 252, 247, 0.95);
border: 1px solid rgba(70, 54, 45, 0.12);
box-shadow: 0 16px 40px rgba(48, 40, 40, 0.08);
}
.page-tag {
font-size: 12px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #8d5d48;
}
h1 {
margin-top: 8px;
font-size: 30px;
color: #2a221d;
}
.page-desc {
margin-top: 12px;
color: #665246;
font-size: 15px;
}
.entry-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 140px;
margin-top: 20px;
padding: 12px 18px;
border-radius: 14px;
background: linear-gradient(135deg, #fe5e08 0%, #d9534f 100%);
color: #fff;
text-decoration: none;
}
.entry-link:hover,
.entry-link:focus,
.entry-link:active {
color: #fff;
background: linear-gradient(135deg, #db5208 0%, #bf4744 100%);
}
</style>