tiny-vue/examples/sites/src/views/components-doc/common.vue

578 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- 一个组件的文档: 描述md + demos + apis -->
<ComponentHeader
:current-json="state.currJson"
:md-string="state.mdString"
class="flex-horizontal flex-horizontal--header"
>
<template #header-right>
<slot name="header-right" />
</template>
</ComponentHeader>
<div
class="docs-content"
:class="{ 'docs-on-robot-show': show }"
id="doc-layout-scroller"
ref="scrollRef"
@scroll="onDocLayoutScroll"
>
<div class="ti-rel cmp-container">
<div class="flex-horizontal docs-content-main">
<div class="docs-tabs-wrap">
<tiny-tabs v-model="state.activeTab" ref="demoTabs" class="docs-content-tabs" @click="onTabsClick">
<tiny-tab-item :title="i18nByKey('demos')" name="demos">
<!-- demos列表 -->
<template v-if="state.currJson?.demos?.length">
<div class="all-demos-container" id="all-demos-container">
<div v-if="apiModeState.demoMode === 'default'" id="demo-list" class="ti-f-c ti-f-wrap demo-list">
<demo-box
v-for="demo in state.currJson.demos"
:key="demo.name"
:observer="state.observer"
:isIntersecting="demo.isIntersecting"
:demo="demo"
:curr-demo-id="state.currDemoId"
class="mb32"
@mounted="demoMounted"
@get-iframe-demo="getIframeDemo"
/>
</div>
<div v-else>
<demo-box
v-if="state.singleDemo"
:isIntersecting="true"
:key="state.singleDemo.name"
:demo="state.singleDemo"
@get-iframe-demo="getIframeDemo"
/>
</div>
</div>
</template>
<!-- 贡献者 -->
<component-contributor :componentId="state.cmpId" />
</tiny-tab-item>
<tiny-tab-item
v-if="state.showApiTab && !isRunningTest && state.currJson.apis?.length"
title="API"
name="api"
>
<!-- api文档 -->
<api-docs
ref="apiDocsRef"
:current-json="state.currJson"
:chart-code="state.chartCode"
:table-data="state.tableData"
id="API"
class="all-api-container"
@jump-to-demo="jumpToDemo"
></api-docs>
</tiny-tab-item>
</tiny-tabs>
<slot name="main-right" />
</div>
<!-- demo与api目录锚点 -->
<aside-anchor
:active-tab="state.activeTab"
:current-json="state.currJson"
:anchor-affix="state.anchorAffix"
:api-types="state.currApiTypes"
:lang-key="state.langKey"
:key="anchorRefreshKey"
@link-click="handleAnchorClick"
></aside-anchor>
</div>
<div v-if="state.currJson.owner" class="ti-abs ti-right24 ti-top24" @click="copyText(state.currJson.owner)">
{{ i18nByKey('doc-owner') }} : {{ state.currJson.owner }}
</div>
</div>
<div id="footer"></div>
</div>
<robotChat v-if="show"></robotChat>
</template>
<script setup lang="ts">
import { reactive, computed, watch, onMounted, nextTick, ref } from 'vue'
import { useRoute } from 'vue-router'
import { TinyTabs, TinyTabItem } from '@opentiny/vue'
import { debounce } from '@opentiny/utils'
import { i18nByKey, getWord, $clone, useApiMode } from '@/tools'
import { router } from '@/router.js'
import { getWebdocPath } from './cmp-config'
import DemoBox from './components/demo.vue'
import AsideAnchor from './components/anchor.vue'
import ComponentHeader from './components/header.vue'
import ComponentContributor from './components/contributor.vue'
import ApiDocs from './components/api-docs.vue'
import useTasksFinish from './composition/useTasksFinish'
import robotChat from './tiny-robot-chat.vue'
const props = defineProps({ loadData: {}, appMode: {}, demoKey: {} })
const emit = defineEmits(['single-demo-change', 'load-page'])
defineOptions({
name: 'CmpPageVue'
})
const scrollRef = ref()
const { apiModeState } = useApiMode()
const isRunningTest = localStorage.getItem('tiny-e2e-test') === 'true'
const anchorRefreshKey = ref(0)
const route = useRoute()
const state = reactive({
langKey: getWord('zh-CN', 'en-US'),
cmpId: '',
observer: null,
currJson: { column: 1, demos: [], apis: [], types: {} },
mdString: '',
currDemoId: '',
iframeUrl: '',
anchorAffix: true,
// 单demo显示时
singleDemo: null,
activeTab: route.hash === '#api' ? 'api' : 'demos',
tableData: {},
currApiTypes: [],
showApiTab: computed(() => state.currApiTypes.length),
chartCode: false
})
watch(
() => state.singleDemo,
(val) => {
emit('single-demo-change', val)
}
)
watch(
() => router.currentRoute.value.params.cmpId,
(cmpId) => {
if (!cmpId) {
state.currJson = {}
} else {
loadPage()
// 切换组件时tabs激活页变成demos
state.activeTab = 'demos'
// 每次切换组件都需要让锚点组件重新刷新
anchorRefreshKey.value++
}
}
)
watch(
() => apiModeState.demoMode,
(value) => {
if (value) {
scrollToLayoutTop()
}
}
)
onMounted(() => {
loadPage()
// 加载公共尾部
const common = new window.TDCommon(['#footer'], { allowDarkTheme: true })
common.renderFooter()
setScrollListener()
})
const getIframeDemo = (demo) => {
state.singleDemo = demo
state.currDemoId = demo.demoId
}
// 封装api表格数据
const parseApiData = () => {
if (!state.currJson.apis?.length) {
return {}
}
const tableData = {}
const apis = state.currJson.apis || []
for (const apiGroup of apis) {
const apiDisplay = {}
for (const apiType of Object.keys(apiGroup)) {
if (Array.isArray(apiGroup[apiType]) && apiGroup[apiType].length) {
const apiArr = apiGroup[apiType].map((i) => {
const { name, type, defaultValue, desc, demoId, typeAnchorName, linkTo, meta, versionTipOption } = i
const item = {
name,
type,
defaultValue: defaultValue || '--',
desc: desc[state.langKey],
demoId,
meta,
versionTipOption,
typeAnchorName: '',
linkTo
}
if (typeAnchorName) {
item.typeAnchorName = `${typeAnchorName?.includes('#') ? '' : '#'}${typeAnchorName}`
item.code = state.currJson.types[i.typeAnchorName]?.code || ''
item.depTypes = state.currJson.types[i.typeAnchorName]?.depTypes || []
}
return item
})
apiDisplay[apiType] = apiArr.sort((a, b) => a.name.localeCompare(b.name))
state.currApiTypes = Array.from(new Set([...state.currApiTypes, apiType]))
}
}
tableData[apiGroup.name] = apiDisplay
}
state.tableData = tableData
}
const apiDocsRef = ref()
const jumpToApi = (hash) => {
state.activeTab = 'api'
apiDocsRef.value.jumpToApi(hash)
}
// 页面加载/点击api中的链接根据hash滚动。
const scrollByHash = (hash) => {
setTimeout(() => {
if (!hash) {
scrollRef.value.scrollTo({
top: 0,
left: 0
})
} else if (state.currJson.types[hash]) {
jumpToApi(hash)
} else {
let scrollTarget
try {
// 用户打开官网有时候会带一些特殊字符的hashtry catch一下防止js报错
scrollTarget = document.querySelector(`#${hash}`)
} catch (err) {}
if (scrollTarget && !isRunningTest) {
// doc-layout-scroller(滚动) > tabs > tab-content(relative) 造成 scrollTarget.offsetTop 是相对于 tab-content的距离
// 所以滚动需要修正 tab-title的占位高度才行
scrollRef.value.scrollTo({
top: scrollTarget.offsetTop,
left: 0,
behavior: 'smooth'
})
}
}
}, 0)
}
// 在singleDemo情况时才需要滚动示例区域到顶
const scrollToLayoutTop = () => {
let hash = router.currentRoute.value.hash?.slice(1)
if (hash !== 'api') {
setTimeout(() => {
scrollRef.value.scrollTo({
top: 0,
left: 0,
behavior: 'smooth'
})
}, 0)
}
}
let finishMountTask
const demoMounted = () => {
finishMountTask()
}
const loadPage = () => {
const lang = getWord('cn', 'en')
state.cmpId = router.currentRoute.value.params.cmpId
state.chartCode = getWebdocPath(state.cmpId) === 'chart'
// 将请求合并起来,这样页面更新一次,页面刷新的时机就固定了
props.loadData({ cmpId: state.cmpId, lang }).then(({ mdString, apisJson, demosJson }) => {
// 1、加载顶部md
state.mdString = mdString
// plus隐藏头部集合
const hideTabHeader = ['interfaces', 'types', 'classes'].includes(state.cmpId)
if (demosJson && !hideTabHeader) {
// 默认设置每个实例demo都不和视图相交
demosJson.demos?.forEach((item) => {
item.isIntersecting = false
})
state.currJson = {
...demosJson,
demos: $clone(demosJson.demos || []), // 克隆一下,避免保存上次的isOpen
column: demosJson.column || '1' // columns可能为空
}
} else {
state.activeTab = 'api'
// 隐藏tab的头部
if (hideTabHeader) {
document.querySelector('.tiny-tabs__header').style.display = 'none'
}
}
const { finishTask, waitTasks: allDemoMounted } = useTasksFinish(state.currJson.demos.length)
finishMountTask = finishTask
if (apisJson) {
state.currJson.apis = apisJson.apis.map((item) => {
Object.keys(item).forEach((key) => {
const apiItem = item[key]
if (Array.isArray(apiItem)) {
item[key] = apiItem
.filter((i) => !i.mode || i.mode.includes(props.appMode))
.map((filterItem) => ({ ...filterItem, demoId: filterItem[props.demoKey] }))
}
})
return item
})
state.currJson.types =
apisJson.types?.reduce((res, cur) => {
res[cur.name] = cur
return res
}, {}) || {}
parseApiData()
}
let hash = router.currentRoute.value.hash?.slice(1)
// 如果有hash,取hash的demo, 没有hash, 取第1项
state.singleDemo = state.currJson.demos.find((d) => d.demoId === hash)
if (!state.singleDemo) {
state.singleDemo = state.currJson.demos[0]
}
emit('load-page', state.singleDemo)
// F5刷新加载时跳到当前示例
// 应当在所有demo渲染完毕后在滚动否则滚动完位置后demo渲染会使滚动位置错位
return allDemoMounted().then(() => {
scrollByHash(hash)
})
})
}
const onDocLayoutScroll = debounce(100, false, () => {
const docLayout = scrollRef.value
const { scrollTop, scrollHeight, clientHeight: layoutHeight } = docLayout
const headerHeight = document.querySelector('.docs-header')?.clientHeight || 0
const footerHeight = document.getElementById('footer')?.clientHeight || 0
const anchorHeight = document.querySelector('#anchor')?.clientHeight || 0
const remainHeight = scrollHeight - scrollTop - layoutHeight // doc-layout-scroller视口下隐藏的部分高度
state.anchorAffix = layoutHeight - headerHeight - (footerHeight - remainHeight) > anchorHeight
})
const setScrollListener = () => {
nextTick(() => {
const docLayout = scrollRef.value
const options = {
root: docLayout,
threshold: 0.2
}
const callback = (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 当demo示例与视图相交才加载对应的vue组件
const demoId = entry.target.id
state.currJson.demos.forEach((item) => {
if (item.demoId === demoId) {
item.isIntersecting = true
}
})
}
})
}
state.observer = new IntersectionObserver(callback, options)
})
}
const copyText = (text) => {
navigator.clipboard.writeText(text)
}
const onTabsClick = (data) => {
if (data.name) {
router.push(`#${data.name}`)
scrollToLayoutTop()
}
}
// 点击 api区域的 name列时
const jumpToDemo = (demoId) => {
state.activeTab = 'demos'
if (demoId.startsWith('chart') || demoId.startsWith('grid')) {
router.push(demoId)
} else {
if (demoId) {
router.push(`#${demoId}`)
}
if (apiModeState.demoMode === 'single') {
state.singleDemo = state.currJson.demos.find((d) => d.demoId === demoId)
}
scrollByHash(demoId)
}
}
// 目录列表上的点击
const handleAnchorClick = (e, data) => {
if (apiModeState.demoMode === 'single' && data.link.startsWith('#')) {
e.preventDefault()
const hash = data.link.slice(1)
const singleDemo = state.currJson.demos.find((d) => d.demoId === hash)
// 单示例模式下如果没有匹配到锚点对应的示例则这不加载示例直接跳转锚点id
if (singleDemo) {
state.singleDemo = singleDemo
scrollToLayoutTop()
}
router.push(data.link)
} else if (apiModeState.demoMode === 'default' && data.link.startsWith('#')) {
// 多示例模式自动会切到相应的位置。只需要记录singleDemo就好了
e.preventDefault()
const hash = data.link.slice(1)
state.currDemoId = hash
state.singleDemo = state.currJson.demos.find((d) => d.demoId === hash)
router.push(data.link)
scrollByHash(hash)
}
}
defineExpose({ loadPage })
const show = ref(false)
onMounted(() => {
// tiny-robot 通过路由参数存在 mcp-robot, 则弹出对话容器
const hasRobot = router.currentRoute.value.hash === '#grid-ai-agent'
show.value = !!hasRobot
document.body.classList.toggle('docs-on-robot-show', show.value)
})
</script>
<style lang="less" scoped>
:global(.docs-on-robot-show .docs-content) {
margin-right: 480px;
}
.docs-content {
flex: 1;
overflow: hidden auto;
margin-top: 16px;
transition: all ease-in-out 0.3s;
.docs-tabs-wrap {
width: 100%;
flex: 1;
display: flex;
justify-content: center;
padding: 0 40px;
}
.docs-content-tabs {
--tv-Tabs-heigh: 48px;
--tv-Tabs-item-font-size: 18px;
--tv-Tabs-header-font-active-text-color: #2f5bea;
--tv-Tabs-item-active-border-color: #2f5bea;
flex: 1;
transition: all ease-in-out 0.3s;
min-width: var(--layout-content-main-min-width);
max-width: var(--layout-content-main-max-width);
:deep(> .tiny-tabs__header) {
position: sticky;
top: 0;
z-index: var(--docs-tabs-header-zindex);
background-color: var(--docs-color-bg);
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
display: block;
width: 100%;
height: 16px;
background: linear-gradient(to bottom, var(--docs-color-bg), transparent);
transform: translateY(100%);
}
.tiny-tabs__item__title {
font-weight: bold;
}
}
& > :deep(.tiny-tabs__content) {
// 不能影响到tabs组件的样式
margin: 0;
overflow: visible;
}
}
}
.one-demo-col2 {
display: grid;
gap: 16px;
grid-template-columns: minmax(0px, 1fr) minmax(0px, 1fr);
align-items: flex-start;
> div {
display: grid;
gap: 16px;
grid-template-columns: 100%;
}
}
.all-demos-container,
.all-api-container {
flex: 1;
padding: 32px 0;
scroll-behavior: smooth;
}
.all-api-container {
padding-top: 12px;
}
.flex-horizontal {
display: flex;
justify-content: space-between;
align-items: flex-start;
column-gap: 16px;
}
.flex-horizontal--header {
overflow: auto;
}
.cmp-container {
p {
font-size: 16px;
line-height: 1.7em;
margin: 12px 0;
}
}
@media (max-width: 1279px) {
.catalog,
.docs-header-spacer {
display: none;
}
.cmp-container {
padding-right: 0;
}
}
@media (max-width: 767px) {
.one-demo-col2 {
grid-template-columns: 100%;
}
}
</style>