- 新增 WikiView 页面:输入标题加载页面,支持内部词条跳转与返回栈 - 新增 WikiNodeRenderer 递归渲染器:标题、段落、加粗斜体下划线删除线、高亮、行内代码、黑幕、分隔线、内外链、有序无序列表、引用、代码块、表格、附件、提示块 - 新增 WikiNotice 提示块与 WikiAttachmentNode 附件组件,图片支持点击查看大图 - 补充维基节点与附件类型定义,新增附件查询接口与附件内容 URL 拼接 - 主页导航 Wiki 卡片改为站内打开 - 中英文各补 10 个文案
This commit is contained in:
1 parent
febbd84ae7
commit
71b8d111fb
10 files changed
+590
-6
No files matched your search
@@ -18,6 +18,7 @@ import SkinSiteNavCards from './SkinSiteNavCards.vue'
|
|||||||
import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
|
import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
|
||||||
import SkinSiteProfileView from './SkinSiteProfileView.vue'
|
import SkinSiteProfileView from './SkinSiteProfileView.vue'
|
||||||
import SkinSiteReviewView from './SkinSiteReviewView.vue'
|
import SkinSiteReviewView from './SkinSiteReviewView.vue'
|
||||||
|
import WikiView from './WikiView.vue'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
@@ -45,6 +46,7 @@ const view = ref<
|
|||||||
| 'bug-detail'
|
| 'bug-detail'
|
||||||
| 'bug-notifications'
|
| 'bug-notifications'
|
||||||
| 'admin'
|
| 'admin'
|
||||||
|
| 'wiki'
|
||||||
>('home')
|
>('home')
|
||||||
const selectedBugId = ref<number | null>(null)
|
const selectedBugId = ref<number | null>(null)
|
||||||
|
|
||||||
@@ -98,6 +100,8 @@ async function copyAddress() {
|
|||||||
@open="openBug"
|
@open="openBug"
|
||||||
/>
|
/>
|
||||||
<SkinSiteAdminView v-else-if="view === 'admin'" @back="view = 'home'" />
|
<SkinSiteAdminView v-else-if="view === 'admin'" @back="view = 'home'" />
|
||||||
|
<WikiView v-else-if="view === 'wiki'" />
|
||||||
|
|
||||||
<SkinSiteBugDetailView
|
<SkinSiteBugDetailView
|
||||||
v-else-if="view === 'bug-detail' && selectedBugId !== null"
|
v-else-if="view === 'bug-detail' && selectedBugId !== null"
|
||||||
:issue-id="selectedBugId"
|
:issue-id="selectedBugId"
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import type { Component } from 'vue'
|
|||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map'): void }>()
|
const emit = defineEmits<{
|
||||||
|
(e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map' | 'wiki'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'app.skin-site.home.nav.title', defaultMessage: 'Skin site' },
|
title: { id: 'app.skin-site.home.nav.title', defaultMessage: 'Skin site' },
|
||||||
@@ -104,8 +106,19 @@ const items: NavItem[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
function open(item: NavItem) {
|
function open(item: NavItem) {
|
||||||
if (item.id === 'download' || item.id === 'review' || item.id === 'bugs' || item.id === 'map') {
|
if (
|
||||||
emit('navigate', item.id === 'download' ? 'downloads' : item.id)
|
item.id === 'download' ||
|
||||||
|
item.id === 'review' ||
|
||||||
|
item.id === 'bugs' ||
|
||||||
|
item.id === 'map' ||
|
||||||
|
item.id === 'wiki'
|
||||||
|
) {
|
||||||
|
emit(
|
||||||
|
'navigate',
|
||||||
|
item.id === 'download'
|
||||||
|
? 'downloads'
|
||||||
|
: (item.id as 'review' | 'bugs' | 'map' | 'wiki'),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(item.href))
|
void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(item.href))
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SpinnerIcon } from '@modrinth/assets'
|
||||||
|
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type SkinSiteWikiPageDetail,
|
||||||
|
skinSiteWikiPage,
|
||||||
|
} from '@/helpers/skin-site-api'
|
||||||
|
|
||||||
|
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ initialTitle?: string }>(), {
|
||||||
|
initialTitle: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'app.skin-site.wiki.title', defaultMessage: 'StarLight Wiki' },
|
||||||
|
searchPlaceholder: {
|
||||||
|
id: 'app.skin-site.wiki.search-placeholder',
|
||||||
|
defaultMessage: 'Enter a page title',
|
||||||
|
},
|
||||||
|
open: { id: 'app.skin-site.wiki.open', defaultMessage: 'Open' },
|
||||||
|
loading: { id: 'app.skin-site.wiki.loading', defaultMessage: 'Loading…' },
|
||||||
|
notFound: {
|
||||||
|
id: 'app.skin-site.wiki.not-found',
|
||||||
|
defaultMessage: 'Page not found.',
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
id: 'app.skin-site.wiki.empty',
|
||||||
|
defaultMessage: 'Enter a page title above to start reading.',
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
id: 'app.skin-site.wiki.updated-at',
|
||||||
|
defaultMessage: 'Last edited {date} by {author}',
|
||||||
|
},
|
||||||
|
revision: { id: 'app.skin-site.wiki.revision', defaultMessage: 'Revision #{id}' },
|
||||||
|
categories: { id: 'app.skin-site.wiki.categories', defaultMessage: 'Categories' },
|
||||||
|
back: { id: 'app.skin-site.wiki.back', defaultMessage: 'Back' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleInput = ref(props.initialTitle)
|
||||||
|
const page = ref<SkinSiteWikiPageDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const notFound = ref(false)
|
||||||
|
const history = ref<string[]>([])
|
||||||
|
|
||||||
|
const currentTitle = computed(() => page.value?.title ?? '')
|
||||||
|
|
||||||
|
async function openPage(title: string) {
|
||||||
|
const trimmed = title.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
titleInput.value = trimmed
|
||||||
|
loading.value = true
|
||||||
|
notFound.value = false
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiPage(trimmed)
|
||||||
|
if (!res.success) {
|
||||||
|
notFound.value = true
|
||||||
|
page.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page.value = res.payload
|
||||||
|
if (currentTitle.value && history.value[history.value.length - 1] !== currentTitle.value) {
|
||||||
|
history.value.push(currentTitle.value)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
notFound.value = true
|
||||||
|
page.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
const prev = history.value.pop()
|
||||||
|
if (prev) void openPage(prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNavigate(title: string) {
|
||||||
|
if (title) void openPage(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.initialTitle) void openPage(props.initialTitle)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="history.length"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
← {{ formatMessage(messages.back) }}
|
||||||
|
</button>
|
||||||
|
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
|
||||||
|
<input
|
||||||
|
v-model="titleInput"
|
||||||
|
type="text"
|
||||||
|
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||||
|
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
|
||||||
|
@keyup.enter="openPage(titleInput)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-brand px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
|
||||||
|
@click="openPage(titleInput)"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.open) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary">
|
||||||
|
<SpinnerIcon class="size-4 animate-spin" />
|
||||||
|
{{ formatMessage(messages.loading) }}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-else-if="notFound"
|
||||||
|
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.notFound) }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="!page"
|
||||||
|
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.empty) }}
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-2 border-b border-solid border-surface-5 pb-2">
|
||||||
|
<h1 class="m-0 text-2xl font-bold text-contrast">{{ page.title }}</h1>
|
||||||
|
<span class="text-[11px] text-secondary">
|
||||||
|
{{ formatMessage(messages.revision, { id: page.revisionId }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="m-0 text-[11px] text-secondary">
|
||||||
|
{{
|
||||||
|
formatMessage(messages.updatedAt, {
|
||||||
|
date: formatDate(page.updatedAt),
|
||||||
|
author: page.authorName,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="page.redirectTarget"
|
||||||
|
class="rounded-lg border border-solid border-yellow/40 bg-yellow/10 p-3 text-xs text-yellow"
|
||||||
|
>
|
||||||
|
重定向到
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="font-semibold underline underline-offset-2"
|
||||||
|
@click="openPage(page.redirectTarget ?? '')"
|
||||||
|
>
|
||||||
|
{{ page.redirectTarget }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="min-w-0 text-sm leading-7 text-contrast">
|
||||||
|
<WikiNodeRenderer :nodes="page.nodes" @navigate="onNavigate" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div v-if="page.categories.length" class="flex flex-wrap items-center gap-2 pt-2">
|
||||||
|
<span class="text-[11px] text-secondary">{{ formatMessage(messages.categories) }}:</span>
|
||||||
|
<span
|
||||||
|
v-for="category in page.categories"
|
||||||
|
:key="category"
|
||||||
|
class="rounded bg-bg-raised px-2 py-0.5 text-[11px] text-secondary"
|
||||||
|
>
|
||||||
|
{{ category }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type SkinSiteWikiAttachmentInfo,
|
||||||
|
skinSiteWikiAttachment,
|
||||||
|
skinSiteWikiAttachmentUrl,
|
||||||
|
} from '@/helpers/skin-site-api'
|
||||||
|
|
||||||
|
const props = defineProps<{ name: string; caption?: string | null }>()
|
||||||
|
|
||||||
|
const attachment = ref<SkinSiteWikiAttachmentInfo | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const viewerOpen = ref(false)
|
||||||
|
|
||||||
|
const contentUrl = computed(() =>
|
||||||
|
attachment.value ? skinSiteWikiAttachmentUrl(attachment.value.id) : '#',
|
||||||
|
)
|
||||||
|
const imageCaption = computed(() => {
|
||||||
|
const caption = props.caption?.trim() ?? ''
|
||||||
|
return caption === props.name || caption === attachment.value?.name ? '' : caption
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiAttachment(props.name)
|
||||||
|
if (res.success) attachment.value = res.payload
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
v-if="loading"
|
||||||
|
class="inline-flex min-h-20 min-w-32 items-center justify-center rounded-lg border border-solid border-surface-5"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-secondary">…</span>
|
||||||
|
</span>
|
||||||
|
<figure v-else-if="attachment?.image" class="my-5 inline-block max-w-full">
|
||||||
|
<button type="button" class="block max-w-full cursor-zoom-in rounded-lg" @click="viewerOpen = true">
|
||||||
|
<img
|
||||||
|
class="max-h-[70vh] max-w-full rounded-lg border border-solid border-surface-5 object-contain shadow-md"
|
||||||
|
:src="contentUrl"
|
||||||
|
:alt="caption || attachment.name"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<figcaption v-if="imageCaption" class="mt-2 text-center text-sm text-secondary">
|
||||||
|
{{ imageCaption }}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
<a
|
||||||
|
v-else-if="attachment"
|
||||||
|
class="inline-flex items-center gap-1 text-brand underline-offset-2 hover:underline"
|
||||||
|
:href="contentUrl"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
{{ caption || attachment.name }}
|
||||||
|
</a>
|
||||||
|
<span v-else class="text-red">附件“{{ name }}”不存在</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="viewerOpen"
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 p-4"
|
||||||
|
@click.self="viewerOpen = false"
|
||||||
|
>
|
||||||
|
<img :src="contentUrl" :alt="caption || attachment?.name" class="max-h-[92vh] max-w-[92vw] object-contain" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type SkinSiteWikiNode } from '@/helpers/skin-site-api'
|
||||||
|
|
||||||
|
import { safeExternalUrl, safeWikiColor } from './wiki-color'
|
||||||
|
|
||||||
|
defineOptions({ name: 'WikiNodeRenderer' })
|
||||||
|
|
||||||
|
defineProps<{ nodes: SkinSiteWikiNode[] }>()
|
||||||
|
const emit = defineEmits<{ (e: 'navigate', title: string): void }>()
|
||||||
|
|
||||||
|
function headingTag(level?: number | null): string {
|
||||||
|
return 'h' + Math.min(6, Math.max(1, level ?? 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(title?: string | null) {
|
||||||
|
emit('navigate', (title ?? '').replaceAll('_', ' '))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-for="(node, index) in nodes" :key="node.type + '-' + index">
|
||||||
|
<component
|
||||||
|
:is="headingTag(node.level)"
|
||||||
|
v-if="node.type === 'heading'"
|
||||||
|
class="wiki-heading scroll-mt-24"
|
||||||
|
:style="{ textAlign: node.align ?? undefined }"
|
||||||
|
>
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</component>
|
||||||
|
<p
|
||||||
|
v-else-if="node.type === 'paragraph'"
|
||||||
|
class="my-3 leading-7 break-words whitespace-pre-wrap"
|
||||||
|
:style="{ textAlign: node.align ?? undefined }"
|
||||||
|
>
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</p>
|
||||||
|
<strong v-else-if="node.type === 'strong'" class="font-bold">
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</strong>
|
||||||
|
<small v-else-if="node.type === 'small'">
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</small>
|
||||||
|
<span
|
||||||
|
v-else-if="node.type === 'color'"
|
||||||
|
:style="{ color: safeWikiColor(node.color ?? '') ?? undefined }"
|
||||||
|
>
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</span>
|
||||||
|
<em v-else-if="node.type === 'emphasis'" class="italic">
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</em>
|
||||||
|
<u v-else-if="node.type === 'underline'">
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</u>
|
||||||
|
<s v-else-if="node.type === 'strike'">
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</s>
|
||||||
|
<mark v-else-if="node.type === 'highlight'" class="rounded bg-yellow/25 px-0.5 text-contrast">
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</mark>
|
||||||
|
<code v-else-if="node.type === 'inlineCode'" class="rounded bg-button-bg px-1 font-mono text-sm">
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</code>
|
||||||
|
<span
|
||||||
|
v-else-if="node.type === 'spoiler'"
|
||||||
|
class="cursor-help rounded bg-contrast text-contrast transition-colors hover:bg-transparent hover:text-inherit"
|
||||||
|
:title="node.tooltip || '我知道你在偷看'"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</span>
|
||||||
|
<br v-else-if="node.type === 'break'" />
|
||||||
|
<hr v-else-if="node.type === 'rule'" class="my-6 border-surface-5" />
|
||||||
|
<button
|
||||||
|
v-else-if="node.type === 'internalLink'"
|
||||||
|
type="button"
|
||||||
|
class="font-medium underline-offset-2 hover:underline"
|
||||||
|
:class="node.exists === false ? 'text-red' : 'text-brand'"
|
||||||
|
:title="node.exists === false ? '该词条尚未创建' : undefined"
|
||||||
|
@click="navigate(node.title)"
|
||||||
|
>
|
||||||
|
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
|
||||||
|
<template v-else>{{ node.text }}</template>
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
v-else-if="node.type === 'externalLink'"
|
||||||
|
class="text-brand underline-offset-2 hover:underline"
|
||||||
|
:href="safeExternalUrl(node.href)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
>
|
||||||
|
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
|
||||||
|
<template v-else>{{ node.text }}</template>
|
||||||
|
</a>
|
||||||
|
<ul v-else-if="node.type === 'unorderedList'" class="my-3 list-disc pl-7">
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</ul>
|
||||||
|
<ol
|
||||||
|
v-else-if="node.type === 'orderedList'"
|
||||||
|
:start="node.start ?? undefined"
|
||||||
|
class="my-3 list-decimal pl-7"
|
||||||
|
>
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</ol>
|
||||||
|
<li v-else-if="node.type === 'listItem'" class="my-1">
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</li>
|
||||||
|
<blockquote
|
||||||
|
v-else-if="node.type === 'quote'"
|
||||||
|
class="my-4 border-l-4 border-brand/60 bg-brand/5 px-4 py-2"
|
||||||
|
>
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</blockquote>
|
||||||
|
<pre
|
||||||
|
v-else-if="node.type === 'code'"
|
||||||
|
class="my-4 overflow-x-auto rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 font-mono text-sm"
|
||||||
|
><code>{{ node.text }}</code></pre>
|
||||||
|
<div v-else-if="node.type === 'table'" class="my-5 overflow-x-auto">
|
||||||
|
<table class="w-full border-collapse text-sm">
|
||||||
|
<tbody>
|
||||||
|
<WikiNodeRenderer
|
||||||
|
v-if="node.children.length"
|
||||||
|
:nodes="node.children"
|
||||||
|
@navigate="navigate"
|
||||||
|
/>
|
||||||
|
<tr v-for="(row, rowIndex) in node.rows" :key="rowIndex">
|
||||||
|
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="border border-solid border-surface-5 px-2 py-1">
|
||||||
|
<WikiNodeRenderer :nodes="cell" @navigate="navigate" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<tr v-else-if="node.type === 'tableRow'">
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</tr>
|
||||||
|
<component
|
||||||
|
:is="node.type === 'tableHeader' ? 'th' : 'td'"
|
||||||
|
v-else-if="node.type === 'tableCell' || node.type === 'tableHeader'"
|
||||||
|
class="border border-solid border-surface-5 px-2 py-1"
|
||||||
|
:colspan="node.colspan ?? 1"
|
||||||
|
:rowspan="node.rowspan ?? 1"
|
||||||
|
>
|
||||||
|
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
|
||||||
|
</component>
|
||||||
|
<WikiAttachmentNode
|
||||||
|
v-else-if="node.type === 'attachment' && node.title"
|
||||||
|
:name="node.title"
|
||||||
|
:caption="node.text"
|
||||||
|
/>
|
||||||
|
<WikiNotice
|
||||||
|
v-else-if="node.type === 'notice' && node.title"
|
||||||
|
:title="node.title"
|
||||||
|
:description="node.text"
|
||||||
|
:variant="node.variant"
|
||||||
|
>
|
||||||
|
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
|
||||||
|
<p v-else-if="node.text" class="whitespace-pre-wrap">{{ node.text }}</p>
|
||||||
|
</WikiNotice>
|
||||||
|
<span v-else>{{ node.text }}</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wiki-heading {
|
||||||
|
margin-top: 1.65rem;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
border-bottom: 1px solid color-mix(in oklab, currentColor 16%, transparent);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1.wiki-heading {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2.wiki-heading {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3.wiki-heading {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
description?: string | null
|
||||||
|
variant?: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const variantClass = computed(() => {
|
||||||
|
switch (props.variant) {
|
||||||
|
case 'warning':
|
||||||
|
return 'border-yellow/40 bg-yellow/10 text-yellow'
|
||||||
|
case 'error':
|
||||||
|
return 'border-red/40 bg-red/10 text-red'
|
||||||
|
case 'success':
|
||||||
|
return 'border-green/40 bg-green/10 text-green'
|
||||||
|
default:
|
||||||
|
return 'border-brand/40 bg-brand/10 text-brand'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="my-4 rounded-lg border border-solid p-4" :class="variantClass">
|
||||||
|
<p class="m-0 text-sm font-bold">{{ title }}</p>
|
||||||
|
<p v-if="description" class="m-0 mt-1 text-xs whitespace-pre-wrap opacity-90">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function safeWikiColor(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
const color = value.trim()
|
||||||
|
return color.length <= 80 &&
|
||||||
|
/^(?:[a-z]{1,20}|#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8}|rgba?\([\d\s.,%]+\))$/i.test(color)
|
||||||
|
? color
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeExternalUrl(url?: string | null): string {
|
||||||
|
if (!url) return '#'
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : '#'
|
||||||
|
} catch {
|
||||||
|
return '#'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1740,11 +1740,45 @@ export function skinSiteDeleteModRuntime(id: number): Promise<SkinSiteResponse<s
|
|||||||
export type SkinSiteWikiReadPolicy = 'PUBLIC' | 'MEMBER'
|
export type SkinSiteWikiReadPolicy = 'PUBLIC' | 'MEMBER'
|
||||||
export type SkinSiteWikiEditPolicy = 'MEMBER' | 'WEB_ADMIN'
|
export type SkinSiteWikiEditPolicy = 'MEMBER' | 'WEB_ADMIN'
|
||||||
|
|
||||||
|
export interface SkinSiteWikiNode {
|
||||||
|
type: string
|
||||||
|
color?: string | null
|
||||||
|
align?: 'left' | 'center' | 'right' | 'justify' | null
|
||||||
|
start?: number | null
|
||||||
|
colspan?: number | null
|
||||||
|
rowspan?: number | null
|
||||||
|
variant?: string | null
|
||||||
|
iconFamily?: string | null
|
||||||
|
iconName?: string | null
|
||||||
|
iconVersion?: number | null
|
||||||
|
linkTitle?: string | null
|
||||||
|
linkLabel?: string | null
|
||||||
|
tooltip?: string | null
|
||||||
|
exists?: boolean | null
|
||||||
|
text?: string | null
|
||||||
|
level?: number | null
|
||||||
|
href?: string | null
|
||||||
|
title?: string | null
|
||||||
|
children: SkinSiteWikiNode[]
|
||||||
|
rows: SkinSiteWikiNode[][][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteWikiAttachmentInfo {
|
||||||
|
id: number
|
||||||
|
versionId: number
|
||||||
|
name: string
|
||||||
|
mime: string
|
||||||
|
size: number
|
||||||
|
image: boolean
|
||||||
|
uploaderName: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SkinSiteWikiPageDetail {
|
export interface SkinSiteWikiPageDetail {
|
||||||
title: string
|
title: string
|
||||||
revisionId: number
|
revisionId: number
|
||||||
source: string
|
source: string
|
||||||
nodes: unknown[]
|
nodes: SkinSiteWikiNode[]
|
||||||
categories: string[]
|
categories: string[]
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
authorName: string
|
authorName: string
|
||||||
@@ -2493,3 +2527,15 @@ export function skinSiteWikiMigrationRollback(
|
|||||||
export function skinSiteWikiMigrationDelete(jobId: number): Promise<SkinSiteResponse<void>> {
|
export function skinSiteWikiMigrationDelete(jobId: number): Promise<SkinSiteResponse<void>> {
|
||||||
return skinSiteRequest<void>('wiki/admin/migration/' + jobId, { method: 'DELETE' })
|
return skinSiteRequest<void>('wiki/admin/migration/' + jobId, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiAttachment(
|
||||||
|
name: string,
|
||||||
|
): Promise<SkinSiteResponse<SkinSiteWikiAttachmentInfo>> {
|
||||||
|
return skinSiteRequest<SkinSiteWikiAttachmentInfo>(
|
||||||
|
'wiki/attachment?name=' + encodeURIComponent(name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiAttachmentUrl(id: number): string {
|
||||||
|
return `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/wiki/attachment/${id}/content`
|
||||||
|
}
|
||||||
@@ -12424,5 +12424,15 @@
|
|||||||
"app.skin-site.admin.wiki.published": {"message": "Published {imported} pages, overwrote {overwritten}."},
|
"app.skin-site.admin.wiki.published": {"message": "Published {imported} pages, overwrote {overwritten}."},
|
||||||
"app.skin-site.admin.wiki.rolled-back": {"message": "Migration batch rolled back."},
|
"app.skin-site.admin.wiki.rolled-back": {"message": "Migration batch rolled back."},
|
||||||
"app.skin-site.admin.wiki.job-deleted": {"message": "Migration record deleted."},
|
"app.skin-site.admin.wiki.job-deleted": {"message": "Migration record deleted."},
|
||||||
"app.skin-site.admin.wiki.select-xml": {"message": "Choose a MediaWiki XML file first."}
|
"app.skin-site.admin.wiki.select-xml": {"message": "Choose a MediaWiki XML file first."},
|
||||||
|
"app.skin-site.wiki.title": {"message": "StarLight Wiki"},
|
||||||
|
"app.skin-site.wiki.search-placeholder": {"message": "Enter a page title"},
|
||||||
|
"app.skin-site.wiki.open": {"message": "Open"},
|
||||||
|
"app.skin-site.wiki.loading": {"message": "Loading…"},
|
||||||
|
"app.skin-site.wiki.not-found": {"message": "Page not found."},
|
||||||
|
"app.skin-site.wiki.empty": {"message": "Enter a page title above to start reading."},
|
||||||
|
"app.skin-site.wiki.updated-at": {"message": "Last edited {date} by {author}"},
|
||||||
|
"app.skin-site.wiki.revision": {"message": "Revision #{id}"},
|
||||||
|
"app.skin-site.wiki.categories": {"message": "Categories"},
|
||||||
|
"app.skin-site.wiki.back": {"message": "Back"}
|
||||||
}
|
}
|
||||||
@@ -12255,5 +12255,15 @@
|
|||||||
"app.skin-site.admin.wiki.published": {"message": "已发布 {imported} 个页面,覆盖 {overwritten} 个。"},
|
"app.skin-site.admin.wiki.published": {"message": "已发布 {imported} 个页面,覆盖 {overwritten} 个。"},
|
||||||
"app.skin-site.admin.wiki.rolled-back": {"message": "迁移批次已安全回滚。"},
|
"app.skin-site.admin.wiki.rolled-back": {"message": "迁移批次已安全回滚。"},
|
||||||
"app.skin-site.admin.wiki.job-deleted": {"message": "迁移记录已删除。"},
|
"app.skin-site.admin.wiki.job-deleted": {"message": "迁移记录已删除。"},
|
||||||
"app.skin-site.admin.wiki.select-xml": {"message": "请先选择 MediaWiki XML 文件。"}
|
"app.skin-site.admin.wiki.select-xml": {"message": "请先选择 MediaWiki XML 文件。"},
|
||||||
|
"app.skin-site.wiki.title": {"message": "StarLight 维基"},
|
||||||
|
"app.skin-site.wiki.search-placeholder": {"message": "输入页面标题"},
|
||||||
|
"app.skin-site.wiki.open": {"message": "打开"},
|
||||||
|
"app.skin-site.wiki.loading": {"message": "加载中…"},
|
||||||
|
"app.skin-site.wiki.not-found": {"message": "页面不存在。"},
|
||||||
|
"app.skin-site.wiki.empty": {"message": "在上方输入页面标题开始阅读。"},
|
||||||
|
"app.skin-site.wiki.updated-at": {"message": "最后编辑于 {date},编辑者 {author}"},
|
||||||
|
"app.skin-site.wiki.revision": {"message": "修订 #{id}"},
|
||||||
|
"app.skin-site.wiki.categories": {"message": "分类"},
|
||||||
|
"app.skin-site.wiki.back": {"message": "返回"}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user