Skip to content

Commit

Permalink
feat(console): setup disposable client extension
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Jan 24, 2022
1 parent 282f5a3 commit ece4dcd
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 89 deletions.
157 changes: 95 additions & 62 deletions plugins/frontend/console/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import { ref, reactive, h, Component, markRaw, defineComponent, resolveComponent } from 'vue'
import { createWebHistory, createRouter } from 'vue-router'
import { DataSource, ClientConfig, Sources } from '@koishijs/plugin-console'
import { ref, reactive, h, markRaw, defineComponent, resolveComponent, watch, Ref } from 'vue'
import { createWebHistory, createRouter, START_LOCATION, RouteRecordNormalized } from 'vue-router'
import { ClientConfig, Sources } from '@koishijs/plugin-console'
import { EChartsOption } from 'echarts'
import Home from './layout/home.vue'
import { Disposable, Extension, PageOptions, Store, ViewOptions } from '~/client'

// data api

declare const KOISHI_CONFIG: ClientConfig

export const config = KOISHI_CONFIG

type Store = {
[K in keyof Sources]?: Sources[K] extends DataSource<infer T> ? T : never
}

export const store = reactive<Store>({})

const socket = ref<WebSocket>(null)
Expand Down Expand Up @@ -77,77 +73,114 @@ export async function connect(endpoint: string) {

// layout api

export interface ViewOptions {
id?: string
type: string
order?: number
component: Component
}

export const views = reactive<Record<string, ViewOptions[]>>({})

export function registerView(options: ViewOptions) {
options.order ??= 0
const list = views[options.type] ||= []
const index = list.findIndex(a => a.order < options.order)
markRaw(options.component)
if (index >= 0) {
list.splice(index, 0, options)
} else {
list.push(options)
export const router = createRouter({
history: createWebHistory(config.uiPath),
linkActiveClass: 'active',
routes: [],
})

export const extensions = reactive<Record<string, Context>>({})

export const routes: Ref<RouteRecordNormalized[]> = ref([])

class Context {
disposables: Disposable[] = []

registerView(options: ViewOptions) {
options.order ??= 0
const list = views[options.type] ||= []
const index = list.findIndex(a => a.order < options.order)
markRaw(options.component)
if (index >= 0) {
list.splice(index, 0, options)
} else {
list.push(options)
}
this.disposables.push(() => {
const index = list.findIndex(item => item === options)
if (index >= 0) list.splice(index, 1)
})
}
}

interface RouteMetaExtension {
icon?: string
order?: number
fields?: readonly (keyof Sources)[]
position?: 'top' | 'bottom' | 'hidden'
}
registerPage(options: PageOptions) {
const { path, name, component, ...rest } = options
const dispose = router.addRoute({
path,
name,
component,
meta: {
order: 0,
position: 'top',
fields: [],
...rest,
},
})
this.disposables.push(dispose)
routes.value = router.getRoutes()
}

install(extension: Extension) {
extension(this)
}

export interface PageOptions extends RouteMetaExtension {
path: string
name: string
component: Component
dispose() {
this.disposables.forEach(dispose => dispose())
}
}

declare module 'vue-router' {
interface RouteMeta extends RouteMetaExtension {}
export function defineExtension(callback: Extension) {
return callback
}

export const router = createRouter({
history: createWebHistory(KOISHI_CONFIG.uiPath),
linkActiveClass: 'active',
routes: [],
const initTask = new Promise<void>((resolve) => {
watch(() => store.http, async (newValue, oldValue) => {
for (const path in extensions) {
if (newValue.includes(path)) continue
extensions[path].dispose()
delete extensions[path]
}

const { redirect } = router.currentRoute.value.query
const tasks = newValue.map(async (path) => {
if (extensions[path]) return
const { default: callback } = await import(/* @vite-ignore */ path)
callback(extensions[path] = new Context())
if (typeof redirect === 'string') {
const location = router.resolve(redirect)
if (location.matched.length) {
router.replace(location)
}
}
})

await Promise.allSettled(tasks)
if (!oldValue) resolve()
}, { deep: true })
})

export function registerPage(options: PageOptions) {
const { path, name, component, ...rest } = options
router.addRoute({
path,
name,
component,
meta: {
order: 0,
position: 'top',
fields: [] as const,
...rest,
},
})
}
router.beforeEach(async (to, from) => {
if (to.matched.length) return

registerPage({
path: '/',
name: '仪表盘',
icon: 'tachometer-alt',
order: 1000,
component: Home,
if (from === START_LOCATION) {
await initTask
to = router.resolve(to.path)
if (to.matched.length) return to
}

const routes = router.getRoutes().filter(item => item.meta.position === 'top')
const path = routes[0]?.path || '/blank'
return {
path,
query: { redirect: to.fullPath },
}
})

// component helper

export namespace Card {
function createFieldComponent(render: Function, fields: readonly (keyof Sources)[] = [] as const) {
function createFieldComponent(render: Function, fields: readonly (keyof Sources)[] = []) {
return defineComponent({
render: () => fields.every(key => store[key]) ? render() : null,
})
Expand Down
29 changes: 23 additions & 6 deletions plugins/frontend/console/client/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,20 @@ declare module '~/client' {

// layout api

export interface PageOptions {
path: string
name: string
declare module 'vue-router' {
interface RouteMeta extends RouteMetaExtension {}
}

interface RouteMetaExtension {
icon?: string
order?: number
position?: 'top' | 'bottom' | 'hidden'
fields?: readonly (keyof Sources)[]
position?: 'top' | 'bottom' | 'hidden'
}

export interface PageOptions extends RouteMetaExtension {
path: string
name: string
component: Component
}

Expand All @@ -44,8 +51,18 @@ declare module '~/client' {
component: Component
}

export function registerPage(options: PageOptions): void
export function registerView(options: ViewOptions): void
export type Disposable = () => void
export type Extension = (ctx: Context) => void

export class Context {
disposables: Disposable[] = []

registerPage(options: PageOptions): void
registerView(options: ViewOptions): void
install(extension: Extension): void
}

export function defineExtension(extension: Extension): Extension

// component helper

Expand Down
23 changes: 23 additions & 0 deletions plugins/frontend/console/client/layout/blank.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<template>
<div class="page-blank">
<h1>404</h1>
<p>如果你看到了这个页面,说明控制台插件已经加载成功,但你尚未安装任何控制台扩展。</p>
</div>
</template>

<style lang="scss" scoped>
.page-blank {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 2rem;
}
</style>
18 changes: 15 additions & 3 deletions plugins/frontend/console/client/layout/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<sidebar/>
<main class="layout-main">
<sidebar v-if="!sidebarHidden"/>
<main class="layout-main" :class="{ 'sidebar-hidden': sidebarHidden }">
<router-view v-if="loaded" #="{ Component }">
<keep-alive>
<component :is="Component"/>
Expand All @@ -19,7 +19,15 @@ import { useRoute } from 'vue-router'
import Sidebar from './sidebar.vue'
const route = useRoute()
const loaded = computed(() => route.meta.fields?.every((key) => store[key]))
const loaded = computed(() => {
if (!route.meta.fields) return true
return route.meta.fields.every((key) => store[key])
})
const sidebarHidden = computed(() => {
return route.meta.position === 'hidden'
})
</script>

Expand All @@ -46,6 +54,10 @@ a {
main.layout-main {
margin-left: var(--aside-width);
overflow-y: hidden;
&.sidebar-hidden {
margin-left: 0;
}
}
p, ul {
Expand Down
3 changes: 2 additions & 1 deletion plugins/frontend/console/client/layout/sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import { useRouter } from 'vue-router'
import { useDark } from '@vueuse/core'
import { reactive } from 'vue'
import { routes } from '../client'
const router = useRouter()
Expand All @@ -40,7 +41,7 @@ router.afterEach(() => {
})
function getRoutes(position: 'top' | 'bottom') {
return router.getRoutes().filter(r => r.meta.position === position).sort((a, b) => b.meta.order - a.meta.order)
return routes.value.filter(r => r.meta.position === position).sort((a, b) => b.meta.order - a.meta.order)
}
const isDark = useDark()
Expand Down
21 changes: 8 additions & 13 deletions plugins/frontend/console/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Markdown from './components/markdown.vue'
import Numeric from './components/numeric.vue'
import View from './components/view'
import App from './layout/index.vue'
import Blank from './layout/blank.vue'

import { ElCascader, ElEmpty, ElTooltip, ElScrollbar, ElSelect, ElTree } from 'element-plus'

Expand Down Expand Up @@ -61,14 +62,14 @@ app.component('k-view', View)

app.provide('ecTheme', 'dark-blue')

app.use(router)

router.beforeEach((route, from) => {
if (from === Router.START_LOCATION && !route.matched.length) {
loadingExtensions.then(() => router.replace(route))
}
router.addRoute({
path: '/blank',
component: Blank,
meta: { fields: [], position: 'hidden' },
})

app.use(router)

router.afterEach((route) => {
if (typeof route.name === 'string') {
document.title = `${route.name} | Koishi 控制台`
Expand All @@ -79,10 +80,4 @@ const endpoint = new URL(config.endpoint, location.origin).toString()

client.connect(endpoint.replace(/^http/, 'ws'))

const loadingExtensions = Promise.all(config.extensions.map(path => {
return import(/* @vite-ignore */ path).catch((error) => {
console.error(error)
})
}))

loadingExtensions.then(() => app.mount('#app'))
app.mount('#app')
4 changes: 4 additions & 0 deletions plugins/frontend/console/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { Context, Schema, Service } from 'koishi'
import HttpService from './http'
import WsService, { Listener } from './ws'

export * from './service'
export * from './http'
export * from './ws'

type SubServices = {
[K in keyof Sources as `console.${K}`]: Sources[K]
}
Expand Down
6 changes: 2 additions & 4 deletions plugins/frontend/console/src/ws.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Awaitable, Context, Dict, Logger, WebSocketLayer } from 'koishi'
import { v4 } from 'uuid'
import { DataSource } from './service'
import { Events } from '.'
import WebSocket from 'ws'

declare module 'koishi' {
Expand Down Expand Up @@ -65,9 +64,8 @@ class WsService extends DataSource {

for (const name of Context.Services) {
if (!name.startsWith('console.')) continue
const service = this.ctx[name]
if (typeof service['get'] !== 'function') continue
Promise.resolve(service['get']()).then((value) => {
Promise.resolve(this.ctx[name]?.['get']?.()).then((value) => {
if (!value) return
const key = name.slice(8)
socket.send(JSON.stringify({ type: 'data', body: { key, value } }))
})
Expand Down

0 comments on commit ece4dcd

Please sign in to comment.