feat: add version history and AI diff highlighting in editor
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ref, nextTick, onMounted } from 'vue'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { sendAIRequest } from '../composables/useAI'
|
||||
|
||||
@@ -40,13 +40,19 @@ async function send() {
|
||||
messages.value.push({ role: 'user', content: userInput })
|
||||
loading.value = true
|
||||
|
||||
if (mode.value === 'json') {
|
||||
await store.saveVersion('AI 修改前')
|
||||
}
|
||||
|
||||
const oldGameData = mode.value === 'json' ? JSON.parse(JSON.stringify(store.gameData)) : undefined
|
||||
|
||||
try {
|
||||
const { result, sessionId: newSid } = await sendAIRequest(fullMessage, mode.value, store.deepseekKey, store.aiSessionId || undefined)
|
||||
if (newSid) store.setAISessionId(newSid)
|
||||
|
||||
if (mode.value === 'json') {
|
||||
messages.value.push({ role: 'assistant', content: result || '已完成' })
|
||||
await store.reloadFromDisk()
|
||||
await store.reloadFromDisk(oldGameData)
|
||||
} else {
|
||||
messages.value.push({ role: 'assistant', content: result || '已完成' })
|
||||
}
|
||||
@@ -68,6 +74,10 @@ function newSession() {
|
||||
messages.value = []
|
||||
errorMsg.value = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.loadVersions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -83,6 +93,16 @@ function newSession() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.versions.length > 0 || store.aiChanges" class="ai-toolbar">
|
||||
<select v-if="store.versions.length > 0" class="ai-version-select" @change="store.restoreVersion(($event.target as HTMLSelectElement).selectedIndex)">
|
||||
<option disabled selected>历史版本</option>
|
||||
<option v-for="v in store.versions" :key="v.timestamp">
|
||||
{{ new Date(v.timestamp).toLocaleString('zh-CN') }} {{ v.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button v-if="store.aiChanges" class="ai-clear-btn" @click="store.clearAIMarkers()">清除高亮</button>
|
||||
</div>
|
||||
|
||||
<div class="ai-chat" ref="chatRef">
|
||||
<div v-if="!store.deepseekKey" class="ai-key-setup">
|
||||
<span class="key-label">DeepSeek API Key</span>
|
||||
@@ -289,4 +309,36 @@ function newSession() {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.ai-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-version-select {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 3px;
|
||||
color: #ccc;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ai-clear-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-clear-btn:hover { color: #fff; border-color: rgba(255,255,255,0.2); }
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,7 @@ const store = useEditorStore()
|
||||
const jsonText = ref('')
|
||||
const errorMsg = ref('')
|
||||
const saved = ref(false)
|
||||
const globalTooltip = ref('')
|
||||
|
||||
watch(() => [props.scene, store.gameData] as const, () => {
|
||||
errorMsg.value = ''
|
||||
@@ -32,6 +33,7 @@ watch(() => [props.scene, store.gameData] as const, () => {
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
function onBlur() {
|
||||
store.clearAIMarkers()
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText.value)
|
||||
@@ -54,7 +56,7 @@ function onBlur() {
|
||||
<template>
|
||||
<div class="node-editor" v-if="scene || jsonText">
|
||||
<div class="editor-header">
|
||||
<h3>{{ scene ? scene.id : '全局配置' }}</h3>
|
||||
<h3>{{ scene ? scene.id : '全局配置' }}<span v-if="!scene && store.aiChanges?.globalFields?.length" class="global-diff-tag" :title="store.aiChanges.globalFields.join(', ')">● 已修改 {{ store.aiChanges.globalFields.length }} 字段</span></h3>
|
||||
<div class="header-actions">
|
||||
<span v-if="saved" class="saved-hint">已保存</span>
|
||||
<span v-if="errorMsg" class="error-hint">JSON 错误: {{ errorMsg }}</span>
|
||||
@@ -165,4 +167,17 @@ function onBlur() {
|
||||
.json-area.error {
|
||||
border-left: 3px solid #e74c3c;
|
||||
}
|
||||
|
||||
.global-diff-tag {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #ffe0b2;
|
||||
background: rgba(230, 81, 0, 0.15);
|
||||
border: 1px solid rgba(230, 81, 0, 0.3);
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { VueFlow, useVueFlow, SmoothStepEdge } from '@vue-flow/core'
|
||||
import { VueFlow, useVueFlow, SmoothStepEdge, Handle, Position } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
@@ -8,6 +8,7 @@ import '@vue-flow/controls/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
import type { Connection } from '@vue-flow/core'
|
||||
import { computePositions, savePosition } from '../composables/useLayout'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
|
||||
const props = defineProps<{
|
||||
sceneNodes: { id: string; label: string }[]
|
||||
@@ -23,6 +24,8 @@ const emit = defineEmits<{
|
||||
clearSelection: []
|
||||
}>()
|
||||
|
||||
const store = useEditorStore()
|
||||
|
||||
const nodes = ref<any[]>([])
|
||||
const edges = ref<any[]>([])
|
||||
const { onNodeClick, onConnect, onNodeContextMenu, onNodeDragStop, onPaneClick, fitView } = useVueFlow()
|
||||
@@ -33,13 +36,23 @@ const ctxMenuNodeId = ref('')
|
||||
|
||||
function makeNodes() {
|
||||
const positions = computePositions(props.sceneNodes, props.sceneEdges, props.startScene)
|
||||
const changes = store.aiChanges
|
||||
return props.sceneNodes.map((n) => {
|
||||
const pos = positions.get(n.id) ?? { x: 0, y: 0 }
|
||||
let badge = ''
|
||||
if (changes) {
|
||||
if (changes.added.includes(n.id)) badge = 'NEW'
|
||||
else if (changes.modified.includes(n.id)) badge = 'MOD'
|
||||
else if (changes.deleted.includes(n.id)) badge = 'DEL'
|
||||
}
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'default',
|
||||
position: pos,
|
||||
data: { label: n.id === props.startScene ? `\u25b6 ${n.label}` : n.label },
|
||||
data: {
|
||||
label: n.id === props.startScene ? `\u25b6 ${n.label}` : n.label,
|
||||
badge,
|
||||
},
|
||||
style: n.id === props.startScene
|
||||
? { background: '#1b5e20', color: '#fff', borderColor: '#388e3c' }
|
||||
: n.id === props.selectedNodeId
|
||||
@@ -99,7 +112,7 @@ function inlineRebuild() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId] as const,
|
||||
() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId, store.aiChanges] as const,
|
||||
() => {
|
||||
const nc = props.sceneNodes.length
|
||||
const ec = props.sceneEdges.length
|
||||
@@ -152,6 +165,16 @@ onConnect((conn: Connection) => {
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="2"
|
||||
>
|
||||
<template #node-default="nodeProps">
|
||||
<div class="custom-node">
|
||||
<Handle type="target" :position="Position.Left" />
|
||||
<div class="custom-node-label">{{ nodeProps.data.label }}</div>
|
||||
<span v-if="nodeProps.data.badge" class="diff-badge" :class="`diff-badge-${nodeProps.data.badge}`">
|
||||
{{ nodeProps.data.badge }}
|
||||
</span>
|
||||
<Handle type="source" :position="Position.Right" />
|
||||
</div>
|
||||
</template>
|
||||
<Background :gap="20" />
|
||||
<Controls />
|
||||
</VueFlow>
|
||||
@@ -207,4 +230,44 @@ onConnect((conn: Connection) => {
|
||||
background: rgba(100,200,255,0.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.custom-node {
|
||||
position: relative;
|
||||
padding: 8px 30px 8px 12px;
|
||||
min-width: 120px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.custom-node-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.diff-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 4px;
|
||||
padding: 1px 5px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
border-radius: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.diff-badge-NEW {
|
||||
background: #2e7d32;
|
||||
color: #c8e6c9;
|
||||
}
|
||||
|
||||
.diff-badge-MOD {
|
||||
background: #e65100;
|
||||
color: #ffe0b2;
|
||||
}
|
||||
|
||||
.diff-badge-DEL {
|
||||
background: #c62828;
|
||||
color: #ffcdd2;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
||||
|
||||
57
editor/db/editorDB.ts
Normal file
57
editor/db/editorDB.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import Dexie, { type Table } from 'dexie'
|
||||
import type { GameData } from '@engine/types'
|
||||
|
||||
export interface VersionRecord {
|
||||
id?: number
|
||||
sourcePath: string
|
||||
timestamp: number
|
||||
label: string
|
||||
gameData: GameData
|
||||
}
|
||||
|
||||
class EditorDB extends Dexie {
|
||||
versions!: Table<VersionRecord, number>
|
||||
|
||||
constructor() {
|
||||
super('EditorVersions')
|
||||
this.version(1).stores({
|
||||
versions: '++id, sourcePath, timestamp',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const db = new EditorDB()
|
||||
|
||||
export async function putVersion(record: VersionRecord): Promise<void> {
|
||||
try {
|
||||
await db.versions.add(record)
|
||||
const all = await db.versions
|
||||
.where('sourcePath')
|
||||
.equals(record.sourcePath)
|
||||
.reverse()
|
||||
.sortBy('timestamp')
|
||||
if (all.length > 20) {
|
||||
const toDelete = all.slice(20)
|
||||
await db.versions.bulkDelete(toDelete.map((v) => v.id!))
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function getVersions(sourcePath: string): Promise<VersionRecord[]> {
|
||||
try {
|
||||
return await db.versions
|
||||
.where('sourcePath')
|
||||
.equals(sourcePath)
|
||||
.reverse()
|
||||
.sortBy('timestamp')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearVersions(sourcePath: string): Promise<void> {
|
||||
try {
|
||||
const records = await db.versions.where('sourcePath').equals(sourcePath).toArray()
|
||||
await db.versions.bulkDelete(records.map((v) => v.id!))
|
||||
} catch {}
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { shallowRef, ref, computed, triggerRef } from 'vue'
|
||||
import type { GameData, SceneNode, Choice } from '@engine/types'
|
||||
import { putVersion, getVersions } from '../db/editorDB'
|
||||
|
||||
export interface AIDiff {
|
||||
added: string[]
|
||||
modified: string[]
|
||||
deleted: string[]
|
||||
globalFields: string[]
|
||||
}
|
||||
|
||||
interface EditorVersion {
|
||||
id?: number
|
||||
sourcePath: string
|
||||
timestamp: number
|
||||
label: string
|
||||
gameData: GameData
|
||||
}
|
||||
|
||||
export const useEditorStore = defineStore('editor', () => {
|
||||
const gameData = shallowRef<GameData>({ scenes: {}, startScene: '', variables: {} })
|
||||
@@ -11,6 +27,8 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
const deepseekKey = ref(localStorage.getItem('deepseek_key') || '')
|
||||
const showAIPanel = ref(false)
|
||||
const aiSessionId = ref('')
|
||||
const aiChanges = ref<AIDiff | null>(null)
|
||||
const versions = ref<EditorVersion[]>([])
|
||||
|
||||
const selectedScene = computed(() => {
|
||||
if (!selectedNodeId.value) return null
|
||||
@@ -151,11 +169,64 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
} catch { /* dev server not running */ }
|
||||
}
|
||||
|
||||
async function reloadFromDisk() {
|
||||
async function saveVersion(label: string) {
|
||||
try {
|
||||
await putVersion({
|
||||
sourcePath: sourcePath.value,
|
||||
timestamp: Date.now(),
|
||||
label,
|
||||
gameData: JSON.parse(JSON.stringify(gameData.value)),
|
||||
})
|
||||
await loadVersions()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function restoreVersion(idx: number) {
|
||||
const v = versions.value[idx]
|
||||
if (!v) return
|
||||
gameData.value = v.gameData
|
||||
startSceneId.value = v.gameData.startScene || ''
|
||||
selectedNodeId.value = null
|
||||
aiChanges.value = null
|
||||
dirty.value = false
|
||||
autoSave()
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
try {
|
||||
versions.value = await getVersions(sourcePath.value)
|
||||
} catch {
|
||||
versions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function clearAIMarkers() {
|
||||
aiChanges.value = null
|
||||
}
|
||||
|
||||
async function reloadFromDisk(oldGameData?: GameData) {
|
||||
try {
|
||||
const resp = await fetch(sourcePath.value)
|
||||
const data = await resp.json()
|
||||
gameData.value = data
|
||||
const newData = await resp.json()
|
||||
if (oldGameData) {
|
||||
const { scenes: newScenes, ...newGlobal } = newData
|
||||
const { scenes: oldScenes, ...oldGlobal } = oldGameData
|
||||
const diff: AIDiff = { added: [], modified: [], deleted: [], globalFields: [] }
|
||||
for (const id of Object.keys(newScenes)) {
|
||||
if (!oldScenes[id]) diff.added.push(id)
|
||||
else if (JSON.stringify(oldScenes[id]) !== JSON.stringify(newScenes[id])) diff.modified.push(id)
|
||||
}
|
||||
for (const id of Object.keys(oldScenes)) {
|
||||
if (!newScenes[id]) diff.deleted.push(id)
|
||||
}
|
||||
for (const key of Object.keys({ ...(oldGlobal as any), ...(newGlobal as any) })) {
|
||||
if (JSON.stringify((oldGlobal as any)[key]) !== JSON.stringify((newGlobal as any)[key])) {
|
||||
diff.globalFields.push(key)
|
||||
}
|
||||
}
|
||||
aiChanges.value = diff
|
||||
}
|
||||
gameData.value = newData
|
||||
selectedNodeId.value = null
|
||||
clearAISession()
|
||||
} catch { /* failed to reload */ }
|
||||
@@ -163,9 +234,10 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
return {
|
||||
gameData, selectedNodeId, selectedScene, startSceneId, dirty, sourcePath,
|
||||
deepseekKey, showAIPanel, aiSessionId,
|
||||
deepseekKey, showAIPanel, aiSessionId, aiChanges, versions,
|
||||
markDirty, loadJSON, exportJSON, addScene, deleteScene,
|
||||
updateScene, addChoice, updateChoice, deleteChoice, generateId,
|
||||
setSourcePath, setDeepseekKey, setAISessionId, clearAISession, autoSave, reloadFromDisk,
|
||||
saveVersion, restoreVersion, loadVersions, clearAIMarkers,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user