feat: 共享记事本前端 zWhiteBoard

textarea + WebSocket 实时同步、心跳、断线重连的便携式 Vue 组件。
复用 zTools2 的 /api/wb/* 与 /api/ws/wb/* 接口,与 zPDF_package 集成范式一致。
可独立运行或经 mainPage 构建期组件 import 集成(:locale + :boardId props)。
This commit is contained in:
2026-07-28 10:32:41 +08:00
commit d7d7450296
17 changed files with 4081 additions and 0 deletions

100
src/App.vue Normal file
View 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
View 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>

View 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 }
}

View File

@@ -0,0 +1,437 @@
// ★ 共享记事本 WS 客户端 + 同步状态机(与 DOM 解耦,便于单元测试)。
//
// 职责:
// - connect(boardId): GET /api/wb/{id} 预取内容确保白板存在,再连 WSonopen 发 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_idsessionStorage 而非 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
View File

@@ -0,0 +1,46 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN.js'
// ★ zWhiteBoard 独立 i18n 实例。中文同步注入(默认语言);英文动态 import() 懒加载。
// 嵌入 mainPage 时,父项目把当前语言经 :locale prop 传入 App.vuewatch 同步到本实例,
// 从而跟随父项目语言开关切换(不共享父的 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
View 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
View 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
View 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
View File

@@ -0,0 +1,36 @@
/* 全局重置 + 设计令牌 -- 仅独立 appmain.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;
}