feat: 共享记事本前端 zWhiteBoard
textarea + WebSocket 实时同步、心跳、断线重连的便携式 Vue 组件。 复用 zTools2 的 /api/wb/* 与 /api/ws/wb/* 接口,与 zPDF_package 集成范式一致。 可独立运行或经 mainPage 构建期组件 import 集成(:locale + :boardId props)。
This commit is contained in:
100
src/App.vue
Normal file
100
src/App.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup>
|
||||
// ★ zWhiteBoard 根组件(便携式)。
|
||||
// 嵌入 mainPage 时经 :locale/:boardId props 传入;独立运行时从 URL 读取 boardId。
|
||||
// WS 同步委托给 useWhiteboard 客户端,WbEditor 仅负责渲染与事件转发。
|
||||
import { ref, watch, onMounted, onUnmounted, useTemplateRef } from 'vue'
|
||||
import { useWhiteboard } from './composables/useWhiteboard.js'
|
||||
import { useLocale } from './composables/useLocale.js'
|
||||
import { loadLocaleAsync } from './i18n/index.js'
|
||||
import WbEditor from './components/WbEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
locale: { type: String, default: 'zh-CN' },
|
||||
boardId: { type: String, default: 'share' },
|
||||
})
|
||||
|
||||
const { t } = useLocale()
|
||||
const client = useWhiteboard()
|
||||
|
||||
// 跟随父项目语言开关切换本子项目 i18n 实例的 locale
|
||||
watch(
|
||||
() => props.locale,
|
||||
(val) => {
|
||||
if (val) loadLocaleAsync(val)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const editorRef = useTemplateRef('editorEl')
|
||||
|
||||
onMounted(() => {
|
||||
client.bindVisibility()
|
||||
client.connectBoard(props.boardId, editorRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
client.unbindVisibility()
|
||||
client.disconnect()
|
||||
})
|
||||
|
||||
// 切换白板时重连
|
||||
watch(
|
||||
() => props.boardId,
|
||||
(val) => {
|
||||
if (val) client.connectBoard(val, editorRef.value)
|
||||
}
|
||||
)
|
||||
|
||||
function onInput(text) {
|
||||
client.sendEdit(text)
|
||||
}
|
||||
|
||||
async function onClear() {
|
||||
if (!client.connected.value) return
|
||||
if (!confirm(t('board.clearConfirm'))) return
|
||||
client.sendClear()
|
||||
}
|
||||
|
||||
async function onCopyText() {
|
||||
const text = client.content.value
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function onCopyLink() {
|
||||
const url = `${location.origin}/whiteboard/${encodeURIComponent(props.boardId)}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="zwb-app">
|
||||
<WbEditor
|
||||
:board-id="props.boardId"
|
||||
:model-value="client.content.value"
|
||||
:connected="client.connected.value"
|
||||
:status-text="client.statusText.value"
|
||||
@input="onInput"
|
||||
@clear="onClear"
|
||||
@copy-text="onCopyText"
|
||||
@copy-link="onCopyLink"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* scoped:样式仅作用于本组件,作为组件被嵌入时不污染宿主。
|
||||
全局重置与设计令牌在 src/styles/global.css(独立 app)/ 宿主 :root(嵌入时)。 */
|
||||
.zwb-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
187
src/components/WbEditor.vue
Normal file
187
src/components/WbEditor.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup>
|
||||
// ★ 记事本编辑器:textarea + 工具栏 + 状态条。
|
||||
// 与 WS 同步逻辑解耦 -- content/connected/statusText 由父组件(App.vue)经 props 传入,
|
||||
// 编辑/clear/复制操作经 emit 上报,由父组件委托给 useWhiteboard 客户端。
|
||||
// 这样 WS 逻辑可独立单测,组件仅负责 DOM 渲染与事件转发。
|
||||
import { useLocale } from '../composables/useLocale.js'
|
||||
|
||||
const props = defineProps({
|
||||
boardId: { type: String, default: '' },
|
||||
modelValue: { type: String, default: '' }, // content
|
||||
connected: { type: Boolean, default: false },
|
||||
statusText: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['input', 'clear', 'copy-text', 'copy-link'])
|
||||
|
||||
const { t } = useLocale()
|
||||
|
||||
function onInput(e) {
|
||||
emit('input', e.target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="zwb-editor">
|
||||
<header class="zwb-bar">
|
||||
<div class="zwb-bar-left">
|
||||
<span class="zwb-title">{{ t('app.title') }}</span>
|
||||
<code class="zwb-id" :title="t('board.idLabel')">{{ boardId }}</code>
|
||||
<span class="zwb-online" :class="{ off: !connected }" title="">●</span>
|
||||
</div>
|
||||
<div class="zwb-bar-right">
|
||||
<button type="button" class="zwb-btn" :title="t('board.copyLink')" @click="emit('copy-link')">
|
||||
{{ t('board.copyLink') }}
|
||||
</button>
|
||||
<button type="button" class="zwb-btn" :title="t('board.copyText')" @click="emit('copy-text')">
|
||||
{{ t('board.copyText') }}
|
||||
</button>
|
||||
<button type="button" class="zwb-btn danger" :title="t('board.clear')" @click="emit('clear')">
|
||||
{{ t('board.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="zwb-stage">
|
||||
<textarea
|
||||
class="zwb-textarea"
|
||||
:value="modelValue"
|
||||
:placeholder="t('board.placeholder')"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
@input="onInput"
|
||||
/>
|
||||
<div v-if="statusText" class="zwb-status">{{ t(statusText) }}</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* scoped:样式仅作用于本组件,作为组件被嵌入时不污染宿主。
|
||||
全局重置与设计令牌在 src/styles/global.css(独立 app)/ 宿主 :root(嵌入时)。 */
|
||||
.zwb-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
}
|
||||
.zwb-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6em;
|
||||
padding: 0.5em 0.9em;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.zwb-bar-left,
|
||||
.zwb-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
}
|
||||
.zwb-title {
|
||||
font-weight: 700;
|
||||
font-size: 1.05em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.zwb-id {
|
||||
background: var(--surface-2);
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82em;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace);
|
||||
}
|
||||
.zwb-online {
|
||||
color: var(--success);
|
||||
font-size: 0.7em;
|
||||
}
|
||||
.zwb-online.off {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.zwb-btn {
|
||||
padding: 0.4em 0.9em;
|
||||
font-size: 0.86em;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), border-color var(--transition);
|
||||
}
|
||||
.zwb-btn:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.zwb-btn.danger {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger-weak);
|
||||
}
|
||||
.zwb-btn.danger:hover {
|
||||
background: var(--danger-weak);
|
||||
}
|
||||
.zwb-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.zwb-textarea {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 1em 1.2em;
|
||||
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, Consolas, "JetBrains Mono", monospace);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.zwb-textarea::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.zwb-status {
|
||||
position: absolute;
|
||||
bottom: 0.8em;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.3em 0.9em;
|
||||
border-radius: 16px;
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: var(--shadow);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.zwb-bar {
|
||||
padding: 0.4em 0.5em;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.zwb-id {
|
||||
max-width: 8em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.zwb-btn {
|
||||
padding: 0.45em 0.7em;
|
||||
}
|
||||
.zwb-title {
|
||||
display: none;
|
||||
}
|
||||
.zwb-textarea {
|
||||
font-size: 15px;
|
||||
padding: 0.8em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
14
src/composables/useLocale.js
Normal file
14
src/composables/useLocale.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { computed } from 'vue'
|
||||
import i18n from '../i18n/index.js'
|
||||
|
||||
// ★ 子项目自有的语言辅助 composable。
|
||||
// 关键:直接读模块级 i18n 实例的 global composer,而非 useI18n() 注入。
|
||||
// 原因:useI18n({ useScope: 'global' }) 解析的是「当前运行 app 已安装的全局 i18n」,
|
||||
// 嵌入 mainPage 时那是父项目的实例(不含本子项目文案)-> 文案回退成 key。
|
||||
// 改为直接引用本子项目实例的 .global,无论是否 app.use() 都拿到本子项目的 t。
|
||||
export function useLocale() {
|
||||
const { t, locale } = i18n.global
|
||||
const isZh = computed(() => locale.value === 'zh-CN')
|
||||
const pick = (obj) => (isZh.value ? obj.zh : obj.en)
|
||||
return { t, locale, isZh, pick }
|
||||
}
|
||||
437
src/composables/useWhiteboard.js
Normal file
437
src/composables/useWhiteboard.js
Normal file
@@ -0,0 +1,437 @@
|
||||
// ★ 共享记事本 WS 客户端 + 同步状态机(与 DOM 解耦,便于单元测试)。
|
||||
//
|
||||
// 职责:
|
||||
// - connect(boardId): GET /api/wb/{id} 预取内容确保白板存在,再连 WS;onopen 发 hello + 启心跳
|
||||
// - 断线 2s 固定重连(无指数退避、无最大次数)
|
||||
// - sendEdit(text): 400ms debounce + lastSentText 去重
|
||||
// - sendClear(): 发 clear 帧
|
||||
// - applyRemoteUpdate(newText, editorEl): 最长公共前后缀 + setRangeText 区间替换保光标
|
||||
// - flushSend(): 立即补发未发送编辑
|
||||
// - disconnect(): 停心跳/重连、关 WS、移除页面可见性钩子
|
||||
//
|
||||
// 协议(与 zTools2 whiteboard_controller.py 逐字对齐):
|
||||
// C->S: {type:"hello",client_id} / {type:"edit",content} / {type:"ping"} / {type:"clear"}
|
||||
// S->C: {type:"init",content,version,edit_count} / {type:"pong"} /
|
||||
// {type:"update",content,version,client_id}(已 exclude 发送者)/
|
||||
// {type:"cleared",client_id}(含发送者,用于确认) / {type:"error",msg}
|
||||
//
|
||||
// 同源调用 zTools2 的 /api/wb/* 与 /api/ws/wb/*;fetch 默认 same-origin 带同源 cookie。
|
||||
import { ref } from 'vue'
|
||||
|
||||
const DEBOUNCE_MS = 400
|
||||
const HEARTBEAT_MS = 3000
|
||||
const RECONNECT_MS = 2000
|
||||
|
||||
/**
|
||||
* 计算两段文本的最长公共前后缀,得出变更区间。
|
||||
* 纯函数,便于单测;applyRemoteUpdate 据此用 setRangeText 做区间替换。
|
||||
* @param {string} oldText
|
||||
* @param {string} newText
|
||||
* @returns {{prefix:number, suffixOld:number, suffixNew:number}}
|
||||
*/
|
||||
export function computeDiff(oldText, newText) {
|
||||
if (oldText === newText) {
|
||||
const len = oldText.length
|
||||
return { prefix: len, suffixOld: len, suffixNew: len }
|
||||
}
|
||||
const minLen = Math.min(oldText.length, newText.length)
|
||||
let prefix = 0
|
||||
while (prefix < minLen && oldText[prefix] === newText[prefix]) prefix++
|
||||
|
||||
let suffixOld = oldText.length
|
||||
let suffixNew = newText.length
|
||||
while (suffixOld > prefix && suffixNew > prefix && oldText[suffixOld - 1] === newText[suffixNew - 1]) {
|
||||
suffixOld--
|
||||
suffixNew--
|
||||
}
|
||||
return { prefix, suffixOld, suffixNew }
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建记事本 WS 客户端。依赖注入便于测试(默认用全局 fetch/WebSocket/sessionStorage/location)。
|
||||
* @param {{
|
||||
* fetch?: typeof fetch,
|
||||
* WebSocket?: typeof WebSocket,
|
||||
* sessionStorage?: Storage,
|
||||
* location?: Location,
|
||||
* document?: Document
|
||||
* }} [opts]
|
||||
*/
|
||||
export function createWhiteboardClient(opts = {}) {
|
||||
const fetcher = opts.fetch || (typeof fetch !== 'undefined' ? fetch : null)
|
||||
const Ws = opts.WebSocket || (typeof WebSocket !== 'undefined' ? WebSocket : null)
|
||||
const ss = opts.sessionStorage || (typeof sessionStorage !== 'undefined' ? sessionStorage : null)
|
||||
const loc = opts.location || (typeof location !== 'undefined' ? location : null)
|
||||
const doc = opts.document || (typeof document !== 'undefined' ? document : null)
|
||||
|
||||
// 响应式状态(供组件绑定)
|
||||
const content = ref('')
|
||||
const connected = ref(false)
|
||||
const statusText = ref('')
|
||||
|
||||
// 每个 tab 独立的 client_id(sessionStorage 而非 localStorage,避免多 tab 共享 id)。
|
||||
let clientId = ''
|
||||
if (ss) {
|
||||
clientId = ss.getItem('wb_cid') || ''
|
||||
if (!clientId) {
|
||||
clientId = 'c_' + Math.random().toString(36).slice(2, 10)
|
||||
ss.setItem('wb_cid', clientId)
|
||||
}
|
||||
}
|
||||
|
||||
let boardId = ''
|
||||
let ws = null
|
||||
let heartbeatTimer = null
|
||||
let reconnectTimer = null
|
||||
let debounceTimer = null
|
||||
let lastSentText = ''
|
||||
let suppressInput = false
|
||||
let statusTimer = null
|
||||
let disposed = false
|
||||
// 当前关联的编辑器元素(applyRemoteUpdate 需要 selectionStart/setRangeText)
|
||||
let editorEl = null
|
||||
|
||||
function setStatus(text, isErr) {
|
||||
statusText.value = text
|
||||
if (statusTimer) clearTimeout(statusTimer)
|
||||
statusTimer = setTimeout(() => {
|
||||
if (!disposed) statusText.value = ''
|
||||
}, 1600)
|
||||
}
|
||||
|
||||
function setStickyStatus(text) {
|
||||
if (statusTimer) {
|
||||
clearTimeout(statusTimer)
|
||||
statusTimer = null
|
||||
}
|
||||
statusText.value = text
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === Ws.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify(obj))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 本地编辑 -> debounce -> 发送 ----------
|
||||
function scheduleSend() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
const text = editorEl ? editorEl.value : content.value
|
||||
if (text === lastSentText) return
|
||||
lastSentText = text
|
||||
content.value = text
|
||||
send({ type: 'edit', content: text })
|
||||
}, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function flushSend() {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
const text = editorEl ? editorEl.value : content.value
|
||||
if (text !== lastSentText) {
|
||||
lastSentText = text
|
||||
content.value = text
|
||||
send({ type: 'edit', content: text })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 外部(组件)调用的编辑入口
|
||||
function sendEdit(text) {
|
||||
content.value = text
|
||||
scheduleSend()
|
||||
}
|
||||
|
||||
function sendClear() {
|
||||
if (!connected.value) {
|
||||
setStatus('status.notConnected')
|
||||
return
|
||||
}
|
||||
send({ type: 'clear' })
|
||||
}
|
||||
|
||||
// ---------- 应用远端更新(保留光标) ----------
|
||||
// 用最长公共前后缀算变更区间,仅替换该区间;光标按相对位置调整。
|
||||
// 若本地有未发送编辑(editor.value !== lastSentText),合并后重新 scheduleSend,避免丢弃。
|
||||
function applyRemoteUpdate(newText) {
|
||||
const el = editorEl
|
||||
if (!el) {
|
||||
// 无编辑器引用时仅更新状态值
|
||||
if (newText === content.value) return
|
||||
content.value = newText
|
||||
lastSentText = newText
|
||||
return
|
||||
}
|
||||
const oldText = el.value
|
||||
if (newText === oldText) return
|
||||
|
||||
const selStart = el.selectionStart
|
||||
const selEnd = el.selectionEnd
|
||||
|
||||
const { prefix, suffixOld, suffixNew } = computeDiff(oldText, newText)
|
||||
|
||||
const hadPending = el.value !== lastSentText
|
||||
suppressInput = true
|
||||
el.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, 'end')
|
||||
suppressInput = false
|
||||
lastSentText = el.value
|
||||
content.value = el.value
|
||||
|
||||
// 光标调整:变更区间前不动;后平移差值;区间内移到末尾
|
||||
const delta = suffixNew - suffixOld
|
||||
let newStart = selStart
|
||||
let newEnd = selEnd
|
||||
if (selStart <= prefix) {
|
||||
// 光标在变更前,不变
|
||||
} else if (selStart >= suffixOld) {
|
||||
newStart = selStart + delta
|
||||
newEnd = selEnd + delta
|
||||
} else {
|
||||
newStart = newEnd = suffixNew
|
||||
}
|
||||
try {
|
||||
el.setSelectionRange(newStart, newEnd)
|
||||
} catch {}
|
||||
if (doc && doc.activeElement === el) el.focus()
|
||||
|
||||
if (hadPending) scheduleSend()
|
||||
}
|
||||
|
||||
function isInputSuppressed() {
|
||||
return suppressInput
|
||||
}
|
||||
|
||||
// ---------- WebSocket ----------
|
||||
function wsUrl() {
|
||||
const proto = loc && loc.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = loc ? loc.host : ''
|
||||
return `${proto}//${host}/api/ws/wb/${encodeURIComponent(boardId)}`
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (disposed) return
|
||||
setStickyStatus('status.connecting')
|
||||
try {
|
||||
ws = new Ws(wsUrl())
|
||||
} catch (e) {
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
ws.onopen = () => {
|
||||
connected.value = true
|
||||
setStickyStatus('status.connected')
|
||||
send({ type: 'hello', client_id: clientId })
|
||||
startHeartbeat()
|
||||
}
|
||||
ws.onmessage = (ev) => onMessage(ev.data)
|
||||
ws.onclose = () => onLost('status.disconnected')
|
||||
ws.onerror = () => {}
|
||||
}
|
||||
|
||||
function onMessage(raw) {
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
switch (msg.type) {
|
||||
case 'init':
|
||||
// 连接建立时服务端下发当前文本。若本地有未发送编辑(断线期间输入的),
|
||||
// 不覆盖,而是把本地编辑作为最新版本发上去(last-writer-wins)。
|
||||
// 本地当前文本取编辑器 value(有编辑器时)或 content.value(无编辑器时)。
|
||||
{
|
||||
const localText = editorEl ? editorEl.value : content.value
|
||||
if (localText && localText !== lastSentText) {
|
||||
lastSentText = localText
|
||||
content.value = localText
|
||||
send({ type: 'edit', content: localText })
|
||||
} else {
|
||||
const c = msg.content || ''
|
||||
suppressInput = true
|
||||
if (editorEl) {
|
||||
editorEl.value = c
|
||||
}
|
||||
content.value = c
|
||||
lastSentText = c
|
||||
suppressInput = false
|
||||
if (editorEl && doc && doc.activeElement === editorEl) editorEl.focus()
|
||||
}
|
||||
setStickyStatus('status.synced')
|
||||
}
|
||||
break
|
||||
case 'pong':
|
||||
break
|
||||
case 'update':
|
||||
// 服务端 broadcast 已 exclude 发送者,收到即他人编辑,直接应用。
|
||||
applyRemoteUpdate(msg.content || '')
|
||||
setStatus('status.peerUpdate')
|
||||
break
|
||||
case 'cleared':
|
||||
suppressInput = true
|
||||
if (editorEl) {
|
||||
editorEl.value = ''
|
||||
lastSentText = ''
|
||||
}
|
||||
content.value = ''
|
||||
lastSentText = ''
|
||||
suppressInput = false
|
||||
setStatus(msg.client_id === clientId ? 'status.cleared' : 'status.peerCleared')
|
||||
break
|
||||
case 'error':
|
||||
setStatus(msg.msg || 'status.error')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat()
|
||||
heartbeatTimer = setInterval(() => send({ type: 'ping' }), HEARTBEAT_MS)
|
||||
}
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function onLost(reason) {
|
||||
connected.value = false
|
||||
stopHeartbeat()
|
||||
setStickyStatus(reason ? reason + 'status.reconnecting' : 'status.reconnecting')
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (disposed) return
|
||||
if (reconnectTimer) return
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
}, RECONNECT_MS)
|
||||
}
|
||||
|
||||
// ---------- 页面可见性钩子 ----------
|
||||
function onVisibility() {
|
||||
if (!doc) return
|
||||
if (doc.visibilityState === 'visible') {
|
||||
if (!ws || ws.readyState !== Ws.OPEN) {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
connect()
|
||||
}
|
||||
} else {
|
||||
flushSend()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接到指定白板。先 GET /api/wb/{id} 预取内容(不存在则服务端新建),再连 WS。
|
||||
* @param {string} id boardId
|
||||
* @param {HTMLElement} el 编辑器 textarea 元素(可为 null,纯逻辑模式)
|
||||
*/
|
||||
async function connectBoard(id, el) {
|
||||
boardId = id
|
||||
editorEl = el || null
|
||||
// 切换白板前清理旧连接
|
||||
teardownSocket()
|
||||
if (fetcher) {
|
||||
try {
|
||||
const res = await fetcher(`/api/wb/${encodeURIComponent(boardId)}`, {
|
||||
headers: { accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (res.ok) {
|
||||
const body = await res.json()
|
||||
if (body && typeof body.content === 'string') {
|
||||
if (editorEl) editorEl.value = body.content
|
||||
content.value = body.content
|
||||
lastSentText = body.content
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 预取失败仍尝试连 WS
|
||||
}
|
||||
}
|
||||
connect()
|
||||
}
|
||||
|
||||
function teardownSocket() {
|
||||
stopHeartbeat()
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
if (ws) {
|
||||
try {
|
||||
ws.onclose = null
|
||||
ws.onerror = null
|
||||
ws.onmessage = null
|
||||
ws.onopen = null
|
||||
ws.close()
|
||||
} catch {}
|
||||
ws = null
|
||||
}
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
disposed = true
|
||||
flushSend()
|
||||
teardownSocket()
|
||||
if (statusTimer) {
|
||||
clearTimeout(statusTimer)
|
||||
statusTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定/解绑页面可见性钩子(由组件 onMounted/onUnmounted 调用)
|
||||
function bindVisibility() {
|
||||
if (doc) doc.addEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
function unbindVisibility() {
|
||||
if (doc) doc.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
connected,
|
||||
statusText,
|
||||
clientId,
|
||||
connectBoard,
|
||||
disconnect,
|
||||
sendEdit,
|
||||
sendClear,
|
||||
flushSend,
|
||||
applyRemoteUpdate,
|
||||
scheduleSend,
|
||||
isInputSuppressed,
|
||||
bindVisibility,
|
||||
unbindVisibility,
|
||||
// 暴露内部状态便于测试断言
|
||||
_getLastSentText: () => lastSentText,
|
||||
_getSuppressInput: () => suppressInput,
|
||||
_setEditor: (el) => {
|
||||
editorEl = el
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件用便捷工厂:返回带默认依赖的客户端实例(composable 风格命名)。
|
||||
* 测试用 createWhiteboardClient({ fetch, WebSocket, ... }) 注入 mock。
|
||||
*/
|
||||
export function useWhiteboard() {
|
||||
return createWhiteboardClient()
|
||||
}
|
||||
46
src/i18n/index.js
Normal file
46
src/i18n/index.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zhCN from './locales/zh-CN.js'
|
||||
|
||||
// ★ zWhiteBoard 独立 i18n 实例。中文同步注入(默认语言);英文动态 import() 懒加载。
|
||||
// 嵌入 mainPage 时,父项目把当前语言经 :locale prop 传入 App.vue,watch 同步到本实例,
|
||||
// 从而跟随父项目语言开关切换(不共享父的 messages,仅同步 locale 值,文案来自本子项目自有 locale)。
|
||||
// 注:组件内不用 useI18n() 注入(会解析到宿主实例),改用 ./composables/useLocale.js 直接读本实例。
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN',
|
||||
fallbackLocale: 'zh-CN',
|
||||
messages: {
|
||||
'zh-CN': zhCN
|
||||
}
|
||||
})
|
||||
|
||||
const loadedLocales = new Set(['zh-CN'])
|
||||
|
||||
/**
|
||||
* 懒加载并切换到目标语言。首次切换某语言时动态 import 其语言包并 mergeLocaleMessage。
|
||||
* @param {string} locale
|
||||
*/
|
||||
export async function loadLocaleAsync(locale) {
|
||||
if (i18n.global.locale.value === locale) return
|
||||
if (loadedLocales.has(locale)) {
|
||||
i18n.global.locale.value = locale
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
return
|
||||
}
|
||||
|
||||
let messages
|
||||
if (locale === 'en') {
|
||||
messages = (await import('./locales/en.js')).default
|
||||
} else {
|
||||
i18n.global.locale.value = 'zh-CN'
|
||||
document.documentElement.setAttribute('lang', 'zh-CN')
|
||||
return
|
||||
}
|
||||
|
||||
i18n.global.mergeLocaleMessage(locale, messages)
|
||||
loadedLocales.add(locale)
|
||||
i18n.global.locale.value = locale
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
}
|
||||
|
||||
export default i18n
|
||||
31
src/i18n/locales/en.js
Normal file
31
src/i18n/locales/en.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// English locale -- loaded via dynamic import(), bundled into a separate chunk.
|
||||
export default {
|
||||
app: {
|
||||
title: 'Notepad'
|
||||
},
|
||||
board: {
|
||||
idLabel: 'Notepad ID',
|
||||
switch: 'Switch',
|
||||
placeholder: 'Type here, everyone sees your edits in real time…',
|
||||
clear: 'Clear',
|
||||
clearConfirm: 'Clear all content? Everyone\'s content will be removed.',
|
||||
copyText: 'Copy Text',
|
||||
copyLink: 'Copy Link',
|
||||
copied: 'All text copied',
|
||||
copiedLink: 'Link copied',
|
||||
copyFailed: 'Copy failed',
|
||||
emptyText: 'Content is empty'
|
||||
},
|
||||
status: {
|
||||
connecting: 'Connecting…',
|
||||
connected: 'Connected',
|
||||
synced: 'Synced',
|
||||
peerUpdate: 'Peer updated',
|
||||
reconnecting: 'Reconnecting…',
|
||||
disconnected: 'Connection closed',
|
||||
cleared: 'Cleared',
|
||||
peerCleared: 'Peer cleared content',
|
||||
notConnected: 'Not connected',
|
||||
error: 'Error'
|
||||
}
|
||||
}
|
||||
31
src/i18n/locales/zh-CN.js
Normal file
31
src/i18n/locales/zh-CN.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// 中文语言包(默认语言,同步注入主 chunk,随首屏下发)
|
||||
export default {
|
||||
app: {
|
||||
title: '记事本'
|
||||
},
|
||||
board: {
|
||||
idLabel: '记事本 ID',
|
||||
switch: '切换',
|
||||
placeholder: '在此输入文本,所有人会实时看到你的编辑…',
|
||||
clear: '清空',
|
||||
clearConfirm: '确定清空全部内容?所有人的内容都会被清除。',
|
||||
copyText: '复制文本',
|
||||
copyLink: '复制链接',
|
||||
copied: '已复制全部文本',
|
||||
copiedLink: '链接已复制',
|
||||
copyFailed: '复制失败',
|
||||
emptyText: '内容为空'
|
||||
},
|
||||
status: {
|
||||
connecting: '连接中…',
|
||||
connected: '已连接',
|
||||
synced: '已同步',
|
||||
peerUpdate: '对方有更新',
|
||||
reconnecting: '重连中…',
|
||||
disconnected: '连接已关闭',
|
||||
cleared: '已清空',
|
||||
peerCleared: '对方清空了内容',
|
||||
notConnected: '未连接',
|
||||
error: '错误'
|
||||
}
|
||||
}
|
||||
6
src/main.js
Normal file
6
src/main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import i18n from './i18n/index.js'
|
||||
import './styles/global.css'
|
||||
|
||||
createApp(App).use(i18n).mount('#app')
|
||||
36
src/styles/global.css
Normal file
36
src/styles/global.css
Normal file
@@ -0,0 +1,36 @@
|
||||
/* 全局重置 + 设计令牌 -- 仅独立 app(main.js)加载。
|
||||
作为组件被嵌入时由宿主(mainPage)提供 :root 令牌,本文件的重置仍安全(与宿主一致)。
|
||||
令牌值与 mainPage src/styles/variables.css 对齐,保证独立与嵌入视觉一致。 */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Microsoft YaHei", sans-serif;
|
||||
background: var(--bg, #ffffff);
|
||||
color: var(--text-primary, #1f2329);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #f7f8fa;
|
||||
--surface-2: #f0f2f5;
|
||||
--text-primary: #1f2329;
|
||||
--text-secondary: #6b7280;
|
||||
--text-tertiary: #9ca3af;
|
||||
--accent: #4f46e5;
|
||||
--accent-weak: #eef2ff;
|
||||
--accent-hover: #4338ca;
|
||||
--border: #eceef1;
|
||||
--border-strong: #e0e2e6;
|
||||
--success: #2e7d32;
|
||||
--success-weak: #e8f5e9;
|
||||
--danger: #c62828;
|
||||
--danger-weak: #fbe9e7;
|
||||
--warn: #e65100;
|
||||
--warn-weak: #fff3e0;
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--shadow: 0 1px 3px rgba(17, 24, 39, 0.06), 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||
--transition: 0.18s ease;
|
||||
}
|
||||
Reference in New Issue
Block a user