Private
Public Access
1
0

优化:文件服务器安全重构+编辑器增强+搜索排序+更新面板Markdown渲染

- 路径校验提取validateFilePath+sentinel error替代字符串匹配
- requireUpdateAPI收敛7处重复nil检查
- 端口18765统一为8073,消除分散魔法数字
- CodeMirror添加搜索功能+滚动位置LRU缓存恢复
- 文件列表新增列排序+搜索过滤
- Toolbar重排:快捷访问内嵌+搜索框集成+历史改图标
- 重命名零闪烁:updateFilePath草稿迁移
- changelog用marked渲染+sanitizeHtml防XSS
- MigrateTabConfig扩展map驱动覆盖openclaw-manager→version迁移

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 21:53:31 +08:00
parent 691e38604f
commit 72fef3e56f
23 changed files with 614 additions and 257 deletions

72
app.go
View File

@@ -3,7 +3,6 @@ package main
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
stdruntime "runtime"
@@ -30,7 +29,6 @@ type App struct {
updateAPI *api.UpdateAPI
configAPI *api.ConfigAPI
pdfAPI *api.PdfAPI
fileServer *http.Server
filesystem *filesystem.FileSystemService
isAlwaysOnTop bool
}
@@ -194,7 +192,7 @@ func (a *App) startFileServer() {
return
}
fmt.Println("[文件服务器] 启动在 http://localhost:18765")
fmt.Println("[文件服务器] 启动在 http://localhost:8073")
}
// Shutdown 应用关闭时调用
@@ -415,7 +413,7 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
// Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置)
folderGUIDs := map[string]string{
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
"documents": "{D20B4C7F-5EA7-40D4B25E-039F6F1FCC8A}",
"documents": "{D20B4C7F-5EA7-424C-B25E-039F6F1FCC8A}",
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
}
for name, guid := range folderGUIDs {
@@ -603,68 +601,84 @@ func (a *App) ListSqlTabs() ([]map[string]interface{}, error) {
// ========== 版本更新管理接口 ==========
// CheckUpdate 检查更新UpdateAPI 可能尚未初始化完成)
func (a *App) CheckUpdate() (map[string]interface{}, error) {
// requireUpdateAPI 检查 updateAPI 是否已初始化,未初始化返回统一错误
func (a *App) requireUpdateAPI() (*api.UpdateAPI, error) {
if a.updateAPI == nil {
return nil, fmt.Errorf("更新功能正在初始化中")
}
return a.updateAPI.CheckUpdate()
return a.updateAPI, nil
}
// CheckUpdate 检查更新UpdateAPI 可能尚未初始化完成)
func (a *App) CheckUpdate() (map[string]interface{}, error) {
api, err := a.requireUpdateAPI()
if err != nil {
return nil, err
}
return api.CheckUpdate()
}
// GetCurrentVersion 获取当前版本号
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
if a.updateAPI == nil {
return nil, fmt.Errorf("更新功能正在初始化中")
api, err := a.requireUpdateAPI()
if err != nil {
return nil, err
}
return a.updateAPI.GetCurrentVersion()
return api.GetCurrentVersion()
}
// GetUpdateConfig 获取更新配置
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
if a.updateAPI == nil {
return nil, fmt.Errorf("更新功能正在初始化中")
api, err := a.requireUpdateAPI()
if err != nil {
return nil, err
}
return a.updateAPI.GetUpdateConfig()
return api.GetUpdateConfig()
}
// SetUpdateConfig 设置更新配置
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
if a.updateAPI == nil {
return nil, fmt.Errorf("更新功能正在初始化中")
api, err := a.requireUpdateAPI()
if err != nil {
return nil, err
}
return a.updateAPI.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
return api.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
}
// DownloadUpdate 下载更新包
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
if a.updateAPI == nil {
return nil, fmt.Errorf("更新功能正在初始化中")
api, err := a.requireUpdateAPI()
if err != nil {
return nil, err
}
return a.updateAPI.DownloadUpdate(downloadURL)
return api.DownloadUpdate(downloadURL)
}
// InstallUpdate 安装更新包
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
if a.updateAPI == nil {
return nil, fmt.Errorf("更新功能正在初始化中")
api, err := a.requireUpdateAPI()
if err != nil {
return nil, err
}
return a.updateAPI.InstallUpdate(installerPath, autoRestart)
return api.InstallUpdate(installerPath, autoRestart)
}
// InstallUpdateWithHash 安装更新包(带哈希验证)
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
if a.updateAPI == nil {
return nil, fmt.Errorf("更新功能正在初始化中")
api, err := a.requireUpdateAPI()
if err != nil {
return nil, err
}
return a.updateAPI.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
return api.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
}
// VerifyUpdateFile 验证更新文件哈希值
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
if a.updateAPI == nil {
return nil, fmt.Errorf("更新功能正在初始化中")
api, err := a.requireUpdateAPI()
if err != nil {
return nil, err
}
return a.updateAPI.VerifyUpdateFile(filePath, expectedHash, hashType)
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
}
// startAutoUpdateCheck 启动自动更新检查
@@ -753,7 +767,7 @@ func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
// GetFileServerURL 获取本地文件服务器的URL
func (a *App) GetFileServerURL() string {
return "http://localhost:18765"
return "http://localhost:8073"
}
// DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -136,40 +136,66 @@ func (api *ConfigAPI) SaveAppConfig(req SaveAppConfigRequest) (map[string]interf
}, nil
}
// MigrateTabConfig 迁移旧配置
// MigrateTabConfig 迁移旧配置device 移除 + openclaw-manager 重命名)
func (api *ConfigAPI) MigrateTabConfig() error {
config, _ := api.configService.GetTabConfig()
if config == nil {
return nil
}
// 检查是否包含 device
hasDevice := false
needMigrate := false
// 检查是否包含需要迁移的旧 key
for _, tab := range config.AvailableTabs {
if tab.Key == "device" {
hasDevice = true
if tab.Key == "device" || tab.Key == "openclaw-manager" {
needMigrate = true
break
}
}
if !hasDevice {
if !needMigrate {
return nil
}
// 过滤掉 device
// 映射:旧 key → 新 key不需要的移除
keyMap := map[string]string{
"openclaw-manager": "version",
// "device": "" // 直接过滤
}
newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs))
newVisible := make([]string, 0, len(config.VisibleTabs))
seenKeys := map[string]bool{}
for _, tab := range config.AvailableTabs {
if tab.Key != "device" {
newKey, shouldRename := keyMap[tab.Key]
if shouldRename {
if newKey == "" {
continue // 移除(如 device
}
if seenKeys[newKey] {
continue // 避免重复
}
seenKeys[newKey] = true
newTabs = append(newTabs, service.TabDefinition{Key: newKey, Title: tab.Title, Enabled: tab.Enabled})
} else {
newTabs = append(newTabs, tab)
}
}
for _, key := range config.VisibleTabs {
if key != "device" {
if newKey, ok := keyMap[key]; ok {
if newKey != "" && !seenKeys[newKey] {
newVisible = append(newVisible, newKey)
}
// newKey == "" 时跳过(如 device
} else {
newVisible = append(newVisible, key)
}
}
defaultTab := config.DefaultTab
if newKey, ok := keyMap[defaultTab]; ok && newKey != "" {
defaultTab = newKey
}
if defaultTab == "device" {
defaultTab = "file-system"
}

View File

@@ -2,6 +2,7 @@ package filesystem
import (
"context"
"errors"
"fmt"
"log"
"net/http"
@@ -48,6 +49,35 @@ var (
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
var attrRegexCache sync.Map // map[string]*regexp.Regexp
// 路径校验 sentinel error用 errors.Is 匹配,不依赖字符串)
var (
ErrPathInvalidEncoding = fmt.Errorf("invalid path encoding")
ErrPathTraversal = fmt.Errorf("path traversal detected")
ErrPathUnsafe = fmt.Errorf("unsafe path")
)
// validateFilePath 校验文件路径安全性URL解码 + 路径遍历检测 + 安全检查)
// 返回清理后的绝对路径,或 sentinel error
func validateFilePath(rawPath string, logPrefix string) (string, error) {
decodedPath, err := url.QueryUnescape(rawPath)
if err != nil {
return "", ErrPathInvalidEncoding
}
if strings.Contains(decodedPath, "..") {
return "", ErrPathTraversal
}
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
filePath = filepath.Clean(filePath)
if !isSafePath(filePath) {
return "", ErrPathUnsafe
}
return filePath, nil
}
// LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
type LocalFileServer struct {
server *http.Server
@@ -75,7 +105,7 @@ func StartLocalFileServer() (string, error) {
// 创建服务器(固定端口)
server := &http.Server{
Addr: "localhost:18765",
Addr: "localhost:8073",
Handler: mux,
}
@@ -90,7 +120,7 @@ func StartLocalFileServer() (string, error) {
localFileServer = &LocalFileServer{
server: server,
addr: "localhost:18765",
addr: "localhost:8073",
}
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
@@ -125,7 +155,6 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
log.Printf("[LocalFileHandler] TrimPrefix 后: %s", pathPart)
if pathPart == "" || pathPart == r.URL.Path {
log.Printf("[LocalFileHandler] 路径前缀无效")
@@ -133,33 +162,23 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
return
}
// 🔒 修复:先进行URL解码,防止路径遍历攻击
decodedPath, err := url.QueryUnescape(pathPart)
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]")
if err != nil {
log.Printf("[LocalFileHandler] URL解码失败: %v", err)
log.Printf("[LocalFileHandler] 路径校验失败: %v (%s)", err, pathPart)
switch {
case errors.Is(err, ErrPathInvalidEncoding):
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
return
}
log.Printf("[LocalFileHandler] URL解码后: %s", decodedPath)
// 🔒 修复:在路径转换前检查是否包含危险字符
if strings.Contains(decodedPath, "..") {
log.Printf("[LocalFileHandler] 检测到路径遍历尝试")
case errors.Is(err, ErrPathTraversal):
http.Error(w, "Path traversal detected", http.StatusForbidden)
return
}
// 路径转换(统一使用反斜杠)
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
filePath = filepath.Clean(filePath)
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
// 安全检查
if !isSafePath(filePath) {
log.Printf("[LocalFileHandler] 路径未通过安全检查: %s", filePath)
case errors.Is(err, ErrPathUnsafe):
http.Error(w, "Unsafe path", http.StatusForbidden)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
return
}
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
// 🔒 文件类型白名单检查
ext := strings.ToLower(filepath.Ext(filePath))
@@ -459,33 +478,31 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
}
// 解析参数
filePath := r.URL.Query().Get("path")
var err error
if filePath, err = url.QueryUnescape(filePath); err != nil {
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
return
}
rawPath := r.URL.Query().Get("path")
theme := r.URL.Query().Get("theme")
if theme == "" {
theme = "light"
}
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
// 安全检查
if !isSafePath(filePath) {
log.Printf("[HtmlPreview] 路径未通过安全检查: %s", filePath)
http.Error(w, "Unsafe path", http.StatusForbidden)
return
}
// 检查路径遍历攻击
if strings.Contains(filePath, "..") {
log.Printf("[HtmlPreview] 检测到路径遍历尝试: %s", filePath)
// 校验路径安全性URL解码 + 路径遍历检测 + 安全检查)
filePath, err := validateFilePath(rawPath, "[HtmlPreview]")
if err != nil {
log.Printf("[HtmlPreview] 路径校验失败: %v (%s)", err, rawPath)
switch {
case errors.Is(err, ErrPathInvalidEncoding):
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
case errors.Is(err, ErrPathTraversal):
http.Error(w, "Path traversal detected", http.StatusForbidden)
case errors.Is(err, ErrPathUnsafe):
http.Error(w, "Unsafe path", http.StatusForbidden)
default:
http.Error(w, err.Error(), http.StatusBadRequest)
}
return
}
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
// 读取文件
content, err := os.ReadFile(filePath)
if err != nil {

View File

@@ -44,9 +44,9 @@ var defaultTabConfig = TabConfig{
{Key: "file-system", Title: "文件管理", Enabled: true},
{Key: "db-cli", Title: "数据库", Enabled: true},
{Key: "markdown-editor", Title: "Markdown", Enabled: true},
{Key: "openclaw-manager", Title: "OpenClaw", Enabled: true},
{Key: "version", Title: "版本历史", Enabled: true},
},
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "openclaw-manager"},
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "version"},
DefaultTab: "file-system",
}

12
web/package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.3",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.8",
@@ -414,6 +415,17 @@
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.3",
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.3.tgz",

View File

@@ -25,6 +25,7 @@
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.3",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.8",

View File

@@ -1 +1 @@
0e1fafcbb6b28922a38f6c5316932015
c0e9e27e045c6118704c87fcf34a03de

View File

@@ -101,12 +101,13 @@ import FileSystem from './components/FileSystem/index.vue'
import SettingsPanel from './components/SettingsPanel.vue'
import UpdateNotification from './components/UpdateNotification.vue'
import {useUpdateStore} from './stores/update'
import {useConfigStore} from './stores/config'
import {useConfigStore, type AppConfig} from './stores/config'
// 存储键
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
// 从 localStorage 恢复上次打开的区域,默认为 'file-system'
// 兼容旧版:'user' 是 v0.2.x 之前的 tab key已废弃需迁移
const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
const showSettings = ref(false)
@@ -125,7 +126,7 @@ const appConfig = computed(() => configStore.appConfig)
const visibleTabs = computed(() => configStore.visibleTabs)
// 保存配置
const handleSaveConfig = async (config) => {
const handleSaveConfig = async (config: AppConfig) => {
try {
await configStore.saveConfig(config)
showSettings.value = false
@@ -148,7 +149,7 @@ const loadConfig = async () => {
}
// 获取组件
const getComponent = (key) => {
const getComponent = (key: string) => {
const components = {
'file-system': FileSystem,
'db-cli': DbCli,
@@ -376,4 +377,9 @@ watch(activeTab, (newTab) => {
.arco-tooltip {
--wails-draggable: no-drag;
}
/* 桌面应用:禁止 html/body 级别滚动条,所有滚动由内部组件自行处理 */
html, body {
overflow: hidden !important;
}
</style>

View File

@@ -3,34 +3,25 @@
</template>
<script setup>
import { ref, onMounted, watch, onBeforeUnmount, computed, nextTick } from 'vue'
import { ref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'
import {
EditorView, lineNumbers, highlightActiveLineGutter, keymap,
EditorState, Compartment,
defaultKeymap, history,
bracketMatching, defaultHighlightStyle, syntaxHighlighting,
oneDark
oneDark,
openSearchPanel, search
} from '@/utils/codemirrorExports'
import { useThemeStore } from '@/stores/theme'
import { loadLanguageExtension, getLanguageFromExtension } from '@/utils/codeMirrorLoader'
// ==================== 主题定义 ====================
// 亮色主题的基础样式
const lightTheme = EditorView.theme({
'&': { backgroundColor: '#ffffff' },
'.cm-gutters': { backgroundColor: '#f7f7f7', color: '#999', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#e8e8e8', color: '#333' },
'.cm-line': { caretColor: '#000' },
'.cm-selection': { backgroundColor: '#d9d9d9' },
'.cm-cursor': { borderLeftColor: '#000' }
})
// ==================== Props & Emits ====================
const props = defineProps({
modelValue: { type: String, required: true },
fileExtension: { type: String, default: '' }
fileExtension: { type: String, default: '' },
filePath: { type: String, default: '' },
fileMtime: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
@@ -41,6 +32,36 @@ const themeStore = useThemeStore()
const editorContainer = ref(null)
let view = null
// 滚动位置缓存LRU 最多 5 份,每份 3 分钟过期
const MAX_SCROLL_CACHE = 5
const SCROLL_CACHE_TTL = 3 * 60 * 1000 // 3 分钟
const fileScrollPositions = new Map() // filePath → { scrollTop, anchor, timestamp }
let currentFilePath = ''
let saveScrollTimer = null
// 清理过期缓存 + LRU 淘汰,保持最多 MAX_SCROLL_CACHE 条
const cleanScrollCache = () => {
const now = Date.now()
// 清理过期的
for (const [key, val] of fileScrollPositions) {
if (now - val.timestamp > SCROLL_CACHE_TTL) {
fileScrollPositions.delete(key)
}
}
// LRU超出上限时删除最旧的
if (fileScrollPositions.size > MAX_SCROLL_CACHE) {
let oldestKey = null
let oldestTime = Infinity
for (const [key, val] of fileScrollPositions) {
if (val.timestamp < oldestTime) {
oldestTime = val.timestamp
oldestKey = key
}
}
if (oldestKey) fileScrollPositions.delete(oldestKey)
}
}
// 使用 Compartment 实现动态切换,避免重建编辑器
const themeCompartment = new Compartment()
const languageCompartment = new Compartment()
@@ -87,6 +108,9 @@ const createExtensions = () => {
keymap.of(defaultKeymap),
bracketMatching(),
// 查找替换Ctrl+F / Ctrl+H
search(),
// 内容更新监听(带防抖)
EditorView.updateListener.of((update) => {
if (update.docChanged) {
@@ -98,7 +122,7 @@ const createExtensions = () => {
EditorView.theme({
'&': { height: '100%', fontSize: '13px' },
'.cm-scroller': { overflow: 'auto', fontFamily: 'Consolas, Monaco, Courier New, monospace' },
'.cm-content': { padding: '8px', minHeight: '100%' },
'.cm-content': { padding: '8px' },
'.cm-line': { padding: '0 0' },
'&.cm-focused': { outline: 'none' }
}),
@@ -143,6 +167,12 @@ const createEditor = (docContent = '') => {
view = new EditorView({ state, parent: editorContainer.value })
// 滚动时防抖保存位置
view.scrollDOM.addEventListener('scroll', () => {
if (saveScrollTimer) clearTimeout(saveScrollTimer)
saveScrollTimer = setTimeout(saveScrollPosition, 200)
}, { passive: true })
// 初始化语言
initLanguage()
}
@@ -163,8 +193,10 @@ onMounted(() => {
})
onBeforeUnmount(() => {
if (emitTimeout) {
clearTimeout(emitTimeout)
if (emitTimeout) clearTimeout(emitTimeout)
if (saveScrollTimer) clearTimeout(saveScrollTimer)
if (view?.scrollDOM) {
view.scrollDOM.removeEventListener('scroll', saveScrollPosition)
}
view?.destroy()
view = null
@@ -172,12 +204,64 @@ onBeforeUnmount(() => {
// ==================== 监听器 ====================
// 监听外部内容变化
watch(() => props.modelValue, (newValue) => {
if (view && newValue !== view.state.doc.toString()) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' }
// 保存当前文件滚动位置(防抖)
const saveScrollPosition = () => {
if (!view || !currentFilePath) return
const scroller = view.scrollDOM
if (!scroller) return
fileScrollPositions.set(currentFilePath, {
scrollTop: scroller.scrollTop,
anchor: view.state.selection.main.anchor,
timestamp: Date.now()
})
cleanScrollCache()
}
// 监听外部内容变化(切换文件/文件变更时触发)
watch([() => props.modelValue, () => props.fileMtime], ([newValue, newMtime], [oldValue, oldMtime]) => {
// 文件修改时间变了 → 说明磁盘内容有变更 → 强制刷新
const mtimeChanged = newMtime && oldMtime && newMtime !== oldMtime
if (view && (mtimeChanged || newValue !== view.state.doc.toString())) {
// 先保存旧文件的滚动位置
saveScrollPosition()
const newPath = props.filePath || ''
const isSameFile = currentFilePath && currentFilePath === newPath
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' },
selection: { anchor: 0 }
})
currentFilePath = newPath
if (isSameFile && fileScrollPositions.has(newPath)) {
// 同一文件 → 检查是否过期,未过期则恢复位置
const saved = fileScrollPositions.get(newPath)
if (saved && Date.now() - saved.timestamp <= SCROLL_CACHE_TTL) {
nextTick(() => {
if (view) {
view.dispatch({
selection: { anchor: saved.anchor },
effects: EditorView.scrollIntoView(saved.anchor)
})
view.scrollDOM.scrollTop = saved.scrollTop
}
})
} else {
// 过期了 → 强制滚动到顶部
nextTick(() => {
if (view) view.scrollDOM.scrollTop = 0
})
}
} else {
// 不同文件 → 强制滚动到顶部scrollIntoView 不一定重置 DOM scrollTop
nextTick(() => {
if (view) {
view.scrollDOM.scrollTop = 0
}
})
}
}
})
@@ -214,6 +298,6 @@ watch(() => props.fileExtension, () => {
}
.codemirror-editor :deep(.cm-content) {
height: 100%;
/* 不设 height让 CodeMirror 虚拟滚动自行计算文档高度 */
}
</style>

View File

@@ -155,6 +155,8 @@
<AsyncCodeEditor
:model-value="config.fileContent"
:file-extension="config.currentFileExtension"
:file-path="config.currentFileFullPath"
:file-mtime="config.fileMtime"
@update:model-value="handleContentUpdate"
class="code-editor"
/>
@@ -218,6 +220,8 @@
<AsyncCodeEditor
:model-value="config.fileContent"
:file-extension="config.currentFileExtension"
:file-path="config.currentFileFullPath"
:file-mtime="config.fileMtime"
@update:model-value="handleContentUpdate"
class="code-editor"
/>
@@ -284,6 +288,8 @@
<AsyncCodeEditor
:model-value="config.fileContent"
:file-extension="config.currentFileExtension"
:file-path="config.currentFileFullPath"
:file-mtime="config.fileMtime"
@update:model-value="handleContentUpdate"
class="code-editor"
/>
@@ -337,6 +343,8 @@
<AsyncCodeEditor
:model-value="config.fileContent"
:file-extension="config.currentFileExtension"
:file-path="config.currentFileFullPath"
:file-mtime="config.fileMtime"
@update:model-value="handleContentUpdate"
class="code-editor"
/>
@@ -430,7 +438,7 @@ const htmlPreviewUrl = computed(() => {
return ''
}
const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
return `http://localhost:18765/localfs/html-preview?path=${encodedPath}`
return `http://localhost:8073/localfs/html-preview?path=${encodedPath}`
})
// 计算属性:判断文件是否在当前目录
@@ -775,11 +783,11 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
// 处理 HTML iframe 发送的消息(链接点击)
const handleHtmlIframeMessage = (event: MessageEvent) => {
// 安全检查:接受来自本地文件服务器或同源的消息
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:18765
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
const allowedOrigins = [
window.location.origin,
'null', // about:blank 或 data: URL
'http://localhost:18765', // 本地文件服务器
'http://localhost:8073', // 本地文件服务器
]
if (!allowedOrigins.includes(event.origin)) {
return

View File

@@ -4,7 +4,7 @@
<span class="panel-title">📋 文件列表</span>
<div class="panel-header-right">
<span class="panel-count">{{ config.fileList.length }} </span>
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '150px' }">
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }">
<a-button size="mini" type="text" class="settings-btn">
<icon-more />
</a-button>
@@ -31,6 +31,18 @@
:disabled="col.key === 'name' && visibleCount <= 1"
@change="(val: boolean) => toggleColumn(col.key, val)"
>{{ col.label }}</a-checkbox>
<!-- 可排序列点击图标排序 -->
<span
v-if="colSortMap[col.key]"
class="col-sort-icon"
:class="{ 'col-sort-active': sortBy === colSortMap[col.key] }"
:title="sortBy === colSortMap[col.key] ? (sortOrder === 'asc' ? '升序 → 点击降序' : '降序 → 点击升序') : `按${col.label}排序`"
@click.stop="emit('sort', colSortMap[col.key])"
>
<IconSort v-if="sortBy !== colSortMap[col.key]" />
<IconSortAscending v-else-if="sortOrder === 'asc'" />
<IconSortDescending v-else />
</span>
</div>
</template>
</a-popover>
@@ -101,6 +113,14 @@ interface Emits {
const emit = defineEmits<Emits>()
// 列 key → 排序字段映射
const colSortMap: Record<string, string> = {
icon: 'type',
name: 'name',
time: 'modified_time',
size: 'size'
}
// ========== 列配置(支持显隐 + 排序) ==========
const COL_SETTINGS_KEY = STORAGE_KEYS.FILESYSTEM.COL_SETTINGS
const SHOW_HEADER_KEY = STORAGE_KEYS.FILESYSTEM.SHOW_HEADER
@@ -139,6 +159,7 @@ function loadColSettings(): ColumnConfig[] {
}
const colSettings = ref<ColumnConfig[]>(loadColSettings())
// 默认显示表头localStorage 无值时兼容旧行为)
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) !== 'false')
// 手动持久化(避免 deep watch 频繁写入)
@@ -382,6 +403,25 @@ defineExpose({ focusEditingItem })
color: var(--color-text-2);
}
/* 列项排序图标 */
.col-sort-icon {
margin-left: auto;
font-size: 12px;
color: var(--color-text-4);
cursor: pointer;
padding: 2px;
border-radius: 3px;
flex-shrink: 0;
transition: all 0.15s;
}
.col-sort-icon:hover {
background: var(--color-fill-2);
color: var(--color-text-2);
}
.col-sort-active {
color: rgb(var(--primary-6));
}
/* 滚动容器 */
.file-list-wrapper {
flex: 1;

View File

@@ -27,6 +27,22 @@
</div>
<!-- 正常模式面包屑导航 -->
<div v-else class="path-breadcrumb-wrapper">
<!-- 快捷访问仅图标面包屑前 -->
<a-dropdown>
<a-button size="mini" type="text">
<template #icon><icon-forward /></template>
</a-button>
<template #content>
<a-doption
v-for="shortcut in config.commonPaths"
:key="shortcut.path"
@click="handleGoToPath(shortcut.path)"
>
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
{{ (shortcut.name || '').substring(2) }}
</a-doption>
</template>
</a-dropdown>
<PathBreadcrumb
:path="config.filePath"
@navigate="handleGoToPath"
@@ -49,45 +65,17 @@
</div>
<div class="toolbar-right">
<!-- 快捷路径下拉 -->
<a-dropdown v-if="!config.isBrowsingZip">
<a-button size="small">
<template #icon>
<icon-forward />
</template>
快捷访问
</a-button>
<template #content>
<a-doption
v-for="shortcut in config.commonPaths"
:key="shortcut.path"
@click="handleGoToPath(shortcut.path)"
>
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
{{ (shortcut.name || '').substring(2) }}
</a-doption>
</template>
</a-dropdown>
<!-- 历史记录下拉 -->
<a-dropdown>
<a-button size="small">
<template #icon>
<icon-history />
</template>
历史
</a-button>
<template #content>
<a-doption
v-for="path in config.pathHistory.slice(0, 10)"
:key="path"
@click="handleGoToPath(path)"
>
{{ path }}
</a-doption>
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
</template>
</a-dropdown>
<!-- 搜索框 -->
<a-input-search
:model-value="config.searchKeyword"
placeholder="搜索文件..."
size="small"
class="toolbar-search"
allow-clear
@search="handleSearch"
@update:model-value="handleSearchInput"
@keyup.escape="handleClearSearch"
/>
<!-- 刷新按钮 -->
<a-button
@@ -101,6 +89,29 @@
刷新
</a-button>
<!-- 历史记录下拉仅图标Ctrl+H -->
<a-dropdown
v-model:popup-visible="historyPopupVisible"
>
<a-tooltip content="历史记录 (Ctrl+H)" position="left">
<a-button size="small">
<template #icon><icon-history /></template>
</a-button>
</a-tooltip>
<template #content>
<div class="history-dropdown-content">
<a-doption
v-for="path in config.pathHistory.slice(0, 10)"
:key="path"
@click="handleGoToPath(path)"
>
<span class="history-path-text">{{ path }}</span>
</a-doption>
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
</div>
</template>
</a-dropdown>
<!-- 切换侧边栏 -->
<a-button
size="small"
@@ -132,6 +143,7 @@ const props = defineProps<Props>()
interface Emits {
(e: 'update:filePath', path: string): void
(e: 'update:showSidebar', show: boolean): void
(e: 'update:searchKeyword', keyword: string): void
(e: 'refresh'): void
(e: 'exitZip'): void
(e: 'goToPath', path: string): void
@@ -141,6 +153,9 @@ interface Emits {
const emit = defineEmits<Emits>()
// 历史记录下拉显隐(供父组件 Ctrl+H 调用)
const historyPopupVisible = ref(false)
// 事件处理
const handleGoToPath = (path: string) => {
emit('goToPath', path)
@@ -170,8 +185,28 @@ const handleToggleSidebar = () => {
emit('update:showSidebar', !props.config.showSidebar)
}
const handleSearch = (keyword: string) => {
emit('update:searchKeyword', keyword)
}
const handleSearchInput = (keyword: string) => {
emit('update:searchKeyword', keyword)
}
const handleClearSearch = () => {
emit('update:searchKeyword', '')
}
// 切换历史记录下拉面板(供父组件 Ctrl+H 调用)
const toggleHistoryDropdown = () => {
historyPopupVisible.value = !historyPopupVisible.value
}
const { copied, copy: copyPath } = useClipboardCopy()
// 暴露方法给父组件
defineExpose({ toggleHistoryDropdown })
const handleCopyPath = async () => {
await copyPath(props.config.filePath)
}
@@ -202,6 +237,11 @@ const handleCopyPath = async () => {
flex-shrink: 0;
}
.toolbar-search {
width: 180px;
flex-shrink: 0;
}
.path-input-wrapper {
flex: 1;
min-width: 200px;
@@ -280,4 +320,19 @@ const handleCopyPath = async () => {
overflow: hidden;
text-overflow: ellipsis;
}
/* 历史记录下拉 */
.history-dropdown-content {
max-width: 420px;
max-height: 300px;
overflow-y: auto;
}
.history-path-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 380px;
}
</style>

View File

@@ -13,10 +13,11 @@ import {
isTextEditable, isConfigFile
} from '@/utils/fileTypeHelpers'
import { useFileOperations } from './useFileOperations'
import type { FileItem } from '@/types/file-system'
export interface UseFileEditOptions {
currentFilePath?: any
currentDirectory?: any
currentFilePath?: import('vue').Ref<FileItem | null>
currentDirectory?: import('vue').Ref<string>
}
// 文件大小限制5MB
@@ -46,9 +47,6 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
// 文件版本跟踪(用于防止切换文件后的过期更新)
const fileVersion = ref(0)
// 最后一次文件加载的时间戳,用于过滤过期更新
const lastLoadTime = ref(0)
// 使用文件操作 composable
const { readFile, writeFile } = useFileOperations({
onSuccess: (operation, data) => {
@@ -81,7 +79,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
* 判断是否为二进制文件(基于扩展名)
* 返回值: true=已知二进制, false=已知非二进制(文本/媒体/Office), null=未知需检测
*/
const isBinaryFileByExt = (filepath: any): boolean | null => {
const isBinaryFileByExt = (filepath: string | FileItem): boolean | null => {
const path = getFilePath(filepath)
const ext = getExt(path)
if (!ext) return null // 无扩展名返回 null表示需要进一步检测
@@ -185,9 +183,6 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
// 增加文件版本号,使之前的过期更新失效
fileVersion.value++
// 记录加载时间戳,用于过滤过期更新
lastLoadTime.value = Date.now()
// 注意:不再清空内容,避免 HTML 预览切换时闪烁
// 新内容加载完成后会直接替换旧内容
@@ -456,6 +451,33 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
}
}
/**
* 仅更新文件路径(重命名场景:内容不变,只切换路径关联)
* 迁移草稿 key更新 currentFilePathRef
*/
const updateFilePath = (newPath: string) => {
const oldPath = currentFilePathRef.value
// 迁移草稿(旧 key → 新 key
if (draftKey.value && oldPath !== newPath) {
try {
const draft = localStorage.getItem(draftKey.value)
if (draft) {
const newKey = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${newPath}`
localStorage.setItem(newKey, draft)
localStorage.removeItem(draftKey.value)
draftKey.value = newKey
}
} catch (error) {
console.warn('[useFileEdit] 草稿迁移失败:', error)
}
}
// 只更新内部路径字符串引用,不触碰 currentFilePath它是 FileItem 对象,由父组件管理)
// 这样不会触发 watch → clearDraft
currentFilePathRef.value = newPath
}
/**
* 重置文件内容
*/
@@ -501,14 +523,10 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
}
/**
* 更新文件内容
* 注意:需要确保更新后 fileContent 和 originalContent 保持正确的同步关系
* 更新文件内容(仅版本号匹配时接受,防止快速切换文件时旧更新覆盖新内容)
*/
const updateContent = (content: string, expectedVersion?: number) => {
// 如果提供了期望的版本号,检查是否匹配
// 这用于防止快速切换文件时,旧文件的防抖更新覆盖新文件的内容
if (expectedVersion !== undefined && expectedVersion !== fileVersion.value) {
// 版本不匹配,这是一个过期的更新,忽略它
console.debug('[useFileEdit] 忽略过期更新(版本不匹配):', {
expected: expectedVersion,
current: fileVersion.value,
@@ -517,25 +535,9 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
return
}
// 额外检查:如果更新是在文件加载后的短时间内,可能是过期更新
// 防抖时间是 150ms我们使用 300ms 的安全边际
const timeSinceLoad = Date.now() - lastLoadTime.value
if (timeSinceLoad < 300) {
console.debug('[useFileEdit] 忽略过期更新(时间窗口内):', {
timeSinceLoad,
content: content.substring(0, 50)
})
return
}
// 确保只有在内容真正改变时才更新
if (fileContent.value !== content) {
fileContent.value = content
}
// 自动保存草稿(防抖)
// 实际实现应该使用防抖函数
// saveDraft()
}
/**
@@ -556,12 +558,6 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
return filePath.startsWith(currentDirectory.value)
}
// 监听文件内容变化,自动保存草稿
watch(fileContent, () => {
// 实际实现应该使用防抖
// saveDraft()
}, { deep: true })
// 监听文件路径变化,清除草稿
watch(currentFilePath, (newPath, oldPath) => {
if (newPath !== oldPath) {
@@ -604,6 +600,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
// 其他
resetContent,
clearContent,
updateFilePath,
setEditorHeight,
// 文件类型检查

View File

@@ -29,8 +29,17 @@ export interface UseFilePreviewOptions {
export function useFilePreview(options: UseFilePreviewOptions = {}) {
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
// 文件服务器 URL硬编码,与旧版本保持一致
const fileServerURL = 'http://localhost:18765'
// 文件服务器 URL优先从后端获取,降级到默认值
let _fileServerURL = 'http://localhost:8073'
const initFileServerURL = async () => {
try {
const url = await window.go.main.App.GetFileServerURL()
if (url) _fileServerURL = url
} catch { /* 使用默认值 */ }
}
initFileServerURL()
const getFileServerURL = () => _fileServerURL
// 预览 URL
const previewUrl = ref('')
@@ -45,7 +54,7 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
const getPreviewUrl = (path: string): string => {
if (!path) return ''
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
return `${fileServerURL}/localfs/${normalizeFilePath(path, true)}`
return `${getFileServerURL()}/localfs/${normalizeFilePath(path, true)}`
}
/**
@@ -188,12 +197,6 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
// 文件类型判断(同步,基于扩展名)
getFileType,
isImageFile,
isVideoFile,
isAudioFile,
isPdfFile,
isHtmlFile,
isMarkdownFile,
isPreviewable,
isEditable,

View File

@@ -2,6 +2,7 @@
<div class="file-system-container">
<!-- 顶部工具栏 -->
<Toolbar
ref="toolbarRef"
:config="toolbarConfig"
@update:file-path="handleFilePathUpdate"
@update:show-sidebar="handleSidebarToggle"
@@ -10,6 +11,7 @@
@go-to-path="handleGoToPath"
@open-file="handleOpenFile"
@navigate-to-zip-directory="handleNavigateToZipDirectory"
@update:search-keyword="handleSearchKeywordUpdate"
@show-message="handleShowMessage"
/>
@@ -152,6 +154,7 @@ const isEditableWithPreview = (filename: string): boolean => {
const fileList = ref<FileItem[]>([])
const fileLoading = ref(false)
const selectedFileItem = ref<FileItem | null>(null)
const toolbarRef = ref<InstanceType<typeof import('./components/Toolbar.vue').default> | null>(null)
// 排序状态(带 localStorage 持久化)
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
@@ -187,6 +190,18 @@ const editingFileName = ref('')
// 侧边栏
const showSidebar = ref(true)
// 搜索
const searchKeyword = ref('')
// 过滤后的文件列表(基于搜索关键词)
const filteredFileList = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
if (!keyword) return fileList.value
return fileList.value.filter(item =>
item.name.toLowerCase().includes(keyword)
)
})
// 面板宽度(带 localStorage 持久化)
const restorePanelWidth = (): { left: number; right: number } => {
try {
@@ -271,7 +286,7 @@ const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, dete
})
// 文件编辑
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
useFileEdit({
currentFilePath: selectedFileItem,
currentDirectory: filePath
@@ -295,7 +310,8 @@ const toolbarConfig = computed(() => ({
fileLoading: fileLoading.value,
showSidebar: showSidebar.value,
sortBy: sortBy.value,
sortOrder: sortOrder.value
sortOrder: sortOrder.value,
searchKeyword: searchKeyword.value
}))
// 侧边栏配置
@@ -307,7 +323,7 @@ const sidebarConfig = computed(() => ({
// 文件列表面板配置
const fileListPanelConfig = computed(() => ({
fileList: fileList.value,
fileList: filteredFileList.value,
fileLoading: fileLoading.value,
selectedFileItem: selectedFileItem.value,
editingFilePath: editingFilePath.value,
@@ -363,7 +379,8 @@ const fileEditorPanelConfig = computed(() => {
imageLoading: imageLoading.value,
currentImageDimensions: currentImageDimensions.value,
currentFileExtension,
isBinaryFile: isBinaryFileRef.value
isBinaryFile: isBinaryFileRef.value,
fileMtime: selectedFileItem.value?.modified_time || ''
}
})
@@ -382,6 +399,10 @@ const handleRefresh = async () => {
await loadDirectory(filePath.value)
}
const handleSearchKeywordUpdate = (keyword: string) => {
searchKeyword.value = keyword
}
const handleGoToPath = async (path: string) => {
await navigate(path)
}
@@ -619,24 +640,12 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
}
try {
// 如果重命名的是当前打开的文件,先关闭编辑器和预览
if (selectedFileItem.value?.path === oldPath) {
// 如果是文件(不是文件夹),才需要关闭编辑器
if (!selectedFileItem.value.isDir) {
// 清空编辑器内容
await clearContent()
// 标记是否需要重命名后仅更新路径(内容不变,零闪烁)
let needUpdatePath = false
// 清空预览URL
if (previewUrl.value) {
previewUrl.value = ''
}
}
// 取消选中状态
selectedFileItem.value = null
// 等待文件句柄释放(文件需要更长时间)
await new Promise(resolve => setTimeout(resolve, 300))
// 如果重命名的是当前打开的文件
if (selectedFileItem.value?.path === oldPath && !selectedFileItem.value.isDir) {
needUpdatePath = true
}
const renamedFile = await fileOps.rename(oldPath, trimmedName)
@@ -650,6 +659,13 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
}
Message.success(`✓ 重命名成功: ${trimmedName}`)
// 仅更新路径关联,不重新加载内容(编辑器内容不变,零闪烁)
if (needUpdatePath && !renamedFile.isDir) {
selectedFileItem.value = renamedFile
updateFilePath(newPath)
updatePreviewUrl(newPath)
}
} catch (error: any) {
// 提取错误信息
let errorMsg = error?.message || error?.toString() || '未知错误'
@@ -1237,12 +1253,23 @@ const handleKeyDown = async (event: KeyboardEvent) => {
return
}
// F5 刷新文件列表
// F5 刷新文件列表 + 重载当前预览文件
if (event.key === 'F5') {
event.preventDefault()
if (filePath.value) {
loadDirectory(filePath.value)
await loadDirectory(filePath.value)
// 如果有正在预览的文件,同时重新加载其内容(类似重新点击一次)
if (selectedFileItem.value && !selectedFileItem.value.isDir) {
await loadFileContent(selectedFileItem.value.path)
}
}
return
}
// Ctrl+H 打开历史记录面板
if ((event.ctrlKey || event.metaKey) && event.key === 'h') {
event.preventDefault()
toolbarRef.value?.toggleHistoryDropdown?.()
return
}

View File

@@ -6,6 +6,8 @@
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
import { Modal, Message, Progress } from '@arco-design/web-vue'
import { useUpdateStore } from '../stores/update'
import { marked } from '../utils/markedExtensions'
import { sanitizeHtml } from '@/utils/fileUtils'
const props = defineProps({
modelValue: {
@@ -66,8 +68,8 @@ const showUpdateModal = () => {
title: forceUpdate.value ? '重要更新' : '发现新版本',
content: () => {
const elements = [
h('div', { style: { marginBottom: '12px' } }, [
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)' } }, '版本:'),
h('div', { style: { marginBottom: '8px' } }, [
h('span', { style: { fontSize: '13px', color: 'var(--color-text-2)' } }, '版本:'),
h('span', { style: { fontSize: '14px', color: 'var(--color-text-1)', marginLeft: '8px' } }, currentVersion.value),
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)', marginLeft: '12px', marginRight: '12px' } }, '→'),
h('span', { style: { fontSize: '16px', color: 'rgb(var(--primary-6))', fontWeight: '500' } }, latestVersion.value)
@@ -76,20 +78,23 @@ const showUpdateModal = () => {
// 更新日志
if (changelog.value) {
const changelogHtml = (() => { try { return sanitizeHtml(String(marked.parse(changelog.value))) } catch { return changelog.value } })()
elements.push(
h('div', { style: { marginBottom: '12px' } }, [
h('div', { style: { fontSize: '13px', color: 'var(--color-text-2)', marginBottom: '8px' } }, '更新内容:'),
h('div', { style: { marginBottom: '8px' } }, [
h('div', { style: { fontSize: '12px', color: 'var(--color-text-2)', marginBottom: '4px' } }, '更新内容:'),
h('div', {
style: {
fontSize: '13px',
fontSize: '12px',
color: 'var(--color-text-2)',
lineHeight: '1.8',
padding: '12px',
lineHeight: '1.6',
padding: '10px 12px',
background: 'var(--color-fill-1)',
borderRadius: '4px',
whiteSpace: 'pre-wrap'
}
}, changelog.value)
maxHeight: '240px',
overflowY: 'auto'
},
innerHTML: changelogHtml
})
])
)
}
@@ -104,7 +109,7 @@ const showUpdateModal = () => {
}
if (metadata.length > 0) {
elements.push(
h('div', { style: { marginBottom: '12px', fontSize: '13px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
h('div', { style: { marginBottom: '4px', fontSize: '12px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
)
}

View File

@@ -30,7 +30,7 @@
<div class="changelog-title">
{{ updateInfo.has_update ? '最新版本更新内容' : '当前版本更新内容' }}
</div>
<div class="changelog">{{ updateInfo.changelog }}</div>
<div class="changelog" v-html="renderChangelog(updateInfo.changelog)" />
</div>
</a-card>
@@ -92,8 +92,8 @@
:status="downloadStatus"
/>
<div class="progress-info">
<span>{{ formatFileSize(progressInfo.downloaded) }} / {{ formatFileSize(progressInfo.total) }}</span>
<span v-if="progressInfo.speed > 0">{{ formatSpeed(progressInfo.speed) }}</span>
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span>
<span v-if="progressInfo.speed > 0">{{ updateStore.formatSpeed(progressInfo.speed) }}</span>
</div>
</div>
@@ -118,6 +118,8 @@ import { Message, Modal } from '@arco-design/web-vue'
import { storeToRefs } from 'pinia'
import { IconHistory } from '@arco-design/web-vue/es/icon'
import { useUpdateStore } from '../stores/update'
import { marked } from '../utils/markedExtensions'
import { sanitizeHtml } from '@/utils/fileUtils'
// Emits
defineEmits(['open-version-history'])
@@ -134,13 +136,10 @@ const lastCheckTime = ref('-')
const installResult = ref(null)
const downloadedFile = ref(null)
// 工具函数
const formatFileSize = (bytes) => {
return updateStore.formatFileSize(bytes)
}
const formatSpeed = (bytesPerSecond) => {
return updateStore.formatSpeed(bytesPerSecond)
/** 渲染 changelogMarkdown → HTML */
function renderChangelog(text: string): string {
if (!text) return ''
try { return sanitizeHtml(marked.parse(text) as string) } catch { return text }
}
// 加载当前版本
@@ -283,29 +282,70 @@ onUnmounted(() => {
}
.changelog-section {
margin-top: 16px;
margin-top: 12px;
}
.changelog-title {
font-size: 14px;
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
margin-bottom: 8px;
color: var(--color-text-2);
margin-bottom: 6px;
}
.changelog {
background: var(--color-fill-2);
padding: 12px;
background: var(--color-fill-1);
padding: 10px 12px;
border-radius: 4px;
white-space: pre-wrap;
margin: 8px 0;
max-height: 200px;
margin: 0;
max-height: 280px;
overflow-y: auto;
font-size: 13px;
line-height: 1.6;
font-size: 12px;
line-height: 1.65;
color: var(--color-text-2);
}
.changelog :deep(h4) {
font-size: 12px;
font-weight: 600;
color: var(--color-text-1);
margin: 8px 0 3px;
}
.changelog :deep(h4:first-child) {
margin-top: 0;
}
.changelog :deep(ul) {
list-style: none;
padding: 0;
margin: 1px 0;
}
.changelog :deep(li) {
position: relative;
padding-left: 14px;
margin: 1px 0;
}
.changelog :deep(li::before) {
content: '·';
position: absolute;
left: 4px;
color: var(--color-text-4);
font-weight: bold;
}
.changelog :deep(code) {
background: var(--color-fill-3);
padding: 0 4px;
border-radius: 3px;
font-size: 11px;
}
.changelog :deep(p) {
margin: 2px 0;
}
.download-progress {
margin-top: 16px;
padding: 16px;

View File

@@ -15,7 +15,7 @@ interface TabConfig {
/**
* 应用配置类型
*/
interface AppConfig {
export interface AppConfig {
tabs: TabConfig[]
visibleTabs: string[]
defaultTab: string

View File

@@ -115,6 +115,8 @@ export interface ToolbarConfig {
sortBy: string
/** 排序方向 */
sortOrder: string
/** 搜索关键词 */
searchKeyword: string
}
/**
@@ -199,6 +201,8 @@ export interface FileEditorPanelConfig {
currentFileExtension: string
/** 是否为二进制文件 */
isBinaryFile: boolean
/** 文件修改时间(用于检测外部变更) */
fileMtime: string
}
/**

View File

@@ -10,4 +10,7 @@ export { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
export { oneDark } from '@codemirror/theme-one-dark'
// 查找替换
export { openSearchPanel, closeSearchPanel, search, searchKeymap, SearchQuery } from '@codemirror/search'
// 语言包通过 codeMirrorLoader 动态导入,避免全量打包

View File

@@ -38,6 +38,21 @@ export const escapeHtml = (str) => {
.replace(/'/g, '&#039;')
}
/**
* 轻量 HTML 消毒(用于渲染远程 Markdown 等不可信 HTML 片段)
* 移除 script/iframe/object/embed 标签和 on* 事件属性
*/
export const sanitizeHtml = (html) => {
if (!html) return ''
return String(html)
.replace(/<script\b[^<]*(?:<\/script>|$)/gi, '')
.replace(/<iframe\b[^<]*(?:<\/iframe>|$)/gi, '')
.replace(/<object\b[^<]*(?:<\/object>|$)/gi, '')
.replace(/<embed\b[^>]*\/?>/gi, '')
.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '')
}
/**
* 获取文件扩展名(路径安全)
* @param {string} path - 文件路径