mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-06-14 03:26:37 +08:00
feat: 优化数据存储目录迁移逻辑
This commit is contained in:
+87
-168
@@ -11,6 +11,16 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
import {
|
||||||
|
copyDirMerge,
|
||||||
|
copyDirRecursive,
|
||||||
|
ensureMarkerFile,
|
||||||
|
isDirectorySafeToUse,
|
||||||
|
isExistingChatLabDir,
|
||||||
|
isPathSafe,
|
||||||
|
isSubPath,
|
||||||
|
writeMigrationLog,
|
||||||
|
} from './utils/pathUtils'
|
||||||
|
|
||||||
// 缓存路径,避免重复计算
|
// 缓存路径,避免重复计算
|
||||||
let _appDataDir: string | null = null
|
let _appDataDir: string | null = null
|
||||||
@@ -19,8 +29,10 @@ let _legacyDataDir: string | null = null
|
|||||||
// 存储配置文件名(放在 userData 根目录,避免受自定义数据目录影响)
|
// 存储配置文件名(放在 userData 根目录,避免受自定义数据目录影响)
|
||||||
const STORAGE_CONFIG_FILE = 'storage.json'
|
const STORAGE_CONFIG_FILE = 'storage.json'
|
||||||
|
|
||||||
// ChatLab 数据目录的标志性子目录列表(用于识别是否为 ChatLab 数据目录)
|
// ChatLab 数据目录标记文件(用于更严格的目录识别)
|
||||||
const CHATLAB_DATA_DIRS = ['databases', 'ai', 'settings', 'logs', 'temp']
|
const CHATLAB_MARKER_FILE = '.chatlab'
|
||||||
|
// ChatLab 数据目录关键子目录(用于识别已有数据)
|
||||||
|
const CHATLAB_REQUIRED_DIRS = ['databases', 'settings']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取应用数据根目录
|
* 获取应用数据根目录
|
||||||
@@ -123,113 +135,6 @@ export function getCustomDataDir(): string | null {
|
|||||||
return dataDir
|
return dataDir
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查路径是否安全(不在系统关键目录下)
|
|
||||||
*/
|
|
||||||
function isPathSafe(targetPath: string): boolean {
|
|
||||||
// 系统关键目录列表(Windows 和 Unix)
|
|
||||||
const dangerousPaths = [
|
|
||||||
// Windows 系统目录
|
|
||||||
'C:\\Windows',
|
|
||||||
'C:\\Program Files',
|
|
||||||
'C:\\Program Files (x86)',
|
|
||||||
'C:\\ProgramData',
|
|
||||||
// Unix 系统目录
|
|
||||||
'/usr',
|
|
||||||
'/etc',
|
|
||||||
'/bin',
|
|
||||||
'/sbin',
|
|
||||||
'/lib',
|
|
||||||
'/var',
|
|
||||||
'/boot',
|
|
||||||
'/root',
|
|
||||||
'/System',
|
|
||||||
'/Library',
|
|
||||||
]
|
|
||||||
|
|
||||||
const normalizedTarget = targetPath.toLowerCase().replace(/\//g, '\\')
|
|
||||||
|
|
||||||
for (const dangerous of dangerousPaths) {
|
|
||||||
const normalizedDangerous = dangerous.toLowerCase().replace(/\//g, '\\')
|
|
||||||
if (normalizedTarget.startsWith(normalizedDangerous)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查目录是否为空或仅包含 ChatLab 数据
|
|
||||||
*/
|
|
||||||
function isDirectorySafeToUse(dirPath: string): boolean {
|
|
||||||
if (!fs.existsSync(dirPath)) {
|
|
||||||
return true // 目录不存在,可以安全使用
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(dirPath)
|
|
||||||
// 如果目录为空,可以安全使用
|
|
||||||
if (entries.length === 0) return true
|
|
||||||
|
|
||||||
// 如果包含 ChatLab 的标志性子目录,认为是之前的数据目录
|
|
||||||
const hasChatlabStructure = CHATLAB_DATA_DIRS.some((d) => entries.includes(d))
|
|
||||||
return hasChatlabStructure
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 递归合并复制目录(仅复制目标不存在的文件)
|
|
||||||
* @returns 复制结果统计
|
|
||||||
*/
|
|
||||||
function copyDirMerge(
|
|
||||||
src: string,
|
|
||||||
dest: string,
|
|
||||||
stats: { copied: number; skipped: number; errors: string[] } = { copied: 0, skipped: 0, errors: [] }
|
|
||||||
): { copied: number; skipped: number; errors: string[] } {
|
|
||||||
if (!fs.existsSync(src)) return stats
|
|
||||||
|
|
||||||
try {
|
|
||||||
ensureDir(dest)
|
|
||||||
const entries = fs.readdirSync(src, { withFileTypes: true })
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const srcPath = path.join(src, entry.name)
|
|
||||||
const destPath = path.join(dest, entry.name)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
if (!fs.existsSync(destPath)) {
|
|
||||||
copyDirRecursive(srcPath, destPath)
|
|
||||||
stats.copied++
|
|
||||||
} else {
|
|
||||||
copyDirMerge(srcPath, destPath, stats)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!fs.existsSync(destPath)) {
|
|
||||||
fs.copyFileSync(srcPath, destPath)
|
|
||||||
stats.copied++
|
|
||||||
} else {
|
|
||||||
stats.skipped++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = `复制失败: ${srcPath} -> ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
console.error('[Paths]', errorMsg)
|
|
||||||
stats.errors.push(errorMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = `读取目录失败: ${src} -> ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
console.error('[Paths]', errorMsg)
|
|
||||||
stats.errors.push(errorMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置自定义数据目录
|
* 设置自定义数据目录
|
||||||
* @param dataDir 目标目录(为空则恢复默认)
|
* @param dataDir 目标目录(为空则恢复默认)
|
||||||
@@ -245,21 +150,48 @@ export function setCustomDataDir(
|
|||||||
try {
|
try {
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
// 恢复默认路径
|
// 恢复默认路径
|
||||||
// 记录旧目录,下次启动时删除
|
const newDir = getDefaultAppDataDir()
|
||||||
writeStorageConfig({ pendingDeleteDir: oldDir })
|
|
||||||
_appDataDir = null
|
|
||||||
const newDir = getAppDataDir()
|
|
||||||
|
|
||||||
|
// 防止目标目录是当前目录的子目录,避免递归复制
|
||||||
|
if (migrate && oldDir !== newDir && isSubPath(oldDir, newDir)) {
|
||||||
|
return { success: false, error: '目标目录不能是当前数据目录的子目录' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先清除自定义配置,切回默认目录
|
||||||
|
writeStorageConfig({})
|
||||||
|
_appDataDir = newDir
|
||||||
|
|
||||||
|
let canDeleteOldDir = false
|
||||||
if (migrate && oldDir !== newDir) {
|
if (migrate && oldDir !== newDir) {
|
||||||
const migrateResult = copyDirMerge(oldDir, newDir)
|
const migrateResult = copyDirMerge(oldDir, newDir, ensureDir)
|
||||||
console.log(
|
console.log(
|
||||||
`[Paths] 数据迁移完成: 复制 ${migrateResult.copied} 项, 跳过 ${migrateResult.skipped} 项, 错误 ${migrateResult.errors.length} 项`
|
`[Paths] 数据迁移完成: 复制 ${migrateResult.copied} 项, 跳过 ${migrateResult.skipped} 项, 错误 ${migrateResult.errors.length} 项`
|
||||||
)
|
)
|
||||||
if (migrateResult.errors.length > 0) {
|
if (migrateResult.errors.length > 0) {
|
||||||
console.warn('[Paths] 迁移过程中出现错误:', migrateResult.errors)
|
console.warn('[Paths] 迁移过程中出现错误:', migrateResult.errors)
|
||||||
|
console.warn('[Paths] 迁移失败,旧数据目录将不会自动删除')
|
||||||
|
// 迁移失败日志写入 app.log
|
||||||
|
writeMigrationLog(
|
||||||
|
getLogsDir(),
|
||||||
|
`切换为默认目录迁移失败: 从 ${oldDir} 到 ${newDir},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项,错误 ${migrateResult.errors.length} 项`,
|
||||||
|
ensureDir
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
canDeleteOldDir = true
|
||||||
|
// 迁移成功日志写入 app.log
|
||||||
|
writeMigrationLog(
|
||||||
|
getLogsDir(),
|
||||||
|
`切换为默认目录迁移成功: 从 ${oldDir} 到 ${newDir},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项`,
|
||||||
|
ensureDir
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 迁移成功才标记删除旧目录,避免数据丢失
|
||||||
|
if (canDeleteOldDir) {
|
||||||
|
writeStorageConfig({ pendingDeleteDir: oldDir })
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, from: oldDir, to: newDir }
|
return { success: true, from: oldDir, to: newDir }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,33 +199,59 @@ export function setCustomDataDir(
|
|||||||
return { success: false, error: '数据目录必须是绝对路径' }
|
return { success: false, error: '数据目录必须是绝对路径' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 防止目标目录是当前目录的子目录,避免递归复制
|
||||||
|
if (migrate && oldDir !== normalized && isSubPath(oldDir, normalized)) {
|
||||||
|
return { success: false, error: '目标目录不能是当前数据目录的子目录' }
|
||||||
|
}
|
||||||
|
|
||||||
// 安全检查:不能使用系统关键目录
|
// 安全检查:不能使用系统关键目录
|
||||||
if (!isPathSafe(normalized)) {
|
if (!isPathSafe(normalized)) {
|
||||||
return { success: false, error: '不能使用系统关键目录作为数据目录' }
|
return { success: false, error: '不能使用系统关键目录作为数据目录' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 安全检查:目标目录应为空或已有 ChatLab 数据
|
// 安全检查:目标目录应为空或已有 ChatLab 数据
|
||||||
if (!isDirectorySafeToUse(normalized)) {
|
if (!isDirectorySafeToUse(normalized, CHATLAB_MARKER_FILE, CHATLAB_REQUIRED_DIRS)) {
|
||||||
return { success: false, error: '目标目录不为空且不包含 ChatLab 数据,请选择空目录或已有数据目录' }
|
return { success: false, error: '目标目录不为空且不包含 ChatLab 数据,请选择空目录或已有数据目录' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
ensureDir(normalized)
|
ensureDir(normalized)
|
||||||
|
|
||||||
// 记录旧目录,下次启动时删除
|
|
||||||
writeStorageConfig({ dataDir: normalized, pendingDeleteDir: oldDir })
|
|
||||||
_appDataDir = normalized
|
_appDataDir = normalized
|
||||||
|
|
||||||
|
let canDeleteOldDir = false
|
||||||
if (migrate && oldDir !== normalized) {
|
if (migrate && oldDir !== normalized) {
|
||||||
const migrateResult = copyDirMerge(oldDir, normalized)
|
const migrateResult = copyDirMerge(oldDir, normalized, ensureDir)
|
||||||
console.log(
|
console.log(
|
||||||
`[Paths] 数据迁移完成: 复制 ${migrateResult.copied} 项, 跳过 ${migrateResult.skipped} 项, 错误 ${migrateResult.errors.length} 项`
|
`[Paths] 数据迁移完成: 复制 ${migrateResult.copied} 项, 跳过 ${migrateResult.skipped} 项, 错误 ${migrateResult.errors.length} 项`
|
||||||
)
|
)
|
||||||
if (migrateResult.errors.length > 0) {
|
if (migrateResult.errors.length > 0) {
|
||||||
console.warn('[Paths] 迁移过程中出现错误:', migrateResult.errors)
|
console.warn('[Paths] 迁移过程中出现错误:', migrateResult.errors)
|
||||||
|
console.warn('[Paths] 迁移失败,旧数据目录将不会自动删除')
|
||||||
|
// 迁移失败日志写入 app.log
|
||||||
|
writeMigrationLog(
|
||||||
|
getLogsDir(),
|
||||||
|
`切换目录迁移失败: 从 ${oldDir} 到 ${normalized},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项,错误 ${migrateResult.errors.length} 项`,
|
||||||
|
ensureDir
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
canDeleteOldDir = true
|
||||||
|
// 迁移成功日志写入 app.log
|
||||||
|
writeMigrationLog(
|
||||||
|
getLogsDir(),
|
||||||
|
`切换目录迁移成功: 从 ${oldDir} 到 ${normalized},复制 ${migrateResult.copied} 项,跳过 ${migrateResult.skipped} 项`,
|
||||||
|
ensureDir
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 迁移成功才标记删除旧目录,避免数据丢失
|
||||||
|
const config: StorageConfig = { dataDir: normalized }
|
||||||
|
if (canDeleteOldDir) {
|
||||||
|
config.pendingDeleteDir = oldDir
|
||||||
|
}
|
||||||
|
writeStorageConfig(config)
|
||||||
|
|
||||||
return { success: true, from: oldDir, to: normalized }
|
return { success: true, from: oldDir, to: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Paths] Error setting custom data dir:', error)
|
console.error('[Paths] Error setting custom data dir:', error)
|
||||||
@@ -330,16 +288,12 @@ export function cleanupPendingDeleteDir(): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 安全检查:确保待删除目录确实是 ChatLab 数据目录(包含标志性子目录)
|
// 安全检查:确保待删除目录确实是 ChatLab 数据目录(标记文件 + 关键子目录)
|
||||||
if (fs.existsSync(pendingDir)) {
|
if (fs.existsSync(pendingDir) && !isExistingChatLabDir(pendingDir, CHATLAB_MARKER_FILE, CHATLAB_REQUIRED_DIRS)) {
|
||||||
const entries = fs.readdirSync(pendingDir)
|
console.log('[Paths] 跳过清理:待删除目录不是 ChatLab 数据目录:', pendingDir)
|
||||||
const hasChatlabStructure = CHATLAB_DATA_DIRS.some((d) => entries.includes(d))
|
// 清除待删除标记
|
||||||
if (!hasChatlabStructure) {
|
writeStorageConfig({ dataDir: config.dataDir })
|
||||||
console.log('[Paths] 跳过清理:待删除目录不是 ChatLab 数据目录:', pendingDir)
|
return
|
||||||
// 清除待删除标记
|
|
||||||
writeStorageConfig({ dataDir: config.dataDir })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查目录是否存在
|
// 检查目录是否存在
|
||||||
@@ -446,6 +400,8 @@ export function ensureAppDirs(): void {
|
|||||||
ensureDir(getSettingsDir())
|
ensureDir(getSettingsDir())
|
||||||
ensureDir(getTempDir())
|
ensureDir(getTempDir())
|
||||||
ensureDir(getLogsDir())
|
ensureDir(getLogsDir())
|
||||||
|
// 写入数据目录标记文件
|
||||||
|
ensureMarkerFile(getAppDataDir(), CHATLAB_MARKER_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 数据迁移 ====================
|
// ==================== 数据迁移 ====================
|
||||||
@@ -513,7 +469,7 @@ function migrateDirectory(
|
|||||||
|
|
||||||
const stat = fs.statSync(srcPath)
|
const stat = fs.statSync(srcPath)
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
copyDirRecursive(srcPath, destPath)
|
copyDirRecursive(srcPath, destPath, ensureDir)
|
||||||
} else {
|
} else {
|
||||||
fs.copyFileSync(srcPath, destPath)
|
fs.copyFileSync(srcPath, destPath)
|
||||||
}
|
}
|
||||||
@@ -532,24 +488,6 @@ function migrateDirectory(
|
|||||||
return { migratedDirs, skippedDirs }
|
return { migratedDirs, skippedDirs }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入迁移日志到 app.log
|
|
||||||
* 使用内联实现避免循环依赖
|
|
||||||
*/
|
|
||||||
function writeMigrationLog(message: string): void {
|
|
||||||
try {
|
|
||||||
const logDir = getLogsDir()
|
|
||||||
ensureDir(logDir)
|
|
||||||
const logPath = path.join(logDir, 'app.log')
|
|
||||||
const now = new Date()
|
|
||||||
const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
|
|
||||||
const logLine = `[${timestamp}] [MIGRATION] ${message}\n`
|
|
||||||
fs.appendFileSync(logPath, logLine, 'utf-8')
|
|
||||||
} catch {
|
|
||||||
// 日志写入失败时静默处理
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行从 Documents/ChatLab 到新目录的数据迁移
|
* 执行从 Documents/ChatLab 到新目录的数据迁移
|
||||||
* 迁移整个目录的所有内容,采用合并策略:只复制不存在的文件,不覆盖已存在的文件
|
* 迁移整个目录的所有内容,采用合并策略:只复制不存在的文件,不覆盖已存在的文件
|
||||||
@@ -597,13 +535,13 @@ export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 写入迁移日志
|
// 写入迁移日志
|
||||||
writeMigrationLog(summary.join(' | '))
|
writeMigrationLog(getLogsDir(), summary.join(' | '), ensureDir)
|
||||||
|
|
||||||
return { success: true, migratedDirs: result.migratedDirs }
|
return { success: true, migratedDirs: result.migratedDirs }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Paths] Migration failed:', error)
|
console.error('[Paths] Migration failed:', error)
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
writeMigrationLog(`Migration failed: ${errorMsg}`)
|
writeMigrationLog(getLogsDir(), `Migration failed: ${errorMsg}`, ensureDir)
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
migratedDirs: [],
|
migratedDirs: [],
|
||||||
@@ -612,25 +550,6 @@ export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 递归复制目录
|
|
||||||
*/
|
|
||||||
function copyDirRecursive(src: string, dest: string): void {
|
|
||||||
ensureDir(dest)
|
|
||||||
const entries = fs.readdirSync(src, { withFileTypes: true })
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const srcPath = path.join(src, entry.name)
|
|
||||||
const destPath = path.join(dest, entry.name)
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
copyDirRecursive(srcPath, destPath)
|
|
||||||
} else {
|
|
||||||
fs.copyFileSync(srcPath, destPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除旧版数据目录(可选,供用户确认后调用)
|
* 删除旧版数据目录(可选,供用户确认后调用)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ function switchToR2Mirror(): void {
|
|||||||
provider: 'generic',
|
provider: 'generic',
|
||||||
url: R2_MIRROR_URL,
|
url: R2_MIRROR_URL,
|
||||||
})
|
})
|
||||||
logger.info(`[Update] 使用 R2 镜像源: ${R2_MIRROR_URL}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
|
// 系统关键目录列表(用于安全校验)
|
||||||
|
const DANGEROUS_PATHS = [
|
||||||
|
// Windows 系统目录
|
||||||
|
'C:\\Windows',
|
||||||
|
'C:\\Program Files',
|
||||||
|
'C:\\Program Files (x86)',
|
||||||
|
'C:\\ProgramData',
|
||||||
|
// Unix 系统目录
|
||||||
|
'/usr',
|
||||||
|
'/etc',
|
||||||
|
'/bin',
|
||||||
|
'/sbin',
|
||||||
|
'/lib',
|
||||||
|
'/var',
|
||||||
|
'/boot',
|
||||||
|
'/root',
|
||||||
|
'/System',
|
||||||
|
'/Library',
|
||||||
|
]
|
||||||
|
|
||||||
|
// 统一路径标准化(兼容 Windows 大小写差异)
|
||||||
|
function normalizePathForCompare(input: string): string {
|
||||||
|
const resolved = path.resolve(input)
|
||||||
|
const normalized = path.normalize(resolved)
|
||||||
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 child 是否为 parent 的子目录
|
||||||
|
*/
|
||||||
|
export function isSubPath(parent: string, child: string): boolean {
|
||||||
|
const parentPath = normalizePathForCompare(parent)
|
||||||
|
const childPath = normalizePathForCompare(child)
|
||||||
|
|
||||||
|
if (parentPath === childPath) return false
|
||||||
|
return childPath.startsWith(`${parentPath}${path.sep}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查路径是否安全(不在系统关键目录下)
|
||||||
|
*/
|
||||||
|
export function isPathSafe(targetPath: string): boolean {
|
||||||
|
const normalizedTarget = targetPath.toLowerCase().replace(/\//g, '\\')
|
||||||
|
|
||||||
|
for (const dangerous of DANGEROUS_PATHS) {
|
||||||
|
const normalizedDangerous = dangerous.toLowerCase().replace(/\//g, '\\')
|
||||||
|
if (normalizedTarget.startsWith(normalizedDangerous)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查目录是否为空或包含 ChatLab 标记与关键结构
|
||||||
|
*/
|
||||||
|
export function isDirectorySafeToUse(dirPath: string, markerFile: string, requiredDirs: string[]): boolean {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
return true // 目录不存在,可以安全使用
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dirPath)
|
||||||
|
// 如果目录为空,可以安全使用
|
||||||
|
if (entries.length === 0) return true
|
||||||
|
|
||||||
|
return hasChatLabStructure(entries, markerFile, requiredDirs)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查目录是否为已存在的 ChatLab 数据目录
|
||||||
|
*/
|
||||||
|
export function isExistingChatLabDir(dirPath: string, markerFile: string, requiredDirs: string[]): boolean {
|
||||||
|
if (!fs.existsSync(dirPath)) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dirPath)
|
||||||
|
return hasChatLabStructure(entries, markerFile, requiredDirs)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保数据目录标记文件存在
|
||||||
|
*/
|
||||||
|
export function ensureMarkerFile(dirPath: string, markerFile: string): void {
|
||||||
|
try {
|
||||||
|
const markerPath = path.join(dirPath, markerFile)
|
||||||
|
if (!fs.existsSync(markerPath)) {
|
||||||
|
fs.writeFileSync(markerPath, 'ChatLab Data Directory', 'utf-8')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 标记文件写入失败时静默处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归复制目录
|
||||||
|
*/
|
||||||
|
export function copyDirRecursive(src: string, dest: string, ensureDir: (dirPath: string) => void): void {
|
||||||
|
ensureDir(dest)
|
||||||
|
const entries = fs.readdirSync(src, { withFileTypes: true })
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = path.join(src, entry.name)
|
||||||
|
const destPath = path.join(dest, entry.name)
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
copyDirRecursive(srcPath, destPath, ensureDir)
|
||||||
|
} else {
|
||||||
|
fs.copyFileSync(srcPath, destPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopyStats {
|
||||||
|
copied: number
|
||||||
|
skipped: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归合并复制目录(仅复制目标不存在的文件)
|
||||||
|
* @returns 复制结果统计
|
||||||
|
*/
|
||||||
|
export function copyDirMerge(
|
||||||
|
src: string,
|
||||||
|
dest: string,
|
||||||
|
ensureDir: (dirPath: string) => void,
|
||||||
|
stats: CopyStats = { copied: 0, skipped: 0, errors: [] }
|
||||||
|
): CopyStats {
|
||||||
|
if (!fs.existsSync(src)) return stats
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureDir(dest)
|
||||||
|
const entries = fs.readdirSync(src, { withFileTypes: true })
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcPath = path.join(src, entry.name)
|
||||||
|
const destPath = path.join(dest, entry.name)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (!fs.existsSync(destPath)) {
|
||||||
|
copyDirRecursive(srcPath, destPath, ensureDir)
|
||||||
|
stats.copied++
|
||||||
|
} else {
|
||||||
|
copyDirMerge(srcPath, destPath, ensureDir, stats)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!fs.existsSync(destPath)) {
|
||||||
|
fs.copyFileSync(srcPath, destPath)
|
||||||
|
stats.copied++
|
||||||
|
} else {
|
||||||
|
stats.skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = `复制失败: ${srcPath} -> ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
console.error('[Paths]', errorMsg)
|
||||||
|
stats.errors.push(errorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = `读取目录失败: ${src} -> ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
console.error('[Paths]', errorMsg)
|
||||||
|
stats.errors.push(errorMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入迁移日志到 app.log
|
||||||
|
*/
|
||||||
|
export function writeMigrationLog(
|
||||||
|
logDir: string,
|
||||||
|
message: string,
|
||||||
|
ensureDir: (dirPath: string) => void
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
ensureDir(logDir)
|
||||||
|
const logPath = path.join(logDir, 'app.log')
|
||||||
|
const now = new Date()
|
||||||
|
const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
|
||||||
|
const logLine = `[${timestamp}] [MIGRATION] ${message}\n`
|
||||||
|
fs.appendFileSync(logPath, logLine, 'utf-8')
|
||||||
|
} catch {
|
||||||
|
// 日志写入失败时静默处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasChatLabStructure(entries: string[], markerFile: string, requiredDirs: string[]): boolean {
|
||||||
|
const hasMarker = entries.includes(markerFile)
|
||||||
|
const hasRequiredDirs = requiredDirs.every((dir) => entries.includes(dir))
|
||||||
|
return hasMarker && hasRequiredDirs
|
||||||
|
}
|
||||||
@@ -203,54 +203,6 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 数据目录设置 -->
|
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{{ t('settings.storage.dataLocation.title') }}
|
|
||||||
</p>
|
|
||||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ t('settings.storage.dataLocation.description') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0">
|
|
||||||
<UButton icon="i-heroicons-folder-open" variant="ghost" size="xs" @click="openBaseDir">
|
|
||||||
{{ t('settings.storage.dataLocation.open') }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center gap-2">
|
|
||||||
<UInput v-model="dataDir" readonly size="sm" class="min-w-0 flex-1" />
|
|
||||||
<UButton
|
|
||||||
size="sm"
|
|
||||||
variant="soft"
|
|
||||||
:loading="isUpdatingDataDir"
|
|
||||||
:disabled="isUpdatingDataDir"
|
|
||||||
@click="selectDataDir"
|
|
||||||
>
|
|
||||||
{{ t('settings.storage.dataLocation.choose') }}
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
v-if="isCustomDataDir"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
:disabled="isUpdatingDataDir"
|
|
||||||
@click="resetDataDir"
|
|
||||||
>
|
|
||||||
{{ t('settings.storage.dataLocation.reset') }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
{{ t('settings.storage.dataLocation.restartTip') }}
|
|
||||||
</p>
|
|
||||||
<p v-if="dataDirError" class="mt-1 text-xs text-red-500">
|
|
||||||
{{ dataDirError }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div v-if="isLoading && !cacheInfo" class="flex items-center justify-center py-8">
|
<div v-if="isLoading && !cacheInfo" class="flex items-center justify-center py-8">
|
||||||
<UIcon name="i-heroicons-arrow-path" class="h-5 w-5 animate-spin text-gray-400" />
|
<UIcon name="i-heroicons-arrow-path" class="h-5 w-5 animate-spin text-gray-400" />
|
||||||
@@ -328,6 +280,48 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据目录设置 -->
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ t('settings.storage.dataLocation.title') }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('settings.storage.dataLocation.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<UButton icon="i-heroicons-folder-open" variant="ghost" size="xs" @click="openBaseDir">
|
||||||
|
{{ t('settings.storage.dataLocation.open') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center gap-2">
|
||||||
|
<UInput v-model="dataDir" readonly size="sm" class="min-w-0 flex-1" />
|
||||||
|
<UButton
|
||||||
|
size="sm"
|
||||||
|
variant="soft"
|
||||||
|
:loading="isUpdatingDataDir"
|
||||||
|
:disabled="isUpdatingDataDir"
|
||||||
|
@click="selectDataDir"
|
||||||
|
>
|
||||||
|
{{ t('settings.storage.dataLocation.choose') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton v-if="isCustomDataDir" size="sm" variant="ghost" :disabled="isUpdatingDataDir" @click="resetDataDir">
|
||||||
|
{{ t('settings.storage.dataLocation.reset') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
{{ t('settings.storage.dataLocation.restartTip') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="dataDirError" class="mt-1 text-xs text-red-500">
|
||||||
|
{{ dataDirError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 提示信息 -->
|
<!-- 提示信息 -->
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800/50 dark:bg-amber-900/20">
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800/50 dark:bg-amber-900/20">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
@@ -365,7 +359,9 @@ defineExpose({
|
|||||||
{{ pendingNewDir || t('settings.storage.dataLocation.defaultPath') }}
|
{{ pendingNewDir || t('settings.storage.dataLocation.defaultPath') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800/50 dark:bg-amber-900/20">
|
<div
|
||||||
|
class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800/50 dark:bg-amber-900/20"
|
||||||
|
>
|
||||||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||||
{{ t('settings.storage.dataLocation.confirmWarning') }}
|
{{ t('settings.storage.dataLocation.confirmWarning') }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user