feat(chat): 캐릭터 폼에 JSON 내보내기/가져오기 기능 추가
- 툴바에 'JSON 다운로드/업로드' 버튼 추가 - buildSerializablePayload, exportToJson, onImportFileChange, applyImportedData 메서드 구현 - 이미지(image, imageUrl) 및 isActive는 직렬화/역직렬화에서 제외 - 업로드 시 버전 검증 및 길이/개수 제한, 중요도(1~10) 보정 적용 - 사용자 알림 메시지(성공/오류) 한글화
This commit is contained in:
		| @@ -10,6 +10,9 @@ | |||||||
|       <v-spacer /> |       <v-spacer /> | ||||||
|       <v-toolbar-title>{{ isEdit ? '캐릭터 수정' : '캐릭터 등록' }}</v-toolbar-title> |       <v-toolbar-title>{{ isEdit ? '캐릭터 수정' : '캐릭터 등록' }}</v-toolbar-title> | ||||||
|       <v-spacer /> |       <v-spacer /> | ||||||
|  |       <v-btn small outlined color="primary" @click="exportToJson">JSON 다운로드</v-btn> | ||||||
|  |       <input ref="importInput" type="file" accept="application/json,.json" style="display:none" @change="onImportFileChange" /> | ||||||
|  |       <v-btn small color="primary" class="ml-2" @click="$refs.importInput.click()">JSON 업로드</v-btn> | ||||||
|     </v-toolbar> |     </v-toolbar> | ||||||
|  |  | ||||||
|     <v-container> |     <v-container> | ||||||
| @@ -1630,6 +1633,147 @@ export default { | |||||||
|       } finally { |       } finally { | ||||||
|         this.isLoading = false; |         this.isLoading = false; | ||||||
|       } |       } | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     // ===== JSON 내보내기/가져오기 ===== | ||||||
|  |     buildSerializablePayload() { | ||||||
|  |       const c = { | ||||||
|  |         ...this.character, | ||||||
|  |         tags: this.tags.filter(t => t && typeof t === 'string' && t.trim()), | ||||||
|  |         hobbies: [...this.hobbies], | ||||||
|  |         values: [...this.values], | ||||||
|  |         goals: [...this.goals], | ||||||
|  |         relationships: [...this.relationships], | ||||||
|  |         personalities: [...this.personalities], | ||||||
|  |         backgrounds: [...this.backgrounds], | ||||||
|  |         memories: [...this.memories] | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       // 이미지 및 상태 관련 필드 제외 | ||||||
|  |       delete c.image; | ||||||
|  |       delete c.imageUrl; | ||||||
|  |       delete c.isActive; | ||||||
|  |  | ||||||
|  |       return { | ||||||
|  |         version: 1, | ||||||
|  |         ...c | ||||||
|  |       }; | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     exportToJson() { | ||||||
|  |       try { | ||||||
|  |         const payload = this.buildSerializablePayload(); | ||||||
|  |         const json = JSON.stringify(payload, null, 2); | ||||||
|  |         const blob = new Blob([json], { type: 'application/json;charset=utf-8' }); | ||||||
|  |  | ||||||
|  |         const filenameBase = (this.character.name || 'character').replace(/\s+/g, '_'); | ||||||
|  |         const link = document.createElement('a'); | ||||||
|  |         link.href = URL.createObjectURL(blob); | ||||||
|  |         link.download = `${filenameBase}.character.json`; | ||||||
|  |         document.body.appendChild(link); | ||||||
|  |         link.click(); | ||||||
|  |         document.body.removeChild(link); | ||||||
|  |         URL.revokeObjectURL(link.href); | ||||||
|  |         this.notifySuccess('JSON 파일로 내보냈습니다.'); | ||||||
|  |       } catch (e) { | ||||||
|  |         console.error(e); | ||||||
|  |         this.notifyError('내보내기 중 오류가 발생했습니다.'); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     onImportFileChange(e) { | ||||||
|  |       const file = e.target.files && e.target.files[0]; | ||||||
|  |       if (!file) return; | ||||||
|  |       const reader = new FileReader(); | ||||||
|  |       reader.onload = () => { | ||||||
|  |         try { | ||||||
|  |           const text = reader.result; | ||||||
|  |           const data = JSON.parse(text); | ||||||
|  |           this.applyImportedData(data); | ||||||
|  |           this.notifySuccess('JSON 데이터를 불러왔습니다.'); | ||||||
|  |         } catch (err) { | ||||||
|  |           console.error(err); | ||||||
|  |           this.notifyError('유효한 JSON 파일이 아닙니다.'); | ||||||
|  |         } finally { | ||||||
|  |           // 동일 파일 재업로드 허용 | ||||||
|  |           e.target.value = ''; | ||||||
|  |         } | ||||||
|  |       }; | ||||||
|  |       reader.readAsText(file, 'utf-8'); | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     applyImportedData(data) { | ||||||
|  |       // 1) 버전/형식 검증 | ||||||
|  |       if (!data || (data.version !== 1 && data.version !== undefined)) { | ||||||
|  |         throw new Error('지원되지 않는 파일 버전입니다.'); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // 2) 안전한 추출(helper) | ||||||
|  |       const arr = (v) => Array.isArray(v) ? v : []; | ||||||
|  |       const str = (v) => (v == null ? '' : String(v)); | ||||||
|  |  | ||||||
|  |       // 3) 이미지 및 상태 관련 필드 강제 제거 (파일 포맷 요구사항) | ||||||
|  |       if (data) { | ||||||
|  |         delete data.image; | ||||||
|  |         delete data.imageUrl; | ||||||
|  |         delete data.isActive; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // 4) 기본 필드 주입 | ||||||
|  |       const patch = { | ||||||
|  |         name: str(data.name), | ||||||
|  |         description: str(data.description), | ||||||
|  |         systemPrompt: str(data.systemPrompt), | ||||||
|  |         age: str(data.age), | ||||||
|  |         gender: str(data.gender), | ||||||
|  |         mbti: str(data.mbti), | ||||||
|  |         characterType: str(data.characterType), | ||||||
|  |         originalTitle: str(data.originalTitle), | ||||||
|  |         originalLink: str(data.originalLink), | ||||||
|  |         speechPattern: str(data.speechPattern), | ||||||
|  |         speechStyle: str(data.speechStyle), | ||||||
|  |         appearance: str(data.appearance) | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       // 5) 배열 필드 주입 + 간단 검증/정제 | ||||||
|  |       const sanitizeString = (s, max) => String(s || '').trim().substring(0, max); | ||||||
|  |  | ||||||
|  |       this.tags = arr(data.tags).map(t => sanitizeString(t, 50)).slice(0, 20); | ||||||
|  |       this.hobbies = arr(data.hobbies).map(h => sanitizeString(h, 100)).slice(0, 10); | ||||||
|  |       this.values = arr(data.values).map(v => sanitizeString(v, 100)).slice(0, 10); | ||||||
|  |       this.goals = arr(data.goals).map(g => sanitizeString(g, 200)).slice(0, 10); | ||||||
|  |  | ||||||
|  |       this.memories = arr(data.memories).map(m => ({ | ||||||
|  |         title: sanitizeString(m.title, 100), | ||||||
|  |         content: sanitizeString(m.content, 1000), | ||||||
|  |         emotion: sanitizeString(m.emotion, 50) | ||||||
|  |       })).slice(0, 20); | ||||||
|  |  | ||||||
|  |       this.relationships = arr(data.relationships).map(r => ({ | ||||||
|  |         personName: sanitizeString(r.personName, 10), | ||||||
|  |         relationshipName: sanitizeString(r.relationshipName, 20), | ||||||
|  |         description: sanitizeString(r.description, 500), | ||||||
|  |         importance: Math.max(1, Math.min(10, parseInt(r.importance || 1))), | ||||||
|  |         relationshipType: sanitizeString(r.relationshipType, 10), | ||||||
|  |         currentStatus: sanitizeString(r.currentStatus, 10) | ||||||
|  |       })).slice(0, 10); | ||||||
|  |  | ||||||
|  |       this.personalities = arr(data.personalities).map(p => ({ | ||||||
|  |         trait: sanitizeString(p.trait, 100), | ||||||
|  |         description: sanitizeString(p.description, 500) | ||||||
|  |       })).slice(0, 10); | ||||||
|  |  | ||||||
|  |       this.backgrounds = arr(data.backgrounds).map(b => ({ | ||||||
|  |         topic: sanitizeString(b.topic, 100), | ||||||
|  |         description: sanitizeString(b.description, 1000) | ||||||
|  |       })).slice(0, 10); | ||||||
|  |  | ||||||
|  |       // 6) character에 반영 (image는 반드시 null 유지) | ||||||
|  |       this.character = { | ||||||
|  |         ...this.character, | ||||||
|  |         ...patch, | ||||||
|  |         image: null | ||||||
|  |       }; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Yu Sung
					Yu Sung