feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,220 @@
<script setup lang="ts">
// 定义链接节点
import type { LinkNodeProps } from '../../types/component-props'
import { computed, inject, useAttrs } from 'vue'
import { hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip'
import { sanitizeAttrs } from '../../utils/htmlRenderer'
import { customComponentsRevision, getCustomNodeComponents } from '../../utils/nodeComponents'
import EmphasisNode from '../EmphasisNode/EmphasisNode.vue'
import HtmlInlineNode from '../HtmlInlineNode'
import ImageNode from '../ImageNode'
import InlineCodeNode from '../InlineCodeNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import TextNode from '../TextNode'
// 接收props — 把动画/颜色相关配置暴露为props,并通过CSS变量注入样式
const props = withDefaults(defineProps<LinkNodeProps>(), {
showTooltip: true,
})
const inheritedShowTooltips = inject<{ value?: boolean } | undefined>('markstreamShowTooltips', undefined)
const tooltipEnabled = computed(() => {
const inherited = inheritedShowTooltips?.value
if (typeof inherited === 'boolean')
return inherited
return props.showTooltip
})
const cssVars = computed(() => {
const bottom = props.underlineBottom !== undefined
? (typeof props.underlineBottom === 'number' ? `${props.underlineBottom}px` : String(props.underlineBottom))
: '-3px'
const activeOpacity = props.animationOpacity ?? 0.35
const restingOpacity = Math.max(0.12, Math.min(activeOpacity * 0.5, activeOpacity))
const vars: Record<string, string> = {
'--underline-height': `${props.underlineHeight ?? 2}px`,
'--underline-bottom': bottom,
'--underline-opacity': String(activeOpacity),
'--underline-rest-opacity': String(restingOpacity),
'--underline-duration': `${props.animationDuration ?? 1.6}s`,
'--underline-timing': props.animationTiming ?? 'ease-in-out',
'--underline-iteration': typeof props.animationIteration === 'number' ? String(props.animationIteration) : (props.animationIteration ?? 'infinite'),
}
if (props.color)
vars['--link-color'] = props.color
return vars
})
// Available node components for child rendering
const nodeComponents = {
text: TextNode,
strong: StrongNode,
strikethrough: StrikethroughNode,
emphasis: EmphasisNode,
image: ImageNode,
html_inline: HtmlInlineNode,
inline_code: InlineCodeNode,
}
const customComponents = computed(() => {
void customComponentsRevision.value
return getCustomNodeComponents(props.customId)
})
// 获取子节点组件,优先使用用户自定义组件
function getChildComponent(child: any) {
const customComponent = customComponents.value[child.type]
if (customComponent)
return customComponent
return nodeComponents[child.type] || null
}
// forward any non-prop attributes (e.g. custom-id) to the rendered element
const attrs = useAttrs()
const nodeAttrs = computed(() => {
const rawAttrs = (props.node as any)?.attrs
if (!rawAttrs || typeof rawAttrs !== 'object')
return {}
const normalized: Record<string, string> = {}
if (Array.isArray(rawAttrs)) {
for (const attr of rawAttrs) {
if (!Array.isArray(attr) || !attr[0])
continue
normalized[String(attr[0])] = String(attr[1] ?? '')
}
}
else {
for (const [key, value] of Object.entries(rawAttrs)) {
if (!key || value == null || value === false)
continue
normalized[key] = value === true ? '' : String(value)
}
}
return sanitizeAttrs(normalized)
})
const anchorAttrs = computed(() => {
const merged = {
...(attrs as Record<string, unknown>),
...nodeAttrs.value,
} as Record<string, unknown>
// `title` is controlled by `showTooltip` behavior and should not be overridden.
delete merged.title
return merged
})
// Tooltip handlers using singleton tooltip
function onAnchorEnter(e: Event) {
if (!tooltipEnabled.value)
return
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
// show the link href in tooltip; fall back to title/text if href missing
const txt = props.node?.title || props.node?.href || props.node?.text || ''
showTooltipForAnchor(e.currentTarget as HTMLElement, txt, 'top', false, origin)
}
function onAnchorLeave() {
if (!tooltipEnabled.value)
return
hideTooltip()
}
const title = computed(() => {
const rawTitle = props.node?.title
if (typeof rawTitle === 'string' && rawTitle.trim().length > 0)
return rawTitle
return String(props.node?.href ?? '')
})
</script>
<template>
<a
v-if="!node.loading"
class="link-node"
:href="node.href"
:title="tooltipEnabled ? '' : title"
:aria-label="`Link: ${title}`"
:aria-hidden="node.loading ? 'true' : 'false'"
target="_blank"
rel="noopener noreferrer"
v-bind="anchorAttrs"
:style="cssVars"
@mouseenter="(e) => onAnchorEnter(e)"
@mouseleave="onAnchorLeave"
>
<component
:is="getChildComponent(child)"
v-for="(child, index) in node.children"
:key="`${indexKey || 'emphasis'}-${index}`"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'link-text'}-${index}`"
/>
</a>
<span v-else class="link-loading inline-flex items-baseline gap-1.5" :aria-hidden="!node.loading ? 'true' : 'false'" v-bind="attrs" :style="cssVars">
<span class="link-text-wrapper relative inline-flex">
<span class="leading-[normal] link-text">
<TextNode
class="leading-[normal] link-text"
:node="{ type: 'text', content: String(node.text ?? ''), raw: String(node.text ?? '') }"
:index-key="`${indexKey || 'link-text'}-loading`"
/>
</span>
<span class="link-loading-indicator" aria-hidden="true" />
</span>
</span>
</template>
<style scoped>
.link-node {
color: var(--link-color);
text-decoration: none;
}
.link-node:hover {
text-decoration: underline;
text-underline-offset: .2rem
}
.link-loading .link-text-wrapper {
position: relative;
}
.link-loading {
color: var(--link-color);
}
.link-loading .link-text {
position: relative;
z-index: 2;
}
.link-loading-indicator {
position: absolute;
left: 0;
right: 0;
height: var(--underline-height, 2px);
bottom: var(--underline-bottom, -3px);
background: currentColor;
border-radius: 999px;
will-change: opacity;
opacity: var(--underline-rest-opacity, 0.18);
animation: underlinePulse var(--underline-duration, 1.6s) var(--underline-timing, ease-in-out) var(--underline-iteration, infinite);
}
@keyframes underlinePulse {
0%, 100% { opacity: var(--underline-rest-opacity, 0.18); }
50% { opacity: var(--underline-opacity, 0.35); }
}
@media (prefers-reduced-motion: reduce) {
.link-loading-indicator {
animation: none;
opacity: var(--underline-rest-opacity, 0.18);
}
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import LinkNode from './LinkNode.vue'
LinkNode.install = (app: App) => {
app.component(LinkNode.__name as string, LinkNode)
}
export default LinkNode