diff --git a/dashboard/src/components/extension/mod-manager/BatchOperationBar.vue b/dashboard/src/components/extension/mod-manager/BatchOperationBar.vue new file mode 100644 index 0000000000..e0daf2d91d --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/BatchOperationBar.vue @@ -0,0 +1,234 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/DetachedTabPane.vue b/dashboard/src/components/extension/mod-manager/DetachedTabPane.vue new file mode 100644 index 0000000000..2ed9db5162 --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/DetachedTabPane.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/dashboard/src/components/extension/mod-manager/GlobalPanel.vue b/dashboard/src/components/extension/mod-manager/GlobalPanel.vue new file mode 100644 index 0000000000..d7999ee149 --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/GlobalPanel.vue @@ -0,0 +1,86 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/LegacyInstalledView.vue b/dashboard/src/components/extension/mod-manager/LegacyInstalledView.vue new file mode 100644 index 0000000000..a1be9839dc --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/LegacyInstalledView.vue @@ -0,0 +1,331 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/ModManagerLayout.vue b/dashboard/src/components/extension/mod-manager/ModManagerLayout.vue new file mode 100644 index 0000000000..963c426f0f --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/ModManagerLayout.vue @@ -0,0 +1,500 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/ModTopToolbar.vue b/dashboard/src/components/extension/mod-manager/ModTopToolbar.vue new file mode 100644 index 0000000000..f967297577 --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/ModTopToolbar.vue @@ -0,0 +1,149 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/PluginBehaviorPanel.vue b/dashboard/src/components/extension/mod-manager/PluginBehaviorPanel.vue new file mode 100644 index 0000000000..75464f9fcd --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/PluginBehaviorPanel.vue @@ -0,0 +1,91 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/PluginChangelogPanel.vue b/dashboard/src/components/extension/mod-manager/PluginChangelogPanel.vue new file mode 100644 index 0000000000..79fabc4cbb --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/PluginChangelogPanel.vue @@ -0,0 +1,137 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/PluginConfigPanel.vue b/dashboard/src/components/extension/mod-manager/PluginConfigPanel.vue new file mode 100644 index 0000000000..6517d770f2 --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/PluginConfigPanel.vue @@ -0,0 +1,171 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/PluginDualList.vue b/dashboard/src/components/extension/mod-manager/PluginDualList.vue new file mode 100644 index 0000000000..11c67b5054 --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/PluginDualList.vue @@ -0,0 +1,153 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/PluginInfoPanel.vue b/dashboard/src/components/extension/mod-manager/PluginInfoPanel.vue new file mode 100644 index 0000000000..27abc5e6c6 --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/PluginInfoPanel.vue @@ -0,0 +1,155 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/PluginListTable.vue b/dashboard/src/components/extension/mod-manager/PluginListTable.vue new file mode 100644 index 0000000000..a8a4fea20e --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/PluginListTable.vue @@ -0,0 +1,750 @@ + + + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/PluginOverviewPanel.vue b/dashboard/src/components/extension/mod-manager/PluginOverviewPanel.vue new file mode 100644 index 0000000000..5237d8d738 --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/PluginOverviewPanel.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/dashboard/src/components/extension/mod-manager/PluginPanel.vue b/dashboard/src/components/extension/mod-manager/PluginPanel.vue new file mode 100644 index 0000000000..0d2a923ccc --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/PluginPanel.vue @@ -0,0 +1,342 @@ + + + + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/PluginWelcomePanel.vue b/dashboard/src/components/extension/mod-manager/PluginWelcomePanel.vue new file mode 100644 index 0000000000..ccbc59452b --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/PluginWelcomePanel.vue @@ -0,0 +1,194 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/PluginWorkspace.vue b/dashboard/src/components/extension/mod-manager/PluginWorkspace.vue new file mode 100644 index 0000000000..9ca380bb0d --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/PluginWorkspace.vue @@ -0,0 +1,125 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/ResizableSplitPane.vue b/dashboard/src/components/extension/mod-manager/ResizableSplitPane.vue new file mode 100644 index 0000000000..4c087974e7 --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/ResizableSplitPane.vue @@ -0,0 +1,301 @@ + + + + + \ No newline at end of file diff --git a/dashboard/src/components/extension/mod-manager/types.ts b/dashboard/src/components/extension/mod-manager/types.ts new file mode 100644 index 0000000000..f37eb690cb --- /dev/null +++ b/dashboard/src/components/extension/mod-manager/types.ts @@ -0,0 +1,66 @@ +export interface ApiResponse { + status: 'ok' | 'error' | string + message?: string | null + data: T +} + +// 插件 Handler 信息(来自 /api/plugin/get 的 handlers 数组) +// 参考 astrbot/dashboard/routes/plugin.py:get_plugin_handlers_info() +export interface PluginHandlerInfo { + event_type: string + event_type_h: string + handler_full_name: string + handler_name: string + desc: string + cmd?: string + type?: string + sub_command?: string + has_admin?: boolean +} + +// 插件摘要信息(来自 /api/plugin/get) +// 参考 astrbot/dashboard/routes/plugin.py:get_plugins() +export interface PluginSummary { + name: string + display_name?: string | null + repo: string + author: string + desc: string + version: string + reserved: boolean + activated: boolean + + // 后端字段(注意拼写错误) + online_vesion?: string + + // 前端计算写入 + online_version?: string + has_update?: boolean + + handlers: PluginHandlerInfo[] + logo?: string | null +} + +// 配置缓存条目 +export interface PluginConfigCacheEntry { + metadata: Record + config: Record + fetchedAt: number + inFlight?: Promise +} + +// 配置缓存 API +export interface PluginConfigCacheApi { + get(pluginName: string): PluginConfigCacheEntry | undefined + prefetch(pluginName: string): Promise + getOrFetch(pluginName: string): Promise + updateConfig(pluginName: string, config: Record): Promise + invalidate(pluginName: string): void + isStale(pluginName: string): boolean +} + +// 视图模式 +export type InstalledViewMode = 'mod' | 'legacy' + +// 插件面板 Tab +export type PluginPanelTab = 'info' | 'config' | 'behavior' | 'overview' | 'changelog' | 'reserved' \ No newline at end of file diff --git a/dashboard/src/components/shared/ReadmeDialog.vue b/dashboard/src/components/shared/ReadmeDialog.vue index f7c2d2faf6..f2ae3cc28b 100644 --- a/dashboard/src/components/shared/ReadmeDialog.vue +++ b/dashboard/src/components/shared/ReadmeDialog.vue @@ -455,237 +455,3 @@ const showActionArea = computed(() => { - - diff --git a/dashboard/src/composables/useMarkdownIt.ts b/dashboard/src/composables/useMarkdownIt.ts new file mode 100644 index 0000000000..c5ed7053b0 --- /dev/null +++ b/dashboard/src/composables/useMarkdownIt.ts @@ -0,0 +1,59 @@ +import MarkdownIt from 'markdown-it' +import hljs from 'highlight.js' +import 'highlight.js/styles/github-dark.css' + +// Shared markdown-it instance with highlight.js code highlighting. +// Used by PluginWelcomePanel and PluginOverviewPanel. +// We use markdown-it directly (instead of markstream-vue's MarkdownRender) +// to preserve raw HTML inline layout — markstream-vue wraps each node +// in width:100% Vue containers, which breaks inline flow of tags +// inside
. + +const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true, + breaks: false, +}) + +md.enable(['table', 'strikethrough']) +md.renderer.rules.table_open = () => '
' +md.renderer.rules.table_close = () => '
' + +md.renderer.rules.fence = (tokens, idx) => { + const token = tokens[idx] + const lang = token.info.trim() || '' + const code = token.content + + const highlighted = + lang && hljs.getLanguage(lang) + ? hljs.highlight(code, { language: lang }).value + : md.utils.escapeHtml(code) + + return `
+ ${lang ? `${lang}` : ''} +
${highlighted}
+
` +} + +/** + * Render markdown to HTML, post-processing all links to open in new tabs. + */ +export function renderMarkdown(source: string): string { + if (!source) return '' + + const rawHtml = md.render(source) + + // Post-process: make all links open in new tab + const tempDiv = document.createElement('div') + tempDiv.innerHTML = rawHtml + tempDiv.querySelectorAll('a').forEach((a) => { + const href = a.getAttribute('href') || '' + if (href && !href.startsWith('#')) { + a.setAttribute('target', '_blank') + a.setAttribute('rel', 'noopener noreferrer') + } + }) + + return tempDiv.innerHTML +} diff --git a/dashboard/src/composables/usePluginConfigCache.ts b/dashboard/src/composables/usePluginConfigCache.ts new file mode 100644 index 0000000000..9996fed29c --- /dev/null +++ b/dashboard/src/composables/usePluginConfigCache.ts @@ -0,0 +1,180 @@ +import axios from 'axios' +import type { ApiResponse, PluginConfigCacheApi, PluginConfigCacheEntry } from '@/components/extension/mod-manager/types' + +const DEFAULT_TTL_MS = 10 * 60 * 1000 + +function deepClone(value: T): T { + if (typeof structuredClone === 'function') { + return structuredClone(value) + } + return JSON.parse(JSON.stringify(value)) as T +} + +function now() { + return Date.now() +} + +type PluginConfigGetPayload = { + metadata: Record + config: Record +} + +type PluginConfigGetResponse = ApiResponse + +type PluginConfigUpdateResponse = ApiResponse + +const cache = new Map() + +async function fetchPluginConfig(pluginName: string): Promise { + const response = await axios.get('/api/config/get', { + params: { plugin_name: pluginName } + }) + + if (response.data?.status !== 'ok') { + const message = response.data?.message || 'Failed to fetch plugin config' + throw new Error(message) + } + + const payload = response.data.data + return { + metadata: deepClone(payload?.metadata || {}), + config: deepClone(payload?.config || {}) + } +} + +async function updatePluginConfig(pluginName: string, config: Record): Promise { + const response = await axios.post( + '/api/config/plugin/update', + config, + { params: { plugin_name: pluginName } } + ) + + if (response.data?.status !== 'ok') { + const message = response.data?.message || `Failed to save plugin config: ${pluginName}` + throw new Error(message) + } +} + +function ensureEntry(pluginName: string): PluginConfigCacheEntry { + const existing = cache.get(pluginName) + if (existing) return existing + + const entry: PluginConfigCacheEntry = { + metadata: {}, + config: {}, + fetchedAt: 0 + } + cache.set(pluginName, entry) + return entry +} + +function isStaleInternal(entry: PluginConfigCacheEntry, ttlMs: number) { + if (!entry.fetchedAt) return true + return now() - entry.fetchedAt > ttlMs +} + +function startRefresh(pluginName: string, entry: PluginConfigCacheEntry) { + if (entry.inFlight) return entry.inFlight + + const inFlight = (async () => { + const payload = await fetchPluginConfig(pluginName) + entry.metadata = payload.metadata + entry.config = payload.config + entry.fetchedAt = now() + })() + + const cleanup = () => { + if (entry.inFlight === wrapped) { + entry.inFlight = undefined + } + } + + const wrapped = inFlight.then( + () => { + cleanup() + }, + (err) => { + cleanup() + throw err + } + ) + + entry.inFlight = wrapped + return wrapped +} + +export function usePluginConfigCache(options?: { ttlMs?: number }): PluginConfigCacheApi & { + save: (pluginName: string, config: Record) => Promise +} { + const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS + + function get(pluginName: string) { + return cache.get(pluginName) + } + + function invalidate(pluginName: string) { + cache.delete(pluginName) + } + + function isStale(pluginName: string) { + const entry = cache.get(pluginName) + if (!entry) return true + return isStaleInternal(entry, ttlMs) + } + + async function getOrFetch(pluginName: string): Promise { + const entry = ensureEntry(pluginName) + + const stale = isStaleInternal(entry, ttlMs) + const hasValue = Boolean(entry.fetchedAt) + + if (!stale && hasValue) { + return entry + } + + if (entry.inFlight) { + await entry.inFlight + return entry + } + + if (hasValue && stale) { + // 过期:立即返回旧值,并后台刷新 + startRefresh(pluginName, entry).catch(() => {}) + return entry + } + + // 首次加载:需要阻塞等待结果 + await startRefresh(pluginName, entry) + return entry + } + + async function prefetch(pluginName: string): Promise { + try { + await getOrFetch(pluginName) + } catch { + // 预加载不阻塞 UI,不向外抛错 + } + } + + async function save(pluginName: string, config: Record): Promise { + await updatePluginConfig(pluginName, config) + const entry = ensureEntry(pluginName) + entry.config = deepClone(config) + entry.fetchedAt = now() + // metadata 不变;如需同步元数据,调用 invalidate + getOrFetch + } + + async function updateConfig(pluginName: string, config: Record): Promise { + await save(pluginName, config) + } + + return { + get, + prefetch, + getOrFetch, + updateConfig, + invalidate, + isStale, + save + } +} \ No newline at end of file diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index 07affcd62a..7662827479 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -27,8 +27,9 @@ "all": "All" }, "views": { - "card": "Card View", - "list": "List View" + "card": "Card Layout", + "list": "List Layout", + "modManager": "Manager Layout" }, "buttons": { "showSystemPlugins": "Show System Extensions", @@ -86,7 +87,8 @@ "tags": "Tags", "eventType": "Event Type", "specificType": "Specific Type", - "trigger": "Trigger" + "trigger": "Trigger", + "index": "Index" } }, "empty": { @@ -354,5 +356,57 @@ }, "pluginChangelog": { "menuTitle": "View Changelog" + }, + "modManager": { + "viewToggle": { + "switchToLegacy": "Switch to Classic View", + "switchToModManager": "Switch to Manager Layout" + }, + "panelTabs": { + "info": "Profile", + "config": "Config", + "overview": "Docs", + "changelog": "Changelog", + "reserved": "Extension" + }, + "welcome": { + "title": "Welcome to AstrBot Plugin Manager", + "subtitle": "Manage, configure and monitor your plugins", + "links": { + "docs": "Documentation" + }, + "actions": { + "viewOnGithub": "View on GitHub", + "retry": "Retry" + }, + "readme": { + "loading": "Loading README...", + "errors": { + "rateLimited": "GitHub API rate limit exceeded, please try again later", + "fetchFailed": "Failed to fetch README" + }, + "empty": { + "title": "No README available", + "subtitle": "This project has not provided a README file" + } + }, + "tip": { + "market": "Tip: You can discover and install more plugins in the Plugin Market tab." + } + }, + "changelogPanel": { + "loading": "Loading changelog...", + "errors": { + "fetchFailed": "Failed to fetch changelog", + "fetchError": "An error occurred while fetching the changelog" + }, + "actions": { + "retry": "Retry" + }, + "empty": { + "title": "No changelog available", + "subtitle": "This plugin has not provided a changelog" + } + } } } diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json index 2ff06f5737..af7b95506f 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/extension.json +++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json @@ -1,4 +1,4 @@ -{ +{ "title": "Плагины", "subtitle": "Управление и настройка расширений системы", "tabs": { @@ -27,8 +27,9 @@ "all": "Все" }, "views": { - "card": "Плитка", - "list": "Список" + "card": "Плитка (Макет)", + "list": "Список (Макет)", + "modManager": "Менеджер" }, "buttons": { "showSystemPlugins": "Показать системные", @@ -86,7 +87,8 @@ "tags": "Теги", "eventType": "Тип события", "specificType": "Тип", - "trigger": "Триггер" + "trigger": "Триггер", + "index": "№" } }, "empty": { @@ -354,5 +356,57 @@ }, "pluginChangelog": { "menuTitle": "Журнал изменений" + }, + "modManager": { + "viewToggle": { + "switchToLegacy": "Переключить на классический вид", + "switchToModManager": "Переключить на менеджер" + }, + "panelTabs": { + "info": "Обзор", + "config": "Настройки", + "overview": "Документы", + "changelog": "Изменения", + "reserved": "Расширение" + }, + "welcome": { + "title": "Добро пожаловать в менеджер плагинов AstrBot", + "subtitle": "Управляйте, настраивайте и отслеживайте свои плагины", + "links": { + "docs": "Документация" + }, + "actions": { + "viewOnGithub": "Открыть на GitHub", + "retry": "Повторить" + }, + "readme": { + "loading": "Загрузка README...", + "errors": { + "rateLimited": "Превышен лимит запросов GitHub API, попробуйте позже", + "fetchFailed": "Не удалось загрузить README" + }, + "empty": { + "title": "README отсутствует", + "subtitle": "Этот проект ещё не предоставил файл README" + } + }, + "tip": { + "market": "Совет: Вы можете найти и установить новые плагины на вкладке «Магазин плагинов»." + } + }, + "changelogPanel": { + "loading": "Загрузка журнала изменений...", + "errors": { + "fetchFailed": "Не удалось загрузить журнал изменений", + "fetchError": "Произошла ошибка при загрузке журнала" + }, + "actions": { + "retry": "Повторить" + }, + "empty": { + "title": "Журнал изменений отсутствует", + "subtitle": "Этот плагин ещё не предоставил журнал изменений" + } + } } } \ No newline at end of file diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index f42173ffa0..63d1159432 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -27,8 +27,9 @@ "all": "全部" }, "views": { - "card": "卡片视图", - "list": "列表视图" + "card": "卡片布局", + "list": "列表布局", + "modManager": "管理器布局" }, "buttons": { "showSystemPlugins": "显示系统插件", @@ -86,7 +87,8 @@ "tags": "标签", "eventType": "行为类型", "specificType": "具体类型", - "trigger": "触发方式" + "trigger": "触发方式", + "index": "序号" } }, "empty": { @@ -354,5 +356,57 @@ }, "pluginChangelog": { "menuTitle": "查看更新日志" + }, + "modManager": { + "viewToggle": { + "switchToLegacy": "切换到经典视图", + "switchToModManager": "切换到管理器布局" + }, + "panelTabs": { + "info": "概况", + "config": "配置", + "overview": "文档", + "changelog": "更新日志", + "reserved": "扩展区" + }, + "welcome": { + "title": "欢迎使用 AstrBot 插件管理器", + "subtitle": "管理、配置和监控你的插件", + "links": { + "docs": "文档" + }, + "actions": { + "viewOnGithub": "在 GitHub 上查看", + "retry": "重试" + }, + "readme": { + "loading": "正在加载 README...", + "errors": { + "rateLimited": "GitHub API 请求频率受限,请稍后再试", + "fetchFailed": "获取 README 失败" + }, + "empty": { + "title": "暂无 README", + "subtitle": "该项目尚未提供 README 文件" + } + }, + "tip": { + "market": "提示:你可以在「插件市场」标签页中发现和安装更多插件。" + } + }, + "changelogPanel": { + "loading": "正在加载更新日志...", + "errors": { + "fetchFailed": "获取更新日志失败", + "fetchError": "获取更新日志时发生错误" + }, + "actions": { + "retry": "重试" + }, + "empty": { + "title": "暂无更新日志", + "subtitle": "该插件尚未提供更新日志" + } + } } } diff --git a/dashboard/src/scss/components/_MarkdownBody.scss b/dashboard/src/scss/components/_MarkdownBody.scss new file mode 100644 index 0000000000..8e26d660a8 --- /dev/null +++ b/dashboard/src/scss/components/_MarkdownBody.scss @@ -0,0 +1,247 @@ +.markdown-body { + --markdown-border: rgba(128, 128, 128, 0.3); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, + sans-serif; + line-height: 1.6; + padding: 8px 0; + color: var(--v-theme-secondaryText); +} + +.markdown-body [align="center"] { + text-align: center; +} +.markdown-body [align="center"] > div { + text-align: center; +} +.markdown-body [align="right"] { + text-align: right; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; + scroll-margin-top: 12px; +} + +.markdown-body h1 { + font-size: 2em; + border-bottom: 1px solid var(--v-theme-border); + padding-bottom: 0.3em; +} +.markdown-body h2 { + font-size: 1.5em; + border-bottom: 1px solid var(--v-theme-border); + padding-bottom: 0.3em; +} +.markdown-body p { + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body .code-block-wrapper { + position: relative; + margin-bottom: 16px; +} +.markdown-body .code-lang-label { + position: absolute; + top: 8px; + left: 12px; + font-size: 12px; + color: #8b949e; + text-transform: uppercase; + font-weight: 500; + z-index: 1; +} + +.markdown-body .copy-code-btn { + position: absolute; + top: 8px; + right: 8px; + background: rgba(110, 118, 129, 0.4); + border: none; + border-radius: 6px; + padding: 6px; + cursor: pointer; + color: #c9d1d9; + display: flex; + align-items: center; + justify-content: center; + transition: + background-color 0.2s, + color 0.2s; + z-index: 1; +} + +.markdown-body .copy-code-btn:hover { + background: rgba(110, 118, 129, 0.6); + color: #fff; +} + +.markdown-body code { + padding: 0.2em 0.4em; + margin: 0; + background-color: rgba(110, 118, 129, 0.2); + border-radius: 6px; + font-size: 85%; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.markdown-body pre.hljs { + padding: 16px; + padding-top: 32px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #0d1117; + border-radius: 6px; + margin: 0; +} + +.markdown-body pre.hljs code { + background-color: transparent; + padding: 0; + border-radius: 0; + color: #c9d1d9; +} +.markdown-body ul, +.markdown-body ol { + padding-left: 2em; + margin-bottom: 16px; +} + +.markdown-body img { + display: inline-block; + max-width: 100% !important; + width: auto !important; + height: auto !important; + margin: 4px 0; + box-sizing: border-box; + background-color: var(--v-theme-background); + border-radius: 3px; + vertical-align: middle; +} + +// Constrain images inside table cells to prevent row height explosion +.markdown-body table td img, +.markdown-body table th img { + max-height: 200px; + object-fit: contain; +} + +.markdown-body img[src*="shields.io"], +.markdown-body img[src*="badge"], +.markdown-body img[src*="hellogithub"], +.markdown-body img[src*="trendshift"] { + display: inline-block; + vertical-align: middle; + height: auto; + margin: 2px 4px; + background-color: transparent; +} + +.markdown-body blockquote { + padding: 0 1em; + color: var(--v-theme-secondaryText); + border-left: 0.25em solid var(--v-theme-border); + margin-bottom: 16px; +} + +.markdown-body a { + color: var(--v-theme-primary); + text-decoration: none; +} +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + width: 100%; + margin-bottom: 0; + border: 1px solid var(--markdown-border); +} +.markdown-body .table-container { + width: 100%; + overflow-x: auto; + margin-bottom: 16px; + border: 1px solid var(--markdown-border); + border-radius: 6px; +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid var(--markdown-border); +} +.markdown-body table th { + font-weight: 600; + background-color: rgba(128, 128, 128, 0.1); +} +.markdown-body table tr { + background-color: transparent; +} +.markdown-body table tr:nth-child(2n) { + background-color: rgba(128, 128, 128, 0.05); +} + +.markdown-body hr { + height: 0.25em; + padding: 0; + margin: 24px 0; + background-color: var(--v-theme-containerBg); + border: 0; +} + +.markdown-body details { + margin-bottom: 16px; + border: 1px solid var(--v-theme-border); + border-radius: 6px; + padding: 8px 12px; + background-color: var(--v-theme-surface); +} + +.markdown-body details[open] { + padding-bottom: 12px; +} +.markdown-body summary { + cursor: pointer; + font-weight: 600; + padding: 4px 0; + list-style: none; + display: flex; + align-items: center; + gap: 6px; +} + +.markdown-body summary::before { + content: "▶"; + font-size: 0.75em; + transition: transform 0.2s ease; +} +.markdown-body details[open] summary::before { + transform: rotate(90deg); +} +.markdown-body summary::-webkit-details-marker { + display: none; +} +.markdown-body details > *:not(summary) { + margin-top: 12px; +} + +.markdown-body .hljs-keyword, +.markdown-body .hljs-selector-tag, +.markdown-body .hljs-title, +.markdown-body .hljs-section, +.markdown-body .hljs-doctag, +.markdown-body .hljs-name, +.markdown-body .hljs-strong { + font-weight: bold; +} diff --git a/dashboard/src/scss/style.scss b/dashboard/src/scss/style.scss index d473cbb68e..f6dcc18e18 100644 --- a/dashboard/src/scss/style.scss +++ b/dashboard/src/scss/style.scss @@ -13,6 +13,7 @@ @import './components/VTextField'; @import './components/VTabs'; @import './components/VScrollbar'; +@import './components/MarkdownBody'; @import './pages/dashboards'; diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue index 82d0d75e5c..2545631f30 100644 --- a/dashboard/src/views/extension/InstalledPluginsTab.vue +++ b/dashboard/src/views/extension/InstalledPluginsTab.vue @@ -2,8 +2,10 @@ import PluginSortControl from "@/components/extension/PluginSortControl.vue"; import ExtensionCard from "@/components/shared/ExtensionCard.vue"; import StyledMenu from "@/components/shared/StyledMenu.vue"; +import ModManagerLayout from "@/components/extension/mod-manager/ModManagerLayout.vue"; import defaultPluginIcon from "@/assets/images/plugin_icon.png"; import { normalizeTextInput } from "@/utils/inputValue"; +import { computed, ref, watch } from "vue"; const props = defineProps({ state: { @@ -155,6 +157,28 @@ const { handleLocaleChange, searchDebounceTimer, } = props.state; + +// MOD manager view mode persistence +const VIEW_MODE_KEY = 'pluginManager.installedViewMode' +const installedViewMode = ref(localStorage.getItem(VIEW_MODE_KEY) || 'legacy') +watch(installedViewMode, (val) => { + localStorage.setItem(VIEW_MODE_KEY, val) +}) + +// Unified three-state view mode: 'card' | 'list' | 'mod' +const combinedViewMode = computed(() => { + if (installedViewMode.value === 'mod') return 'mod' + return isListView.value ? 'list' : 'card' +}) + +const setCombinedViewMode = (val) => { + if (val === 'mod') { + installedViewMode.value = 'mod' + } else { + installedViewMode.value = 'legacy' + isListView.value = val === 'list' + } +}