新增角色管理、世界观设定、章节管理和AI工具调用功能
- 角色管理:CRUD API + 卡片网格UI,每个角色约200字描述 - 世界观设定:单篇长文编辑器,建议500字以上 - 章节管理:CRUD + 排序 API,左右分栏编辑器,单章5000字上限 - AI工具调用:支持工具审批流程和结构化工具调用 - 小说详情页新增章节、角色、世界观入口按钮 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
212
frontend/src/views/WorldSettingView.vue
Normal file
212
frontend/src/views/WorldSettingView.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchNovel } from '@/api/novels'
|
||||
import { fetchWorldSetting, saveWorldSetting } from '@/api/worldSetting'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Novel } from '@/types/novel'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const novelId = Number(route.params.id)
|
||||
const novel = ref<Novel | null>(null)
|
||||
const content = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const hasChanges = ref(false)
|
||||
|
||||
const contentLength = computed(() => content.value.length)
|
||||
const contentHint = computed(() => {
|
||||
if (contentLength.value === 0) return '建议至少 500 字'
|
||||
if (contentLength.value < 500) return `${contentLength.value} 字,建议至少 500 字`
|
||||
return `${contentLength.value} 字`
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [n, ws] = await Promise.all([
|
||||
fetchNovel(novelId),
|
||||
fetchWorldSetting(novelId),
|
||||
])
|
||||
novel.value = n
|
||||
content.value = ws.content
|
||||
} catch {
|
||||
toast.error('加载失败')
|
||||
router.push({ name: 'home' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
hasChanges.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await saveWorldSetting(novelId, { content: content.value })
|
||||
hasChanges.value = false
|
||||
toast.success('世界观已保存')
|
||||
} catch {
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading && novel" class="world-view">
|
||||
<div class="world-header">
|
||||
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
|
||||
← 返回
|
||||
</BaseButton>
|
||||
<div class="header-center">
|
||||
<h1 class="world-title">{{ novel.title }}</h1>
|
||||
<p class="world-subtitle">世界观设定</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
:loading="saving"
|
||||
:disabled="!hasChanges"
|
||||
@click="handleSave"
|
||||
>
|
||||
保存
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<div class="editor-hint">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2" />
|
||||
<path d="M8 4v5M8 11v1" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>描述故事发生的世界背景、历史、规则、地理环境等设定</span>
|
||||
</div>
|
||||
|
||||
<BaseTextarea
|
||||
v-model="content"
|
||||
placeholder="在此描述你的故事世界..."
|
||||
:rows="16"
|
||||
:maxlength="5000"
|
||||
:max-height="600"
|
||||
@input="onInput"
|
||||
/>
|
||||
|
||||
<div class="editor-footer">
|
||||
<span class="word-count" :class="{ 'word-count--low': contentLength < 500 && contentLength > 0 }">
|
||||
{{ contentHint }}
|
||||
</span>
|
||||
<span v-if="hasChanges" class="unsaved-hint">未保存的更改</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="world-loading">
|
||||
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
|
||||
<div class="loading-pulse" style="height: 1rem; width: 20%; margin: 0 auto var(--space-2xl)" />
|
||||
<div class="loading-pulse" style="height: 300px; width: 100%" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.world-view {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||
}
|
||||
|
||||
.world-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.world-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink-900);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.world-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-400);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
background: var(--paper-50);
|
||||
border: 1px solid var(--paper-200);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.editor-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-400);
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--paper-100);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.word-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-300);
|
||||
}
|
||||
|
||||
.word-count--low {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.unsaved-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vermilion);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.world-loading {
|
||||
max-width: 800px;
|
||||
margin: var(--space-3xl) auto;
|
||||
}
|
||||
|
||||
.loading-pulse {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--paper-100) 25%,
|
||||
var(--paper-200) 50%,
|
||||
var(--paper-100) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: shimmer 1.5s ease infinite;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user