Compare commits

...

3 Commits
main ... main

13 changed files with 1086 additions and 63 deletions

BIN
doc/image-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
doc/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

154
doc/文档tab页.md Normal file
View File

@ -0,0 +1,154 @@
# 文档页面开发记录
## 文档页面实现功能
1. 文档分页获取文件为markdown格式
代码示例
```html
<!-- webview\src\components\DocCodePanel.vue -->
<template>
<MarkdownViewer :file-path="filePath"/>
</template>
<script>
// 文档路径
// const filePath = ref('__localWebViewPath/ruanjian.md')
const filePath = ref('__localWorkspacePath/ruanjian.md')
</script>
```
- 文件支持的路径及修改方式
1. 支持插件自身目录
filePath的值以 `__localWebViewPath/` 开头
2. 支持工作区目录
filePath的值以 `__localWorkspacePath/` 开头
2. 布局:
![alt 布局](image.png)
- 左侧为标题目录
1. 支持折叠展开
1. 支持点击时 右侧文档滚动到对应位置
- 右侧为文档主题
1. 支持展示图片
1. 支持特殊标记实现点击时打开工作区内文件中的某个函数
目前暂定格式与示例\
示例:
[打开vscode中的src/test.js:testFunction](__workspace/src/test.js?functionName=resolveTripleslashReference)
格式:
[显示文字](__workspace/[文件的相对路径]?functionName=[函数名])
关键代码:
``` js
///webview\src\components\MarkdownViewer.vue
/// ...
const renderLink = renderer.link
const handleDelegateClick = (event) => {
// 通过事件冒泡捕获目标元素
const target = event.target.closest('.openFileAndGoToFunction')
if (target) {
const { filepath, functionname } = target.dataset
console.log(filepath, functionname, '_**=== filepath, functionname');
console.log(target.dataset, '_**=== target.dataset');
if (window.acquireVsCodeApi && window.vscodeApi) {
window.vscodeApi.postMessage({
type: 'openFileAndGoToFunction',
filePath: filepath,
functionName: functionname
});
}
}
}
renderer.link = function (src) {
// 工作区跳转
if (src.href.startsWith("__workspace/")) {
const { href, title, tokens } = src
const text = renderer.parser.parseInline(tokens);
// const cleanHref = cleanUrl(href);
const titleTip = href.slice("__workspace/".length);
const [filePath, functionName] = titleTip.split('?functionName=')
console.log(filePath, functionName, '_**=== filePath, functionName');
return `<a href="javascript:void 0" class="openFileAndGoToFunction" title="点击打开关联文件函数: ${titleTip}" data-href="${href}" data-filepath="${filePath}" data-functionname="${functionName}">${text}</a>`
}
return renderLink.call(renderer, src);
}
```
```typescript
// src\extension\ChatViewProvider.ts
async openFileAndGoToFunction(message: ListenerParam): Promise<void | false> {
if (message.type !== 'openFileAndGoToFunction') { return false };
const rootUri = vscode.workspace.workspaceFolders?.[0]?.uri;
const filePath = message.filePath; // 确保路径正确,相对于工作区
this._logger(rootUri + '#####' + filePath)
const targetFunc = message.functionName
if (!filePath || !targetFunc) {
vscode.window.showWarningMessage(`未提供文件: ${filePath} 或函数: ${targetFunc}`)
return
}
try {
// 打开文件
const docInfo = await this.openWorkspaceFile(filePath);
if (!docInfo) {
vscode.window.showWarningMessage(`无法打开或创建文件: ${filePath}`)
return
}
const { editor, document } = docInfo
// 获取文档符号
const symbols = await vscode.commands.executeCommand<vscode.DocumentSymbol[]>(
'vscode.executeDocumentSymbolProvider',
document.uri
)
if (symbols) {
const funcSymbol = this.findFunction(symbols, targetFunc)
if (funcSymbol) {
// 跳转到目标位置
editor.revealRange(funcSymbol.range, vscode.TextEditorRevealType.InCenter)
editor.selection = new vscode.Selection(funcSymbol.range.start, funcSymbol.range.end)
} else {
vscode.window.showWarningMessage(`未找到函数 ${targetFunc}`)
}
}
} catch (error) {
vscode.window.showErrorMessage(`无法打开文件: ${error}`)
}
}
// 打开工作区的文件
async openWorkspaceFile(relativePath: string) {
// 1. 获取工作区根路径
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri;
if (!workspaceRoot) {
vscode.window.showErrorMessage(" 未检测到工作区");
return;
}
// 2. 拼接完整路径(自动处理跨平台路径)
const targetUri = vscode.Uri.joinPath(workspaceRoot, relativePath);
// 3. 文件存在性检测
try {
await vscode.workspace.fs.stat(targetUri);
} catch {
const createNew = await vscode.window.showInformationMessage(
"文件不存在,是否创建?",
"创建", "取消"
);
if (createNew === "创建") {
await vscode.workspace.fs.writeFile(targetUri, Buffer.from(""));
} else {
return;
}
}
this._logger(targetUri + ':::targetUri')
// 4. 打开文件编辑器
const document = await vscode.workspace.openTextDocument(targetUri);
const editor = await vscode.window.showTextDocument(document);
return { document, editor }
}
```
1. 插件信息与修改: 若需要改为标题或列表, 修改 renderer 的对应方法以及触发跳转, 渲染的元素添加类名
[marked使用文档](https://marked.nodejs.cn/using_pro#hooks)
![alt text](image-1.png)

View File

@ -1,13 +1,23 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import * as WS from 'ws';
type ListenerParam = {
type: string,
data: any,
filePath?: string
functionName?: string
mdPath?: string
}
type WebviewViewIns = vscode.WebviewView['webview']
export class ChatViewProvider implements vscode.WebviewViewProvider { export class ChatViewProvider implements vscode.WebviewViewProvider {
public static readonly viewType = 'aiChatView'; public static readonly viewType = 'aiChatView';
private _view?: vscode.WebviewView; private _view?: vscode.WebviewView;
private _logger: (message: string) => void; private _logger: (message: string) => void;
private _wsInstance?: WS | null
constructor( constructor(
private readonly _extensionUri: vscode.Uri, private readonly _extensionUri: vscode.Uri,
logger?: (message: string) => void logger?: (message: string) => void
@ -30,15 +40,25 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
this._extensionUri this._extensionUri
] ]
}; };
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
const webview = webviewView.webview
// 在生产环境中使用打包后的Webview文件
this._logger('WebView HTML内容已设置'); this._logger('WebView HTML内容已设置');
let wsInstance = null
// 处理来自Webview的消息 // 处理来自Webview的消息
webviewView.webview.onDidReceiveMessage(message => { webviewView.webview.onDidReceiveMessage(message => {
this._logger(`收到WebView消息: ${message.type}`); this._logger(`收到WebView消息: ${message.type}`);
if (message.type?.startsWith('ws:')) {
this.wsConnect(message, webview)
return
}
switch (message.type) { switch (message.type) {
case 'loadMd':
this.fetchMdFile(message, webview)
break
case 'openFileAndGoToFunction':
this.openFileAndGoToFunction(message)
break
case 'getSettings': case 'getSettings':
this._logger(`处理getSettings请求`); this._logger(`处理getSettings请求`);
this._sendSettings(); this._sendSettings();
@ -412,4 +432,170 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
} }
return text; return text;
} }
wsConnect(message: ListenerParam, webview: WebviewViewIns): void | false {
switch (message.type) {
case 'ws:connect':
// 建立 WebSocket 连接
this._wsInstance = new WS('ws://47.117.75.243:8080/ws', {
headers: { 'X-Api-Key': 'simpletest2025_094' }
})
// 连接打开
this._wsInstance.on('open', () => {
webview.postMessage({ type: 'ws:open', data: true })
})
// 转发服务器消息到 Webview
this._wsInstance.on('message', (data) => {
this._logger('ws:message ===== ' + data.toString())
webview.postMessage({
type: 'ws:message',
data: data
})
})
// 错误处理
this._wsInstance.on('error', (err) => {
webview.postMessage({
type: 'ws:error',
data: '连接失败: ' + err.message
})
})
// 连接关闭处理
this._wsInstance.on('close', () => {
webview.postMessage({ type: 'ws:close' })
})
break
case 'ws:send':
// 转发 Webview 的消息到服务器
if (this._wsInstance) {
this._logger('ws:send' + message.data)
this._wsInstance.send(message.data)
}
break
case 'ws:disconnect':
if (this._wsInstance) {
this._wsInstance.close()
this._wsInstance = null
}
break
default:
return false
}
}
findFunction(symbols: vscode.DocumentSymbol[], name: string): vscode.DocumentSymbol | false {
for (const symbol of symbols) {
if (symbol.kind === vscode.SymbolKind.Function && symbol.name === name) {
return symbol;
}
if (symbol.children) {
const found = this.findFunction(symbol.children, name);
if (found) return found;
}
}
return false;
}
async openFileAndGoToFunction(message: ListenerParam): Promise<void | false> {
if (message.type !== 'openFileAndGoToFunction') { return false };
const rootUri = vscode.workspace.workspaceFolders?.[0]?.uri;
const filePath = message.filePath; // 确保路径正确,相对于工作区
this._logger(rootUri + '#####' + filePath)
const targetFunc = message.functionName
if (!filePath || !targetFunc) {
vscode.window.showWarningMessage(`未提供文件: ${filePath} 或函数: ${targetFunc}`)
return
}
try {
// 打开文件
const docInfo = await this.openWorkspaceFile(filePath);
if (!docInfo) {
vscode.window.showWarningMessage(`无法打开或创建文件: ${filePath}`)
return
}
const { editor, document } = docInfo
// 获取文档符号
const symbols = await vscode.commands.executeCommand<vscode.DocumentSymbol[]>(
'vscode.executeDocumentSymbolProvider',
document.uri
)
if (symbols) {
const funcSymbol = this.findFunction(symbols, targetFunc)
if (funcSymbol) {
// 跳转到目标位置
editor.revealRange(funcSymbol.range, vscode.TextEditorRevealType.InCenter)
editor.selection = new vscode.Selection(funcSymbol.range.start, funcSymbol.range.end)
} else {
vscode.window.showWarningMessage(`未找到函数 ${targetFunc}`)
}
}
} catch (error) {
vscode.window.showErrorMessage(`无法打开文件: ${error}`)
}
}
// 获取本地markdown文件
fetchMdFile(message: ListenerParam, webview: WebviewViewIns): false | void {
if (message.type !== 'loadMd') { return false }
if (message.mdPath) {
let filePath
const isFromWorkspace = message.mdPath.startsWith('__localWorkspacePath/')
if (isFromWorkspace) {
// 1. 获取工作区根路径
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri;
if (!workspaceRoot) {
vscode.window.showErrorMessage(" 未检测到工作区");
return;
}
// 2. 拼接完整路径(自动处理跨平台路径)
filePath = vscode.Uri.joinPath(workspaceRoot, message.mdPath.replace('__localWorkspacePath/', '')).fsPath;
// filePath = path.join(workspaceRoot.toString(), message.mdPath.replace('__localWorkspacePath/', ''))
} else {
filePath = path.join(this._extensionUri.fsPath, 'webview', 'dist', message.mdPath.replace('__localWebViewPath/', ''))
}
this._logger(filePath + '::: 当前md路径')
try {
const content = fs.readFileSync(filePath, 'utf-8')
webview.postMessage({ type: 'mdContent', data: content })
} catch (error) {
this._logger('获取 md 内容失败' + error)
webview.postMessage({ type: 'mdContent', data: '获取 md 内容失败' })
}
return
}
webview.postMessage({ type: 'mdContent', data: '未正确传入md文件路径' })
}
// 打开工作区的文件
async openWorkspaceFile(relativePath: string) {
// 1. 获取工作区根路径
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri;
if (!workspaceRoot) {
vscode.window.showErrorMessage(" 未检测到工作区");
return;
}
// 2. 拼接完整路径(自动处理跨平台路径)
const targetUri = vscode.Uri.joinPath(workspaceRoot, relativePath);
// 3. 文件存在性检测
try {
await vscode.workspace.fs.stat(targetUri);
} catch {
const createNew = await vscode.window.showInformationMessage(
"文件不存在,是否创建?",
"创建", "取消"
);
if (createNew === "创建") {
await vscode.workspace.fs.writeFile(targetUri, Buffer.from(""));
} else {
return;
}
}
this._logger(targetUri + ':::targetUri')
// 4. 打开文件编辑器
const document = await vscode.workspace.openTextDocument(targetUri);
const editor = await vscode.window.showTextDocument(document);
return { document, editor }
}
} }

View File

@ -16,6 +16,8 @@
"axios": "^1.6.0", "axios": "^1.6.0",
"bufferutil": "^4.0.9", "bufferutil": "^4.0.9",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"highlight.js": "^11.11.1",
"highlightjs-line-numbers.js": "^2.9.0",
"marked": "^15.0.7", "marked": "^15.0.7",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"utf-8-validate": "^6.0.5", "utf-8-validate": "^6.0.5",
@ -1776,6 +1778,21 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/highlightjs-line-numbers.js": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/highlightjs-line-numbers.js/-/highlightjs-line-numbers.js-2.9.0.tgz",
"integrity": "sha512-hMYK5VU+Qi0HmkkdZxamV71ALu9Hq2icQk2WP8OX5q7IPMilSv47ILlJu+fBvxAQdhjW6wONnSQeypsbeRM7WQ==",
"license": "MIT"
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",

View File

@ -18,6 +18,8 @@
"axios": "^1.6.0", "axios": "^1.6.0",
"bufferutil": "^4.0.9", "bufferutil": "^4.0.9",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"highlight.js": "^11.11.1",
"highlightjs-line-numbers.js": "^2.9.0",
"marked": "^15.0.7", "marked": "^15.0.7",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"utf-8-validate": "^6.0.5", "utf-8-validate": "^6.0.5",

View File

@ -0,0 +1,45 @@
# 软件功能方案
## 一、项目概述
### (一)项目背景
在当今数字化时代,数据可视化已成为企业和个人进行数据分析、决策支持以及信息展示的重要手段。本项目旨在满足市场对于高效、便捷、美观的数据可视化工具的需求,进一步拓展和优化 VISSLM BI 的功能,为用户提供更多样化、专业化的数据可视化解决方案。
增强 VISSLM BI 的数据可视化能力、交互性、易用性以及拓展性,使其能够更好地适应不同行业、不同规模用户的多样化数据可视化需求,提升用户在数据处理、分析和展示过程中的效率和体验,为用户提供具体而有价值的数据可视化工具。
- **图表库丰富**
- 基础图库:支持 G2Plot 原生图表和 ECharts 自定义图表,提供 50 + 标准组件,涵盖柱状图、折线图、饼图、雷达图、散点图等多种常见图表类型,满足用户在不同场景下的数据展示需求。
- **快捷键:**
- 支持 win 系统的快捷键操作,提高用户操作效率,例如快速复制、粘贴、删除组件,以及切换不同的编辑模式等功能。
- **蓝图事件**
- 可视化蓝图联动:提供可视化蓝图功能,用户可以通过节点连线的方式,快速配置组件之间的联动效果。例如,当某个图表的数据发生变化时,可以自动触发其他相关图表的更新或显示特定的提示信息,实现数据可视化元素之间的高效交互,增强数据展示的动态性和连贯性。
- **快速主题与滤镜**
- 全局主题与滤镜:支持全局主题设置,用户可以一键切换不同的主题风格,包括颜色、字体、背景等元素,快速调整数据可视化的整体视觉效果。同时,提供滤镜功能,用户可以对图表进行美化处理,如添加阴影、渐变色等效果,提升数据可视化的美观度
### (二)项目目标
增强 VISSLM BI 的数据可视化能力、交互性、易用性以及拓展性,使其能够更好地适应不同行业、不同规模用户的多样化数据可视化需求,提升用户在数据处理、分析和展示过程中的效率和体验,为用户提供具体而有价值的数据可视化工具。
- **图表库丰富**
- 基础图库:支持 G2Plot 原生图表和 ECharts 自定义图表,提供 50 + 标准组件,涵盖柱状图、折线图、饼图、雷达图、散点图等多种常见图表类型,满足用户在不同场景下的数据展示需求。
- **快捷键:**
- 支持 win 系统的快捷键操作,提高用户操作效率,例如快速复制、粘贴、删除组件,以及切换不同的编辑模式等功能。
- **蓝图事件**
- 可视化蓝图联动:提供可视化蓝图功能,用户可以通过节点连线的方式,快速配置组件之间的联动效果。例如,当某个图表的数据发生变化时,可以自动触发其他相关图表的更新或显示特定的提示信息,实现数据可视化元素之间的高效交互,增强数据展示的动态性和连贯性。
- **快速主题与滤镜**
- 全局主题与滤镜:支持全局主题设置,用户可以一键切换不同的主题风格,包括颜色、字体、背景等元素,快速调整数据可视化的整体视觉效果。同时,提供滤镜功能,用户可以对图表进行美化处理,如添加阴影、渐变色等效果,提升数据可视化的美观度
## 二、功能说明
### (一)数据可视化设计
- **图表库丰富**
- 基础图库:支持 G2Plot 原生图表和 ECharts 自定义图表,提供 50 + 标准组件,涵盖柱状图、折线图、饼图、雷达图、散点图等多种常见图表类型,满足用户在不同场景下的数据展示需求。
- **快捷键:**
- 支持 win 系统的快捷键操作,提高用户操作效率,例如快速复制、粘贴、删除组件,以及切换不同的编辑模式等功能。
- **蓝图事件**
- 可视化蓝图联动:提供可视化蓝图功能,用户可以通过节点连线的方式,快速配置组件之间的联动效果。例如,当某个图表的数据发生变化时,可以自动触发其他相关图表的更新或显示特定的提示信息,实现数据可视化元素之间的高效交互,增强数据展示的动态性和连贯性。
- **快速主题与滤镜**
- 全局主题与滤镜:支持全局主题设置,用户可以一键切换不同的主题风格,包括颜色、字体、背景等元素,快速调整数据可视化的整体视觉效果。同时,提供滤镜功能,用户可以对图表进行美化处理,如添加阴影、渐变色等效果,提升数据可视化的美观度。
![image](https://img-s.msn.cn/tenant/amp/entityid/AA1DBjm3.img?w=768&h=512&m=6)
示例:
[打开vscode中的src/test.js:testFunction](__workspace/src/test.js?functionName=resolveTripleslashReference)
格式:
[显示文字](__workspace/[文件的相对路径]?functionName=[函数名])

View File

@ -8,7 +8,8 @@
<a-tab-pane key="chat" tab="CHAT"></a-tab-pane> <a-tab-pane key="chat" tab="CHAT"></a-tab-pane>
<a-tab-pane key="examples" tab="用例"></a-tab-pane> <a-tab-pane key="examples" tab="用例"></a-tab-pane>
<a-tab-pane key="flow" tab="流程图"></a-tab-pane> <a-tab-pane key="flow" tab="流程图"></a-tab-pane>
<a-tab-pane key="docs" tab="文档"></a-tab-pane> <a-tab-pane key="docCode" tab="文档"></a-tab-pane>
<a-tab-pane key="docs" tab="帮助"></a-tab-pane>
</a-tabs> </a-tabs>
<div :class="$style.actions"> <div :class="$style.actions">
<a-button type="text" @click="openSettings"> <a-button type="text" @click="openSettings">
@ -35,6 +36,7 @@ import FlowPanel from './components/FlowPanel.vue';
import DocsPanel from './components/DocsPanel.vue'; import DocsPanel from './components/DocsPanel.vue';
import SettingsModal from './components/SettingsModal.vue'; import SettingsModal from './components/SettingsModal.vue';
import { useThemeStore } from './store/themeStore'; import { useThemeStore } from './store/themeStore';
import DocCodePanel from './components/DocCodePanel.vue';
// //
const activeKey = ref('chat'); const activeKey = ref('chat');
@ -47,6 +49,7 @@ const componentMap = {
chat: ChatPanel, chat: ChatPanel,
examples: ExamplesPanel, examples: ExamplesPanel,
flow: FlowPanel, flow: FlowPanel,
docCode: DocCodePanel,
docs: DocsPanel docs: DocsPanel
}; };

View File

@ -0,0 +1,69 @@
<template>
<div :class="$style.docCodePanel">
<Header :class="$style.header" title="文档" />
<div :class="$style.content">
<MarkdownViewer :file-path="filePath"/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Header from './Header.vue';
import MarkdownViewer from './MarkdownViewer.vue';
//
// const filePath = ref('__localWebViewPath/ruanjian.md')
const filePath = ref('__localWorkspacePath/ruanjian.md')
// if(!window.acquireVsCodeApi){
// filePath.value = '/ruanjian.md'
// }
/*
//#region wsdoc
if(window.vscodeApi){
window.vscodeApi.postMessage({
type: 'ws:connect',
})
window.addEventListener('message', event => {
if (event.data.type === 'ws:open' && window.vscodeApi) {
window.vscodeApi.postMessage({
type: 'log',
message: 'ws连接成功'
})
let selected_text = {
"filepath": "example\\\\game.py", //
"range": {
"start": { "line": 33, "character": 0 }, // 330
"end": { "line": 45, "character": 41 } // 4541
},
"text": `def change_direction(new_direction): \n\n\tglobal direction \n\n\tif new_direction == 'left': \n\t\tif direction != 'right': \n\t\t\tdirection = new_direction \n\telif new_direction == 'right': \n\t\tif direction != 'left': \n\t\t\tdirection = new_direction \n\telif new_direction == 'up': \n\t\tif direction != 'down': \n\t\t\tdirection = new_direction \n\telif new_direction == 'down': \n\t\tif direction != 'up': \n\t\t\tdirection = new_direction` //
}
let req = {
"cmd": "exec_docstring",
"request_id": 123, //
"model": "local qwen2.5-coder:7b", //
"stream": false,
"selected_text": selected_text,
}
window.vscodeApi.postMessage({
type: 'ws:send',
data: JSON.stringify(req)
})
}
})
}
//#endregion
*/
</script>
<style module lang="scss">
.docCodePanel {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--vscode-editor-background);
}
.header {
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,56 @@
<template>
<Tree :tree-data="treeData" :class="$style.tree" :expandedKeys="expandedKeys" :selectedKeys="selectedKeys" :blockNode="true"
@expand="handleExpand" @select="handleSelect" />
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { Tree } from 'ant-design-vue';
interface TreeNode {
key: number;
level: number;
title: string;
anchor: number;
children?: TreeNode[];
}
const props = defineProps<{
data: TreeNode[];
}>();
//
const expandedKeys = ref<number[]>([]);
const selectedKeys = ref<number[]>([]);
//
const treeData = computed(() => {
const result: TreeNode[] = props.data;
//
expandedKeys.value = result.map(n => n.key);
return result;
});
// /
const handleExpand: any = (keys: number[]) => {
expandedKeys.value = keys;
};
//
const handleSelect: any = (keys: number[], { node }: { node: { dataRef: TreeNode } }) => {
selectedKeys.value = keys;
//
// window.location.hash = `#${node.dataRef.key}`;
let element = document.querySelector(`[id="${node.dataRef.key}"]`)
element?.scrollIntoView({
behavior: 'smooth',
block: 'center'
})
};
</script>
<style module lang="scss">
.tree {
background-color: var(--vscode-editor-background);
}
</style>

View File

@ -0,0 +1,489 @@
<template>
<div class="markdown-viewer">
<!-- 目录侧边栏 -->
<aside class="toc-sidebar" ref="tocSidebar">
<div class="toc-header">
<h2>目录</h2>
</div>
<div class="toc-content">
<MDOutlineTree :data="tocTreeData" />
</div>
</aside>
<!-- 主内容区域 -->
<main class="content" ref="content" @click="handleDelegateClick">
<div v-if="loading" class="loading-indicator">加载中...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<div v-else v-html="renderedContent" class="markdown-content"></div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, onBeforeMount, unref } from 'vue';
import { marked } from 'marked';
import { throttle } from 'lodash-es';
import MDOutlineTree from './MDOutlineTree.vue'
let hljs;
const hljsPms = import('highlight.js').then(res => {
hljs = window.hljs = res.default;
return import('highlightjs-line-numbers.js');
}).then(() => {
hljs.initLineNumbersOnLoad();
return hljs;
});
const props = defineProps({
filePath: {
type: String,
required: true
},
debounceTime: {
type: Number,
default: 100
}
});
//
const loading = ref(true);
const error = ref(null);
const markdownText = ref('');
const renderedContent = ref('');
const toc = ref([]);
const activeSection = ref('');
const content = ref(null);
const tocSidebar = ref(null);
const tocTreeData = ref([])
// marked
const renderer = new marked.Renderer();
renderer.code = (code, language) => {
const highlighted = hljs.highlightAuto(code).value;
return `
<div class="code-block">
<pre class="hljs"><code>${highlighted}</code></pre>
</div>
`;
};
marked.setOptions({ renderer });
// h
renderer.heading = ({ text, depth }) => {
const escapedText = toc.value.length
// text.toLowerCase().replace(/[^\w]+/g, '-');
toc.value.push({
id: escapedText,
level: depth,
title: text,
anchor: escapedText
})
const node = {
key: escapedText,
title: text
}
let parent;
let currParent;
let data;
let loopData = data = unref(tocTreeData.value);
let d = depth;
while (d >= 1) {
if (d === 1) {
loopData.push(node);
}
parent = loopData.at(-1);
if (!parent) {
loopData.push(parent = { key: Math.rendom(), children: [] });
}
loopData = parent.children = parent.children ?? [];
d--;
}
return `
<h${depth} id="${escapedText}">
<a name="${escapedText}" class="anchor" href="#${escapedText}">
<span class="header-link"></span>
</a>
${text}
</h${depth}>`;
};
//
const renderList = renderer.listitem
// ,
renderer.listitem = (src) => {
return renderList.call(renderer, src);
}
const handleDelegateClick = (event) => {
//
const target = event.target.closest('.openFileAndGoToFunction')
if (target) {
const { filepath, functionname } = target.dataset
console.log(filepath, functionname, '_**=== filepath, functionname');
console.log(target.dataset, '_**=== target.dataset');
if (window.acquireVsCodeApi && window.vscodeApi) {
window.vscodeApi.postMessage({
type: 'openFileAndGoToFunction',
filePath: filepath,
functionName: functionname
});
}
}
}
const renderLink = renderer.link
renderer.link = function (src) {
/*
{
"type": "link",
"raw": "[打开vscode中的文件1](__workspace/)",
"href": "__workspace/",
"title": null,
"text": "打开vscode中的文件1",
"tokens": [
{
"type": "text",
"raw": "打开vscode中的文件1",
"text": "打开vscode中的文件1",
"escaped": false
}
]
}
*/
//
if (src.href.startsWith("__workspace/")) {
const { href, title, tokens } = src
const text = renderer.parser.parseInline(tokens);
// const cleanHref = cleanUrl(href);
const titleTip = href.slice("__workspace/".length);
const [filePath, functionName] = titleTip.split('?functionName=')
console.log(filePath, functionName, '_**=== filePath, functionName');
return `<a href="javascript:void 0" class="openFileAndGoToFunction" title="点击打开关联文件函数: ${titleTip}" data-href="${href}" data-filepath="${filePath}" data-functionname="${functionName}">${text}</a>`
}
return renderLink.call(renderer, src);
}
marked.setOptions({
renderer,
highlight: (code, lang) => {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(lang, code).value;
}
return hljs.highlightAuto(code).value;
},
pedantic: false,
gfm: true,
breaks: false,
sanitize: false,
smartLists: true,
smartypants: false,
xhtml: false
});
// Markdown
const loadMarkdownFile = async () => {
try {
const filePath = props.filePath
loading.value = true;
error.value = null;
let fetchMdPms
if (filePath.startsWith('__localWebViewPath/') || filePath.startsWith('__localWorkspacePath/')) {
if (!window.vscodeApi) {
console.error('获取__localWebViewPath的md文档时, 无法获取VSCode API')
return
}
window.vscodeApi.postMessage({
type: 'log',
message: '[MarkdownViewer] 获取本地markdown...' + filePath
});
window.vscodeApi.postMessage({
type: 'loadMd',
mdPath: filePath
});
window.addEventListener('message', event => {
if (event.data.type === 'mdContent') {
window.vscodeApi.postMessage({
type: 'log',
message: '[MarkdownViewer] 获取本地markdown成功'
});
markdownText.value = event.data.data
}
});
} else {
fetchMdPms = fetch(props.filePath);
const response = await fetchMdPms
if (!response.ok) throw new Error('无法加载Markdown文件');
markdownText.value = await response.text();
}
await hljsPms;
//
hljs.highlightAll();
renderMarkdown();
} catch (err) {
error.value = err.message;
console.error('加载Markdown文件失败:', err);
} finally {
loading.value = false;
}
};
// Markdown
const renderMarkdown = () => {
toc.value = []; //
renderedContent.value = marked(markdownText.value);
// DOM
nextTick(() => {
document.querySelectorAll('pre code').forEach(block => {
hljs.highlightElement(block);
});
// , todo
// setupHeadingObservers();
});
};
// IntersectionObserver
let observer = null;
const setupHeadingObservers = () => {
// observer
if (observer) {
observer.disconnect();
}
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
activeSection.value = entry.target.id;
}
});
},
{
root: null,
rootMargin: '0px 0px -50% 0px',
threshold: [0, 0.5, 1]
}
);
headings.forEach(heading => {
observer.observe(heading);
});
};
//
const scrollToHeading = (anchor) => {
const element = document.getElementById(anchor);
if (element) {
//
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
// URL
history.replaceState(null, null, `#${anchor}`);
}
};
//
const handleResize = throttle(() => {
if (window.innerWidth >= 768) {
}
}, props.debounceTime);
//
onBeforeMount(() => {
loadMarkdownFile();
})
onMounted(() => {
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (observer) {
observer.disconnect();
}
});
</script>
<style scoped>
/* 行号容器样式 */
.hljs-ln-numbers {
text-align: center;
border-right: 1px solid;
/* 右侧分割线 */
padding-right: 10px;
}
/* 代码块容器 */
.code-block {
position: relative;
padding-left: 3em;
/* 留出行号空间 */
}
/* 行号与代码对齐 */
.hljs-ln td:first-child {
vertical-align: top;
}
.markdown-viewer {
display: flex;
/* min-height: 100vh; */
position: relative;
}
.toc-sidebar {
width: min(30vw, 280px);
height: calc(100vh - 100px);
overflow-y: auto;
padding: 20px;
box-sizing: border-box;
border-right: 1px solid;
position: sticky;
top: 0;
transition: transform 0.3s ease;
z-index: 10;
}
.toc-header {
padding-bottom: 15px;
border-bottom: 1px solid;
margin-bottom: 15px;
}
.toc-header h2 {
margin: 0;
font-size: 1.2rem;
}
.toc-content {
height: calc(100vh - 200px);
}
.toc-content ul {
list-style: none;
padding: 0;
margin: 0;
}
.toc-content li {
margin: 6px 0;
line-height: 1.4;
}
.toc-content a {
display: block;
padding: 4px 8px;
text-decoration: none;
border-radius: 3px;
transition: all 0.2s;
font-size: 0.9rem;
}
.toc-content a.active {
font-weight: 600;
}
.content {
flex: 1;
height: calc(100vh - 100px);
padding: 16px;
overflow-y: auto;
max-width: 800px;
margin: 0 auto;
}
.loading-indicator,
.error-message {
padding: 20px;
text-align: center;
}
/* Markdown内容样式 */
.markdown-content {
line-height: 1.6;
}
.markdown-content :deep(h1),
.markdown-content :deep(h2),
.markdown-content :deep(h3),
.markdown-content :deep(h4),
.markdown-content :deep(h5),
.markdown-content :deep(h6) {
margin-top: 1.5em;
margin-bottom: 0.8em;
position: relative;
}
.markdown-content :deep(h1) {
font-size: 2em;
padding-bottom: 0.3em;
}
.markdown-content :deep(h2) {
font-size: 1.5em;
padding-bottom: 0.3em;
}
.markdown-content :deep(.anchor) {
position: absolute;
left: -20px;
opacity: 0;
transition: opacity 0.2s;
}
.markdown-content :deep(h1:hover .anchor),
.markdown-content :deep(h2:hover .anchor),
.markdown-content :deep(h3:hover .anchor),
.markdown-content :deep(h4:hover .anchor),
.markdown-content :deep(h5:hover .anchor),
.markdown-content :deep(h6:hover .anchor) {
opacity: 1;
}
/* 移动端样式 */
@media (max-width: 400px) {
.mobile-menu-button {
display: block;
}
.toc-sidebar {
position: fixed;
top: 100px;
bottom: 0;
left: 0;
transform: translateX(-100%);
width: 30%;
max-width: 300px;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
}
.markdown-viewer.mobile-menu-open .toc-sidebar {
transform: translateX(0);
}
.content {
padding: 20px;
width: 100%;
max-width: none;
}
}
/* 代码块样式 */
.markdown-content :deep(pre) {
border-radius: 6px;
padding: 16px;
overflow: auto;
line-height: 1.45;
margin-bottom: 16px;
}
.markdown-content :deep(code) {
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%;
}
</style>

View File

@ -4,7 +4,9 @@ import { resolve } from 'path';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue({
include: [/\.vue$/, /\.md$/] // <-- allows Vue to compile Markdown files
})],
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, 'src'), '@': resolve(__dirname, 'src'),