feat: 初始提交
This commit is contained in:
30
frontend/src/App.vue
Normal file
30
frontend/src/App.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import CustomAlert from '@/components/CustomAlert.vue'
|
||||
import { globalAlert } from '@/composables/useAlert'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<RouterView />
|
||||
|
||||
<!-- 全局提示框 -->
|
||||
<CustomAlert
|
||||
v-for="alert in globalAlert.alerts.value"
|
||||
:key="alert.id"
|
||||
:visible="alert.visible"
|
||||
:type="alert.type"
|
||||
:title="alert.title"
|
||||
:message="alert.message"
|
||||
:show-cancel="alert.showCancel"
|
||||
:confirm-text="alert.confirmText"
|
||||
:cancel-text="alert.cancelText"
|
||||
@confirm="globalAlert.closeAlert(alert.id, true)"
|
||||
@cancel="globalAlert.closeAlert(alert.id, false)"
|
||||
@close="globalAlert.closeAlert(alert.id, false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
271
frontend/src/api/admin.ts
Normal file
271
frontend/src/api/admin.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import router from '@/router'
|
||||
import type { NovelSectionResponse, NovelSectionType } from '@/api/novel'
|
||||
|
||||
// API 配置
|
||||
export const API_BASE_URL = import.meta.env.MODE === 'production' ? '' : 'http://127.0.0.1:8000'
|
||||
export const ADMIN_API_PREFIX = '/api/admin'
|
||||
|
||||
// 统一请求封装
|
||||
const request = async (url: string, options: RequestInit = {}) => {
|
||||
const authStore = useAuthStore()
|
||||
const headers = new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
})
|
||||
|
||||
if (authStore.isAuthenticated && authStore.token) {
|
||||
headers.set('Authorization', `Bearer ${authStore.token}`)
|
||||
}
|
||||
|
||||
const response = await fetch(url, { ...options, headers })
|
||||
|
||||
if (response.status === 401) {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
throw new Error('会话已过期,请重新登录')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.detail || `请求失败,状态码: ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const adminRequest = (path: string, options: RequestInit = {}) =>
|
||||
request(`${API_BASE_URL}${ADMIN_API_PREFIX}${path}`, options)
|
||||
|
||||
// 类型定义
|
||||
export interface Statistics {
|
||||
novel_count: number
|
||||
user_count: number
|
||||
api_request_count: number
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number
|
||||
username: string
|
||||
email?: string | null
|
||||
is_admin: boolean
|
||||
}
|
||||
|
||||
export interface NovelProjectSummary {
|
||||
id: string
|
||||
title: string
|
||||
genre: string
|
||||
last_edited: string
|
||||
completed_chapters: number
|
||||
total_chapters: number
|
||||
}
|
||||
|
||||
export interface AdminNovelSummary extends NovelProjectSummary {
|
||||
owner_id: number
|
||||
owner_username: string
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
chapter_number: number
|
||||
title: string
|
||||
summary: string
|
||||
content?: string | null
|
||||
status?: string
|
||||
version_id?: string | number | null
|
||||
versions?: any[]
|
||||
word_count?: number
|
||||
}
|
||||
|
||||
export interface NovelProject {
|
||||
id: string
|
||||
user_id: number
|
||||
title: string
|
||||
initial_prompt: string
|
||||
conversation_history: any[]
|
||||
blueprint?: any
|
||||
chapters: Chapter[]
|
||||
}
|
||||
|
||||
export interface PromptItem {
|
||||
id: number
|
||||
name: string
|
||||
title?: string | null
|
||||
content: string
|
||||
tags?: string[] | null
|
||||
}
|
||||
|
||||
export interface PromptCreatePayload {
|
||||
name: string
|
||||
content: string
|
||||
title?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export type PromptUpdatePayload = Partial<Omit<PromptCreatePayload, 'name'>>
|
||||
|
||||
export interface UpdateLog {
|
||||
id: number
|
||||
content: string
|
||||
created_at: string
|
||||
created_by?: string | null
|
||||
is_pinned: boolean
|
||||
}
|
||||
|
||||
export interface UpdateLogPayload {
|
||||
content?: string
|
||||
is_pinned?: boolean
|
||||
}
|
||||
|
||||
export interface DailyRequestLimit {
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
key: string
|
||||
value: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface SystemConfigUpsertPayload {
|
||||
value: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type SystemConfigUpdatePayload = Partial<SystemConfigUpsertPayload>
|
||||
|
||||
export class AdminAPI {
|
||||
private static request(path: string, options: RequestInit = {}) {
|
||||
return adminRequest(path, options)
|
||||
}
|
||||
|
||||
// Overview
|
||||
static getStatistics(): Promise<Statistics> {
|
||||
return this.request('/stats')
|
||||
}
|
||||
|
||||
// Users
|
||||
static listUsers(): Promise<AdminUser[]> {
|
||||
return this.request('/users')
|
||||
}
|
||||
|
||||
// Novels
|
||||
static listNovels(): Promise<AdminNovelSummary[]> {
|
||||
return this.request('/novel-projects')
|
||||
}
|
||||
|
||||
static getNovelDetails(projectId: string): Promise<NovelProject> {
|
||||
return this.request(`/novel-projects/${projectId}`)
|
||||
}
|
||||
|
||||
static getNovelSection(projectId: string, section: NovelSectionType): Promise<NovelSectionResponse> {
|
||||
return this.request(`/novel-projects/${projectId}/sections/${section}`)
|
||||
}
|
||||
|
||||
static getNovelChapter(projectId: string, chapterNumber: number): Promise<Chapter> {
|
||||
return this.request(`/novel-projects/${projectId}/chapters/${chapterNumber}`)
|
||||
}
|
||||
|
||||
// Prompts
|
||||
static listPrompts(): Promise<PromptItem[]> {
|
||||
return this.request('/prompts')
|
||||
}
|
||||
|
||||
static createPrompt(payload: PromptCreatePayload): Promise<PromptItem> {
|
||||
return this.request('/prompts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
static getPrompt(id: number): Promise<PromptItem> {
|
||||
return this.request(`/prompts/${id}`)
|
||||
}
|
||||
|
||||
static updatePrompt(id: number, payload: PromptUpdatePayload): Promise<PromptItem> {
|
||||
return this.request(`/prompts/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
static deletePrompt(id: number): Promise<void> {
|
||||
return this.request(`/prompts/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// Update logs
|
||||
static listUpdateLogs(): Promise<UpdateLog[]> {
|
||||
return this.request('/update-logs')
|
||||
}
|
||||
|
||||
static createUpdateLog(payload: UpdateLogPayload & { content: string }): Promise<UpdateLog> {
|
||||
return this.request('/update-logs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
static updateUpdateLog(id: number, payload: UpdateLogPayload): Promise<UpdateLog> {
|
||||
return this.request(`/update-logs/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
static deleteUpdateLog(id: number): Promise<void> {
|
||||
return this.request(`/update-logs/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// Settings
|
||||
static getDailyRequestLimit(): Promise<DailyRequestLimit> {
|
||||
return this.request('/settings/daily-request-limit')
|
||||
}
|
||||
|
||||
static setDailyRequestLimit(limit: number): Promise<DailyRequestLimit> {
|
||||
return this.request('/settings/daily-request-limit', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ limit })
|
||||
})
|
||||
}
|
||||
|
||||
static listSystemConfigs(): Promise<SystemConfig[]> {
|
||||
return this.request('/system-configs')
|
||||
}
|
||||
|
||||
static upsertSystemConfig(key: string, payload: SystemConfigUpsertPayload): Promise<SystemConfig> {
|
||||
return this.request(`/system-configs/${key}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ key, ...payload })
|
||||
})
|
||||
}
|
||||
|
||||
static patchSystemConfig(key: string, payload: SystemConfigUpdatePayload): Promise<SystemConfig> {
|
||||
return this.request(`/system-configs/${key}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
static deleteSystemConfig(key: string): Promise<void> {
|
||||
return this.request(`/system-configs/${key}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
static changePassword(oldPassword: string, newPassword: string): Promise<void> {
|
||||
return this.request('/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
61
frontend/src/api/llm.ts
Normal file
61
frontend/src/api/llm.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const API_PREFIX = '/api';
|
||||
const LLM_BASE = `${API_PREFIX}/llm-config`;
|
||||
|
||||
export interface LLMConfig {
|
||||
user_id: number;
|
||||
llm_provider_url: string | null;
|
||||
llm_provider_api_key: string | null;
|
||||
llm_provider_model: string | null;
|
||||
}
|
||||
|
||||
export interface LLMConfigCreate {
|
||||
llm_provider_url?: string;
|
||||
llm_provider_api_key?: string;
|
||||
llm_provider_model?: string;
|
||||
}
|
||||
|
||||
const getHeaders = () => {
|
||||
const authStore = useAuthStore();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authStore.token}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const getLLMConfig = async (): Promise<LLMConfig | null> => {
|
||||
const response = await fetch(LLM_BASE, {
|
||||
method: 'GET',
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch LLM config');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const createOrUpdateLLMConfig = async (config: LLMConfigCreate): Promise<LLMConfig> => {
|
||||
const response = await fetch(LLM_BASE, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save LLM config');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteLLMConfig = async (): Promise<void> => {
|
||||
const response = await fetch(LLM_BASE, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete LLM config');
|
||||
}
|
||||
};
|
||||
292
frontend/src/api/novel.ts
Normal file
292
frontend/src/api/novel.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import router from '@/router'
|
||||
|
||||
// API 配置
|
||||
// 在生产环境中使用相对路径,在开发环境中使用绝对路径
|
||||
export const API_BASE_URL = import.meta.env.MODE === 'production' ? '' : 'http://127.0.0.1:8000'
|
||||
export const API_PREFIX = '/api'
|
||||
|
||||
// 统一的请求处理函数
|
||||
const request = async (url: string, options: RequestInit = {}) => {
|
||||
const authStore = useAuthStore()
|
||||
const headers = new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
})
|
||||
|
||||
if (authStore.isAuthenticated && authStore.token) {
|
||||
headers.set('Authorization', `Bearer ${authStore.token}`)
|
||||
}
|
||||
|
||||
const response = await fetch(url, { ...options, headers })
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token 失效或未授权
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
throw new Error('会话已过期,请重新登录')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.detail || `请求失败,状态码: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// 类型定义
|
||||
export interface NovelProject {
|
||||
id: string
|
||||
title: string
|
||||
initial_prompt: string
|
||||
blueprint?: Blueprint
|
||||
chapters: Chapter[]
|
||||
conversation_history: ConversationMessage[]
|
||||
}
|
||||
|
||||
export interface NovelProjectSummary {
|
||||
id: string
|
||||
title: string
|
||||
genre: string
|
||||
last_edited: string
|
||||
completed_chapters: number
|
||||
total_chapters: number
|
||||
}
|
||||
|
||||
export interface Blueprint {
|
||||
title?: string
|
||||
target_audience?: string
|
||||
genre?: string
|
||||
style?: string
|
||||
tone?: string
|
||||
one_sentence_summary?: string
|
||||
full_synopsis?: string
|
||||
world_setting?: any
|
||||
characters?: Character[]
|
||||
relationships?: any[]
|
||||
chapter_outline?: ChapterOutline[]
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
name: string
|
||||
description: string
|
||||
identity?: string
|
||||
personality?: string
|
||||
goals?: string
|
||||
abilities?: string
|
||||
relationship_to_protagonist?: string
|
||||
}
|
||||
|
||||
export interface ChapterOutline {
|
||||
chapter_number: number
|
||||
title: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface ChapterVersion {
|
||||
content: string
|
||||
style?: string
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
chapter_number: number
|
||||
title: string
|
||||
summary: string
|
||||
content: string | null
|
||||
versions: string[] | null // versions是字符串数组,不是对象数组
|
||||
evaluation: string | null
|
||||
generation_status: 'not_generated' | 'generating' | 'evaluating' | 'selecting' | 'failed' | 'evaluation_failed' | 'waiting_for_confirm' | 'successful'
|
||||
word_count?: number // 字数统计
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ConverseResponse {
|
||||
ai_message: string
|
||||
ui_control: UIControl
|
||||
conversation_state: any
|
||||
is_complete: boolean
|
||||
ready_for_blueprint?: boolean // 新增:表示准备生成蓝图
|
||||
}
|
||||
|
||||
export interface BlueprintGenerationResponse {
|
||||
blueprint: Blueprint
|
||||
ai_message: string
|
||||
}
|
||||
|
||||
export interface UIControl {
|
||||
type: 'single_choice' | 'text_input'
|
||||
options?: Array<{ id: string; label: string }>
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export interface ChapterGenerationResponse {
|
||||
versions: ChapterVersion[] // Renamed from chapter_versions for consistency
|
||||
evaluation: string | null
|
||||
ai_message: string
|
||||
chapter_number: number
|
||||
}
|
||||
|
||||
export interface DeleteNovelsResponse {
|
||||
status: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type NovelSectionType = 'overview' | 'world_setting' | 'characters' | 'relationships' | 'chapter_outline' | 'chapters'
|
||||
|
||||
export interface NovelSectionResponse {
|
||||
section: NovelSectionType
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
// API 函数
|
||||
const NOVELS_BASE = `${API_BASE_URL}${API_PREFIX}/novels`
|
||||
const WRITER_PREFIX = '/api/writer'
|
||||
const WRITER_BASE = `${API_BASE_URL}${WRITER_PREFIX}/novels`
|
||||
|
||||
export class NovelAPI {
|
||||
static async createNovel(title: string, initialPrompt: string): Promise<NovelProject> {
|
||||
return request(NOVELS_BASE, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, initial_prompt: initialPrompt })
|
||||
})
|
||||
}
|
||||
|
||||
static async getNovel(projectId: string): Promise<NovelProject> {
|
||||
return request(`${NOVELS_BASE}/${projectId}`)
|
||||
}
|
||||
|
||||
static async getChapter(projectId: string, chapterNumber: number): Promise<Chapter> {
|
||||
return request(`${NOVELS_BASE}/${projectId}/chapters/${chapterNumber}`)
|
||||
}
|
||||
|
||||
static async getSection(projectId: string, section: NovelSectionType): Promise<NovelSectionResponse> {
|
||||
return request(`${NOVELS_BASE}/${projectId}/sections/${section}`)
|
||||
}
|
||||
|
||||
static async converseConcept(
|
||||
projectId: string,
|
||||
userInput: any,
|
||||
conversationState: any = {}
|
||||
): Promise<ConverseResponse> {
|
||||
const formattedUserInput = userInput || { id: null, value: null }
|
||||
return request(`${NOVELS_BASE}/${projectId}/concept/converse`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user_input: formattedUserInput,
|
||||
conversation_state: conversationState
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
static async generateBlueprint(projectId: string): Promise<BlueprintGenerationResponse> {
|
||||
return request(`${NOVELS_BASE}/${projectId}/blueprint/generate`, {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
static async saveBlueprint(projectId: string, blueprint: Blueprint): Promise<NovelProject> {
|
||||
return request(`${NOVELS_BASE}/${projectId}/blueprint/save`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(blueprint)
|
||||
})
|
||||
}
|
||||
|
||||
static async generateChapter(projectId: string, chapterNumber: number): Promise<NovelProject> {
|
||||
return request(`${WRITER_BASE}/${projectId}/chapters/generate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ chapter_number: chapterNumber })
|
||||
})
|
||||
}
|
||||
|
||||
static async evaluateChapter(projectId: string, chapterNumber: number): Promise<NovelProject> {
|
||||
return request(`${WRITER_BASE}/${projectId}/chapters/evaluate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ chapter_number: chapterNumber })
|
||||
})
|
||||
}
|
||||
|
||||
static async selectChapterVersion(
|
||||
projectId: string,
|
||||
chapterNumber: number,
|
||||
versionIndex: number
|
||||
): Promise<NovelProject> {
|
||||
return request(`${WRITER_BASE}/${projectId}/chapters/select`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
chapter_number: chapterNumber,
|
||||
version_index: versionIndex
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
static async getAllNovels(): Promise<NovelProjectSummary[]> {
|
||||
return request(NOVELS_BASE)
|
||||
}
|
||||
|
||||
static async deleteNovels(projectIds: string[]): Promise<DeleteNovelsResponse> {
|
||||
return request(NOVELS_BASE, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(projectIds)
|
||||
})
|
||||
}
|
||||
|
||||
static async updateChapterOutline(
|
||||
projectId: string,
|
||||
chapterOutline: ChapterOutline
|
||||
): Promise<NovelProject> {
|
||||
return request(`${WRITER_BASE}/${projectId}/chapters/update-outline`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(chapterOutline)
|
||||
})
|
||||
}
|
||||
|
||||
static async deleteChapter(
|
||||
projectId: string,
|
||||
chapterNumbers: number[]
|
||||
): Promise<NovelProject> {
|
||||
return request(`${WRITER_BASE}/${projectId}/chapters/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ chapter_numbers: chapterNumbers })
|
||||
})
|
||||
}
|
||||
|
||||
static async generateChapterOutline(
|
||||
projectId: string,
|
||||
startChapter: number,
|
||||
numChapters: number
|
||||
): Promise<NovelProject> {
|
||||
return request(`${WRITER_BASE}/${projectId}/chapters/outline`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
start_chapter: startChapter,
|
||||
num_chapters: numChapters
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
static async updateBlueprint(projectId: string, data: Record<string, any>): Promise<NovelProject> {
|
||||
return request(`${NOVELS_BASE}/${projectId}/blueprint`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
static async editChapterContent(
|
||||
projectId: string,
|
||||
chapterNumber: number,
|
||||
content: string
|
||||
): Promise<NovelProject> {
|
||||
return request(`${WRITER_BASE}/${projectId}/chapters/edit`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
chapter_number: chapterNumber,
|
||||
content: content
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
29
frontend/src/api/updates.ts
Normal file
29
frontend/src/api/updates.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Using a relative path to avoid potential alias issues
|
||||
import { API_BASE_URL } from './admin';
|
||||
|
||||
// A simplified request function for public endpoints that don't require authentication.
|
||||
const publicRequest = async (url: string, options: RequestInit = {}) => {
|
||||
const response = await fetch(url, { ...options });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Request failed, status code: ${response.status}`);
|
||||
}
|
||||
|
||||
// For DELETE requests which might not have a body
|
||||
if (response.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export interface UpdateLog {
|
||||
id: number;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const getLatestUpdates = (): Promise<UpdateLog[]> => {
|
||||
return publicRequest(`${API_BASE_URL}/api/updates/latest`);
|
||||
};
|
||||
86
frontend/src/assets/base.css
Normal file
86
frontend/src/assets/base.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
1
frontend/src/assets/logo.svg
Normal file
1
frontend/src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
88
frontend/src/assets/main.css
Normal file
88
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,88 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
/* 原始frontend-demo的样式,直接复制 */
|
||||
body {
|
||||
font-family: 'Noto Sans SC', 'Inter', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #F8F7F2; /* A light, neutral background */
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: #f1f1f1; }
|
||||
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 10px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a3a8b0; }
|
||||
|
||||
/* 加载动画 */
|
||||
.loader {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #6366f1;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 元素渐入动画 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.8s ease-in-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 聊天气泡样式 */
|
||||
.chat-bubble-ai {
|
||||
background-color: #f3f4f6;
|
||||
color: #1f2937;
|
||||
border-radius: 20px 20px 20px 5px;
|
||||
}
|
||||
.chat-bubble-user {
|
||||
background-color: #6366f1;
|
||||
color: #ffffff;
|
||||
border-radius: 20px 20px 5px 20px;
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
button,
|
||||
a[href] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown 排版优化(配合 Typography 插件的 prose 类) */
|
||||
.prose {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* 代码块与内联代码 */
|
||||
.prose pre {
|
||||
overflow: auto;
|
||||
}
|
||||
.prose code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 表格滚动 */
|
||||
.prose table {
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 图片自适应 */
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.375rem; /* rounded-md */
|
||||
}
|
||||
81
frontend/src/components/BlueprintCard.vue
Normal file
81
frontend/src/components/BlueprintCard.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">小说蓝图</h3>
|
||||
|
||||
<div v-if="!blueprint" class="text-gray-500 text-center py-8">
|
||||
暂无蓝图信息
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- 基本信息 -->
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">类型:</span>
|
||||
<span class="text-gray-800">{{ blueprint.genre || '未指定' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">风格:</span>
|
||||
<span class="text-gray-800">{{ blueprint.style || '未指定' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">基调:</span>
|
||||
<span class="text-gray-800">{{ blueprint.tone || '未指定' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600">目标读者:</span>
|
||||
<span class="text-gray-800">{{ blueprint.target_audience || '未指定' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 一句话总结 -->
|
||||
<div v-if="blueprint.one_sentence_summary">
|
||||
<h4 class="font-medium text-gray-600 mb-2">一句话总结</h4>
|
||||
<p class="text-gray-800 text-sm">{{ blueprint.one_sentence_summary }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 主要角色 -->
|
||||
<div v-if="blueprint.characters && blueprint.characters.length > 0">
|
||||
<h4 class="font-medium text-gray-600 mb-2">主要角色</h4>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="character in blueprint.characters"
|
||||
:key="character.name"
|
||||
class="text-sm"
|
||||
>
|
||||
<span class="font-medium text-gray-800">{{ character.name }}:</span>
|
||||
<span class="text-gray-600 ml-1">{{ character.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开按钮 -->
|
||||
<button
|
||||
@click="showDetails = !showDetails"
|
||||
class="text-indigo-600 hover:text-indigo-800 text-sm font-medium"
|
||||
>
|
||||
{{ showDetails ? '收起详情' : '查看详情' }}
|
||||
</button>
|
||||
|
||||
<!-- 详细信息 -->
|
||||
<div v-if="showDetails" class="space-y-4 pt-4 border-t">
|
||||
<div v-if="blueprint.full_synopsis">
|
||||
<h4 class="font-medium text-gray-600 mb-2">完整简介</h4>
|
||||
<p class="text-gray-800 text-sm leading-relaxed">{{ blueprint.full_synopsis }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { Blueprint } from '@/api/novel'
|
||||
|
||||
interface Props {
|
||||
blueprint: Blueprint | undefined
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const showDetails = ref(false)
|
||||
</script>
|
||||
293
frontend/src/components/BlueprintConfirmation.vue
Normal file
293
frontend/src/components/BlueprintConfirmation.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<div class="p-8 bg-white rounded-2xl shadow-2xl fade-in">
|
||||
<h2 class="text-3xl font-bold text-center text-gray-800 mb-6">信息收集完成!</h2>
|
||||
|
||||
<div class="text-center mb-8">
|
||||
<p class="text-lg text-gray-600 mb-4">{{ aiMessage }}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
我们已经收集了足够的信息来为您创建详细的小说蓝图。点击下方按钮开始生成您的专属故事大纲。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 高级加载状态 -->
|
||||
<div v-if="isGenerating" class="text-center py-12">
|
||||
<!-- 主加载动画 -->
|
||||
<div class="relative mx-auto mb-8 w-24 h-24">
|
||||
<!-- 外圆环 -->
|
||||
<div
|
||||
class="absolute inset-0 border-4 rounded-full transition-colors duration-500"
|
||||
:class="progress >= 100 ? 'border-green-100' : 'border-indigo-100'"
|
||||
></div>
|
||||
<!-- 旋转的渐变圆环 -->
|
||||
<div
|
||||
class="absolute inset-0 border-4 border-transparent rounded-full transition-colors duration-500"
|
||||
:class="[
|
||||
progress >= 100
|
||||
? 'border-t-green-500 border-r-green-400'
|
||||
: 'border-t-indigo-500 border-r-indigo-400',
|
||||
progress < 100 ? 'animate-spin' : ''
|
||||
]"
|
||||
></div>
|
||||
<!-- 内部脉冲圆 -->
|
||||
<div
|
||||
class="absolute inset-3 rounded-full animate-pulse opacity-20 transition-colors duration-500"
|
||||
:class="progress >= 100 ? 'bg-green-500' : 'bg-indigo-500'"
|
||||
></div>
|
||||
<!-- 中心图标 -->
|
||||
<div
|
||||
class="absolute inset-6 rounded-full flex items-center justify-center transition-colors duration-500"
|
||||
:class="progress >= 100 ? 'bg-green-500' : 'bg-indigo-500'"
|
||||
>
|
||||
<svg
|
||||
v-if="progress >= 100"
|
||||
class="w-6 h-6 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="w-6 h-6 text-white animate-pulse"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载文本和进度 -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xl font-semibold text-gray-800 animate-pulse">{{ loadingText }}</h3>
|
||||
<p class="text-gray-600">AI正在为您精心打造独特的故事蓝图...</p>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="w-full max-w-md mx-auto">
|
||||
<!-- <div class="flex justify-between text-xs text-gray-500 mb-2">
|
||||
<span>生成进度</span>
|
||||
<span>{{ Math.round(progress) }}%</span>
|
||||
</div> -->
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-1000 ease-out relative"
|
||||
:class="progress >= 100 ? 'bg-gradient-to-r from-green-500 to-emerald-600' : 'bg-gradient-to-r from-indigo-500 to-purple-600'"
|
||||
:style="{ width: `${progress}%` }"
|
||||
>
|
||||
<!-- 闪光效果 -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent opacity-30 animate-shimmer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 倒计时 -->
|
||||
<!-- <div class="text-sm text-gray-500">
|
||||
<span>预计完成时间: {{ timeRemaining }}秒</span>
|
||||
</div> -->
|
||||
|
||||
<!-- 温馨提示 -->
|
||||
<div class="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p class="text-sm text-blue-800">
|
||||
<svg class="inline w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
AI正在分析您的创意偏好,生成过程需要一些时间,请耐心等待...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-else class="text-center space-x-4">
|
||||
<!-- <button
|
||||
@click="$emit('back')"
|
||||
class="bg-gray-200 text-gray-700 font-bold py-3 px-8 rounded-full hover:bg-gray-300 transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
返回对话
|
||||
</button> -->
|
||||
<button
|
||||
@click="generateBlueprint"
|
||||
:disabled="isGenerating"
|
||||
class="bg-gradient-to-r from-indigo-500 to-purple-600 text-white font-bold py-3 px-8 rounded-full hover:from-indigo-600 hover:to-purple-700 transition-all duration-300 transform hover:scale-105 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
>
|
||||
<span class="flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
|
||||
</svg>
|
||||
开始创建蓝图
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted, inject } from 'vue'
|
||||
import { useNovelStore } from '@/stores/novel'
|
||||
import { globalAlert } from '@/composables/useAlert'
|
||||
|
||||
interface Props {
|
||||
aiMessage: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
blueprintGenerated: [response: any]
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const novelStore = useNovelStore()
|
||||
const isGenerating = ref(false)
|
||||
const progress = ref(0)
|
||||
const timeElapsed = ref(0)
|
||||
const maxTime = 180 // 180秒超时
|
||||
|
||||
let progressTimer: NodeJS.Timeout | null = null
|
||||
let timeoutTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// 动态加载文本
|
||||
const loadingText = computed(() => {
|
||||
if (progress.value >= 100) {
|
||||
return '生成完成!正在准备展示...'
|
||||
}
|
||||
|
||||
const messages = [
|
||||
'正在分析故事结构...',
|
||||
'构建角色关系网络...',
|
||||
'生成情节发展脉络...',
|
||||
'完善世界观设定...',
|
||||
'优化章节安排...',
|
||||
'最后润色细节...'
|
||||
]
|
||||
|
||||
const index = Math.floor((progress.value / 100) * messages.length)
|
||||
return messages[Math.min(index, messages.length - 1)]
|
||||
})
|
||||
|
||||
// 剩余时间计算
|
||||
const timeRemaining = computed(() => {
|
||||
return Math.max(0, maxTime - timeElapsed.value)
|
||||
})
|
||||
|
||||
const generateBlueprint = async () => {
|
||||
isGenerating.value = true
|
||||
progress.value = 0
|
||||
timeElapsed.value = 0
|
||||
|
||||
// 启动进度条动画
|
||||
progressTimer = setInterval(() => {
|
||||
timeElapsed.value += 0.1
|
||||
|
||||
// 非线性进度增长,前面快后面慢
|
||||
const normalizedTime = timeElapsed.value / maxTime
|
||||
if (normalizedTime < 0.7) {
|
||||
// 前70%时间内进度到80%
|
||||
progress.value = Math.min(80, (normalizedTime / 0.7) * 80)
|
||||
} else {
|
||||
// 后30%时间内从80%到95%
|
||||
const remainingProgress = (normalizedTime - 0.7) / 0.3
|
||||
progress.value = Math.min(95, 80 + remainingProgress * 15)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// 60秒超时
|
||||
timeoutTimer = setTimeout(() => {
|
||||
clearTimers()
|
||||
isGenerating.value = false
|
||||
globalAlert.showError('生成超时,请稍后重试。如果问题持续,请检查网络连接。', '生成超时')
|
||||
}, maxTime * 1000)
|
||||
|
||||
try {
|
||||
// 直接调用store中的API
|
||||
console.log('开始调用generateBlueprint API...')
|
||||
const response = await novelStore.generateBlueprint()
|
||||
console.log('API调用成功,收到响应:', response)
|
||||
|
||||
// API成功后,快速完成进度条到100%
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer)
|
||||
progressTimer = null
|
||||
}
|
||||
|
||||
// 动画到100%并显示完成状态
|
||||
progress.value = 100
|
||||
|
||||
// 等待一下让用户看到100%完成状态,然后再切换界面
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
|
||||
// 清理并重置状态
|
||||
clearTimers()
|
||||
isGenerating.value = false
|
||||
|
||||
// 通知父组件生成完成
|
||||
emit('blueprintGenerated', response)
|
||||
|
||||
} catch (error) {
|
||||
console.error('生成蓝图失败:', error)
|
||||
clearTimers()
|
||||
isGenerating.value = false
|
||||
globalAlert.showError(`生成蓝图失败: ${error instanceof Error ? error.message : '未知错误'}`, '生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
const clearTimers = () => {
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer)
|
||||
progressTimer = null
|
||||
}
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* 自定义动画增强 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* 按钮悬停效果增强 */
|
||||
.transform {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.hover\:scale-105:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 禁用状态样式 */
|
||||
.disabled\:transform-none:disabled {
|
||||
transform: none !important;
|
||||
}
|
||||
</style>
|
||||
443
frontend/src/components/BlueprintDisplay.vue
Normal file
443
frontend/src/components/BlueprintDisplay.vue
Normal file
@@ -0,0 +1,443 @@
|
||||
<template>
|
||||
<div class="p-8 bg-white rounded-2xl shadow-2xl fade-in">
|
||||
<h2 class="text-3xl font-bold text-center text-gray-800 mb-6">你的故事蓝图已生成!</h2>
|
||||
|
||||
<!-- AI消息 -->
|
||||
<div v-if="aiMessage" class="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p class="text-blue-800">{{ aiMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div class="prose max-w-none p-6 bg-gray-50 rounded-lg border border-gray-200" v-html="formattedBlueprint"></div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isSaving" class="text-center py-8">
|
||||
<!-- 保存动画 -->
|
||||
<div class="relative mx-auto mb-6 w-16 h-16">
|
||||
<!-- 旋转圆环 -->
|
||||
<div class="absolute inset-0 border-4 border-green-100 rounded-full"></div>
|
||||
<div class="absolute inset-0 border-4 border-transparent border-t-green-500 rounded-full animate-spin"></div>
|
||||
<!-- 中心保存图标 -->
|
||||
<div class="absolute inset-2 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white animate-pulse" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7.707 10.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V6a1 1 0 10-2 0v5.586l-1.293-1.293z"></path>
|
||||
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v1a1 1 0 11-2 0V4H7v1a1 1 0 11-2 0V4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2 animate-pulse">正在保存蓝图...</h3>
|
||||
<p class="text-gray-600">即将跳转到写作工作台,开始您的创作之旅</p>
|
||||
|
||||
<!-- 保存进度指示 -->
|
||||
<div class="mt-4 w-32 mx-auto">
|
||||
<div class="w-full bg-gray-200 rounded-full h-1">
|
||||
<div class="h-1 bg-gradient-to-r from-green-400 to-green-600 rounded-full animate-pulse" style="width: 100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center mt-8 space-x-4">
|
||||
<button
|
||||
@click="confirmRegenerate"
|
||||
class="bg-gray-200 text-gray-700 font-bold py-3 px-8 rounded-full hover:bg-gray-300 transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
<span class="flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
重新生成
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="confirmBlueprint"
|
||||
:disabled="isSaving"
|
||||
class="bg-gradient-to-r from-green-500 to-emerald-600 text-white font-bold py-3 px-8 rounded-full hover:from-green-600 hover:to-emerald-700 transition-all duration-300 transform hover:scale-105 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
>
|
||||
<span class="flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
确认并开始创作
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { globalAlert } from '@/composables/useAlert'
|
||||
import type { Blueprint } from '@/api/novel'
|
||||
|
||||
interface DisplayField {
|
||||
label: string;
|
||||
value: any;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
type ExtractedFields = Record<string, DisplayField>;
|
||||
|
||||
interface Props {
|
||||
blueprint: Blueprint | null
|
||||
aiMessage?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
regenerate: []
|
||||
}>()
|
||||
|
||||
const isSaving = ref(false)
|
||||
|
||||
const confirmRegenerate = async () => {
|
||||
const confirmed = await globalAlert.showConfirm('重新生成会覆盖当前蓝图,确定继续吗?', '重新生成确认')
|
||||
if (confirmed) {
|
||||
emit('regenerate')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmBlueprint = async () => {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await emit('confirm')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formattedBlueprint = computed(() => {
|
||||
if (!props.blueprint) {
|
||||
return '<p class="text-center text-red-500">抱歉,生成大纲失败,未能获取到最终数据。</p>'
|
||||
}
|
||||
|
||||
const blueprint = props.blueprint
|
||||
|
||||
// Helper function to safely access nested properties
|
||||
const safe = (value: any, fallback = '待补充') => value || fallback
|
||||
|
||||
// Create section with icon and styling
|
||||
const createSection = (title: string, content: string, icon: string) => `
|
||||
<div class="mb-8 bg-white rounded-xl border border-gray-200 p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-8 h-8 bg-indigo-100 rounded-lg flex items-center justify-center mr-3">
|
||||
${icon}
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-800">${title}</h3>
|
||||
</div>
|
||||
<div class="prose max-w-none text-gray-700">
|
||||
${content}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Icons
|
||||
const icons = {
|
||||
summary: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>',
|
||||
story: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>',
|
||||
world: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clip-rule="evenodd"></path></svg>',
|
||||
characters: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"></path></svg>',
|
||||
relationships: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd"></path></svg>',
|
||||
chapters: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4zM18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z"></path></svg>'
|
||||
}
|
||||
|
||||
// Format characters with enhanced styling - 动态兼容所有字段
|
||||
const formatCharacters = (characters: any[]) => {
|
||||
if (!characters || characters.length === 0) return '<p class="text-gray-500 italic">暂无角色信息</p>'
|
||||
|
||||
return characters.map(char => {
|
||||
if (typeof char === 'object' && char.name) {
|
||||
const name = char.name
|
||||
|
||||
// 定义字段映射和图标,支持多种可能的key名称
|
||||
const fieldMappings = {
|
||||
identity: {
|
||||
keys: ['identity_background', 'identity', 'background', '身份背景', '身份'],
|
||||
label: '🎭 身份背景',
|
||||
priority: 1
|
||||
},
|
||||
personality: {
|
||||
keys: ['personality_traits', 'personality', 'traits', 'character', '性格特质', '性格'],
|
||||
label: '🎨 性格特质',
|
||||
priority: 2
|
||||
},
|
||||
goal: {
|
||||
keys: ['core_goal', 'goal', 'objectives', 'aims', '核心目标', '目标'],
|
||||
label: '🎯 核心目标',
|
||||
priority: 3
|
||||
},
|
||||
abilities: {
|
||||
keys: ['abilities_skills', 'abilities', 'skills', 'powers', '能力技能', '能力', '技能'],
|
||||
label: '⚡ 能力技能',
|
||||
priority: 4
|
||||
},
|
||||
relationship: {
|
||||
keys: ['relationship_with_protagonist', 'relationship_to_protagonist', 'relationship', 'relation', '与主角关系', '关系'],
|
||||
label: '🤝 与主角关系',
|
||||
priority: 5
|
||||
},
|
||||
role: {
|
||||
keys: ['role', 'character_role', 'story_role', '角色定位', '角色'],
|
||||
label: '👤 角色定位',
|
||||
priority: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 提取所有字段
|
||||
const extractedFields: ExtractedFields = {}
|
||||
const usedKeys = new Set(['name']) // 已使用的key
|
||||
|
||||
// 按优先级提取已知字段
|
||||
Object.entries(fieldMappings).forEach(([fieldType, mapping]) => {
|
||||
for (const key of mapping.keys) {
|
||||
if (char[key] && !usedKeys.has(key)) {
|
||||
extractedFields[fieldType] = {
|
||||
value: char[key],
|
||||
label: mapping.label,
|
||||
priority: mapping.priority
|
||||
}
|
||||
usedKeys.add(key)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 提取剩余的未知字段
|
||||
Object.entries(char).forEach(([key, value]) => {
|
||||
if (!usedKeys.has(key) && value && typeof value === 'string' && value.trim()) {
|
||||
// 为未知字段生成友好的标签
|
||||
const friendlyLabel = key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, str => str.toUpperCase())
|
||||
|
||||
extractedFields[`unknown_${key}`] = {
|
||||
value: value,
|
||||
label: `📝 ${friendlyLabel}`,
|
||||
priority: 99
|
||||
}
|
||||
usedKeys.add(key)
|
||||
}
|
||||
})
|
||||
|
||||
// 按优先级排序字段
|
||||
const sortedFields = Object.entries(extractedFields).sort(([,a], [,b]) => a.priority - b.priority)
|
||||
|
||||
// 生成HTML
|
||||
let fieldsHTML = ''
|
||||
sortedFields.forEach(([fieldType, field]) => {
|
||||
if (fieldType === 'role') {
|
||||
// role字段显示为标签,不在详细信息中
|
||||
return
|
||||
}
|
||||
|
||||
fieldsHTML += `
|
||||
<div class="bg-white/70 rounded-lg p-3">
|
||||
<span class="font-medium text-gray-700 block mb-1">${field.label}:</span>
|
||||
<span class="text-gray-800">${field.value}</span>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const roleField = extractedFields.role
|
||||
|
||||
return `
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 border-l-4 border-indigo-400 rounded-lg p-5 mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-lg font-bold text-indigo-800 flex items-center">
|
||||
<span class="w-2 h-2 bg-indigo-500 rounded-full mr-2"></span>
|
||||
${name}
|
||||
</h4>
|
||||
${roleField ? `<span class="bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full text-xs font-medium">${roleField.value}</span>` : ''}
|
||||
</div>
|
||||
<div class="space-y-3 text-sm">
|
||||
${fieldsHTML}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
// 处理简单的角色结构 (向后兼容)
|
||||
else if (typeof char === 'object' && char.description) {
|
||||
const desc = char.description
|
||||
const identity = desc.identity || ''
|
||||
const personality = desc.personality || ''
|
||||
const relationship = desc.relationship_to_protagonist || ''
|
||||
|
||||
return `
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 border-l-4 border-indigo-400 rounded-lg p-5 mb-4">
|
||||
<h4 class="text-lg font-bold text-indigo-800 mb-3 flex items-center">
|
||||
<span class="w-2 h-2 bg-indigo-500 rounded-full mr-2"></span>
|
||||
${char.name}
|
||||
</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
${identity ? `<div class="flex items-start"><span class="font-medium text-gray-600 min-w-16">身份:</span><span class="text-gray-800">${identity}</span></div>` : ''}
|
||||
${personality ? `<div class="flex items-start"><span class="font-medium text-gray-600 min-w-16">性格:</span><span class="text-gray-800">${personality}</span></div>` : ''}
|
||||
${relationship ? `<div class="flex items-start"><span class="font-medium text-gray-600 min-w-16">关系:</span><span class="text-gray-800">${relationship}</span></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
// 处理最简单的结构
|
||||
else {
|
||||
return `
|
||||
<div class="bg-gray-50 border-l-4 border-gray-300 rounded-lg p-4 mb-3">
|
||||
<h4 class="font-semibold text-gray-800">${char.name || '未知角色'}</h4>
|
||||
<p class="text-gray-600 text-sm mt-1">${char.description || '无描述'}</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}).join('')
|
||||
}
|
||||
|
||||
// Format world setting with enhanced styling
|
||||
const formatWorldSetting = (worldSetting: any) => {
|
||||
if (!worldSetting || typeof worldSetting !== 'object') return '<p class="text-gray-500 italic">暂无世界设定信息</p>'
|
||||
|
||||
let html = ''
|
||||
|
||||
if (worldSetting.core_rules) {
|
||||
html += `
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
|
||||
<h4 class="font-semibold text-amber-800 mb-2 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
|
||||
核心设定
|
||||
</h4>
|
||||
<p class="text-amber-700">${worldSetting.core_rules}</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
if (worldSetting.key_locations && worldSetting.key_locations.length > 0) {
|
||||
html += `
|
||||
<div class="mb-4">
|
||||
<h4 class="font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-teal-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path></svg>
|
||||
关键地点
|
||||
</h4>
|
||||
<div class="grid gap-3">
|
||||
${worldSetting.key_locations.map((loc: any) => `
|
||||
<div class="bg-teal-50 border-l-3 border-teal-400 p-3 rounded-r-lg">
|
||||
<h5 class="font-medium text-teal-800">${loc.name}</h5>
|
||||
<p class="text-teal-700 text-sm mt-1">${loc.description}</p>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
if (worldSetting.factions && worldSetting.factions.length > 0) {
|
||||
html += `
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-800 mb-3 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-purple-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"></path></svg>
|
||||
主要势力
|
||||
</h4>
|
||||
<div class="grid gap-3">
|
||||
${worldSetting.factions.map((fac: any) => `
|
||||
<div class="bg-purple-50 border-l-3 border-purple-400 p-3 rounded-r-lg">
|
||||
<h5 class="font-medium text-purple-800">${fac.name}</h5>
|
||||
<p class="text-purple-700 text-sm mt-1">${fac.description}</p>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
return html || '<p class="text-gray-500 italic">暂无世界设定详细信息</p>'
|
||||
}
|
||||
|
||||
// Format relationships with enhanced styling - 支持新的数据结构
|
||||
const formatRelationships = (relationships: any[]) => {
|
||||
if (!relationships || relationships.length === 0) return '<p class="text-gray-500 italic">暂无关系设定</p>'
|
||||
|
||||
return `
|
||||
<div class="space-y-3">
|
||||
${relationships.map(rel => {
|
||||
// 支持新的字段名:character_from, character_to 以及旧的 source, target
|
||||
const fromChar = rel.character_from || rel.source || '角色A'
|
||||
const toChar = rel.character_to || rel.target || '角色B'
|
||||
const description = rel.description || '暂无描述'
|
||||
|
||||
return `
|
||||
<div class="bg-rose-50 border border-rose-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<span class="font-medium text-rose-800 bg-white px-3 py-1 rounded-full text-sm shadow-sm">${fromChar}</span>
|
||||
<svg class="w-5 h-5 mx-3 text-rose-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="font-medium text-rose-800 bg-white px-3 py-1 rounded-full text-sm shadow-sm">${toChar}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-rose-700 bg-white/50 rounded-lg p-3">
|
||||
<span class="font-medium">关系描述:</span>${description}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}).join('')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Header with title and badges
|
||||
const headerHTML = `
|
||||
<div class="text-center mb-8 p-6 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl text-white">
|
||||
<h1 class="text-4xl font-bold mb-4">${safe(blueprint.title, '未知标题')}</h1>
|
||||
<div class="flex flex-wrap justify-center gap-3 mb-4">
|
||||
<span class="bg-white/20 backdrop-blur-sm px-4 py-2 rounded-full text-sm font-medium">${safe(blueprint.genre, '未指定')}</span>
|
||||
<span class="bg-white/20 backdrop-blur-sm px-4 py-2 rounded-full text-sm font-medium">${safe(blueprint.style, '未指定')}</span>
|
||||
<span class="bg-white/20 backdrop-blur-sm px-4 py-2 rounded-full text-sm font-medium">${safe(blueprint.tone, '未指定')}</span>
|
||||
<span class="bg-white/20 backdrop-blur-sm px-4 py-2 rounded-full text-sm font-medium">${safe(blueprint.target_audience, '未指定')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Summary section
|
||||
const summaryHTML = createSection(
|
||||
'故事梗概',
|
||||
`
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-5 mb-4">
|
||||
<h4 class="font-semibold text-blue-800 mb-2">一句话总结</h4>
|
||||
<p class="text-lg italic text-blue-700">"${safe(blueprint.one_sentence_summary)}"</p>
|
||||
</div>
|
||||
<div class="prose max-w-none">
|
||||
<h4 class="font-semibold text-gray-800 mb-3">完整简介</h4>
|
||||
<p class="text-gray-700 leading-relaxed">${safe(blueprint.full_synopsis)}</p>
|
||||
</div>
|
||||
`,
|
||||
icons.summary
|
||||
)
|
||||
|
||||
// Chapters section with enhanced styling
|
||||
const chaptersHTML = `
|
||||
<div class="space-y-4">
|
||||
${(blueprint.chapter_outline || []).map((ch, index) => `
|
||||
<div class="group relative overflow-hidden bg-gradient-to-r from-gray-50 to-white border border-gray-200 rounded-lg p-5 hover:shadow-md transition-all duration-300">
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b from-indigo-500 to-purple-600 transform origin-top group-hover:scale-y-110 transition-transform duration-300"></div>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<span class="text-indigo-600 font-bold text-sm">${ch.chapter_number}</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-lg font-bold text-gray-800 mb-2 group-hover:text-indigo-600 transition-colors duration-300">第 ${ch.chapter_number} 章: ${ch.title}</h4>
|
||||
<p class="text-gray-600 leading-relaxed">${ch.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`
|
||||
|
||||
return `
|
||||
${headerHTML}
|
||||
${summaryHTML}
|
||||
${createSection('世界设定', formatWorldSetting(blueprint.world_setting), icons.world)}
|
||||
${createSection('主要角色', formatCharacters(blueprint.characters || []), icons.characters)}
|
||||
${createSection('角色关系', formatRelationships(blueprint.relationships || []), icons.relationships)}
|
||||
${createSection('章节大纲', chaptersHTML, icons.chapters)}
|
||||
`
|
||||
})
|
||||
</script>
|
||||
73
frontend/src/components/BlueprintEditModal.vue
Normal file
73
frontend/src/components/BlueprintEditModal.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div v-if="show" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex justify-center items-center" @click.self="$emit('close')">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h3 class="text-xl font-semibold text-gray-800">编辑 {{ title }}</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<ChapterOutlineEditor v-if="props.field === 'chapter_outline'" v-model="editableContent" />
|
||||
<KeyLocationsEditor v-else-if="props.field === 'world_setting.key_locations'" v-model="editableContent" />
|
||||
<CharactersEditor v-else-if="props.field === 'characters'" v-model="editableContent" />
|
||||
<RelationshipsEditor v-else-if="props.field === 'relationships'" v-model="editableContent" />
|
||||
<FactionsEditor v-else-if="props.field === 'world_setting.factions'" v-model="editableContent" />
|
||||
<textarea
|
||||
v-else
|
||||
v-model="editableContent"
|
||||
class="w-full h-64 p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition duration-150 ease-in-out"
|
||||
placeholder="请输入内容..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="px-6 py-4 bg-gray-50 rounded-b-lg flex justify-end space-x-3">
|
||||
<button @click="$emit('close')" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
取消
|
||||
</button>
|
||||
<button @click="saveChanges" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, defineProps, defineEmits } from 'vue';
|
||||
import ChapterOutlineEditor from './ChapterOutlineEditor.vue';
|
||||
import KeyLocationsEditor from './KeyLocationsEditor.vue';
|
||||
import CharactersEditor from './CharactersEditor.vue';
|
||||
import RelationshipsEditor from './RelationshipsEditor.vue';
|
||||
import FactionsEditor from './FactionsEditor.vue';
|
||||
import type { ChapterOutline } from '@/api/novel';
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
title: String,
|
||||
content: {
|
||||
type: [String, Object, Array],
|
||||
default: ''
|
||||
},
|
||||
field: String
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'save']);
|
||||
|
||||
const editableContent = ref<any>('');
|
||||
|
||||
watch(() => props.show, (isVisible) => {
|
||||
if (isVisible) {
|
||||
// 当模态框显示时,进行一次性的深克隆,创建一个独立的、可编辑的副本。
|
||||
// 这会切断与外部的响应式链接,避免编辑时的大规模更新。
|
||||
// 我们在这里恢复使用 JSON.parse(JSON.stringify(...)),因为它的性能开销
|
||||
// 在“一次性”操作中是可接受的,并且是实现深克隆和响应式隔离的最简单方法。
|
||||
try {
|
||||
editableContent.value = JSON.parse(JSON.stringify(props.content || ''));
|
||||
} catch (e) {
|
||||
// 对于无法序列化的简单类型,直接赋值
|
||||
editableContent.value = props.content || '';
|
||||
}
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const saveChanges = () => {
|
||||
emit('save', { field: props.field, content: editableContent.value });
|
||||
};
|
||||
</script>
|
||||
96
frontend/src/components/ChapterList.vue
Normal file
96
frontend/src/components/ChapterList.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">章节列表</h3>
|
||||
|
||||
<div v-if="chapterOutline.length === 0" class="text-gray-500 text-center py-8">
|
||||
暂无章节大纲
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="outline in chapterOutline"
|
||||
:key="outline.chapter_number"
|
||||
class="border rounded-lg p-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-gray-800">
|
||||
第{{ outline.chapter_number }}章: {{ outline.title }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ outline.summary }}</p>
|
||||
|
||||
<!-- 章节状态 -->
|
||||
<div class="mt-2">
|
||||
<span
|
||||
:class="getChapterStatusClass(outline.chapter_number)"
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
||||
>
|
||||
{{ getChapterStatus(outline.chapter_number) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 ml-4">
|
||||
<!-- 查看按钮 -->
|
||||
<button
|
||||
v-if="isChapterCompleted(outline.chapter_number)"
|
||||
@click="$emit('selectChapter', outline.chapter_number)"
|
||||
class="px-3 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors text-sm"
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<button
|
||||
v-if="!isChapterCompleted(outline.chapter_number)"
|
||||
@click="$emit('generateChapter', outline.chapter_number)"
|
||||
class="px-3 py-1 bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors text-sm"
|
||||
>
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Chapter, ChapterOutline } from '@/api/novel'
|
||||
|
||||
interface Props {
|
||||
chapters: Chapter[]
|
||||
chapterOutline: ChapterOutline[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
selectChapter: [chapterNumber: number]
|
||||
generateChapter: [chapterNumber: number]
|
||||
}>()
|
||||
|
||||
const isChapterCompleted = (chapterNumber: number) => {
|
||||
return props.chapters.some(ch => ch.chapter_number === chapterNumber && ch.content)
|
||||
}
|
||||
|
||||
const getChapterStatus = (chapterNumber: number) => {
|
||||
const chapter = props.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
if (!chapter) return '未开始'
|
||||
if (chapter.content) return '已完成'
|
||||
if (chapter.versions && chapter.versions.length > 0) return '待选择'
|
||||
return '未开始'
|
||||
}
|
||||
|
||||
const getChapterStatusClass = (chapterNumber: number) => {
|
||||
const status = getChapterStatus(chapterNumber)
|
||||
switch (status) {
|
||||
case '已完成':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case '待选择':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
65
frontend/src/components/ChapterOutlineEditor.vue
Normal file
65
frontend/src/components/ChapterOutlineEditor.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="space-y-4 max-h-96 overflow-y-auto p-1">
|
||||
<div v-for="(chapter, index) in localOutline" :key="index" class="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
||||
<div class="flex items-center mb-2">
|
||||
<span class="font-bold text-indigo-600 mr-2">第 {{ chapter.chapter_number }} 章</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="chapter.title"
|
||||
class="flex-grow p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition"
|
||||
placeholder="章节标题"
|
||||
/>
|
||||
<button @click="removeChapter(index)" class="ml-2 text-red-400 hover:text-red-600 transition-colors p-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="chapter.summary"
|
||||
class="w-full h-24 p-2 mt-2 border border-gray-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 transition text-sm"
|
||||
placeholder="章节摘要"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
|
||||
import type { ChapterOutline } from '@/api/novel';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => ChapterOutline[],
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const localOutline = ref<ChapterOutline[]>([]);
|
||||
let syncing = false;
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
syncing = true;
|
||||
// Deep copy to prevent modifying the original prop
|
||||
localOutline.value = JSON.parse(JSON.stringify(newVal || []));
|
||||
nextTick(() => {
|
||||
syncing = false;
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
// Watch for local changes and emit them upwards
|
||||
watch(localOutline, (newVal) => {
|
||||
if (syncing) return;
|
||||
emit('update:modelValue', JSON.parse(JSON.stringify(newVal)));
|
||||
}, { deep: true });
|
||||
|
||||
const removeChapter = (index: number) => {
|
||||
localOutline.value.splice(index, 1);
|
||||
// Re-number all subsequent chapters to ensure they are sequential
|
||||
localOutline.value.forEach((chapter, i) => {
|
||||
chapter.chapter_number = i + 1;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
111
frontend/src/components/ChapterWorkspace.vue
Normal file
111
frontend/src/components/ChapterWorkspace.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<!-- 章节头部 -->
|
||||
<div v-if="chapterOutline" class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">
|
||||
第{{ chapterOutline.chapter_number }}章: {{ chapterOutline.title }}
|
||||
</h2>
|
||||
<p class="text-gray-600 mt-2">{{ chapterOutline.summary }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 无选择状态 -->
|
||||
<div v-if="!chapterOutline" class="text-center py-12 text-gray-500">
|
||||
请从左侧选择一个章节开始工作
|
||||
</div>
|
||||
|
||||
<!-- 已完成的章节 -->
|
||||
<div v-else-if="chapter && chapter.content" class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold text-gray-800">已发布内容</h3>
|
||||
<button
|
||||
@click="confirmRegenerate"
|
||||
class="px-4 py-2 bg-indigo-100 text-indigo-700 rounded hover:bg-indigo-200 transition-colors"
|
||||
>
|
||||
重新生成版本
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="prose max-w-none p-4 bg-gray-50 rounded-lg border">
|
||||
<div class="whitespace-pre-wrap">{{ chapter.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成结果选择 -->
|
||||
<div v-else-if="generationResult" class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold text-gray-800">选择版本</h3>
|
||||
<button
|
||||
@click="confirmRegenerate"
|
||||
class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- AI评估 -->
|
||||
<div class="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<h4 class="font-medium text-blue-800 mb-2">AI评估建议</h4>
|
||||
<p class="text-blue-700 text-sm">{{ generationResult.evaluation }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 版本选择 -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="(version, index) in generationResult.versions"
|
||||
:key="index"
|
||||
class="border rounded-lg p-4 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
@click="$emit('selectVersion', index)"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<h4 class="font-medium text-gray-800">版本 {{ index + 1 }}</h4>
|
||||
<button
|
||||
@click.stop="$emit('selectVersion', index)"
|
||||
class="px-3 py-1 bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors text-sm"
|
||||
>
|
||||
选择此版本
|
||||
</button>
|
||||
</div>
|
||||
<div class="prose max-w-none text-sm text-gray-700 whitespace-pre-wrap">
|
||||
{{ version }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 等待生成状态 -->
|
||||
<div v-else class="text-center py-12">
|
||||
<div class="text-gray-500 mb-4">点击左侧的"生成"按钮开始创作这一章</div>
|
||||
<button
|
||||
@click="confirmRegenerate"
|
||||
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
开始生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { globalAlert } from '@/composables/useAlert'
|
||||
import type { Chapter, ChapterOutline, ChapterGenerationResponse } from '@/api/novel'
|
||||
|
||||
interface Props {
|
||||
chapter: Chapter | null
|
||||
chapterOutline: ChapterOutline | null
|
||||
generationResult: ChapterGenerationResponse | null
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectVersion: [versionIndex: number]
|
||||
regenerate: []
|
||||
}>()
|
||||
|
||||
const confirmRegenerate = async () => {
|
||||
const confirmed = await globalAlert.showConfirm('重新生成会覆盖当前章节的现有结果,确定继续吗?', '重新生成确认')
|
||||
if (confirmed) {
|
||||
emit('regenerate')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
94
frontend/src/components/CharactersEditor.vue
Normal file
94
frontend/src/components/CharactersEditor.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="space-y-4 max-h-96 overflow-y-auto p-1">
|
||||
<div v-for="(character, index) in localCharacters" :key="index" class="p-4 border border-gray-200 rounded-lg bg-gray-50 relative">
|
||||
<button @click="removeCharacter(index)" class="absolute top-2 right-2 text-red-400 hover:text-red-600 transition-colors p-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">姓名</label>
|
||||
<input type="text" v-model="character.name" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">身份</label>
|
||||
<input type="text" v-model="character.identity" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">性格</label>
|
||||
<input type="text" v-model="character.personality" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">目标</label>
|
||||
<input type="text" v-model="character.goals" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">能力</label>
|
||||
<input type="text" v-model="character.abilities" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">与主角关系</label>
|
||||
<input type="text" v-model="character.relationship_to_protagonist" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="addCharacter" class="w-full mt-4 px-4 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
+ 添加新角色
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
|
||||
|
||||
interface Character {
|
||||
name: string;
|
||||
identity: string;
|
||||
personality: string;
|
||||
goals: string;
|
||||
abilities: string;
|
||||
relationship_to_protagonist: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => Character[],
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const localCharacters = ref<Character[]>([]);
|
||||
let syncing = false;
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
syncing = true;
|
||||
localCharacters.value = JSON.parse(JSON.stringify(newVal || []));
|
||||
nextTick(() => {
|
||||
syncing = false;
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
watch(localCharacters, (newVal) => {
|
||||
if (syncing) return;
|
||||
emit('update:modelValue', JSON.parse(JSON.stringify(newVal)));
|
||||
}, { deep: true });
|
||||
|
||||
const addCharacter = () => {
|
||||
localCharacters.value.push({
|
||||
name: '',
|
||||
identity: '',
|
||||
personality: '',
|
||||
goals: '',
|
||||
abilities: '',
|
||||
relationship_to_protagonist: ''
|
||||
});
|
||||
};
|
||||
|
||||
const removeCharacter = (index: number) => {
|
||||
localCharacters.value.splice(index, 1);
|
||||
};
|
||||
</script>
|
||||
76
frontend/src/components/ChatBubble.vue
Normal file
76
frontend/src/components/ChatBubble.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div :class="bubbleClass">
|
||||
<!-- AI 消息支持 markdown 渲染 -->
|
||||
<div
|
||||
v-if="type === 'ai'"
|
||||
class="prose prose-sm max-w-none prose-headings:mt-2 prose-headings:mb-1 prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0"
|
||||
v-html="renderedMessage"
|
||||
></div>
|
||||
<!-- 用户消息保持原样 -->
|
||||
<div v-else>{{ message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
type: 'user' | 'ai'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 简单的 markdown 解析函数
|
||||
const parseMarkdown = (text: string): string => {
|
||||
if (!text) return ''
|
||||
|
||||
// 处理转义字符
|
||||
let parsed = text
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\\"/g, '"')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\\\/g, '\\')
|
||||
|
||||
// 处理加粗文本 **text**
|
||||
parsed = parsed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
|
||||
// 处理斜体文本 *text*
|
||||
parsed = parsed.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
|
||||
|
||||
// 处理选项列表 A) text
|
||||
parsed = parsed.replace(/^([A-Z])\)\s*\*\*(.*?)\*\*(.*)/gm, '<div class="mb-2"><span class="inline-flex items-center justify-center w-6 h-6 bg-indigo-100 text-indigo-600 text-sm font-bold rounded-full mr-2">$1</span><strong>$2</strong>$3</div>')
|
||||
|
||||
// 处理普通换行
|
||||
parsed = parsed.replace(/\n/g, '<br>')
|
||||
|
||||
// 处理多个连续的 <br> 标签为段落
|
||||
parsed = parsed.replace(/(<br\s*\/?>\s*){2,}/g, '</p><p class="mt-2">')
|
||||
|
||||
// 包装在段落标签中
|
||||
if (!parsed.includes('<p>')) {
|
||||
parsed = `<p>${parsed}</p>`
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
const renderedMessage = computed(() => {
|
||||
if (props.type === 'ai') {
|
||||
return parseMarkdown(props.message)
|
||||
}
|
||||
return props.message
|
||||
})
|
||||
|
||||
const wrapperClass = computed(() => {
|
||||
return `w-full flex ${props.type === 'ai' ? 'justify-start' : 'justify-end'}`
|
||||
})
|
||||
|
||||
const bubbleClass = computed(() => {
|
||||
const baseClass = 'max-w-md lg:max-w-lg p-4 rounded-lg shadow-md fade-in'
|
||||
const typeClass = props.type === 'ai' ? 'chat-bubble-ai' : 'chat-bubble-user'
|
||||
return `${baseClass} ${typeClass}`
|
||||
})
|
||||
</script>
|
||||
177
frontend/src/components/ConversationInput.vue
Normal file
177
frontend/src/components/ConversationInput.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="fade-in">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading || !uiControl" class="flex justify-center items-center p-4">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
|
||||
<!-- 单选题 -->
|
||||
<div v-else-if="uiControl.type === 'single_choice'">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||
<button
|
||||
v-for="option in uiControl.options"
|
||||
:key="option.id"
|
||||
@click="handleOptionSelect(option.id, option.label)"
|
||||
class="p-3 bg-indigo-100 text-indigo-700 rounded-lg hover:bg-indigo-200 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
<button
|
||||
@click="isManualInput = true"
|
||||
class="p-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
>
|
||||
我要输入
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="handleTextSubmit" class="flex items-center gap-3">
|
||||
<textarea
|
||||
v-model="textInput"
|
||||
:placeholder="isManualInput ? '请输入您的想法...' : '选择上方选项或点击“我要输入”'"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-2xl focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none transition-all disabled:bg-gray-100 resize-none overflow-y-auto leading-relaxed"
|
||||
:disabled="!isManualInput"
|
||||
rows="5"
|
||||
ref="textInputRef"
|
||||
@input="handleTextareaInput"
|
||||
></textarea>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-shrink-0 w-12 h-12 bg-indigo-500 rounded-full flex items-center justify-center hover:bg-indigo-600 transition-all shadow-md disabled:bg-gray-300"
|
||||
:disabled="!isManualInput"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-white"
|
||||
>
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 文本输入 -->
|
||||
<form v-else-if="uiControl.type === 'text_input'" @submit.prevent="handleTextSubmit" class="flex items-center gap-3">
|
||||
<textarea
|
||||
v-model="textInput"
|
||||
:placeholder="uiControl.placeholder || '请输入...'"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-2xl focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none transition-all resize-none overflow-y-auto leading-relaxed"
|
||||
required
|
||||
ref="textInputRef"
|
||||
rows="5"
|
||||
@input="handleTextareaInput"
|
||||
></textarea>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-shrink-0 w-12 h-12 bg-indigo-500 rounded-full flex items-center justify-center hover:bg-indigo-600 transition-all shadow-md"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-white"
|
||||
>
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import type { UIControl } from '@/api/novel'
|
||||
|
||||
interface Props {
|
||||
uiControl: UIControl | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
submit: [userInput: { id: string; value: string } | null]
|
||||
}>()
|
||||
|
||||
const textInput = ref('')
|
||||
const textInputRef = ref<HTMLTextAreaElement>()
|
||||
const isManualInput = ref(false)
|
||||
|
||||
const MIN_ROWS = 5
|
||||
const MAX_ROWS = 5
|
||||
|
||||
const adjustTextareaHeight = () => {
|
||||
const textarea = textInputRef.value
|
||||
if (!textarea) {
|
||||
return
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight || '0') || 20
|
||||
const minHeight = lineHeight * MIN_ROWS
|
||||
const maxHeight = lineHeight * MAX_ROWS
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
const targetHeight = Math.min(maxHeight, Math.max(minHeight, textarea.scrollHeight))
|
||||
textarea.style.height = `${targetHeight}px`
|
||||
}
|
||||
|
||||
const handleTextareaInput = () => {
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
|
||||
const handleOptionSelect = (id: string, label: string) => {
|
||||
emit('submit', { id, value: label })
|
||||
}
|
||||
|
||||
const handleTextSubmit = () => {
|
||||
if (textInput.value.trim()) {
|
||||
emit('submit', { id: 'text_input', value: textInput.value.trim() })
|
||||
textInput.value = ''
|
||||
nextTick(() => adjustTextareaHeight())
|
||||
}
|
||||
}
|
||||
|
||||
// 当输入控件变为文本输入时,自动聚焦
|
||||
watch(
|
||||
() => props.uiControl,
|
||||
async (newControl) => {
|
||||
// 每次控件更新时,都重置手动输入状态和文本内容
|
||||
isManualInput.value = false
|
||||
textInput.value = ''
|
||||
|
||||
await nextTick()
|
||||
adjustTextareaHeight()
|
||||
|
||||
if (newControl?.type === 'text_input') {
|
||||
textInputRef.value?.focus()
|
||||
}
|
||||
},
|
||||
{ deep: true } // 使用 deep watch 确保即使是相同类型的控件也能触发
|
||||
)
|
||||
|
||||
// 监听手动输入状态的变化,以聚焦输入框
|
||||
watch(isManualInput, async (newValue) => {
|
||||
if (newValue) {
|
||||
await nextTick()
|
||||
adjustTextareaHeight()
|
||||
textInputRef.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
198
frontend/src/components/CustomAlert.vue
Normal file
198
frontend/src/components/CustomAlert.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-2xl shadow-2xl max-w-md w-full transform transition-all duration-300 ease-out"
|
||||
:class="visible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'"
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div
|
||||
class="flex items-center p-6 pb-4"
|
||||
:class="headerColorClass"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center mr-4"
|
||||
:class="iconBgClass"
|
||||
>
|
||||
<!-- 错误图标 -->
|
||||
<svg
|
||||
v-if="type === 'error'"
|
||||
class="w-6 h-6 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<!-- 成功图标 -->
|
||||
<svg
|
||||
v-else-if="type === 'success'"
|
||||
class="w-6 h-6 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<!-- 警告图标 -->
|
||||
<svg
|
||||
v-else-if="type === 'warning'"
|
||||
class="w-6 h-6 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<!-- 确认图标 -->
|
||||
<svg
|
||||
v-else-if="type === 'confirmation'"
|
||||
class="w-6 h-6 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<!-- 信息图标 -->
|
||||
<svg
|
||||
v-else
|
||||
class="w-6 h-6 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">{{ titleText }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="px-6 pb-4">
|
||||
<p class="text-gray-600 leading-relaxed">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="flex justify-end gap-3 p-6 pt-4 bg-gray-50 rounded-b-2xl">
|
||||
<button
|
||||
v-if="showCancel"
|
||||
@click="handleCancel"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium transition-colors duration-200"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleConfirm"
|
||||
class="px-6 py-2 rounded-lg font-medium transition-all duration-200 transform hover:scale-105"
|
||||
:class="confirmButtonClass"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
type?: 'success' | 'error' | 'warning' | 'info' | 'confirmation'
|
||||
title?: string
|
||||
message: string
|
||||
showCancel?: boolean
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'info',
|
||||
title: '',
|
||||
showCancel: false,
|
||||
confirmText: '确定',
|
||||
cancelText: '取消'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
cancel: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const titleText = computed(() => {
|
||||
if (props.title) return props.title
|
||||
|
||||
switch (props.type) {
|
||||
case 'success': return '操作成功'
|
||||
case 'error': return '出现错误'
|
||||
case 'warning': return '警告提示'
|
||||
case 'confirmation': return '请确认'
|
||||
default: return '提示信息'
|
||||
}
|
||||
})
|
||||
|
||||
const headerColorClass = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'success': return ''
|
||||
case 'error': return ''
|
||||
case 'warning': return ''
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const iconBgClass = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'success': return 'bg-green-500'
|
||||
case 'error': return 'bg-red-500'
|
||||
case 'warning': return 'bg-amber-500'
|
||||
case 'confirmation': return 'bg-gray-500'
|
||||
default: return 'bg-blue-500'
|
||||
}
|
||||
})
|
||||
|
||||
const confirmButtonClass = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'success': return 'bg-green-500 hover:bg-green-600 text-white shadow-lg hover:shadow-green-200'
|
||||
case 'error': return 'bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-red-200'
|
||||
case 'warning': return 'bg-amber-500 hover:bg-amber-600 text-white shadow-lg hover:shadow-amber-200'
|
||||
case 'confirmation': return 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-lg hover:shadow-indigo-200'
|
||||
default: return 'bg-blue-500 hover:bg-blue-600 text-white shadow-lg hover:shadow-blue-200'
|
||||
}
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义动画 */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.transform {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
73
frontend/src/components/FactionsEditor.vue
Normal file
73
frontend/src/components/FactionsEditor.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="space-y-4 max-h-96 overflow-y-auto p-1">
|
||||
<div v-for="(faction, index) in localFactions" :key="index" class="p-4 border border-gray-200 rounded-lg bg-gray-50 relative">
|
||||
<button @click="removeFaction(index)" class="absolute top-2 right-2 text-red-400 hover:text-red-600 transition-colors p-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="mb-2">
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">阵营名称</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="faction.name"
|
||||
class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent"
|
||||
placeholder="例如:幽灵侦探林远"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">描述</label>
|
||||
<textarea
|
||||
v-model="faction.description"
|
||||
class="w-full h-20 p-2 mt-1 border border-gray-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 transition text-sm"
|
||||
placeholder="关于这个阵营的详细描述..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="addFaction" class="w-full mt-4 px-4 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
+ 添加新阵营
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
|
||||
|
||||
interface Faction {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => Faction[],
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const localFactions = ref<Faction[]>([]);
|
||||
let syncing = false;
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
syncing = true;
|
||||
localFactions.value = JSON.parse(JSON.stringify(newVal || []));
|
||||
nextTick(() => {
|
||||
syncing = false;
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
watch(localFactions, (newVal) => {
|
||||
if (syncing) return;
|
||||
emit('update:modelValue', JSON.parse(JSON.stringify(newVal)));
|
||||
}, { deep: true });
|
||||
|
||||
const addFaction = () => {
|
||||
localFactions.value.push({ name: '', description: '' });
|
||||
};
|
||||
|
||||
const removeFaction = (index: number) => {
|
||||
localFactions.value.splice(index, 1);
|
||||
};
|
||||
</script>
|
||||
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
msg: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
frontend/src/components/InspirationLoading.vue
Normal file
22
frontend/src/components/InspirationLoading.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="absolute inset-0 bg-white flex flex-col items-center justify-center text-center p-8 transition-opacity duration-500">
|
||||
<div class="relative mb-8">
|
||||
<div class="w-24 h-24 bg-gradient-to-r from-indigo-400 to-purple-500 rounded-full mx-auto flex items-center justify-center animate-pulse shadow-lg">
|
||||
<svg class="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute inset-0 w-24 h-24 bg-gradient-to-r from-indigo-400 to-purple-500 rounded-full mx-auto animate-ping opacity-30"></div>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-3">正在为你准备灵感空间...</h2>
|
||||
<div class="space-y-2 text-gray-600">
|
||||
<p class="animate-pulse" style="animation-delay: 0.2s">✨ 连接文思泉涌的AI...</p>
|
||||
<p class="animate-pulse" style="animation-delay: 0.6s">🎨 铺开创意的画卷...</p>
|
||||
<p class="animate-pulse" style="animation-delay: 1s;">🚀 准备开启灵感之旅!</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// This is a purely presentational component.
|
||||
</script>
|
||||
73
frontend/src/components/KeyLocationsEditor.vue
Normal file
73
frontend/src/components/KeyLocationsEditor.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="space-y-4 max-h-96 overflow-y-auto p-1">
|
||||
<div v-for="(location, index) in localLocations" :key="index" class="p-4 border border-gray-200 rounded-lg bg-gray-50 relative">
|
||||
<button @click="removeLocation(index)" class="absolute top-2 right-2 text-red-400 hover:text-red-600 transition-colors p-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="mb-2">
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">地点名称</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="location.name"
|
||||
class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent"
|
||||
placeholder="例如:林远生前的公寓"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">描述</label>
|
||||
<textarea
|
||||
v-model="location.description"
|
||||
class="w-full h-20 p-2 mt-1 border border-gray-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 transition text-sm"
|
||||
placeholder="关于这个地点的详细描述..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="addLocation" class="w-full mt-4 px-4 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
+ 添加新地点
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
|
||||
|
||||
interface KeyLocation {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => KeyLocation[],
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const localLocations = ref<KeyLocation[]>([]);
|
||||
let syncing = false;
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
syncing = true;
|
||||
localLocations.value = JSON.parse(JSON.stringify(newVal || []));
|
||||
nextTick(() => {
|
||||
syncing = false;
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
watch(localLocations, (newVal) => {
|
||||
if (syncing) return;
|
||||
emit('update:modelValue', JSON.parse(JSON.stringify(newVal)));
|
||||
}, { deep: true });
|
||||
|
||||
const addLocation = () => {
|
||||
localLocations.value.push({ name: '', description: '' });
|
||||
};
|
||||
|
||||
const removeLocation = (index: number) => {
|
||||
localLocations.value.splice(index, 1);
|
||||
};
|
||||
</script>
|
||||
63
frontend/src/components/LLMSettings.vue
Normal file
63
frontend/src/components/LLMSettings.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="bg-white/70 backdrop-blur-xl rounded-2xl shadow-lg p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6">LLM 配置</h2>
|
||||
<h5 class="text-1xl font-bold text-gray-800 mb-6">建议使用自己的中转API和KEY</h5>
|
||||
<form @submit.prevent="handleSave" class="space-y-6">
|
||||
<div>
|
||||
<label for="url" class="block text-sm font-medium text-gray-700">API URL</label>
|
||||
<input type="text" id="url" v-model="config.llm_provider_url" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="https://api.example.com/v1">
|
||||
</div>
|
||||
<div>
|
||||
<label for="key" class="block text-sm font-medium text-gray-700">API Key</label>
|
||||
<input type="password" id="key" v-model="config.llm_provider_api_key" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="留空则使用默认Key">
|
||||
</div>
|
||||
<div>
|
||||
<label for="model" class="block text-sm font-medium text-gray-700">Model</label>
|
||||
<input type="text" id="model" v-model="config.llm_provider_model" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="留空则使用默认模型">
|
||||
</div>
|
||||
<div class="flex justify-end space-x-4 pt-4">
|
||||
<button type="button" @click="handleDelete" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">删除配置</button>
|
||||
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getLLMConfig, createOrUpdateLLMConfig, deleteLLMConfig, type LLMConfigCreate } from '@/api/llm';
|
||||
|
||||
const config = ref<LLMConfigCreate>({
|
||||
llm_provider_url: '',
|
||||
llm_provider_api_key: '',
|
||||
llm_provider_model: '',
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const existingConfig = await getLLMConfig();
|
||||
if (existingConfig) {
|
||||
config.value = {
|
||||
llm_provider_url: existingConfig.llm_provider_url || '',
|
||||
llm_provider_api_key: existingConfig.llm_provider_api_key || '',
|
||||
llm_provider_model: existingConfig.llm_provider_model || '',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
await createOrUpdateLLMConfig(config.value);
|
||||
alert('设置已保存!');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm('确定要删除您的自定义LLM配置吗?删除后将恢复为默认配置。')) {
|
||||
await deleteLLMConfig();
|
||||
config.value = {
|
||||
llm_provider_url: '',
|
||||
llm_provider_api_key: '',
|
||||
llm_provider_model: '',
|
||||
};
|
||||
alert('配置已删除!');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
170
frontend/src/components/ProjectCard.vue
Normal file
170
frontend/src/components/ProjectCard.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div
|
||||
class="group bg-white rounded-xl border border-gray-200 p-5 hover:shadow-xl hover:-translate-y-1.5 transition-all duration-300 flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div :class="themeClasses.bg" class="w-12 h-12 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:class="themeClasses.text"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 cursor-pointer" @click="$emit('detail', project.id)">
|
||||
<h3 class="font-bold text-lg text-gray-900 hover:text-indigo-600 transition-colors">{{ project.title }}</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ project.genre || '未知类型' }} | {{ getStatusText }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
最后编辑: {{ project.last_edited }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>完成进度</span>
|
||||
<span>{{ progress }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div :class="themeClasses.progress" class="h-2 rounded-full transition-all duration-300" :style="{ width: `${progress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目信息标签 -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<span v-if="project.genre"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ project.genre }}
|
||||
</span>
|
||||
<span v-if="chapterCount > 0"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{{ chapterCount }} 章节
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
||||
{{ project.last_edited }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区域 -->
|
||||
<div class="flex gap-2 opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-y-2 group-hover:translate-y-0">
|
||||
<button
|
||||
@click.stop="$emit('detail', project.id)"
|
||||
class="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-medium py-2 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center gap-1"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
查看详情
|
||||
</button>
|
||||
<button
|
||||
@click.stop="handleDelete"
|
||||
class="px-3 py-2 bg-red-100 hover:bg-red-200 text-red-600 rounded-lg transition-colors duration-200 flex items-center justify-center"
|
||||
title="删除项目"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clip-rule="evenodd"></path>
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click.stop="$emit('continue', project)"
|
||||
:class="[themeClasses.bg, themeClasses.text, 'flex-1 text-sm font-semibold py-2 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center gap-1 hover:opacity-80']"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
|
||||
</svg>
|
||||
继续创作
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { NovelProjectSummary } from '@/api/novel'
|
||||
|
||||
interface Props {
|
||||
project: NovelProjectSummary
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', id: string): void
|
||||
(e: 'detail', id: string): void
|
||||
(e: 'continue', project: NovelProjectSummary): void
|
||||
(e: 'delete', id: string): void
|
||||
}>()
|
||||
|
||||
const themeClasses = computed(() => {
|
||||
const colors = {
|
||||
'科幻': { bg: 'bg-indigo-100', text: 'text-indigo-600', progress: 'bg-indigo-500' },
|
||||
'悬疑': { bg: 'bg-teal-100', text: 'text-teal-600', progress: 'bg-teal-500' },
|
||||
'奇幻': { bg: 'bg-green-100', text: 'text-green-600', progress: 'bg-green-500' },
|
||||
'东方奇幻': { bg: 'bg-purple-100', text: 'text-purple-600', progress: 'bg-purple-500' },
|
||||
'穿越': { bg: 'bg-pink-100', text: 'text-pink-600', progress: 'bg-pink-500' },
|
||||
default: { bg: 'bg-gray-100', text: 'text-gray-600', progress: 'bg-gray-500' }
|
||||
}
|
||||
|
||||
// 使用后端返回的 genre 字段进行匹配
|
||||
const genre = props.project.genre || ''
|
||||
const genreKey = Object.keys(colors).find(key => key !== 'default' && genre.includes(key)) || 'default'
|
||||
|
||||
return colors[genreKey as keyof typeof colors]
|
||||
})
|
||||
|
||||
// 使用后端预计算的进度数据
|
||||
const progress = computed(() => {
|
||||
const { completed_chapters, total_chapters } = props.project
|
||||
return total_chapters > 0 ? Math.round((completed_chapters / total_chapters) * 100) : 0
|
||||
})
|
||||
|
||||
const getStatusText = computed(() => {
|
||||
const { completed_chapters, total_chapters } = props.project
|
||||
|
||||
if (completed_chapters > 0) {
|
||||
return `已完成 ${completed_chapters}/${total_chapters} 章`
|
||||
} else if (total_chapters > 0) {
|
||||
return '准备创作'
|
||||
} else {
|
||||
return '蓝图完成'
|
||||
}
|
||||
})
|
||||
|
||||
// 使用后端返回的预计算数据
|
||||
const chapterCount = computed(() => {
|
||||
return props.project.total_chapters
|
||||
})
|
||||
|
||||
// 由于 NovelProjectSummary 没有 characters 信息,我们暂时返回 0 或者隐藏这个标签
|
||||
const characterCount = computed(() => {
|
||||
return 0 // 后端 Summary 没有提供角色数量
|
||||
})
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.project.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
80
frontend/src/components/RelationshipsEditor.vue
Normal file
80
frontend/src/components/RelationshipsEditor.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="space-y-4 max-h-96 overflow-y-auto p-1">
|
||||
<div v-for="(relationship, index) in localRelationships" :key="index" class="p-4 border border-gray-200 rounded-lg bg-gray-50 relative">
|
||||
<button @click="removeRelationship(index)" class="absolute top-2 right-2 text-red-400 hover:text-red-600 transition-colors p-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">从</label>
|
||||
<input type="text" v-model="relationship.character_from" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" placeholder="例如:林远" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">到</label>
|
||||
<input type="text" v-model="relationship.character_to" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" placeholder="例如:苏晴" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-600 mb-1">关系描述</label>
|
||||
<textarea
|
||||
v-model="relationship.description"
|
||||
class="w-full h-20 p-2 mt-1 border border-gray-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 transition text-sm"
|
||||
placeholder="关于这段关系的详细描述..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="addRelationship" class="w-full mt-4 px-4 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
+ 添加新关系
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
|
||||
|
||||
interface Relationship {
|
||||
character_from: string;
|
||||
character_to: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => Relationship[],
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const localRelationships = ref<Relationship[]>([]);
|
||||
let syncing = false;
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
syncing = true;
|
||||
localRelationships.value = JSON.parse(JSON.stringify(newVal || []));
|
||||
nextTick(() => {
|
||||
syncing = false;
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
watch(localRelationships, (newVal) => {
|
||||
if (syncing) return;
|
||||
emit('update:modelValue', JSON.parse(JSON.stringify(newVal)));
|
||||
}, { deep: true });
|
||||
|
||||
const addRelationship = () => {
|
||||
localRelationships.value.push({
|
||||
character_from: '',
|
||||
character_to: '',
|
||||
description: ''
|
||||
});
|
||||
};
|
||||
|
||||
const removeRelationship = (index: number) => {
|
||||
localRelationships.value.splice(index, 1);
|
||||
};
|
||||
</script>
|
||||
94
frontend/src/components/TheWelcome.vue
Normal file
94
frontend/src/components/TheWelcome.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
|
||||
you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
100
frontend/src/components/Tooltip.vue
Normal file
100
frontend/src/components/Tooltip.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div ref="triggerRef" class="inline-block" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
|
||||
<slot></slot>
|
||||
<Teleport to="body">
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="showTooltip && text"
|
||||
ref="tooltipRef"
|
||||
:style="tooltipStyle"
|
||||
class="fixed z-50 p-3 text-sm leading-tight text-white bg-gray-800 rounded-lg shadow-lg max-w-xs"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
text?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const showTooltip = ref(false)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
const tooltipPosition = ref({ top: 0, left: 0 })
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
top: `${tooltipPosition.value.top}px`,
|
||||
left: `${tooltipPosition.value.left}px`,
|
||||
}))
|
||||
|
||||
let leaveTimeout: NodeJS.Timeout
|
||||
let enterTimeout: NodeJS.Timeout
|
||||
|
||||
const onMouseEnter = () => {
|
||||
clearTimeout(leaveTimeout)
|
||||
enterTimeout = setTimeout(async () => {
|
||||
showTooltip.value = true
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
}, 1000) // 延迟1秒显示
|
||||
}
|
||||
|
||||
const onMouseLeave = () => {
|
||||
clearTimeout(enterTimeout) // 清除进入的计时器
|
||||
leaveTimeout = setTimeout(() => {
|
||||
showTooltip.value = false
|
||||
}, 200) // 增加延时以便鼠标可以移动到 tooltip 上
|
||||
}
|
||||
|
||||
const onTooltipEnter = () => {
|
||||
clearTimeout(leaveTimeout)
|
||||
}
|
||||
|
||||
const onTooltipLeave = () => {
|
||||
showTooltip.value = false
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!triggerRef.value || !tooltipRef.value) return
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const tooltipRect = tooltipRef.value.getBoundingClientRect()
|
||||
|
||||
let top = triggerRect.top - tooltipRect.height - 8 // 默认在上方,留 8px 间距
|
||||
let left = triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2)
|
||||
|
||||
// 如果上方空间不足,则显示在下方
|
||||
if (top < 0) {
|
||||
top = triggerRect.bottom + 8
|
||||
}
|
||||
|
||||
// 如果左侧超出屏幕,则向右对齐
|
||||
if (left < 0) {
|
||||
left = 8
|
||||
}
|
||||
|
||||
// 如果右侧超出屏幕,则向左对齐
|
||||
if (left + tooltipRect.width > window.innerWidth) {
|
||||
left = window.innerWidth - tooltipRect.width - 8
|
||||
}
|
||||
|
||||
tooltipPosition.value = { top, left }
|
||||
}
|
||||
</script>
|
||||
63
frontend/src/components/TypewriterEffect.vue
Normal file
63
frontend/src/components/TypewriterEffect.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<h1 class="typewriter text-4xl md:text-5xl font-extrabold text-center text-gray-800 tracking-wider" :style="{ '--char-count': fullText.length }">
|
||||
{{ displayedText }}
|
||||
</h1>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const fullText = props.text;
|
||||
const displayedText = ref('');
|
||||
let index = 0;
|
||||
|
||||
onMounted(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (index < fullText.length) {
|
||||
displayedText.value += fullText.charAt(index);
|
||||
index++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 150); // Adjust typing speed here
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.typewriter {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
border-right: 0.1em solid #333; /* Blinking cursor */
|
||||
animation: typing 2s steps(var(--char-count, 10), end), blink-caret 0.75s step-end infinite;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Typing effect */
|
||||
@keyframes typing {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cursor blinking effect */
|
||||
@keyframes blink-caret {
|
||||
from,
|
||||
to {
|
||||
border-color: transparent;
|
||||
}
|
||||
50% {
|
||||
border-color: #333;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
87
frontend/src/components/WelcomeItem.vue
Normal file
87
frontend/src/components/WelcomeItem.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
309
frontend/src/components/admin/NovelManagement.vue
Normal file
309
frontend/src/components/admin/NovelManagement.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<n-card class="novel-management-card" size="large" :bordered="false">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">小说管理</span>
|
||||
<n-tag size="small" type="primary" round>共 {{ novels.length }} 项</n-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-space vertical size="large">
|
||||
<n-alert v-if="error" type="error" closable @close="error = null">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<template #default>
|
||||
<n-empty
|
||||
v-if="!novels.length && !loading"
|
||||
description="暂无小说项目"
|
||||
class="empty-state"
|
||||
/>
|
||||
<div v-else>
|
||||
<n-space v-if="isMobile" vertical size="large">
|
||||
<n-card
|
||||
v-for="novel in novels"
|
||||
:key="novel.id"
|
||||
size="small"
|
||||
embedded
|
||||
class="novel-card"
|
||||
>
|
||||
<template #header>
|
||||
<div class="mobile-card-header">
|
||||
<span class="mobile-card-title">{{ novel.title }}</span>
|
||||
<n-tag size="small" type="info" round>{{ novel.genre || '未分类' }}</n-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mobile-meta">
|
||||
<span class="mobile-label">编号</span>
|
||||
<span class="mobile-value">{{ novel.id }}</span>
|
||||
</div>
|
||||
<div class="mobile-meta">
|
||||
<span class="mobile-label">创作者</span>
|
||||
<span class="mobile-value">{{ novel.owner_username }}</span>
|
||||
</div>
|
||||
<div class="mobile-meta">
|
||||
<span class="mobile-label">进度</span>
|
||||
<span class="mobile-value">{{ formatProgress(novel) }}</span>
|
||||
</div>
|
||||
<div class="mobile-meta">
|
||||
<span class="mobile-label">最近更新</span>
|
||||
<span class="mobile-value">{{ formatDate(novel.last_edited) }}</span>
|
||||
</div>
|
||||
<template #footer>
|
||||
<n-button type="primary" size="small" block @click="viewDetails(novel.id)">
|
||||
查看详情
|
||||
</n-button>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-space>
|
||||
<n-data-table
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data="novels"
|
||||
:pagination="pagination"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
class="novel-table"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</n-spin>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NEmpty,
|
||||
NSpin,
|
||||
NTag,
|
||||
NSpace,
|
||||
type DataTableColumns
|
||||
} from 'naive-ui'
|
||||
|
||||
import { AdminAPI } from '@/api/admin'
|
||||
import type { AdminNovelSummary } from '@/api/admin'
|
||||
|
||||
const novels = ref<AdminNovelSummary[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const isMobile = ref(false)
|
||||
const router = useRouter()
|
||||
|
||||
const pagination = {
|
||||
pageSize: 8,
|
||||
showSizePicker: false
|
||||
}
|
||||
|
||||
const updateLayout = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
const formatDate = (value: string | null | undefined) => {
|
||||
if (!value) return '未记录'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '未记录' : date.toLocaleString()
|
||||
}
|
||||
|
||||
const formatProgress = (novel: Pick<AdminNovelSummary, 'completed_chapters' | 'total_chapters'>) => {
|
||||
const total = novel.total_chapters || 0
|
||||
const completed = novel.completed_chapters || 0
|
||||
return `${completed} / ${total}`
|
||||
}
|
||||
|
||||
const viewDetails = (novelId: string) => {
|
||||
router.push(`/admin/novel/${novelId}`)
|
||||
}
|
||||
|
||||
const columns: DataTableColumns<AdminNovelSummary> = [
|
||||
{
|
||||
title: '项目',
|
||||
key: 'title',
|
||||
ellipsis: { tooltip: true },
|
||||
render(row) {
|
||||
return h('div', { class: 'table-title-cell' }, [
|
||||
h('div', { class: 'table-title' }, row.title),
|
||||
h('div', { class: 'table-subtitle' }, row.id)
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
key: 'genre',
|
||||
render(row) {
|
||||
return h(
|
||||
NTag,
|
||||
{ type: 'info', size: 'small', round: true, bordered: false },
|
||||
{ default: () => row.genre || '未分类' }
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '创作者',
|
||||
key: 'owner_username',
|
||||
render(row) {
|
||||
return h('span', { class: 'table-owner' }, row.owner_username)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
key: 'progress',
|
||||
render(row) {
|
||||
return h('span', { class: 'table-progress' }, formatProgress(row))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '最近更新',
|
||||
key: 'last_edited',
|
||||
render(row) {
|
||||
return h('span', { class: 'table-date' }, formatDate(row.last_edited))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
align: 'center',
|
||||
render(row) {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
tertiary: true,
|
||||
onClick: () => viewDetails(row.id)
|
||||
},
|
||||
{ default: () => '详情' }
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const fetchNovels = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
novels.value = await AdminAPI.listNovels()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '获取小说数据失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateLayout()
|
||||
window.addEventListener('resize', updateLayout)
|
||||
fetchNovels()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateLayout)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.novel-management-card {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.novel-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-title-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.table-subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.table-owner,
|
||||
.table-progress,
|
||||
.table-date {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.novel-card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.mobile-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.mobile-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mobile-label {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mobile-value {
|
||||
color: #111827;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.card-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
155
frontend/src/components/admin/PasswordManagement.vue
Normal file
155
frontend/src/components/admin/PasswordManagement.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<n-space vertical size="large" class="password-container">
|
||||
<n-card :bordered="false" class="password-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">管理员密码修改</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-alert v-if="mustReset" type="warning" class="mb-4">
|
||||
为保障安全,请先更新默认密码后再继续使用管理后台。
|
||||
</n-alert>
|
||||
|
||||
<n-alert v-if="error" type="error" closable @close="error = null" class="mb-4">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
<n-spin :show="submitting">
|
||||
<n-form class="password-form" label-placement="top" @submit.prevent="handleSubmit">
|
||||
<n-form-item label="当前密码">
|
||||
<n-input
|
||||
v-model:value="form.oldPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="请输入当前管理员密码"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="新密码">
|
||||
<n-input
|
||||
v-model:value="form.newPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="请输入至少 8 位新密码"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="确认新密码">
|
||||
<n-input
|
||||
v-model:value="form.confirmPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="请再次输入新密码"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-space justify="end">
|
||||
<n-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
保存新密码
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NSpace, NSpin } from 'naive-ui'
|
||||
|
||||
import { AdminAPI } from '@/api/admin'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { showAlert } = useAlert()
|
||||
|
||||
const form = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const mustReset = computed(() => authStore.mustChangePassword && authStore.user?.is_admin)
|
||||
|
||||
const resetForm = () => {
|
||||
form.oldPassword = ''
|
||||
form.newPassword = ''
|
||||
form.confirmPassword = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = null
|
||||
|
||||
if (!form.oldPassword.trim() || !form.newPassword.trim()) {
|
||||
error.value = '请填写完整的密码信息'
|
||||
return
|
||||
}
|
||||
|
||||
if (form.newPassword.length < 8) {
|
||||
error.value = '新密码长度需至少 8 位'
|
||||
return
|
||||
}
|
||||
|
||||
if (form.newPassword === form.oldPassword) {
|
||||
error.value = '新密码不能与当前密码相同'
|
||||
return
|
||||
}
|
||||
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
error.value = '两次输入的新密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await AdminAPI.changePassword(form.oldPassword, form.newPassword)
|
||||
await authStore.fetchUser()
|
||||
resetForm()
|
||||
await showAlert('密码已更新,请使用新密码继续操作', 'success')
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '密码更新失败'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.password-container {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.password-card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.password-form {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
421
frontend/src/components/admin/PromptManagement.vue
Normal file
421
frontend/src/components/admin/PromptManagement.vue
Normal file
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<n-card :bordered="false" class="admin-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">提示词管理</span>
|
||||
<n-space :size="12">
|
||||
<n-button quaternary size="small" @click="fetchPrompts" :loading="loading">
|
||||
刷新
|
||||
</n-button>
|
||||
<n-button type="primary" size="small" @click="openCreateModal">
|
||||
新建 Prompt
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-space vertical size="large">
|
||||
<n-alert v-if="error" type="error" closable @close="error = null">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<div :class="['prompt-layout', { mobile: isMobile }]">
|
||||
<div class="prompt-sidebar">
|
||||
<n-scrollbar class="prompt-scroll">
|
||||
<n-empty v-if="!prompts.length && !loading" description="暂无提示词" />
|
||||
<n-space v-else vertical size="small">
|
||||
<n-button
|
||||
v-for="prompt in prompts"
|
||||
:key="prompt.id"
|
||||
type="primary"
|
||||
:ghost="selectedPrompt?.id !== prompt.id"
|
||||
quaternary
|
||||
block
|
||||
@click="selectPrompt(prompt)"
|
||||
>
|
||||
<div class="prompt-item">
|
||||
<span class="prompt-name">{{ prompt.title || prompt.name }}</span>
|
||||
<n-tag v-if="prompt.tags?.length" size="tiny" type="info">
|
||||
{{ prompt.tags.length }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
|
||||
<div class="prompt-editor">
|
||||
<div v-if="!selectedPrompt" class="empty-editor">
|
||||
<n-empty description="请选择一个提示词以编辑" />
|
||||
</div>
|
||||
<div v-else class="editor-content">
|
||||
<n-form label-placement="top" :model="editForm">
|
||||
<n-form-item label="唯一标识">
|
||||
<n-input v-model:value="editForm.name" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item label="标题">
|
||||
<n-input
|
||||
v-model:value="editForm.title"
|
||||
placeholder="用于后台识别的标题,可为空"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="标签">
|
||||
<n-dynamic-tags
|
||||
v-model:value="editForm.tags"
|
||||
size="small"
|
||||
placeholder="输入标签后回车"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="提示词内容">
|
||||
<n-input
|
||||
v-model:value="editForm.content"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: isMobile ? 8 : 16, maxRows: 40 }"
|
||||
placeholder="请输入完整的提示词内容..."
|
||||
class="prompt-textarea"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-space justify="end">
|
||||
<n-popconfirm
|
||||
v-if="selectedPrompt"
|
||||
placement="bottom"
|
||||
positive-text="删除"
|
||||
negative-text="取消"
|
||||
type="error"
|
||||
@positive-click="deletePrompt"
|
||||
>
|
||||
<template #trigger>
|
||||
<n-button type="error" quaternary :loading="deleting">
|
||||
删除
|
||||
</n-button>
|
||||
</template>
|
||||
确认删除该 Prompt?
|
||||
</n-popconfirm>
|
||||
<n-button type="primary" :loading="saving" @click="savePrompt">
|
||||
保存修改
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-modal v-model:show="createModalVisible" preset="card" title="新建 Prompt" class="prompt-modal">
|
||||
<n-form label-placement="top" :model="createForm">
|
||||
<n-form-item label="唯一标识(必填)">
|
||||
<n-input v-model:value="createForm.name" placeholder="例如 concept / outline" />
|
||||
</n-form-item>
|
||||
<n-form-item label="标题">
|
||||
<n-input v-model:value="createForm.title" placeholder="可选,用于后台展示" />
|
||||
</n-form-item>
|
||||
<n-form-item label="标签">
|
||||
<n-dynamic-tags
|
||||
v-model:value="createForm.tags"
|
||||
size="small"
|
||||
placeholder="输入标签后回车"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="内容">
|
||||
<n-input
|
||||
v-model:value="createForm.content"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 10, maxRows: 30 }"
|
||||
placeholder="输入提示词内容..."
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button quaternary @click="closeCreateModal">取消</n-button>
|
||||
<n-button type="primary" :loading="creating" @click="createPrompt">创建</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDynamicTags,
|
||||
NEmpty,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NScrollbar,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NTag
|
||||
} from 'naive-ui'
|
||||
|
||||
import { AdminAPI, type PromptCreatePayload, type PromptItem } from '@/api/admin'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
|
||||
const { showAlert } = useAlert()
|
||||
|
||||
const prompts = ref<PromptItem[]>([])
|
||||
const selectedPrompt = ref<PromptItem | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const creating = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
title: '',
|
||||
content: '',
|
||||
tags: [] as string[]
|
||||
})
|
||||
|
||||
const createModalVisible = ref(false)
|
||||
const createForm = reactive<PromptCreatePayload>({
|
||||
name: '',
|
||||
title: '',
|
||||
content: '',
|
||||
tags: []
|
||||
})
|
||||
|
||||
const isMobile = ref(false)
|
||||
|
||||
const updateLayout = () => {
|
||||
isMobile.value = window.innerWidth < 920
|
||||
}
|
||||
|
||||
const fetchPrompts = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
prompts.value = await AdminAPI.listPrompts()
|
||||
if (selectedPrompt.value) {
|
||||
const refreshed = prompts.value.find((item) => item.id === selectedPrompt.value?.id)
|
||||
if (refreshed) {
|
||||
selectPrompt(refreshed)
|
||||
} else {
|
||||
resetSelection()
|
||||
}
|
||||
} else if (prompts.value.length) {
|
||||
selectPrompt(prompts.value[0])
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取提示词列表失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetSelection = () => {
|
||||
selectedPrompt.value = null
|
||||
editForm.name = ''
|
||||
editForm.title = ''
|
||||
editForm.content = ''
|
||||
editForm.tags = []
|
||||
}
|
||||
|
||||
const selectPrompt = (prompt: PromptItem) => {
|
||||
selectedPrompt.value = prompt
|
||||
editForm.name = prompt.name
|
||||
editForm.title = prompt.title || ''
|
||||
editForm.content = prompt.content
|
||||
editForm.tags = prompt.tags ? [...prompt.tags] : []
|
||||
}
|
||||
|
||||
const savePrompt = async () => {
|
||||
if (!selectedPrompt.value) return
|
||||
if (!editForm.content.trim()) {
|
||||
showAlert('提示词内容不能为空', 'error')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const updated = await AdminAPI.updatePrompt(selectedPrompt.value.id, {
|
||||
title: editForm.title || undefined,
|
||||
content: editForm.content,
|
||||
tags: editForm.tags
|
||||
})
|
||||
selectPrompt(updated)
|
||||
const index = prompts.value.findIndex((item) => item.id === updated.id)
|
||||
if (index !== -1) {
|
||||
prompts.value.splice(index, 1, updated)
|
||||
}
|
||||
showAlert('保存成功', 'success')
|
||||
} catch (err) {
|
||||
showAlert(err instanceof Error ? err.message : '保存失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deletePrompt = async () => {
|
||||
if (!selectedPrompt.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await AdminAPI.deletePrompt(selectedPrompt.value.id)
|
||||
showAlert('删除成功', 'success')
|
||||
prompts.value = prompts.value.filter((item) => item.id !== selectedPrompt.value?.id)
|
||||
resetSelection()
|
||||
} catch (err) {
|
||||
showAlert(err instanceof Error ? err.message : '删除失败', 'error')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
createModalVisible.value = true
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
createModalVisible.value = false
|
||||
createForm.name = ''
|
||||
createForm.title = ''
|
||||
createForm.content = ''
|
||||
createForm.tags = []
|
||||
}
|
||||
|
||||
const createPrompt = async () => {
|
||||
if (!createForm.name.trim() || !createForm.content.trim()) {
|
||||
showAlert('名称与内容均为必填项', 'error')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const created = await AdminAPI.createPrompt({
|
||||
name: createForm.name.trim(),
|
||||
title: createForm.title?.trim() || undefined,
|
||||
content: createForm.content,
|
||||
tags: createForm.tags?.length ? [...createForm.tags] : undefined
|
||||
})
|
||||
prompts.value.unshift(created)
|
||||
selectPrompt(created)
|
||||
showAlert('创建成功', 'success')
|
||||
closeCreateModal()
|
||||
} catch (err) {
|
||||
showAlert(err instanceof Error ? err.message : '创建失败', 'error')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateLayout()
|
||||
window.addEventListener('resize', updateLayout)
|
||||
fetchPrompts()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateLayout)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.prompt-layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 20px;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.prompt-layout.mobile {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.prompt-sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prompt-layout.mobile .prompt-sidebar {
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.prompt-scroll {
|
||||
max-height: 520px;
|
||||
}
|
||||
|
||||
.prompt-layout.mobile .prompt-scroll {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.prompt-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prompt-name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.prompt-editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.prompt-textarea :deep(textarea) {
|
||||
font-family: 'Fira Code', 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prompt-modal {
|
||||
max-width: min(720px, 90vw);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.prompt-sidebar {
|
||||
width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.card-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
355
frontend/src/components/admin/SettingsManagement.vue
Normal file
355
frontend/src/components/admin/SettingsManagement.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<n-space vertical size="large" class="admin-settings">
|
||||
<n-card :bordered="false">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">每日请求额度</span>
|
||||
<n-button quaternary size="small" @click="fetchDailyLimit" :loading="dailyLimitLoading">
|
||||
刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<n-spin :show="dailyLimitLoading">
|
||||
<n-alert v-if="dailyLimitError" type="error" closable @close="dailyLimitError = null">
|
||||
{{ dailyLimitError }}
|
||||
</n-alert>
|
||||
<n-form label-placement="top" class="limit-form">
|
||||
<n-form-item label="未配置 API Key 的用户每日可用请求次数">
|
||||
<n-input-number
|
||||
v-model:value="dailyLimit"
|
||||
:min="0"
|
||||
:step="10"
|
||||
placeholder="请输入每日请求上限"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-space justify="end">
|
||||
<n-button type="primary" :loading="dailyLimitSaving" @click="saveDailyLimit">
|
||||
保存设置
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
|
||||
<n-card :bordered="false">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">系统配置</span>
|
||||
<n-button type="primary" size="small" @click="openCreateModal">
|
||||
新增配置
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-spin :show="configLoading">
|
||||
<n-alert v-if="configError" type="error" closable @close="configError = null">
|
||||
{{ configError }}
|
||||
</n-alert>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="configs"
|
||||
:loading="configLoading"
|
||||
:bordered="false"
|
||||
:row-key="rowKey"
|
||||
class="config-table"
|
||||
/>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</n-space>
|
||||
|
||||
<n-modal
|
||||
v-model:show="configModalVisible"
|
||||
preset="card"
|
||||
:title="modalTitle"
|
||||
class="config-modal"
|
||||
:style="{ width: '520px', maxWidth: '92vw' }"
|
||||
>
|
||||
<n-form label-placement="top" :model="configForm">
|
||||
<n-form-item label="Key">
|
||||
<n-input
|
||||
v-model:value="configForm.key"
|
||||
:disabled="!isCreateMode"
|
||||
placeholder="请输入唯一 Key"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="值">
|
||||
<n-input v-model:value="configForm.value" placeholder="配置的具体值" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input v-model:value="configForm.description" placeholder="配置项的用途说明,可选" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button quaternary @click="closeConfigModal">取消</n-button>
|
||||
<n-button type="primary" :loading="configSaving" @click="submitConfig">
|
||||
保存
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NSpin,
|
||||
type DataTableColumns
|
||||
} from 'naive-ui'
|
||||
|
||||
import {
|
||||
AdminAPI,
|
||||
type DailyRequestLimit,
|
||||
type SystemConfig,
|
||||
type SystemConfigUpdatePayload,
|
||||
type SystemConfigUpsertPayload
|
||||
} from '@/api/admin'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
|
||||
const { showAlert } = useAlert()
|
||||
|
||||
const dailyLimit = ref<number | null>(null)
|
||||
const dailyLimitLoading = ref(false)
|
||||
const dailyLimitSaving = ref(false)
|
||||
const dailyLimitError = ref<string | null>(null)
|
||||
|
||||
const configs = ref<SystemConfig[]>([])
|
||||
const configLoading = ref(false)
|
||||
const configSaving = ref(false)
|
||||
const configError = ref<string | null>(null)
|
||||
|
||||
const configModalVisible = ref(false)
|
||||
const isCreateMode = ref(true)
|
||||
const configForm = reactive<SystemConfig>({
|
||||
key: '',
|
||||
value: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const rowKey = (row: SystemConfig) => row.key
|
||||
|
||||
const modalTitle = computed(() => (isCreateMode.value ? '新增配置项' : '编辑配置项'))
|
||||
|
||||
const fetchDailyLimit = async () => {
|
||||
dailyLimitLoading.value = true
|
||||
dailyLimitError.value = null
|
||||
try {
|
||||
const result = await AdminAPI.getDailyRequestLimit()
|
||||
dailyLimit.value = result.limit
|
||||
} catch (err) {
|
||||
dailyLimitError.value = err instanceof Error ? err.message : '加载每日限制失败'
|
||||
} finally {
|
||||
dailyLimitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveDailyLimit = async () => {
|
||||
if (dailyLimit.value === null || dailyLimit.value < 0) {
|
||||
showAlert('请设置有效的每日额度', 'error')
|
||||
return
|
||||
}
|
||||
dailyLimitSaving.value = true
|
||||
try {
|
||||
await AdminAPI.setDailyRequestLimit(dailyLimit.value)
|
||||
showAlert('每日额度已更新', 'success')
|
||||
} catch (err) {
|
||||
showAlert(err instanceof Error ? err.message : '保存失败', 'error')
|
||||
} finally {
|
||||
dailyLimitSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
configLoading.value = true
|
||||
configError.value = null
|
||||
try {
|
||||
configs.value = await AdminAPI.listSystemConfigs()
|
||||
} catch (err) {
|
||||
configError.value = err instanceof Error ? err.message : '加载配置失败'
|
||||
} finally {
|
||||
configLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
isCreateMode.value = true
|
||||
configForm.key = ''
|
||||
configForm.value = ''
|
||||
configForm.description = ''
|
||||
configModalVisible.value = true
|
||||
}
|
||||
|
||||
const openEditModal = (config: SystemConfig) => {
|
||||
isCreateMode.value = false
|
||||
configForm.key = config.key
|
||||
configForm.value = config.value
|
||||
configForm.description = config.description || ''
|
||||
configModalVisible.value = true
|
||||
}
|
||||
|
||||
const closeConfigModal = () => {
|
||||
configModalVisible.value = false
|
||||
configSaving.value = false
|
||||
}
|
||||
|
||||
const submitConfig = async () => {
|
||||
if (!configForm.key.trim() || !configForm.value.trim()) {
|
||||
showAlert('Key 与 Value 均为必填项', 'error')
|
||||
return
|
||||
}
|
||||
configSaving.value = true
|
||||
try {
|
||||
let updated: SystemConfig
|
||||
if (isCreateMode.value) {
|
||||
updated = await AdminAPI.upsertSystemConfig(configForm.key.trim(), {
|
||||
value: configForm.value,
|
||||
description: configForm.description || undefined
|
||||
})
|
||||
configs.value.unshift(updated)
|
||||
} else {
|
||||
updated = await AdminAPI.patchSystemConfig(configForm.key, {
|
||||
value: configForm.value,
|
||||
description: configForm.description || undefined
|
||||
} as SystemConfigUpdatePayload)
|
||||
const index = configs.value.findIndex((item) => item.key === updated.key)
|
||||
if (index !== -1) {
|
||||
configs.value.splice(index, 1, updated)
|
||||
}
|
||||
}
|
||||
showAlert('配置已保存', 'success')
|
||||
closeConfigModal()
|
||||
} catch (err) {
|
||||
showAlert(err instanceof Error ? err.message : '保存失败', 'error')
|
||||
} finally {
|
||||
configSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteConfig = async (key: string) => {
|
||||
try {
|
||||
await AdminAPI.deleteSystemConfig(key)
|
||||
configs.value = configs.value.filter((item) => item.key !== key)
|
||||
showAlert('配置已删除', 'success')
|
||||
} catch (err) {
|
||||
showAlert(err instanceof Error ? err.message : '删除失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataTableColumns<SystemConfig> = [
|
||||
{
|
||||
title: 'Key',
|
||||
key: 'key',
|
||||
width: 220,
|
||||
ellipsis: { tooltip: true }
|
||||
},
|
||||
{
|
||||
title: '值',
|
||||
key: 'value',
|
||||
ellipsis: { tooltip: true }
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
key: 'description',
|
||||
ellipsis: { tooltip: true },
|
||||
render(row) {
|
||||
return row.description || '—'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
align: 'center',
|
||||
width: 160,
|
||||
render(row) {
|
||||
return h(
|
||||
NSpace,
|
||||
{ justify: 'center', size: 'small' },
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
tertiary: true,
|
||||
onClick: () => openEditModal(row)
|
||||
},
|
||||
{ default: () => '编辑' }
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{
|
||||
'positive-text': '删除',
|
||||
'negative-text': '取消',
|
||||
type: 'error',
|
||||
placement: 'left',
|
||||
onPositiveClick: () => deleteConfig(row.key)
|
||||
},
|
||||
{
|
||||
default: () => '确认删除该配置项?',
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'small', type: 'error', quaternary: true },
|
||||
{ default: () => '删除' }
|
||||
)
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
fetchDailyLimit()
|
||||
fetchConfigs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-settings {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.limit-form {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.config-modal {
|
||||
max-width: min(640px, 92vw);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.card-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
146
frontend/src/components/admin/Statistics.vue
Normal file
146
frontend/src/components/admin/Statistics.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<n-card :bordered="false" class="admin-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">数据总览</span>
|
||||
<n-button quaternary size="small" @click="fetchStats" :loading="loading">
|
||||
刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-space vertical size="large">
|
||||
<n-alert v-if="error" type="error" closable @close="error = null">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<n-grid :cols="gridCols" :x-gap="16" :y-gap="16">
|
||||
<n-gi>
|
||||
<n-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon">📚</div>
|
||||
<n-statistic label="小说总数" :value="stats?.novel_count ?? 0" show-separator>
|
||||
<template #suffix>部</template>
|
||||
</n-statistic>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon">👥</div>
|
||||
<n-statistic label="用户总数" :value="stats?.user_count ?? 0" show-separator>
|
||||
<template #suffix>人</template>
|
||||
</n-statistic>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon">⚡</div>
|
||||
<n-statistic label="API 请求总数" :value="stats?.api_request_count ?? 0" show-separator>
|
||||
<template #suffix>次</template>
|
||||
</n-statistic>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NGi,
|
||||
NGrid,
|
||||
NSpin,
|
||||
NStatistic,
|
||||
NSpace
|
||||
} from 'naive-ui'
|
||||
|
||||
import { AdminAPI, type Statistics } from '@/api/admin'
|
||||
|
||||
const stats = ref<Statistics | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isMobile = ref(false)
|
||||
|
||||
const updateLayout = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
const gridCols = computed(() => (isMobile.value ? 1 : 3))
|
||||
|
||||
const fetchStats = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
stats.value = await AdminAPI.getStatistics()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取统计数据失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateLayout()
|
||||
window.addEventListener('resize', updateLayout)
|
||||
fetchStats()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateLayout)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-card {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(79, 70, 229, 0));
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
262
frontend/src/components/admin/UpdateLogManagement.vue
Normal file
262
frontend/src/components/admin/UpdateLogManagement.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<n-card :bordered="false" class="admin-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">更新日志管理</span>
|
||||
<n-button quaternary size="small" @click="fetchLogs" :loading="loading">
|
||||
刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-space vertical size="large">
|
||||
<n-alert v-if="error" type="error" closable @close="error = null">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
<n-card size="small" class="form-card">
|
||||
<n-form :model="form" label-placement="top">
|
||||
<n-form-item label="更新内容">
|
||||
<n-input
|
||||
v-model:value="form.content"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 10 }"
|
||||
placeholder="输入新的更新日志..."
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="置顶">
|
||||
<n-switch v-model:value="form.isPinned" />
|
||||
</n-form-item>
|
||||
<n-space justify="end">
|
||||
<n-button type="primary" :loading="submitting" @click="addLog" :disabled="!form.content.trim()">
|
||||
发布日志
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<n-empty v-if="!logs.length && !loading" description="目前还没有更新记录" />
|
||||
<n-space v-else vertical size="large">
|
||||
<n-card
|
||||
v-for="log in orderedLogs"
|
||||
:key="log.id"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
class="log-card"
|
||||
>
|
||||
<div class="log-header">
|
||||
<n-space align="center" size="small">
|
||||
<n-tag v-if="log.is_pinned" type="warning" :bordered="false">置顶</n-tag>
|
||||
<span class="log-date">{{ formatDate(log.created_at) }}</span>
|
||||
<span v-if="log.created_by" class="log-author">by {{ log.created_by }}</span>
|
||||
</n-space>
|
||||
<n-space size="small">
|
||||
<n-switch
|
||||
:value="log.is_pinned"
|
||||
size="small"
|
||||
:loading="togglingId === log.id"
|
||||
@update:value="(value) => togglePin(log, value)"
|
||||
>
|
||||
<template #checked>置顶</template>
|
||||
<template #unchecked>置顶</template>
|
||||
</n-switch>
|
||||
<n-popconfirm
|
||||
placement="left"
|
||||
positive-text="删除"
|
||||
negative-text="取消"
|
||||
type="error"
|
||||
@positive-click="() => deleteLog(log.id)"
|
||||
>
|
||||
<template #trigger>
|
||||
<n-button quaternary type="error" size="small" :loading="deletingId === log.id">
|
||||
删除
|
||||
</n-button>
|
||||
</template>
|
||||
确认删除该更新日志?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</div>
|
||||
<div class="log-content">
|
||||
{{ log.content }}
|
||||
</div>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-spin>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NEmpty,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NPopconfirm,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NSwitch,
|
||||
NTag
|
||||
} from 'naive-ui'
|
||||
|
||||
import { AdminAPI, type UpdateLog } from '@/api/admin'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
|
||||
const { showAlert } = useAlert()
|
||||
|
||||
const logs = ref<UpdateLog[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const deletingId = ref<number | null>(null)
|
||||
const togglingId = ref<number | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const form = ref({
|
||||
content: '',
|
||||
isPinned: false
|
||||
})
|
||||
|
||||
const orderedLogs = computed(() => {
|
||||
return [...logs.value].sort((a, b) => {
|
||||
if (a.is_pinned && !b.is_pinned) return -1
|
||||
if (!a.is_pinned && b.is_pinned) return 1
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
})
|
||||
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
logs.value = await AdminAPI.listUpdateLogs()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取更新日志失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.value.content = ''
|
||||
form.value.isPinned = false
|
||||
}
|
||||
|
||||
const addLog = async () => {
|
||||
if (!form.value.content.trim()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await AdminAPI.createUpdateLog({
|
||||
content: form.value.content.trim(),
|
||||
is_pinned: form.value.isPinned
|
||||
})
|
||||
logs.value.unshift(created)
|
||||
resetForm()
|
||||
showAlert('更新日志发布成功', 'success')
|
||||
} catch (err) {
|
||||
showAlert(err instanceof Error ? err.message : '发布失败', 'error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteLog = async (id: number) => {
|
||||
deletingId.value = id
|
||||
try {
|
||||
await AdminAPI.deleteUpdateLog(id)
|
||||
logs.value = logs.value.filter((item) => item.id !== id)
|
||||
showAlert('删除成功', 'success')
|
||||
} catch (err) {
|
||||
showAlert(err instanceof Error ? err.message : '删除失败', 'error')
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const togglePin = async (log: UpdateLog, value: boolean) => {
|
||||
togglingId.value = log.id
|
||||
try {
|
||||
const updated = await AdminAPI.updateUpdateLog(log.id, { is_pinned: value })
|
||||
const index = logs.value.findIndex((item) => item.id === log.id)
|
||||
if (index !== -1) {
|
||||
logs.value.splice(index, 1, updated)
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert(err instanceof Error ? err.message : '更新失败', 'error')
|
||||
} finally {
|
||||
togglingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
const d = new Date(date)
|
||||
return Number.isNaN(d.getTime()) ? date : d.toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(fetchLogs)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, rgba(15, 118, 110, 0.06), rgba(15, 118, 110, 0));
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.log-date {
|
||||
font-size: 0.85rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.log-author {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
font-size: 0.95rem;
|
||||
color: #1f2937;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.card-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
176
frontend/src/components/admin/UserManagement.vue
Normal file
176
frontend/src/components/admin/UserManagement.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<n-card :bordered="false" class="admin-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">用户管理</span>
|
||||
<n-space :size="12">
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
clearable
|
||||
round
|
||||
placeholder="搜索用户名或邮箱"
|
||||
@update:value="handleSearch"
|
||||
class="search-input"
|
||||
/>
|
||||
<n-button quaternary size="small" @click="fetchUsers" :loading="loading">
|
||||
刷新
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-space vertical size="large">
|
||||
<n-alert v-if="error" type="error" closable @close="error = null">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="filteredUsers"
|
||||
:bordered="false"
|
||||
:pagination="pagination"
|
||||
:row-key="rowKey"
|
||||
class="user-table"
|
||||
/>
|
||||
</n-spin>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NInput,
|
||||
NSpin,
|
||||
NTag,
|
||||
NSpace,
|
||||
type DataTableColumns
|
||||
} from 'naive-ui'
|
||||
|
||||
import { AdminAPI, type AdminUser } from '@/api/admin'
|
||||
|
||||
const users = ref<AdminUser[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const keyword = ref('')
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
showSizePicker: false
|
||||
})
|
||||
|
||||
const columns: DataTableColumns<AdminUser> = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
sorter: (a, b) => a.id - b.id,
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
key: 'username',
|
||||
ellipsis: { tooltip: true }
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
key: 'email',
|
||||
ellipsis: { tooltip: true },
|
||||
render(row) {
|
||||
return row.email || '—'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
key: 'is_admin',
|
||||
align: 'center',
|
||||
render(row) {
|
||||
return h(
|
||||
NTag,
|
||||
{
|
||||
type: row.is_admin ? 'success' : 'default',
|
||||
bordered: false,
|
||||
size: 'small'
|
||||
},
|
||||
{ default: () => (row.is_admin ? '管理员' : '普通用户') }
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
if (!keyword.value.trim()) {
|
||||
return users.value
|
||||
}
|
||||
const q = keyword.value.trim().toLowerCase()
|
||||
return users.value.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(q) ||
|
||||
(user.email && user.email.toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
|
||||
const rowKey = (row: AdminUser) => row.id
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
users.value = await AdminAPI.listUsers()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取用户数据失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
}
|
||||
|
||||
onMounted(fetchUsers)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: min(230px, 60vw);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
frontend/src/components/icons/IconCommunity.vue
Normal file
7
frontend/src/components/icons/IconCommunity.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
frontend/src/components/icons/IconDocumentation.vue
Normal file
7
frontend/src/components/icons/IconDocumentation.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
frontend/src/components/icons/IconEcosystem.vue
Normal file
7
frontend/src/components/icons/IconEcosystem.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
frontend/src/components/icons/IconSupport.vue
Normal file
7
frontend/src/components/icons/IconSupport.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
19
frontend/src/components/icons/IconTooling.vue
Normal file
19
frontend/src/components/icons/IconTooling.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-slate-900">章节大纲</h2>
|
||||
<p class="text-sm text-slate-500">故事结构与章节节奏一目了然</p>
|
||||
</div>
|
||||
<div v-if="editable" class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-3 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg"
|
||||
@click="$emit('add')"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
新增章节
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-3 py-2 text-sm text-gray-500 hover:text-indigo-600 transition-colors"
|
||||
@click="emitEdit('chapter_outline', '章节大纲', outline)"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
编辑大纲
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="relative border-l border-slate-200 ml-3 space-y-8">
|
||||
<li
|
||||
v-for="chapter in outline"
|
||||
:key="chapter.chapter_number"
|
||||
class="ml-6"
|
||||
>
|
||||
<span class="absolute -left-3 mt-1 flex h-6 w-6 items-center justify-center rounded-full bg-indigo-500 text-white text-xs font-semibold">
|
||||
{{ chapter.chapter_number }}
|
||||
</span>
|
||||
<div class="bg-white/95 rounded-2xl border border-slate-200 shadow-sm p-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-lg font-semibold text-slate-900">{{ chapter.title || `第${chapter.chapter_number}章` }}</h3>
|
||||
<span class="text-xs text-slate-400">#{{ chapter.chapter_number }}</span>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-slate-600 leading-6 whitespace-pre-line">{{ chapter.summary || '暂无摘要' }}</p>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!outline.length" class="ml-6 text-slate-400 text-sm">暂无章节大纲</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineEmits, defineProps } from 'vue'
|
||||
|
||||
interface OutlineItem {
|
||||
chapter_number: number
|
||||
title: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
outline: OutlineItem[]
|
||||
editable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit', payload: { field: string; title: string; value: any }): void
|
||||
(e: 'add'): void
|
||||
}>()
|
||||
|
||||
const emitEdit = (field: string, title: string, value: any) => {
|
||||
if (!props.editable) return
|
||||
emit('edit', { field, title, value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ChapterOutlineSection'
|
||||
})
|
||||
</script>
|
||||
711
frontend/src/components/novel-detail/ChaptersSection.vue
Normal file
711
frontend/src/components/novel-detail/ChaptersSection.vue
Normal file
@@ -0,0 +1,711 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full min-h-0 overflow-hidden relative">
|
||||
<div class="flex flex-row flex-1 h-full lg:min-h-0 overflow-hidden">
|
||||
<!-- 移动端遮罩层 -->
|
||||
<div
|
||||
v-if="showChapterList"
|
||||
class="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
@click="showChapterList = false"
|
||||
></div>
|
||||
|
||||
<!-- 章节列表侧边栏 -->
|
||||
<aside
|
||||
class="fixed lg:static inset-y-0 left-0 z-50 w-72 lg:w-72 bg-white lg:bg-slate-50/70 border-r border-slate-200 flex flex-col h-full min-h-0 max-h-full overflow-hidden transition-transform duration-300 lg:translate-x-0 shadow-2xl lg:shadow-none"
|
||||
:class="showChapterList ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="px-5 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-slate-900">章节</h3>
|
||||
<span class="text-xs text-slate-500">{{ chapters.length }} 篇</span>
|
||||
</div>
|
||||
<ul class="flex-1 h-full overflow-y-auto divide-y divide-slate-200 overscroll-contain">
|
||||
<li v-for="(chapter, index) in chapters" :key="chapter.chapter_number">
|
||||
<button
|
||||
class="w-full text-left px-5 py-3 transition-colors duration-200"
|
||||
:class="selectedChapter?.chapter_number === chapter.chapter_number ? 'bg-indigo-50 text-indigo-600 font-semibold' : 'hover:bg-slate-50 lg:hover:bg-white text-slate-700'"
|
||||
@click="selectChapter(chapter.chapter_number)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 text-xs font-semibold text-slate-500 bg-slate-100 rounded-full">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span class="truncate">{{ chapter.title || `第${chapter.chapter_number}章` }}</span>
|
||||
</div>
|
||||
<span v-if="chapterCache.has(chapter.chapter_number)" class="text-xs text-slate-400">
|
||||
{{ calculateWordCount(chapterCache.get(chapter.chapter_number)?.content) }} 字
|
||||
</span>
|
||||
<span v-else class="text-xs text-slate-400">-</span>
|
||||
</div>
|
||||
<p v-if="chapter.summary" class="mt-1 text-xs text-slate-500 truncate">
|
||||
{{ chapter.summary }}
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<section class="flex-1 flex flex-col bg-white h-full min-h-0 max-h-full overflow-hidden relative">
|
||||
<!-- 移动端浮动按钮 -->
|
||||
<button
|
||||
v-if="!showChapterList"
|
||||
@click="showChapterList = true"
|
||||
class="lg:hidden fixed bottom-6 left-6 z-30 w-14 h-14 bg-indigo-600 text-white rounded-full shadow-lg flex items-center justify-center hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="h-full flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-10 h-10 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p class="text-sm text-slate-500">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="h-full flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-12 h-12 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else-if="selectedChapter">
|
||||
<!-- Header with Status and Tabs -->
|
||||
<header class="px-6 py-4 border-b border-slate-200 bg-slate-50/50">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="flex-1">
|
||||
<h4 class="text-xl font-bold text-slate-900">{{ selectedChapter.title || `第${selectedChapter.chapter_number}章` }}</h4>
|
||||
<div class="flex items-center gap-3 mt-1.5">
|
||||
<span class="text-sm text-slate-500">第 {{ selectedChapter.chapter_number }} 章</span>
|
||||
<span class="text-sm text-slate-400">·</span>
|
||||
<span class="text-sm text-slate-500">{{ calculateWordCount(selectedChapter.content) }} 字</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg border transition-colors duration-200"
|
||||
:class="selectedChapter?.content ? 'border-indigo-200 text-indigo-600 hover:bg-indigo-50' : 'border-slate-200 text-slate-400 cursor-not-allowed'"
|
||||
:disabled="!selectedChapter?.content"
|
||||
@click="exportChapterAsTxt"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v16h16V4m-4 4l-4-4-4 4m4-4v12" />
|
||||
</svg>
|
||||
导出TXT
|
||||
</button>
|
||||
<span v-if="selectedChapter.generation_status"
|
||||
class="px-3 py-1 text-xs font-medium rounded-full"
|
||||
:class="getStatusColor(selectedChapter.generation_status)">
|
||||
{{ getStatusLabel(selectedChapter.generation_status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200"
|
||||
:class="activeTab === tab.key
|
||||
? 'bg-white text-indigo-600 shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-white/50'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.badge && getTabBadgeCount(tab.key)"
|
||||
class="ml-1.5 px-1.5 py-0.5 text-xs rounded-full"
|
||||
:class="activeTab === tab.key ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-200 text-slate-600'">
|
||||
{{ getTabBadgeCount(tab.key) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<article class="flex-1 h-full overflow-y-auto min-h-0 overscroll-contain">
|
||||
<!-- 正文 Tab -->
|
||||
<div v-show="activeTab === 'content'" class="px-2 py-3">
|
||||
<div class="max-w-full space-y-4">
|
||||
<!-- Summary Cards -->
|
||||
<div v-if="selectedChapter.summary || selectedChapter.real_summary" class="grid gap-4">
|
||||
<div v-if="selectedChapter.summary" class="bg-blue-50 border border-blue-100 rounded-xl p-4">
|
||||
<h5 class="text-xs font-semibold text-blue-900 mb-2 flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
计划大纲
|
||||
</h5>
|
||||
<p class="text-sm text-blue-800 leading-relaxed">{{ selectedChapter.summary }}</p>
|
||||
</div>
|
||||
<div v-if="selectedChapter.real_summary" class="bg-green-50 border border-green-100 rounded-xl p-4">
|
||||
<h5 class="text-xs font-semibold text-green-900 mb-2 flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
实际内容概要
|
||||
</h5>
|
||||
<div class="prose prose-sm prose-green max-w-none text-green-800" v-html="renderMarkdown(selectedChapter.real_summary)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="prose prose-slate max-w-none p-4 sm:p-6 rounded-xl bg-[var(--paper-card)]">
|
||||
<div class="text-base text-slate-900 leading-8 whitespace-pre-wrap font-serif">
|
||||
{{ selectedChapter.content || '暂无内容' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 版本 Tab -->
|
||||
<div v-show="activeTab === 'versions'" class="px-2 py-3">
|
||||
<div class="max-w-full">
|
||||
<div v-if="selectedChapter.versions && selectedChapter.versions.length > 0" class="space-y-4">
|
||||
<div v-for="(version, index) in selectedChapter.versions" :key="index"
|
||||
class="border border-slate-200 rounded-xl p-5 hover:border-indigo-300 hover:shadow-md transition-all duration-200 group cursor-pointer"
|
||||
@click="openVersionModal(version, index)">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h5 class="text-sm font-semibold text-slate-900 flex items-center gap-2">
|
||||
<span class="w-6 h-6 bg-indigo-100 text-indigo-600 rounded-full flex items-center justify-center text-xs font-bold">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
版本 {{ index + 1 }}
|
||||
</h5>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-slate-500">{{ calculateWordCount(version) }} 字</span>
|
||||
<span class="text-xs font-medium text-indigo-600 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
点击查看全文 →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-slate-700 leading-7 whitespace-pre-wrap line-clamp-4">
|
||||
{{ version }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-12 text-slate-400">
|
||||
暂无版本记录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评审 Tab -->
|
||||
<div v-show="activeTab === 'evaluation'" class="px-2 py-3">
|
||||
<div class="max-w-full">
|
||||
<div v-if="evaluationData" class="space-y-4">
|
||||
<!-- 最佳选择 -->
|
||||
<div v-if="evaluationData.best_choice" class="bg-gradient-to-br from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 bg-indigo-500 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h5 class="text-lg font-bold text-indigo-900 mb-2">最佳版本选择</h5>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="px-3 py-1 bg-indigo-500 text-white text-sm font-bold rounded-full">
|
||||
版本 {{ evaluationData.best_choice }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="evaluationData.reason_for_choice" class="text-sm text-indigo-900 leading-relaxed">
|
||||
{{ evaluationData.reason_for_choice }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 各版本详细评审 -->
|
||||
<div v-if="evaluationData.evaluation" class="space-y-4">
|
||||
<div v-for="(versionEval, versionKey) in evaluationData.evaluation" :key="versionKey"
|
||||
class="border border-slate-200 rounded-xl overflow-hidden"
|
||||
:class="isSelectedVersion(versionKey, evaluationData.best_choice) ? 'ring-2 ring-indigo-400' : ''">
|
||||
<!-- 版本标题 -->
|
||||
<div class="px-5 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
|
||||
<h6 class="font-bold text-slate-900 flex items-center gap-2">
|
||||
<span class="w-6 h-6 bg-slate-700 text-white rounded-full flex items-center justify-center text-xs">
|
||||
{{ getVersionNumber(versionKey) }}
|
||||
</span>
|
||||
{{ getVersionLabel(versionKey) }}
|
||||
</h6>
|
||||
<span v-if="isSelectedVersion(versionKey, evaluationData.best_choice)"
|
||||
class="px-2.5 py-1 bg-indigo-100 text-indigo-700 text-xs font-semibold rounded-full">
|
||||
最佳
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-3">
|
||||
<!-- 优点 -->
|
||||
<div v-if="versionEval.pros && versionEval.pros.length > 0"
|
||||
class="bg-green-50 border border-green-100 rounded-lg p-3">
|
||||
<h6 class="text-xs font-bold text-green-900 mb-2 flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
优点
|
||||
</h6>
|
||||
<ul class="space-y-1.5">
|
||||
<li v-for="(item, idx) in versionEval.pros" :key="idx"
|
||||
class="flex items-start gap-2 text-xs text-green-800 leading-relaxed">
|
||||
<span class="w-1 h-1 bg-green-500 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||
<span>{{ item }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 缺点 -->
|
||||
<div v-if="versionEval.cons && versionEval.cons.length > 0"
|
||||
class="bg-red-50 border border-red-100 rounded-lg p-3">
|
||||
<h6 class="text-xs font-bold text-red-900 mb-2 flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
缺点
|
||||
</h6>
|
||||
<ul class="space-y-1.5">
|
||||
<li v-for="(item, idx) in versionEval.cons" :key="idx"
|
||||
class="flex items-start gap-2 text-xs text-red-800 leading-relaxed">
|
||||
<span class="w-1 h-1 bg-red-500 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||
<span>{{ item }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 总体评价 -->
|
||||
<div v-if="versionEval.overall_review"
|
||||
class="bg-blue-50 border border-blue-100 rounded-lg p-3">
|
||||
<h6 class="text-xs font-bold text-blue-900 mb-2">总体评价</h6>
|
||||
<p class="text-xs text-blue-800 leading-relaxed">{{ versionEval.overall_review }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简单格式兼容 -->
|
||||
<div v-else-if="evaluationData.decision || evaluationData.feedback" class="space-y-4">
|
||||
<!-- 评审决策 -->
|
||||
<div v-if="evaluationData.decision" class="bg-gradient-to-br from-indigo-50 to-blue-50 border border-indigo-200 rounded-xl p-4">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-indigo-500 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="text-sm font-bold text-indigo-900">评审决策</h5>
|
||||
<p class="text-xs text-indigo-700">{{ evaluationData.decision }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评分卡片 -->
|
||||
<div v-if="evaluationData.scores" class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div v-for="(score, key) in evaluationData.scores" :key="key"
|
||||
class="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-slate-600">{{ getScoreLabel(key) }}</span>
|
||||
<span class="text-lg font-bold" :class="getScoreColor(score)">{{ score }}</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 rounded-full h-2">
|
||||
<div class="h-2 rounded-full transition-all duration-300"
|
||||
:class="getScoreBarColor(score)"
|
||||
:style="{ width: `${(score / 10) * 100}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细反馈 -->
|
||||
<div v-if="evaluationData.feedback"
|
||||
class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<h5 class="text-sm font-bold text-slate-900 mb-3">详细反馈</h5>
|
||||
<p class="text-sm text-slate-700 leading-relaxed whitespace-pre-wrap">{{ evaluationData.feedback }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg class="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-slate-400">暂无评审意见</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="h-full flex items-center justify-center text-slate-400">
|
||||
<div class="text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<p class="text-sm">请选择章节查看详细内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 版本全文弹窗 -->
|
||||
<transition
|
||||
enter-active-class="transition-all duration-300"
|
||||
leave-active-class="transition-all duration-300"
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="versionModal.show" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
@click="closeVersionModal">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[85vh] overflow-hidden"
|
||||
@click.stop>
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-slate-50">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-8 h-8 bg-indigo-500 text-white rounded-full flex items-center justify-center text-sm font-bold">
|
||||
{{ versionModal.index + 1 }}
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-900">版本 {{ versionModal.index + 1 }}</h3>
|
||||
<p class="text-xs text-slate-500">{{ calculateWordCount(versionModal.content) }} 字</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="closeVersionModal"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-200 transition-colors">
|
||||
<svg class="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="overflow-y-auto p-6 max-h-[calc(85vh-5rem)]">
|
||||
<div class="prose prose-slate max-w-none">
|
||||
<div class="text-base text-slate-900 leading-8 whitespace-pre-wrap font-serif">
|
||||
{{ versionModal.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps, ref, watch } from 'vue'
|
||||
import { NovelAPI } from '@/api/novel'
|
||||
import { AdminAPI } from '@/api/admin'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { marked } from 'marked'
|
||||
|
||||
interface ChapterItem {
|
||||
chapter_number: number
|
||||
title?: string | null
|
||||
summary?: string | null
|
||||
content?: string | null
|
||||
word_count?: number
|
||||
}
|
||||
|
||||
interface ChapterDetail extends ChapterItem {
|
||||
real_summary?: string | null
|
||||
versions?: string[] | null
|
||||
evaluation?: string | null
|
||||
generation_status?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
chapters: ChapterItem[]
|
||||
isAdmin?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = route.params.id as string
|
||||
|
||||
const selectedChapter = ref<ChapterDetail | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const activeTab = ref<'content' | 'versions' | 'evaluation'>('content')
|
||||
|
||||
// 移动端章节列表显示状态
|
||||
const showChapterList = ref(false)
|
||||
|
||||
// 版本弹窗状态
|
||||
const versionModal = ref({
|
||||
show: false,
|
||||
content: '',
|
||||
index: 0
|
||||
})
|
||||
|
||||
// 缓存已加载的章节详情
|
||||
const chapterCache = new Map<number, ChapterDetail>()
|
||||
|
||||
const chapters = computed(() => props.chapters || [])
|
||||
|
||||
// Tab 配置
|
||||
const tabs = [
|
||||
{ key: 'content' as const, label: '正文', badge: false },
|
||||
{ key: 'versions' as const, label: '版本', badge: true },
|
||||
{ key: 'evaluation' as const, label: '评审', badge: false }
|
||||
]
|
||||
|
||||
// 计算字数的辅助函数
|
||||
const calculateWordCount = (content: string | null | undefined): number => {
|
||||
if (!content) return 0
|
||||
// 移除所有空白字符后计算字数
|
||||
return content.replace(/\s/g, '').length
|
||||
}
|
||||
|
||||
// 获取状态标签
|
||||
const getStatusLabel = (status: string): string => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'not_generated': '未生成',
|
||||
'generating': '生成中',
|
||||
'evaluating': '评审中',
|
||||
'selecting': '选择中',
|
||||
'failed': '生成失败',
|
||||
'evaluation_failed': '评审失败',
|
||||
'waiting_for_confirm': '待确认',
|
||||
'successful': '已完成'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'not_generated': 'bg-slate-100 text-slate-600',
|
||||
'generating': 'bg-blue-100 text-blue-700',
|
||||
'evaluating': 'bg-purple-100 text-purple-700',
|
||||
'selecting': 'bg-yellow-100 text-yellow-700',
|
||||
'failed': 'bg-red-100 text-red-700',
|
||||
'evaluation_failed': 'bg-orange-100 text-orange-700',
|
||||
'waiting_for_confirm': 'bg-amber-100 text-amber-700',
|
||||
'successful': 'bg-green-100 text-green-700'
|
||||
}
|
||||
return colorMap[status] || 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
|
||||
// 获取 Tab Badge 数量
|
||||
const getTabBadgeCount = (tabKey: string): number => {
|
||||
if (!selectedChapter.value) return 0
|
||||
if (tabKey === 'versions') {
|
||||
return selectedChapter.value.versions?.length || 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const sanitizeFileName = (name: string): string => {
|
||||
return name.replace(/[\\/:*?"<>|]/g, '_')
|
||||
}
|
||||
|
||||
const exportChapterAsTxt = () => {
|
||||
const chapter = selectedChapter.value
|
||||
if (!chapter) return
|
||||
|
||||
const title = chapter.title?.trim() || `第${chapter.chapter_number}章`
|
||||
const safeTitle = sanitizeFileName(title) || `chapter-${chapter.chapter_number}`
|
||||
const content = chapter.content ?? ''
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${safeTitle}.txt`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// 打开版本弹窗
|
||||
const openVersionModal = (content: string, index: number) => {
|
||||
versionModal.value = {
|
||||
show: true,
|
||||
content,
|
||||
index
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭版本弹窗
|
||||
const closeVersionModal = () => {
|
||||
versionModal.value.show = false
|
||||
}
|
||||
|
||||
// 解析评审数据
|
||||
const evaluationData = computed(() => {
|
||||
if (!selectedChapter.value?.evaluation) return null
|
||||
|
||||
try {
|
||||
// 尝试解析 JSON
|
||||
const parsed = JSON.parse(selectedChapter.value.evaluation)
|
||||
return parsed
|
||||
} catch {
|
||||
// 如果不是 JSON,返回简单的文本格式
|
||||
return {
|
||||
feedback: selectedChapter.value.evaluation
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 获取评分标签
|
||||
const getScoreLabel = (key: string | number): string => {
|
||||
const normalizedKey = typeof key === 'number' ? key.toString() : key
|
||||
const labelMap: Record<string, string> = {
|
||||
'plot': '情节',
|
||||
'character': '人物',
|
||||
'writing': '文笔',
|
||||
'logic': '逻辑',
|
||||
'emotion': '情感',
|
||||
'creativity': '创意',
|
||||
'coherence': '连贯性',
|
||||
'engagement': '吸引力'
|
||||
}
|
||||
return labelMap[normalizedKey] || normalizedKey
|
||||
}
|
||||
|
||||
// 获取评分颜色
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 8) return 'text-green-600'
|
||||
if (score >= 6) return 'text-blue-600'
|
||||
if (score >= 4) return 'text-amber-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
// 获取评分条颜色
|
||||
const getScoreBarColor = (score: number): string => {
|
||||
if (score >= 8) return 'bg-green-500'
|
||||
if (score >= 6) return 'bg-blue-500'
|
||||
if (score >= 4) return 'bg-amber-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
// 从版本 key 中提取版本号 (version1 -> 1)
|
||||
const getVersionNumber = (versionKey: string | number): number => {
|
||||
const normalizedKey = typeof versionKey === 'number' ? versionKey.toString() : versionKey
|
||||
const match = normalizedKey.match(/\d+/)
|
||||
return match ? parseInt(match[0]) : 0
|
||||
}
|
||||
|
||||
// 获取版本标签
|
||||
const getVersionLabel = (versionKey: string | number): string => {
|
||||
const num = getVersionNumber(versionKey)
|
||||
return `版本 ${num}`
|
||||
}
|
||||
|
||||
// 判断是否为选中的版本
|
||||
const isSelectedVersion = (versionKey: string | number, bestChoice?: number): boolean => {
|
||||
if (!bestChoice) return false
|
||||
return getVersionNumber(versionKey) === bestChoice
|
||||
}
|
||||
|
||||
// 渲染 Markdown
|
||||
const renderMarkdown = (text: string | null | undefined): string => {
|
||||
if (!text) return ''
|
||||
try {
|
||||
return marked.parse(text, { breaks: true }) as string
|
||||
} catch (error) {
|
||||
console.error('Markdown 渲染失败:', error)
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
// 加载章节详情
|
||||
const loadChapterDetail = async (chapterNumber: number) => {
|
||||
// 检查缓存
|
||||
if (chapterCache.has(chapterNumber)) {
|
||||
selectedChapter.value = chapterCache.get(chapterNumber)!
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const detail: ChapterDetail = props.isAdmin
|
||||
? await AdminAPI.getNovelChapter(projectId, chapterNumber)
|
||||
: await NovelAPI.getChapter(projectId, chapterNumber)
|
||||
|
||||
// 存入缓存
|
||||
chapterCache.set(chapterNumber, detail)
|
||||
selectedChapter.value = detail
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '加载失败'
|
||||
console.error('加载章节详情失败:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
chapters,
|
||||
async (list) => {
|
||||
if (list.length === 0) {
|
||||
selectedChapter.value = null
|
||||
return
|
||||
}
|
||||
// 自动选中第一个章节(但不加载详情,等用户点击)
|
||||
if (!selectedChapter.value && list.length > 0) {
|
||||
await loadChapterDetail(list[0].chapter_number)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const selectChapter = async (chapterNumber: number) => {
|
||||
activeTab.value = 'content' // 切换章节时重置到正文标签
|
||||
await loadChapterDetail(chapterNumber)
|
||||
// 移动端选择章节后关闭章节列表
|
||||
showChapterList.value = false
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => props.isAdmin ?? false)
|
||||
|
||||
defineExpose({
|
||||
focusChapter: async (chapterNumber: number) => {
|
||||
const target = chapters.value.find(ch => ch.chapter_number === chapterNumber)
|
||||
if (target) {
|
||||
await loadChapterDetail(chapterNumber)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-4 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-6 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 6;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ChaptersSection'
|
||||
})
|
||||
</script>
|
||||
97
frontend/src/components/novel-detail/CharactersSection.vue
Normal file
97
frontend/src/components/novel-detail/CharactersSection.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-slate-900">主要角色</h2>
|
||||
<p class="text-sm text-slate-500">了解故事中核心人物的目标与个性</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="editable"
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-indigo-600 transition-colors"
|
||||
@click="emitEdit('characters', '主要角色', data?.characters)">
|
||||
<svg class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<article
|
||||
v-for="(character, index) in characters"
|
||||
:key="index"
|
||||
class="bg-white/95 rounded-2xl border border-slate-200 shadow-sm hover:shadow-lg transition-all duration-300">
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4 mb-4">
|
||||
<div class="w-16 h-16 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 text-lg font-semibold">
|
||||
{{ character.name?.slice(0, 1) || '角' }}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-slate-900">{{ character.name || '未命名角色' }}</h3>
|
||||
<p v-if="character.identity" class="text-sm text-indigo-500 font-medium">{{ character.identity }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="space-y-3 text-sm text-slate-600">
|
||||
<div v-if="character.personality">
|
||||
<dt class="font-semibold text-slate-800 mb-1">性格</dt>
|
||||
<dd class="leading-6">{{ character.personality }}</dd>
|
||||
</div>
|
||||
<div v-if="character.goals">
|
||||
<dt class="font-semibold text-slate-800 mb-1">目标</dt>
|
||||
<dd class="leading-6">{{ character.goals }}</dd>
|
||||
</div>
|
||||
<div v-if="character.abilities">
|
||||
<dt class="font-semibold text-slate-800 mb-1">能力</dt>
|
||||
<dd class="leading-6">{{ character.abilities }}</dd>
|
||||
</div>
|
||||
<div v-if="character.relationship_to_protagonist">
|
||||
<dt class="font-semibold text-slate-800 mb-1">与主角的关系</dt>
|
||||
<dd class="leading-6">{{ character.relationship_to_protagonist }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="!characters.length" class="bg-white/95 rounded-2xl border border-dashed border-slate-300 p-10 text-center text-slate-400">
|
||||
暂无角色信息
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineEmits, defineProps } from 'vue'
|
||||
|
||||
interface CharacterItem {
|
||||
name?: string
|
||||
identity?: string
|
||||
personality?: string
|
||||
goals?: string
|
||||
abilities?: string
|
||||
relationship_to_protagonist?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
data: { characters?: CharacterItem[] } | null
|
||||
editable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit', payload: { field: string; title: string; value: any }): void
|
||||
}>()
|
||||
|
||||
const characters = computed(() => props.data?.characters || [])
|
||||
|
||||
const emitEdit = (field: string, title: string, value: any) => {
|
||||
if (!props.editable) return
|
||||
emit('edit', { field, title, value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CharactersSection'
|
||||
})
|
||||
</script>
|
||||
96
frontend/src/components/novel-detail/OverviewSection.vue
Normal file
96
frontend/src/components/novel-detail/OverviewSection.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-indigo-600 uppercase tracking-wide">核心摘要</h3>
|
||||
<p class="text-gray-500 text-xs">快速了解项目的定位与调性</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="editable"
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-indigo-600 transition-colors"
|
||||
@click="emitEdit('one_sentence_summary', '核心摘要', data?.one_sentence_summary)">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-slate-800 text-lg leading-relaxed min-h-[2.5rem]">{{ data?.one_sentence_summary || '暂无' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-4">
|
||||
<h4 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">目标受众</h4>
|
||||
<p class="text-base font-medium text-slate-800 min-h-[1.5rem]">{{ data?.target_audience || '暂无' }}</p>
|
||||
</div>
|
||||
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-4">
|
||||
<h4 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">类型</h4>
|
||||
<p class="text-base font-medium text-slate-800 min-h-[1.5rem]">{{ data?.genre || '暂无' }}</p>
|
||||
</div>
|
||||
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-4">
|
||||
<h4 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">风格</h4>
|
||||
<p class="text-base font-medium text-slate-800 min-h-[1.5rem]">{{ data?.style || '暂无' }}</p>
|
||||
</div>
|
||||
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-4">
|
||||
<h4 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">基调</h4>
|
||||
<p class="text-base font-medium text-slate-800 min-h-[1.5rem]">{{ data?.tone || '暂无' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<h3 class="text-lg font-semibold text-slate-900">完整剧情梗概</h3>
|
||||
<button
|
||||
v-if="editable"
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-indigo-600 transition-colors"
|
||||
@click="emitEdit('full_synopsis', '完整剧情梗概', data?.full_synopsis)">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="prose prose-sm max-w-none text-slate-600 leading-7 whitespace-pre-line">
|
||||
<p>{{ data?.full_synopsis || '暂无' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineEmits, defineProps } from 'vue'
|
||||
|
||||
interface OverviewData {
|
||||
one_sentence_summary?: string | null
|
||||
target_audience?: string | null
|
||||
genre?: string | null
|
||||
style?: string | null
|
||||
tone?: string | null
|
||||
full_synopsis?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
data: OverviewData | null
|
||||
editable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit', payload: { field: string; title: string; value: any }): void
|
||||
}>()
|
||||
|
||||
const emitEdit = (field: string, title: string, value: any) => {
|
||||
if (!props.editable) return
|
||||
emit('edit', { field, title, value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'OverviewSection'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-slate-900">人物关系</h2>
|
||||
<p class="text-sm text-slate-500">角色之间的纽带与冲突</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="editable"
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-indigo-600 transition-colors"
|
||||
@click="emitEdit('relationships', '人物关系', data?.relationships)">
|
||||
<svg class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div
|
||||
v-for="(relation, index) in relationships"
|
||||
:key="index"
|
||||
class="bg-white/95 rounded-2xl border border-slate-200 shadow-sm p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 font-semibold">
|
||||
{{ relation.character_from?.slice(0, 1) || '角' }}
|
||||
</div>
|
||||
<span class="font-semibold text-slate-900 truncate">{{ relation.character_from || '未知角色' }}</span>
|
||||
</div>
|
||||
<svg class="text-slate-400" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="font-semibold text-slate-900 truncate">{{ relation.character_to || '未知角色' }}</span>
|
||||
<div class="w-10 h-10 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-600 font-semibold">
|
||||
{{ relation.character_to?.slice(0, 1) || '角' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 bg-slate-50 border border-slate-100 rounded-xl p-4 text-center">
|
||||
<p class="text-sm font-semibold text-slate-700">{{ relation.relationship_type || '关系' }}</p>
|
||||
<p class="text-xs text-slate-500 leading-5 mt-1">{{ relation.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!relationships.length" class="bg-white/95 rounded-2xl border border-dashed border-slate-300 p-10 text-center text-slate-400">
|
||||
暂无人际关系信息
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineEmits, defineProps } from 'vue'
|
||||
|
||||
interface RelationshipItem {
|
||||
character_from?: string
|
||||
character_to?: string
|
||||
relationship_type?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
data: { relationships?: RelationshipItem[] } | null
|
||||
editable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit', payload: { field: string; title: string; value: any }): void
|
||||
}>()
|
||||
|
||||
const relationships = computed(() => props.data?.relationships || [])
|
||||
|
||||
const emitEdit = (field: string, title: string, value: any) => {
|
||||
if (!props.editable) return
|
||||
emit('edit', { field, title, value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'RelationshipsSection'
|
||||
})
|
||||
</script>
|
||||
130
frontend/src/components/novel-detail/WorldSettingSection.vue
Normal file
130
frontend/src/components/novel-detail/WorldSettingSection.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<h3 class="text-lg font-semibold text-slate-900">核心规则</h3>
|
||||
<button
|
||||
v-if="editable"
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-indigo-600 transition-colors"
|
||||
@click="emitEdit('world_setting.core_rules', '核心规则', worldSetting.core_rules)">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-slate-600 leading-7 whitespace-pre-line">{{ worldSetting.core_rules || '暂无' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center text-slate-900 font-semibold">
|
||||
<svg class="mr-2 text-indigo-500" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18"/><path d="M6 18H4a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h2v7Z"/><path d="M18 18h2a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2h-2v7Z"/></svg>
|
||||
<span>关键地点</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="editable"
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-indigo-600 transition-colors"
|
||||
@click="emitEdit('world_setting.key_locations', '关键地点', worldSetting.key_locations)">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="space-y-4 text-sm text-slate-600">
|
||||
<li v-for="(item, index) in locations" :key="index" class="bg-slate-50 border border-slate-100 rounded-xl p-4">
|
||||
<strong class="block text-slate-800 mb-1">{{ item.title }}</strong>
|
||||
<span class="text-xs text-slate-500 leading-5">{{ item.description }}</span>
|
||||
</li>
|
||||
<li v-if="!locations.length" class="text-slate-400 text-sm">暂无数据</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center text-slate-900 font-semibold">
|
||||
<svg class="mr-2 text-indigo-500" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span>主要阵营</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="editable"
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-indigo-600 transition-colors"
|
||||
@click="emitEdit('world_setting.factions', '主要阵营', worldSetting.factions)">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="space-y-4 text-sm text-slate-600">
|
||||
<li v-for="(item, index) in factions" :key="index" class="bg-slate-50 border border-slate-100 rounded-xl p-4">
|
||||
<strong class="block text-slate-800 mb-1">{{ item.title }}</strong>
|
||||
<span class="text-xs text-slate-500 leading-5">{{ item.description }}</span>
|
||||
</li>
|
||||
<li v-if="!factions.length" class="text-slate-400 text-sm">暂无数据</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineEmits, defineProps } from 'vue'
|
||||
|
||||
interface ListItem {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
data: Record<string, any> | null
|
||||
editable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit', payload: { field: string; title: string; value: any }): void
|
||||
}>()
|
||||
|
||||
const worldSetting = computed(() => props.data?.world_setting || {})
|
||||
|
||||
const normalizeList = (source: any): ListItem[] => {
|
||||
if (!source) return []
|
||||
if (Array.isArray(source)) {
|
||||
return source.map((item: any) => {
|
||||
if (typeof item === 'string') {
|
||||
const [title, ...rest] = item.split(':')
|
||||
return {
|
||||
title: title || item,
|
||||
description: rest.join(':') || '暂无描述'
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: item?.name || '未命名',
|
||||
description: item?.description || item?.details || '暂无描述'
|
||||
}
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const locations = computed(() => normalizeList(worldSetting.value?.key_locations))
|
||||
const factions = computed(() => normalizeList(worldSetting.value?.factions))
|
||||
|
||||
const emitEdit = (field: string, title: string, value: any) => {
|
||||
if (!props.editable) return
|
||||
emit('edit', { field, title, value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'WorldSettingSection'
|
||||
})
|
||||
</script>
|
||||
596
frontend/src/components/shared/NovelDetailShell.vue
Normal file
596
frontend/src/components/shared/NovelDetailShell.vue
Normal file
@@ -0,0 +1,596 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col overflow-hidden bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/40">
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-40 bg-white/90 backdrop-blur-lg border-b border-slate-200/60 shadow-sm">
|
||||
<div class="max-w-[1800px] mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Left: Title & Info -->
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
class="lg:hidden flex-shrink-0 p-2 text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all duration-200"
|
||||
@click="toggleSidebar"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-xl sm:text-2xl lg:text-3xl font-bold text-slate-900 truncate">
|
||||
{{ formattedTitle }}
|
||||
</h1>
|
||||
<p v-if="overviewMeta.updated_at" class="text-xs sm:text-sm text-slate-500 mt-0.5">
|
||||
最近更新:{{ overviewMeta.updated_at }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
class="px-3 py-2 sm:px-4 text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 border border-slate-200 rounded-lg transition-all duration-200 hover:shadow-md"
|
||||
@click="goBack"
|
||||
>
|
||||
<span class="hidden sm:inline">返回列表</span>
|
||||
<span class="sm:hidden">返回</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!isAdmin"
|
||||
class="px-3 py-2 sm:px-4 text-sm font-medium text-white bg-gradient-to-r from-indigo-600 to-indigo-700 hover:from-indigo-700 hover:to-indigo-800 rounded-lg transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
@click="goToWritingDesk"
|
||||
>
|
||||
<span class="hidden sm:inline">开始创作</span>
|
||||
<span class="sm:hidden">创作</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex max-w-[1800px] mx-auto w-full flex-1 min-h-0 overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="fixed left-0 top-[73px] bottom-0 z-30 w-72 bg-white/95 backdrop-blur-lg border-r border-slate-200/60 shadow-2xl transform transition-transform duration-300 ease-out lg:translate-x-0"
|
||||
:class="isSidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<!-- Sidebar Header -->
|
||||
<div class="hidden lg:flex items-center justify-between px-6 py-5 border-b border-slate-200/60">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500"></div>
|
||||
<span class="text-sm font-semibold text-slate-700 uppercase tracking-wide">
|
||||
{{ isAdmin ? '内容视图' : '蓝图导航' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="px-4 py-6 space-y-1.5 overflow-y-auto h-[calc(100%-5rem)] lg:h-[calc(100%-5rem)]">
|
||||
<button
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
type="button"
|
||||
@click="switchSection(section.key)"
|
||||
:class="[
|
||||
'w-full group flex items-center gap-3 rounded-xl px-4 py-3.5 text-sm font-medium transition-all duration-200',
|
||||
activeSection === section.key
|
||||
? 'bg-gradient-to-r from-indigo-50 to-indigo-100/80 text-indigo-700 shadow-sm ring-1 ring-indigo-200/50'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg transition-all duration-200"
|
||||
:class="activeSection === section.key
|
||||
? 'bg-gradient-to-br from-indigo-500 to-indigo-600 text-white shadow-md'
|
||||
: 'bg-slate-100 text-slate-500 group-hover:bg-slate-200'"
|
||||
>
|
||||
<component :is="getSectionIcon(section.key)" class="w-5 h-5" />
|
||||
</span>
|
||||
<span class="text-left flex-1">
|
||||
<span class="block font-semibold">{{ section.label }}</span>
|
||||
<span class="text-xs font-normal opacity-70">{{ section.description }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Sidebar Overlay (Mobile) -->
|
||||
<transition
|
||||
enter-active-class="transition-opacity duration-300"
|
||||
leave-active-class="transition-opacity duration-300"
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isSidebarOpen"
|
||||
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-20 lg:hidden"
|
||||
@click="toggleSidebar"
|
||||
></div>
|
||||
</transition>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 lg:ml-72 min-h-0 flex flex-col h-full">
|
||||
<div class="flex-1 min-h-0 h-full px-4 sm:px-6 lg:px-8 xl:px-12 py-6 sm:py-8 flex flex-col overflow-hidden box-border">
|
||||
<div class="flex-1 flex flex-col min-h-0 h-full">
|
||||
<!-- Content Card -->
|
||||
<div class="flex-1 h-full bg-white/95 backdrop-blur-sm rounded-2xl border border-slate-200/60 shadow-xl p-6 sm:p-8 lg:p-10 min-h-[20rem] transition-shadow duration-300 hover:shadow-2xl flex flex-col box-border" :class="contentCardClass">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isSectionLoading" class="flex flex-col items-center justify-center py-20 sm:py-28">
|
||||
<div class="relative">
|
||||
<div class="w-12 h-12 border-4 border-indigo-100 rounded-full"></div>
|
||||
<div class="absolute top-0 left-0 w-12 h-12 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-slate-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="currentError" class="flex flex-col items-center justify-center py-20 sm:py-28 space-y-4">
|
||||
<div class="w-16 h-16 bg-red-50 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-slate-600 text-center">{{ currentError }}</p>
|
||||
<button
|
||||
class="px-6 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-indigo-600 to-indigo-700 hover:from-indigo-700 hover:to-indigo-800 rounded-lg shadow-md hover:shadow-lg transition-all duration-200"
|
||||
@click="reloadSection(activeSection, true)"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<component
|
||||
v-else
|
||||
:is="currentComponent"
|
||||
v-bind="componentProps"
|
||||
:class="componentContainerClass"
|
||||
@edit="handleSectionEdit"
|
||||
@add="startAddChapter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blueprint Edit Modal -->
|
||||
<BlueprintEditModal
|
||||
v-if="!isAdmin"
|
||||
:show="isModalOpen"
|
||||
:title="modalTitle"
|
||||
:content="modalContent"
|
||||
:field="modalField"
|
||||
@close="isModalOpen = false"
|
||||
@save="handleSave"
|
||||
/>
|
||||
|
||||
<!-- Add Chapter Modal -->
|
||||
<transition
|
||||
enter-active-class="transition-all duration-300"
|
||||
leave-active-class="transition-all duration-300"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div v-if="isAddChapterModalOpen && !isAdmin" class="fixed inset-0 z-50 flex items-center justify-center px-4">
|
||||
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="cancelNewChapter"></div>
|
||||
<div class="relative bg-white rounded-2xl shadow-2xl border border-slate-200 p-6 sm:p-8 w-full max-w-lg transform transition-all" @click.stop>
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-6">新增章节大纲</h3>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label for="new-chapter-title" class="block text-sm font-semibold text-slate-700 mb-2">
|
||||
章节标题
|
||||
</label>
|
||||
<input
|
||||
id="new-chapter-title"
|
||||
v-model="newChapterTitle"
|
||||
type="text"
|
||||
class="block w-full rounded-lg border border-slate-300 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-all duration-200"
|
||||
placeholder="例如:意外的相遇"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-chapter-summary" class="block text-sm font-semibold text-slate-700 mb-2">
|
||||
章节摘要
|
||||
</label>
|
||||
<textarea
|
||||
id="new-chapter-summary"
|
||||
v-model="newChapterSummary"
|
||||
rows="4"
|
||||
class="block w-full rounded-lg border border-slate-300 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-all duration-200 resize-none"
|
||||
placeholder="简要描述本章发生的主要事件"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-5 py-2.5 text-sm font-medium text-slate-600 bg-white hover:bg-slate-50 border border-slate-200 rounded-lg transition-all duration-200"
|
||||
@click="cancelNewChapter"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-5 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-indigo-600 to-indigo-700 hover:from-indigo-700 hover:to-indigo-800 rounded-lg shadow-md hover:shadow-lg transition-all duration-200"
|
||||
@click="saveNewChapter"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, h } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useNovelStore } from '@/stores/novel'
|
||||
import { NovelAPI } from '@/api/novel'
|
||||
import { AdminAPI } from '@/api/admin'
|
||||
import type { NovelProject, NovelSectionResponse, NovelSectionType } from '@/api/novel'
|
||||
import BlueprintEditModal from '@/components/BlueprintEditModal.vue'
|
||||
import OverviewSection from '@/components/novel-detail/OverviewSection.vue'
|
||||
import WorldSettingSection from '@/components/novel-detail/WorldSettingSection.vue'
|
||||
import CharactersSection from '@/components/novel-detail/CharactersSection.vue'
|
||||
import RelationshipsSection from '@/components/novel-detail/RelationshipsSection.vue'
|
||||
import ChapterOutlineSection from '@/components/novel-detail/ChapterOutlineSection.vue'
|
||||
import ChaptersSection from '@/components/novel-detail/ChaptersSection.vue'
|
||||
|
||||
interface Props {
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
type SectionKey = NovelSectionType
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isAdmin: false
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const novelStore = useNovelStore()
|
||||
|
||||
const projectId = route.params.id as string
|
||||
const isSidebarOpen = ref(typeof window !== 'undefined' ? window.innerWidth >= 1024 : true)
|
||||
|
||||
const sections: Array<{ key: SectionKey; label: string; description: string }> = [
|
||||
{ key: 'overview', label: '项目概览', description: '定位与整体梗概' },
|
||||
{ key: 'world_setting', label: '世界设定', description: '规则、地点与阵营' },
|
||||
{ key: 'characters', label: '主要角色', description: '人物性格与目标' },
|
||||
{ key: 'relationships', label: '人物关系', description: '角色之间的联系' },
|
||||
{ key: 'chapter_outline', label: '章节大纲', description: props.isAdmin ? '故事章节规划' : '故事结构规划' },
|
||||
{ key: 'chapters', label: '章节内容', description: props.isAdmin ? '生成章节与正文' : '生成状态与摘要' }
|
||||
]
|
||||
|
||||
const sectionComponents: Record<SectionKey, any> = {
|
||||
overview: OverviewSection,
|
||||
world_setting: WorldSettingSection,
|
||||
characters: CharactersSection,
|
||||
relationships: RelationshipsSection,
|
||||
chapter_outline: ChapterOutlineSection,
|
||||
chapters: ChaptersSection
|
||||
}
|
||||
|
||||
// Section icons as functional components
|
||||
const getSectionIcon = (key: SectionKey) => {
|
||||
const icons: Record<SectionKey, any> = {
|
||||
overview: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
|
||||
h('rect', { x: 3, y: 3, width: 18, height: 18, rx: 2 }),
|
||||
h('line', { x1: 3, y1: 9, x2: 21, y2: 9 }),
|
||||
h('line', { x1: 9, y1: 21, x2: 9, y2: 9 })
|
||||
]),
|
||||
world_setting: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
|
||||
h('circle', { cx: 12, cy: 12, r: 10 }),
|
||||
h('path', { d: 'M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z' })
|
||||
]),
|
||||
characters: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
|
||||
h('path', { d: 'M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2' }),
|
||||
h('circle', { cx: 9, cy: 7, r: 4 }),
|
||||
h('path', { d: 'M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75' })
|
||||
]),
|
||||
relationships: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
|
||||
h('path', { d: 'M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2' }),
|
||||
h('circle', { cx: 9, cy: 7, r: 4 }),
|
||||
h('path', { d: 'M22 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75' })
|
||||
]),
|
||||
chapter_outline: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
|
||||
h('line', { x1: 8, y1: 6, x2: 21, y2: 6 }),
|
||||
h('line', { x1: 8, y1: 12, x2: 21, y2: 12 }),
|
||||
h('line', { x1: 8, y1: 18, x2: 21, y2: 18 }),
|
||||
h('line', { x1: 3, y1: 6, x2: 3.01, y2: 6 }),
|
||||
h('line', { x1: 3, y1: 12, x2: 3.01, y2: 12 }),
|
||||
h('line', { x1: 3, y1: 18, x2: 3.01, y2: 18 })
|
||||
]),
|
||||
chapters: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
|
||||
h('path', { d: 'M4 19.5A2.5 2.5 0 016.5 17H20' }),
|
||||
h('path', { d: 'M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z' })
|
||||
])
|
||||
}
|
||||
return icons[key]
|
||||
}
|
||||
|
||||
const sectionData = reactive<Partial<Record<SectionKey, any>>>({})
|
||||
const sectionLoading = reactive<Record<SectionKey, boolean>>({
|
||||
overview: false,
|
||||
world_setting: false,
|
||||
characters: false,
|
||||
relationships: false,
|
||||
chapter_outline: false,
|
||||
chapters: false
|
||||
})
|
||||
const sectionError = reactive<Record<SectionKey, string | null>>({
|
||||
overview: null,
|
||||
world_setting: null,
|
||||
characters: null,
|
||||
relationships: null,
|
||||
chapter_outline: null,
|
||||
chapters: null
|
||||
})
|
||||
|
||||
const overviewMeta = reactive<{ title: string; updated_at: string | null }>({
|
||||
title: '加载中...',
|
||||
updated_at: null
|
||||
})
|
||||
|
||||
const activeSection = ref<SectionKey>('overview')
|
||||
|
||||
// Modal state (user mode only)
|
||||
const isModalOpen = ref(false)
|
||||
const modalTitle = ref('')
|
||||
const modalContent = ref<any>('')
|
||||
const modalField = ref('')
|
||||
|
||||
// Add chapter modal state (user mode only)
|
||||
const isAddChapterModalOpen = ref(false)
|
||||
const newChapterTitle = ref('')
|
||||
const newChapterSummary = ref('')
|
||||
const originalBodyOverflow = ref('')
|
||||
|
||||
const novel = computed(() => !props.isAdmin ? novelStore.currentProject as NovelProject | null : null)
|
||||
|
||||
const formattedTitle = computed(() => {
|
||||
const title = overviewMeta.title || '加载中...'
|
||||
return title.startsWith('《') && title.endsWith('》') ? title : `《${title}》`
|
||||
})
|
||||
|
||||
const componentContainerClass = computed(() => {
|
||||
const fillSections: SectionKey[] = ['chapters']
|
||||
return fillSections.includes(activeSection.value)
|
||||
? 'flex-1 min-h-0 h-full flex flex-col overflow-hidden'
|
||||
: 'overflow-y-auto'
|
||||
})
|
||||
|
||||
const contentCardClass = computed(() => {
|
||||
const fillSections: SectionKey[] = ['chapters']
|
||||
return fillSections.includes(activeSection.value)
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-visible'
|
||||
})
|
||||
|
||||
// 懒加载完整项目(仅在需要编辑时)
|
||||
const ensureProjectLoaded = async () => {
|
||||
if (props.isAdmin || !projectId) return
|
||||
if (novel.value) return // 已加载
|
||||
await novelStore.loadProject(projectId)
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
isSidebarOpen.value = !isSidebarOpen.value
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
isSidebarOpen.value = window.innerWidth >= 1024
|
||||
}
|
||||
|
||||
const loadSection = async (section: SectionKey, force = false) => {
|
||||
if (!projectId) return
|
||||
if (!force && sectionData[section]) {
|
||||
return
|
||||
}
|
||||
|
||||
sectionLoading[section] = true
|
||||
sectionError[section] = null
|
||||
try {
|
||||
const response: NovelSectionResponse = props.isAdmin
|
||||
? await AdminAPI.getNovelSection(projectId, section)
|
||||
: await NovelAPI.getSection(projectId, section)
|
||||
sectionData[section] = response.data
|
||||
if (section === 'overview') {
|
||||
overviewMeta.title = response.data?.title || overviewMeta.title
|
||||
overviewMeta.updated_at = response.data?.updated_at || null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载模块失败:', error)
|
||||
sectionError[section] = error instanceof Error ? error.message : '加载失败'
|
||||
} finally {
|
||||
sectionLoading[section] = false
|
||||
}
|
||||
}
|
||||
|
||||
const reloadSection = (section: SectionKey, force = false) => {
|
||||
loadSection(section, force)
|
||||
}
|
||||
|
||||
const switchSection = (section: SectionKey) => {
|
||||
activeSection.value = section
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
|
||||
isSidebarOpen.value = false
|
||||
}
|
||||
loadSection(section)
|
||||
}
|
||||
|
||||
const goBack = () => router.push(props.isAdmin ? '/admin' : '/workspace')
|
||||
|
||||
const goToWritingDesk = async () => {
|
||||
await ensureProjectLoaded()
|
||||
const project = novel.value
|
||||
if (!project) return
|
||||
const path = project.title === '未命名灵感' ? `/inspiration?project_id=${project.id}` : `/novel/${project.id}`
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const currentComponent = computed(() => sectionComponents[activeSection.value])
|
||||
const isSectionLoading = computed(() => sectionLoading[activeSection.value])
|
||||
const currentError = computed(() => sectionError[activeSection.value])
|
||||
|
||||
const componentProps = computed(() => {
|
||||
const data = sectionData[activeSection.value]
|
||||
const editable = !props.isAdmin
|
||||
|
||||
switch (activeSection.value) {
|
||||
case 'overview':
|
||||
return { data: data || null, editable }
|
||||
case 'world_setting':
|
||||
return { data: data || null, editable }
|
||||
case 'characters':
|
||||
return { data: data || null, editable }
|
||||
case 'relationships':
|
||||
return { data: data || null, editable }
|
||||
case 'chapter_outline':
|
||||
return { outline: data?.chapter_outline || [], editable }
|
||||
case 'chapters':
|
||||
return { chapters: data?.chapters || [], isAdmin: props.isAdmin }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
const handleSectionEdit = (payload: { field: string; title: string; value: any }) => {
|
||||
if (props.isAdmin) return
|
||||
modalField.value = payload.field
|
||||
modalTitle.value = payload.title
|
||||
modalContent.value = payload.value
|
||||
isModalOpen.value = true
|
||||
}
|
||||
|
||||
const resolveSectionKey = (field: string): SectionKey => {
|
||||
if (field.startsWith('world_setting')) return 'world_setting'
|
||||
if (field.startsWith('characters')) return 'characters'
|
||||
if (field.startsWith('relationships')) return 'relationships'
|
||||
if (field.startsWith('chapter_outline')) return 'chapter_outline'
|
||||
return 'overview'
|
||||
}
|
||||
|
||||
const handleSave = async (data: { field: string; content: any }) => {
|
||||
if (props.isAdmin) return
|
||||
await ensureProjectLoaded()
|
||||
const project = novel.value
|
||||
if (!project) return
|
||||
|
||||
const { field, content } = data
|
||||
const payload: Record<string, any> = {}
|
||||
|
||||
if (field.includes('.')) {
|
||||
const [parentField, childField] = field.split('.')
|
||||
payload[parentField] = {
|
||||
...(project.blueprint?.[parentField as keyof typeof project.blueprint] as Record<string, any> | undefined),
|
||||
[childField]: content
|
||||
}
|
||||
} else {
|
||||
payload[field] = content
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedProject = await NovelAPI.updateBlueprint(project.id, payload)
|
||||
novelStore.setCurrentProject(updatedProject)
|
||||
const sectionToReload = resolveSectionKey(field)
|
||||
await loadSection(sectionToReload, true)
|
||||
if (sectionToReload !== 'overview') {
|
||||
await loadSection('overview', true)
|
||||
}
|
||||
isModalOpen.value = false
|
||||
} catch (error) {
|
||||
console.error('保存变更失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const startAddChapter = async () => {
|
||||
if (props.isAdmin) return
|
||||
await ensureProjectLoaded()
|
||||
const outline = sectionData.chapter_outline?.chapter_outline || novel.value?.blueprint?.chapter_outline || []
|
||||
const nextNumber = outline.length > 0 ? Math.max(...outline.map((item: any) => item.chapter_number)) + 1 : 1
|
||||
newChapterTitle.value = `新章节 ${nextNumber}`
|
||||
newChapterSummary.value = ''
|
||||
isAddChapterModalOpen.value = true
|
||||
}
|
||||
|
||||
const cancelNewChapter = () => {
|
||||
isAddChapterModalOpen.value = false
|
||||
}
|
||||
|
||||
const saveNewChapter = async () => {
|
||||
if (props.isAdmin) return
|
||||
await ensureProjectLoaded()
|
||||
const project = novel.value
|
||||
if (!project) return
|
||||
if (!newChapterTitle.value.trim()) {
|
||||
alert('章节标题不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
const existingOutline = project.blueprint?.chapter_outline || []
|
||||
const nextNumber = existingOutline.length > 0 ? Math.max(...existingOutline.map(ch => ch.chapter_number)) + 1 : 1
|
||||
const newOutline = [...existingOutline, {
|
||||
chapter_number: nextNumber,
|
||||
title: newChapterTitle.value,
|
||||
summary: newChapterSummary.value
|
||||
}]
|
||||
|
||||
try {
|
||||
const updatedProject = await NovelAPI.updateBlueprint(project.id, { chapter_outline: newOutline })
|
||||
novelStore.setCurrentProject(updatedProject)
|
||||
await loadSection('chapter_outline', true)
|
||||
isAddChapterModalOpen.value = false
|
||||
} catch (error) {
|
||||
console.error('新增章节失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', handleResize)
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
originalBodyOverflow.value = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
// 只加载必要的 section 数据,不预加载完整项目
|
||||
await loadSection('overview', true)
|
||||
loadSection('world_setting')
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = originalBodyOverflow.value || ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Smooth scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
82
frontend/src/components/writing-desk/WDEditChapterModal.vue
Normal file
82
frontend/src/components/writing-desk/WDEditChapterModal.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div v-if="show" class="fixed inset-0 bg-black/30 z-50 flex justify-center items-center" @click.self="$emit('close')">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-8 transform transition-all duration-300 ease-out" :class="show ? 'scale-100 opacity-100' : 'scale-95 opacity-0'">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800">编辑章节大纲</h2>
|
||||
<button @click="$emit('close')" class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="editableChapter" class="space-y-6">
|
||||
<div>
|
||||
<label for="chapter-title" class="block text-sm font-medium text-gray-700 mb-2">章节标题</label>
|
||||
<input
|
||||
type="text"
|
||||
id="chapter-title"
|
||||
v-model="editableChapter.title"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="请输入章节标题"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="chapter-summary" class="block text-sm font-medium text-gray-700 mb-2">章节摘要</label>
|
||||
<textarea
|
||||
id="chapter-summary"
|
||||
v-model="editableChapter.summary"
|
||||
rows="5"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="请输入章节摘要"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-end gap-4">
|
||||
<button @click="$emit('close')" class="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button @click="saveChanges" class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50" :disabled="!isChanged">
|
||||
保存更改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import type { ChapterOutline } from '@/api/novel'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
chapter: ChapterOutline | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const editableChapter = ref<ChapterOutline | null>(null)
|
||||
|
||||
watch(() => props.chapter, (newChapter) => {
|
||||
if (newChapter) {
|
||||
editableChapter.value = { ...newChapter }
|
||||
} else {
|
||||
editableChapter.value = null
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
const isChanged = computed(() => {
|
||||
if (!props.chapter || !editableChapter.value) {
|
||||
return false
|
||||
}
|
||||
return props.chapter.title !== editableChapter.value.title || props.chapter.summary !== editableChapter.value.summary
|
||||
})
|
||||
|
||||
const saveChanges = () => {
|
||||
if (editableChapter.value && isChanged.value) {
|
||||
emit('save', editableChapter.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
120
frontend/src/components/writing-desk/WDEvaluationDetailModal.vue
Normal file
120
frontend/src/components/writing-desk/WDEvaluationDetailModal.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<!-- 弹窗头部 -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-purple-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a6 6 0 00-6 6v3.586l-1.707 1.707A1 1 0 003 15v1a1 1 0 001 1h12a1 1 0 001-1v-1a1 1 0 00-.293-.707L16 11.586V8a6 6 0 00-6-6zM8.05 17a2 2 0 103.9 0H8.05z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900">AI 评审详情</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗内容 -->
|
||||
<div class="p-6 overflow-y-auto max-h-[calc(80vh-130px)]">
|
||||
<div v-if="parsedEvaluation" class="space-y-6 text-sm">
|
||||
<div class="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
||||
<p class="font-semibold text-purple-800 text-base">🏆 最佳选择:版本 {{ parsedEvaluation.best_choice }}</p>
|
||||
<p class="text-purple-700 mt-2">{{ parsedEvaluation.reason_for_choice }}</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div v-for="(evalResult, versionName) in parsedEvaluation.evaluation" :key="versionName" class="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||
<h5 class="font-bold text-gray-800 text-lg mb-2">版本 {{ String(versionName).replace('version', '') }} 评估</h5>
|
||||
<div class="prose prose-sm max-w-none text-gray-700 space-y-3">
|
||||
<div>
|
||||
<p class="font-semibold text-gray-800">综合评价:</p>
|
||||
<p>{{ evalResult.overall_review }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-800">优点:</p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li v-for="(pro, i) in evalResult.pros" :key="`pro-${i}`">{{ pro }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-800">缺点:</p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li v-for="(con, i) in evalResult.cons" :key="`con-${i}`">{{ con }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="prose prose-sm max-w-none prose-headings:mt-2 prose-headings:mb-1 prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 text-gray-800"
|
||||
v-html="parseMarkdown(evaluation)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗底部操作按钮 -->
|
||||
<div class="flex items-center justify-end p-6 border-t border-gray-200 bg-gray-50">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
evaluation: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits(['close'])
|
||||
|
||||
const parsedEvaluation = computed(() => {
|
||||
if (!props.evaluation) return null
|
||||
try {
|
||||
// First, try to parse the whole string as JSON
|
||||
let data = JSON.parse(props.evaluation);
|
||||
// If successful and it's a string, parse it again (for double-encoded JSON)
|
||||
if (typeof data === 'string') {
|
||||
data = JSON.parse(data);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse evaluation JSON:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const parseMarkdown = (text: string | null): string => {
|
||||
if (!text) return ''
|
||||
let parsed = text
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\\\/g, '\\')
|
||||
parsed = parsed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
parsed = parsed.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
|
||||
parsed = parsed.replace(/^([A-Z])\)\s*\*\*(.*?)\*\*(.*)/gm, '<div class="mb-2"><span class="inline-flex items-center justify-center w-6 h-6 bg-indigo-100 text-indigo-600 text-sm font-bold rounded-full mr-2">$1</span><strong>$2</strong>$3</div>')
|
||||
parsed = parsed.replace(/\n/g, '<br>')
|
||||
parsed = parsed.replace(/(<br\s*\/?>\s*){2,}/g, '</p><p class="mt-2">')
|
||||
if (!parsed.includes('<p>')) {
|
||||
parsed = `<p>${parsed}</p>`
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<TransitionRoot as="template" :show="show">
|
||||
<Dialog as="div" class="relative z-50" @close="$emit('close')">
|
||||
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100" leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200" leave-from="opacity-100 translate-y-0 sm:scale-100" leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-6 sm:w-full sm:max-w-lg">
|
||||
<div class="bg-white px-5 pt-6 pb-5 sm:px-6 sm:pt-6 sm:pb-5">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-indigo-100 sm:mx-0 sm:h-12 sm:w-12">
|
||||
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m6-6H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-center sm:flex-1 sm:text-left">
|
||||
<DialogTitle as="h3" class="text-xl font-semibold leading-7 text-gray-900">生成后续大纲</DialogTitle>
|
||||
<div class="mt-2">
|
||||
<p class="text-base text-gray-500">请输入或选择要生成的后续章节数量。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<label for="numChapters" class="block text-base font-medium text-gray-700">生成数量</label>
|
||||
<input type="number" name="numChapters" id="numChapters" v-model.number="numChapters" class="mt-2 block w-full rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-lg shadow-sm focus:border-indigo-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500" min="1" max="20">
|
||||
<div class="mt-5 flex flex-wrap justify-center gap-3">
|
||||
<button v-for="count in [1, 2, 5, 10]" :key="count" @click="setNumChapters(count)"
|
||||
:class="['px-5 py-2 text-base rounded-full transition-colors duration-150', numChapters === count ? 'bg-indigo-600 text-white shadow-md' : 'bg-gray-200 text-gray-700 hover:bg-gray-300']">
|
||||
{{ count }} 章
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse sm:px-8">
|
||||
<button type="button" class="inline-flex w-full justify-center rounded-lg border border-transparent bg-indigo-600 px-5 py-3 text-base font-semibold text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:ml-3 sm:w-auto" @click="handleGenerate">生成</button>
|
||||
<button type="button" class="mt-3 inline-flex w-full justify-center rounded-lg border border-gray-300 bg-white px-5 py-3 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto" @click="$emit('close')">取消</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['close', 'generate'])
|
||||
|
||||
const numChapters = ref(5)
|
||||
|
||||
const setNumChapters = (count: number) => {
|
||||
numChapters.value = count
|
||||
}
|
||||
|
||||
const handleGenerate = () => {
|
||||
if (numChapters.value > 0) {
|
||||
emit('generate', numChapters.value)
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
86
frontend/src/components/writing-desk/WDHeader.vue
Normal file
86
frontend/src/components/writing-desk/WDHeader.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="flex-shrink-0 z-30 bg-white/80 backdrop-blur-lg border-b border-gray-200 shadow-sm">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- 左侧:项目信息 -->
|
||||
<div class="flex items-center gap-2 sm:gap-4 min-w-0">
|
||||
<button
|
||||
@click="$emit('goBack')"
|
||||
class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors flex-shrink-0"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L4.414 9H17a1 1 0 110 2H4.414l5.293 5.293a1 1 0 010 1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-base sm:text-lg font-bold text-gray-900 truncate">{{ project?.title || '加载中...' }}</h1>
|
||||
<div class="hidden sm:flex items-center gap-2 md:gap-4 text-xs md:text-sm text-gray-600">
|
||||
<span>{{ project?.blueprint?.genre || '--' }}</span>
|
||||
<span class="hidden md:inline">•</span>
|
||||
<span class="hidden md:inline">{{ progress }}% 完成</span>
|
||||
<span class="hidden lg:inline">•</span>
|
||||
<span class="hidden lg:inline">{{ completedChapters }}/{{ totalChapters }} 章</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:操作按钮 -->
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
<button
|
||||
@click="$emit('viewProjectDetail')"
|
||||
class="p-2 sm:px-3 sm:py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="hidden md:inline text-sm">项目详情</span>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-gray-300 hidden sm:block"></div>
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="p-2 sm:px-3 sm:py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span class="hidden md:inline text-sm">退出登录</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('toggleSidebar')"
|
||||
class="p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors lg:hidden"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { NovelProject } from '@/api/novel'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
interface Props {
|
||||
project: NovelProject | null
|
||||
progress: number
|
||||
completedChapters: number
|
||||
totalChapters: number
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits(['goBack', 'viewProjectDetail', 'toggleSidebar'])
|
||||
</script>
|
||||
408
frontend/src/components/writing-desk/WDSidebar.vue
Normal file
408
frontend/src/components/writing-desk/WDSidebar.vue
Normal file
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 侧边栏遮罩 (移动端) -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
@click="$emit('closeSidebar')"
|
||||
class="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 lg:hidden"
|
||||
></div>
|
||||
|
||||
<!-- 左侧:蓝图和章节列表 -->
|
||||
<div
|
||||
:class="[
|
||||
'bg-white rounded-2xl shadow-lg border border-gray-100 transition-all duration-300 h-full',
|
||||
'lg:relative lg:translate-x-0 lg:w-80 lg:flex-shrink-0',
|
||||
sidebarOpen
|
||||
? 'fixed left-4 top-20 bottom-4 w-80 z-50 translate-x-0'
|
||||
: 'lg:w-80 lg:flex-shrink-0 -translate-x-full absolute lg:relative'
|
||||
]"
|
||||
>
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- 蓝图预览卡片 -->
|
||||
<div class="p-6 border-b border-gray-100 flex-shrink-0">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-900">故事蓝图</h2>
|
||||
<p class="text-sm text-gray-600">{{ project.blueprint?.style || '未设定风格' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg p-3">
|
||||
<h3 class="text-sm font-semibold text-blue-900 mb-1">故事概要</h3>
|
||||
<Tooltip :text="project.blueprint?.one_sentence_summary">
|
||||
<p class="text-xs text-blue-700 line-clamp-3">{{ project.blueprint?.one_sentence_summary || '暂无概要' }}</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="bg-purple-50 rounded-lg p-2 text-center">
|
||||
<div class="font-semibold text-purple-800">{{ characterCount }}</div>
|
||||
<div class="text-purple-600">角色</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-2 text-center">
|
||||
<div class="font-semibold text-green-800">{{ relationshipCount }}</div>
|
||||
<div class="text-green-600">关系</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 章节列表 -->
|
||||
<div ref="listContainer" class="flex-1 overflow-y-auto">
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-gray-900">章节大纲</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="hasIncompleteChapters"
|
||||
@click.stop="scrollToFirstIncompleteChapter"
|
||||
class="px-3 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 rounded-full hover:bg-indigo-100 transition-colors"
|
||||
>
|
||||
定位到未完成
|
||||
</button>
|
||||
<span class="bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full text-xs font-medium">
|
||||
{{ totalChapters }} 章
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 pb-6">
|
||||
<div v-if="project.blueprint?.chapter_outline?.length" class="space-y-2">
|
||||
<div
|
||||
v-for="chapter in project.blueprint.chapter_outline"
|
||||
:key="chapter.chapter_number"
|
||||
:ref="el => setChapterRef(chapter.chapter_number, el)"
|
||||
@click="$emit('selectChapter', chapter.chapter_number)"
|
||||
:class="[
|
||||
'group cursor-pointer rounded-lg border-2 p-4 transition-all duration-200',
|
||||
selectedForDeletion.includes(chapter.chapter_number)
|
||||
? 'border-red-300 bg-red-50'
|
||||
: selectedChapterNumber === chapter.chapter_number
|
||||
? 'border-indigo-300 bg-indigo-50 shadow-md'
|
||||
: 'border-gray-200 hover:border-indigo-200 hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 pt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
:disabled="isChapterCompleted(chapter.chapter_number)"
|
||||
:checked="selectedForDeletion.includes(chapter.chapter_number)"
|
||||
@click.stop="toggleSelection(chapter.chapter_number)"
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0',
|
||||
isChapterCompleted(chapter.chapter_number)
|
||||
? 'bg-green-500 text-white'
|
||||
: isChapterGenerating(chapter.chapter_number) || isChapterEvaluating(chapter.chapter_number) || isChapterSelecting(chapter.chapter_number)
|
||||
? 'bg-blue-500 text-white animate-pulse'
|
||||
: isChapterFailed(chapter.chapter_number)
|
||||
? 'bg-red-500 text-white'
|
||||
: selectedChapterNumber === chapter.chapter_number
|
||||
? 'bg-indigo-500 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
]"
|
||||
>
|
||||
<svg v-if="isChapterCompleted(chapter.chapter_number)" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg v-else-if="isChapterGenerating(chapter.chapter_number) || isChapterSelecting(chapter.chapter_number)" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg v-else-if="isChapterEvaluating(chapter.chapter_number)" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a6 6 0 00-6 6v3.586l-1.707 1.707A1 1 0 003 15v1a1 1 0 001 1h12a1 1 0 001-1v-1a1 1 0 00-.293-.707L16 11.586V8a6 6 0 00-6-6zM8.05 17a2 2 0 103.9 0H8.05z"></path>
|
||||
</svg>
|
||||
<svg v-else-if="isChapterFailed(chapter.chapter_number)" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span v-else>{{ chapter.chapter_number }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Tooltip :text="chapter.title">
|
||||
<h4 class="font-semibold text-gray-900 text-sm mb-1 line-clamp-1">{{ chapter.title }}</h4>
|
||||
</Tooltip>
|
||||
<Tooltip :text="chapter.summary">
|
||||
<p class="text-xs text-gray-600 line-clamp-2 leading-relaxed">{{ chapter.summary }}</p>
|
||||
</Tooltip>
|
||||
|
||||
<!-- 章节状态 -->
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span v-if="isChapterCompleted(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
已完成
|
||||
</span>
|
||||
<span v-else-if="isChapterGenerating(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 animate-pulse">
|
||||
生成中...
|
||||
</span>
|
||||
<span v-else-if="isChapterSelecting(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 animate-pulse">
|
||||
选择中...
|
||||
</span>
|
||||
<span v-else-if="isChapterEvaluating(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 animate-pulse">
|
||||
评审中...
|
||||
</span>
|
||||
<span v-else-if="isChapterFailed(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
生成失败
|
||||
</span>
|
||||
<span v-else-if="hasChapterInProgress(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
|
||||
待选择版本
|
||||
</span>
|
||||
<span v-else class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
||||
未开始
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 章节操作按钮 -->
|
||||
<div class="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<button
|
||||
v-if="!isChapterCompleted(chapter.chapter_number)"
|
||||
@click.stop="$emit('editChapter', chapter)"
|
||||
class="p-1.5 text-gray-500 hover:bg-gray-100 rounded-md transition-colors"
|
||||
title="编辑大纲"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path>
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="canGenerateChapter(chapter.chapter_number) || isChapterFailed(chapter.chapter_number) || hasChapterInProgress(chapter.chapter_number)"
|
||||
@click.stop="confirmGenerateChapter(chapter.chapter_number)"
|
||||
:disabled="generatingChapter === chapter.chapter_number || isChapterGenerating(chapter.chapter_number)"
|
||||
class="p-1.5 text-indigo-600 hover:bg-indigo-100 rounded-md transition-colors disabled:opacity-50"
|
||||
:title="isChapterCompleted(chapter.chapter_number) ? '重新生成' : isChapterFailed(chapter.chapter_number) ? '重试' : hasChapterInProgress(chapter.chapter_number) ? '重新生成版本' : '开始创作'"
|
||||
>
|
||||
<svg v-if="generatingChapter === chapter.chapter_number || isChapterGenerating(chapter.chapter_number)" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Batch delete replaces the single delete button -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-gray-500">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4zM18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9z"></path>
|
||||
</svg>
|
||||
<p>暂无章节大纲</p>
|
||||
</div>
|
||||
<div v-if="selectedForDeletion.length > 0" class="mt-4">
|
||||
<button
|
||||
@click="handleDeleteSelected"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>删除选中的 {{ selectedForDeletion.length }} 章</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="$emit('generateOutline')"
|
||||
:disabled="props.isGeneratingOutline"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="props.isGeneratingOutline" class="w-5 h-5 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
|
||||
</svg>
|
||||
<span>{{ props.isGeneratingOutline ? '生成中...' : '生成后续大纲' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { globalAlert } from '@/composables/useAlert'
|
||||
import type { NovelProject } from '@/api/novel'
|
||||
import Tooltip from '@/components/Tooltip.vue'
|
||||
|
||||
interface Props {
|
||||
project: NovelProject
|
||||
sidebarOpen: boolean
|
||||
selectedChapterNumber: number | null
|
||||
generatingChapter: number | null
|
||||
evaluatingChapter: number | null
|
||||
isGeneratingOutline: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits(['closeSidebar', 'selectChapter', 'generateChapter', 'editChapter', 'deleteChapter', 'generateOutline'])
|
||||
|
||||
const selectedForDeletion = ref<number[]>([])
|
||||
const listContainer = ref<HTMLElement | null>(null)
|
||||
const chapterRefs = ref<Record<number, HTMLElement | null>>({})
|
||||
|
||||
const characterCount = computed(() => {
|
||||
return props.project?.blueprint?.characters?.length || 0
|
||||
})
|
||||
|
||||
const relationshipCount = computed(() => {
|
||||
return props.project?.blueprint?.relationships?.length || 0
|
||||
})
|
||||
|
||||
const lastChapterNumber = computed(() => {
|
||||
if (!props.project?.blueprint?.chapter_outline || props.project.blueprint.chapter_outline.length === 0) {
|
||||
return null
|
||||
}
|
||||
return Math.max(...props.project.blueprint.chapter_outline.map(ch => ch.chapter_number))
|
||||
})
|
||||
|
||||
const totalChapters = computed(() => {
|
||||
return props.project?.blueprint?.chapter_outline?.length || 0
|
||||
})
|
||||
|
||||
const hasIncompleteChapters = computed(() => {
|
||||
if (!props.project?.blueprint?.chapter_outline) return false
|
||||
return props.project.blueprint.chapter_outline.some(ch => !isChapterCompleted(ch.chapter_number))
|
||||
})
|
||||
|
||||
function toggleSelection(chapterNumber: number) {
|
||||
if (isChapterCompleted(chapterNumber)) return
|
||||
const index = selectedForDeletion.value.indexOf(chapterNumber)
|
||||
if (index > -1) {
|
||||
selectedForDeletion.value.splice(index, 1)
|
||||
} else {
|
||||
selectedForDeletion.value.push(chapterNumber)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteSelected() {
|
||||
if (selectedForDeletion.value.length === 0) return
|
||||
|
||||
const sortedSelection = [...selectedForDeletion.value].sort((a, b) => a - b)
|
||||
|
||||
if (!lastChapterNumber.value || !sortedSelection.includes(lastChapterNumber.value)) {
|
||||
alert('批量删除必须包含最后一章。')
|
||||
return
|
||||
}
|
||||
|
||||
const isContinuous = sortedSelection.every((num, i) => {
|
||||
return i === 0 || num === sortedSelection[i - 1] + 1
|
||||
})
|
||||
if (!isContinuous) {
|
||||
alert('只能删除连续的章节块。')
|
||||
return
|
||||
}
|
||||
|
||||
emit('deleteChapter', sortedSelection)
|
||||
selectedForDeletion.value = []
|
||||
}
|
||||
|
||||
async function confirmGenerateChapter(chapterNumber: number) {
|
||||
const confirmed = await globalAlert.showConfirm('重新生成会覆盖当前章节的生成结果,确定继续吗?', '重新生成确认')
|
||||
if (confirmed) {
|
||||
emit('generateChapter', chapterNumber)
|
||||
}
|
||||
}
|
||||
|
||||
function setChapterRef(chapterNumber: number, el: Element | ComponentPublicInstance | null) {
|
||||
if (!el) {
|
||||
delete chapterRefs.value[chapterNumber]
|
||||
return
|
||||
}
|
||||
|
||||
const element = el instanceof Element ? el : (el.$el instanceof Element ? el.$el : null)
|
||||
|
||||
if (element) {
|
||||
chapterRefs.value[chapterNumber] = element as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToFirstIncompleteChapter = async () => {
|
||||
if (!props.project?.blueprint?.chapter_outline) return
|
||||
const sorted = [...props.project.blueprint.chapter_outline].sort((a, b) => a.chapter_number - b.chapter_number)
|
||||
const target = sorted.find(chapter => !isChapterCompleted(chapter.chapter_number))
|
||||
if (!target) return
|
||||
await nextTick()
|
||||
const element = chapterRefs.value[target.chapter_number]
|
||||
if (!element) return
|
||||
const container = listContainer.value
|
||||
if (container) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||
} else {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}
|
||||
|
||||
// 章节状态检查
|
||||
const isChapterCompleted = (chapterNumber: number) => {
|
||||
if (!props.project?.chapters) return false
|
||||
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
return chapter && chapter.generation_status === 'successful'
|
||||
}
|
||||
|
||||
const hasChapterInProgress = (chapterNumber: number) => {
|
||||
if (!props.project?.chapters) return false
|
||||
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
return chapter && chapter.generation_status === 'waiting_for_confirm'
|
||||
}
|
||||
|
||||
const isChapterGenerating = (chapterNumber: number) => {
|
||||
if (!props.project?.chapters) return false
|
||||
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
return chapter && chapter.generation_status === 'generating'
|
||||
}
|
||||
|
||||
const isChapterEvaluating = (chapterNumber: number) => {
|
||||
if (!props.project?.chapters) return false
|
||||
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
return chapter && chapter.generation_status === 'evaluating'
|
||||
}
|
||||
|
||||
const isChapterFailed = (chapterNumber: number) => {
|
||||
if (!props.project?.chapters) return false
|
||||
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
return chapter && chapter.generation_status === 'failed'
|
||||
}
|
||||
|
||||
const isChapterSelecting = (chapterNumber: number) => {
|
||||
if (!props.project?.chapters) return false
|
||||
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
return chapter && chapter.generation_status === 'selecting'
|
||||
}
|
||||
|
||||
const canGenerateChapter = (chapterNumber: number) => {
|
||||
if (!props.project?.blueprint?.chapter_outline) return false
|
||||
|
||||
const outlines = props.project.blueprint.chapter_outline.sort((a, b) => a.chapter_number - b.chapter_number)
|
||||
|
||||
for (const outline of outlines) {
|
||||
if (outline.chapter_number >= chapterNumber) break
|
||||
|
||||
const chapter = props.project?.chapters.find(ch => ch.chapter_number === outline.chapter_number)
|
||||
if (!chapter || chapter.generation_status !== 'successful') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const currentChapter = props.project?.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
if (currentChapter && currentChapter.generation_status === 'successful') {
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[80vh] overflow-hidden">
|
||||
<!-- 弹窗头部 -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900">版本详情</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
版本 {{ detailVersionIndex + 1 }}
|
||||
<span class="text-gray-400">•</span>
|
||||
{{ version?.style || '标准' }}风格
|
||||
<span class="text-gray-400">•</span>
|
||||
约 {{ Math.round(cleanVersionContent(version?.content || '').length / 100) * 100 }} 字
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗内容 -->
|
||||
<div class="p-6 overflow-y-auto max-h-[60vh]">
|
||||
<div class="prose max-w-none">
|
||||
<div class="whitespace-pre-wrap text-gray-700 leading-relaxed">
|
||||
{{ cleanVersionContent(version?.content || '') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗底部操作按钮 -->
|
||||
<div class="flex items-center justify-between p-6 border-t border-gray-200 bg-gray-50">
|
||||
<div class="text-sm text-gray-500">
|
||||
<span v-if="isCurrent" class="inline-flex items-center px-2 py-1 rounded-full bg-green-100 text-green-800 font-medium">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
当前选中版本
|
||||
</span>
|
||||
<span v-else class="text-gray-400">未选中版本</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
v-if="!isCurrent"
|
||||
@click="$emit('selectVersion')"
|
||||
class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
选择此版本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChapterVersion } from '@/api/novel'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
detailVersionIndex: number
|
||||
version: ChapterVersion | null
|
||||
isCurrent: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits(['close', 'selectVersion'])
|
||||
|
||||
const cleanVersionContent = (content: string): string => {
|
||||
if (!content) return ''
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed && typeof parsed === 'object' && parsed.content) {
|
||||
content = parsed.content
|
||||
}
|
||||
} catch (error) {
|
||||
// not a json
|
||||
}
|
||||
let cleaned = content.replace(/^"|"$/g, '')
|
||||
cleaned = cleaned.replace(/\\n/g, '\n')
|
||||
cleaned = cleaned.replace(/\\"/g, '"')
|
||||
cleaned = cleaned.replace(/\\t/g, '\t')
|
||||
cleaned = cleaned.replace(/\\\\/g, '\\')
|
||||
return cleaned
|
||||
}
|
||||
</script>
|
||||
391
frontend/src/components/writing-desk/WDWorkspace.vue
Normal file
391
frontend/src/components/writing-desk/WDWorkspace.vue
Normal file
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<div class="flex-1 min-w-0 h-full">
|
||||
<div class="bg-white rounded-2xl shadow-lg border border-gray-100 h-full flex flex-col">
|
||||
<!-- 章节工作区头部 -->
|
||||
<div v-if="selectedChapterNumber" class="border-b border-gray-100 p-6 flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h2 class="text-xl font-bold text-gray-900">第{{ selectedChapterNumber }}章</h2>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
isChapterCompleted(selectedChapterNumber)
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
]"
|
||||
>
|
||||
{{ isChapterCompleted(selectedChapterNumber) ? '已完成' : '未完成' }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-lg text-gray-700 mb-1">{{ selectedChapterOutline?.title || '未知标题' }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ selectedChapterOutline?.summary || '暂无章节描述' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="isChapterCompleted(selectedChapterNumber)"
|
||||
@click="openEditModal"
|
||||
class="px-4 py-2 bg-green-600 text-white hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
|
||||
</svg>
|
||||
手动编辑
|
||||
</button>
|
||||
<button
|
||||
@click="confirmRegenerateChapter"
|
||||
:disabled="generatingChapter === selectedChapterNumber"
|
||||
class="px-4 py-2 bg-indigo-600 text-white hover:bg-indigo-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
<svg v-if="generatingChapter === selectedChapterNumber" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ generatingChapter === selectedChapterNumber ? '生成中...' : '重新生成' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 章节内容展示区 -->
|
||||
<div class="flex-1 p-6 overflow-y-auto">
|
||||
<component
|
||||
:is="currentComponent"
|
||||
v-bind="currentComponentProps"
|
||||
@hideVersionSelector="$emit('hideVersionSelector')"
|
||||
@update:selectedVersionIndex="$emit('update:selectedVersionIndex', $event)"
|
||||
@showVersionDetail="$emit('showVersionDetail', $event)"
|
||||
@confirmVersionSelection="$emit('confirmVersionSelection')"
|
||||
@generateChapter="$emit('generateChapter', $event)"
|
||||
@showVersionSelector="$emit('showVersionSelector')"
|
||||
@regenerateChapter="$emit('regenerateChapter')"
|
||||
@evaluateChapter="$emit('evaluateChapter')"
|
||||
@showEvaluationDetail="$emit('showEvaluationDetail')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑章节内容模态框 -->
|
||||
<div v-if="showEditModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full h-full flex flex-col">
|
||||
<!-- 模态框头部 -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
编辑第{{ selectedChapterNumber }}章内容
|
||||
</h3>
|
||||
<button
|
||||
@click="closeEditModal"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 模态框内容 -->
|
||||
<div class="flex-1 p-6 overflow-hidden">
|
||||
<div class="flex flex-col h-full">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
章节内容
|
||||
</label>
|
||||
<textarea
|
||||
v-model="editingContent"
|
||||
class="flex-1 w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
|
||||
placeholder="请输入章节内容..."
|
||||
:disabled="isSaving"
|
||||
></textarea>
|
||||
<div class="text-sm text-gray-500 mt-2">
|
||||
字数统计: {{ editingContent.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模态框底部 -->
|
||||
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<button
|
||||
@click="closeEditModal"
|
||||
:disabled="isSaving"
|
||||
class="px-4 py-2 text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
@click="saveEditedContent"
|
||||
:disabled="isSaving || !editingContent.trim()"
|
||||
class="px-4 py-2 bg-indigo-600 text-white hover:bg-indigo-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<svg v-if="isSaving" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ isSaving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||
import { globalAlert } from '@/composables/useAlert'
|
||||
import type { Chapter, ChapterOutline, ChapterGenerationResponse, ChapterVersion, NovelProject } from '@/api/novel'
|
||||
import WorkspaceInitial from './workspace/WorkspaceInitial.vue'
|
||||
import ChapterGenerating from './workspace/ChapterGenerating.vue'
|
||||
import VersionSelector from './workspace/VersionSelector.vue'
|
||||
import ChapterContent from './workspace/ChapterContent.vue'
|
||||
import ChapterFailed from './workspace/ChapterFailed.vue'
|
||||
import ChapterEmpty from './workspace/ChapterEmpty.vue'
|
||||
|
||||
interface Props {
|
||||
project: NovelProject | null
|
||||
selectedChapterNumber: number | null
|
||||
generatingChapter: number | null
|
||||
evaluatingChapter: number | null
|
||||
showVersionSelector: boolean
|
||||
chapterGenerationResult: ChapterGenerationResponse | null
|
||||
selectedVersionIndex: number
|
||||
availableVersions: ChapterVersion[]
|
||||
isSelectingVersion?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits([
|
||||
'regenerateChapter',
|
||||
'evaluateChapter',
|
||||
'hideVersionSelector',
|
||||
'update:selectedVersionIndex',
|
||||
'showVersionDetail',
|
||||
'confirmVersionSelection',
|
||||
'generateChapter',
|
||||
'showVersionSelector',
|
||||
'showEvaluationDetail',
|
||||
'fetchChapterStatus',
|
||||
'editChapter'
|
||||
])
|
||||
|
||||
const confirmRegenerateChapter = async () => {
|
||||
const confirmed = await globalAlert.showConfirm('重新生成会覆盖当前章节的现有内容,确定继续吗?', '重新生成确认')
|
||||
if (confirmed) {
|
||||
emit('regenerateChapter')
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑模态框状态
|
||||
const showEditModal = ref(false)
|
||||
const editingContent = ref('')
|
||||
const isSaving = ref(false)
|
||||
|
||||
// 清理版本内容的辅助函数
|
||||
const cleanVersionContent = (content: string): string => {
|
||||
if (!content) return ''
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed && typeof parsed === 'object' && parsed.content) {
|
||||
content = parsed.content
|
||||
}
|
||||
} catch (error) {
|
||||
// not a json
|
||||
}
|
||||
let cleaned = content.replace(/^"|"$/g, '')
|
||||
cleaned = cleaned.replace(/\\n/g, '\n')
|
||||
cleaned = cleaned.replace(/\\"/g, '"')
|
||||
cleaned = cleaned.replace(/\\t/g, '\t')
|
||||
cleaned = cleaned.replace(/\\\\/g, '\\')
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const openEditModal = () => {
|
||||
if (selectedChapter.value?.content) {
|
||||
editingContent.value = cleanVersionContent(selectedChapter.value.content)
|
||||
showEditModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingContent.value = ''
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
const saveEditedContent = async () => {
|
||||
if (!props.selectedChapterNumber || !editingContent.value.trim()) return
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
emit('editChapter', {
|
||||
chapterNumber: props.selectedChapterNumber,
|
||||
content: editingContent.value
|
||||
})
|
||||
closeEditModal()
|
||||
} catch (error) {
|
||||
console.error('保存章节内容失败:', error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectedChapter = computed(() => {
|
||||
if (!props.project || props.selectedChapterNumber === null) return null
|
||||
return props.project.chapters.find(ch => ch.chapter_number === props.selectedChapterNumber) || null
|
||||
})
|
||||
|
||||
const selectedChapterOutline = computed(() => {
|
||||
if (!props.project?.blueprint?.chapter_outline || props.selectedChapterNumber === null) return null
|
||||
return props.project.blueprint.chapter_outline.find(ch => ch.chapter_number === props.selectedChapterNumber) || null
|
||||
})
|
||||
|
||||
const isChapterCompleted = (chapterNumber: number) => {
|
||||
if (!props.project?.chapters) return false
|
||||
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
return chapter && chapter.generation_status === 'successful'
|
||||
}
|
||||
|
||||
const isChapterGenerating = (chapterNumber: number) => {
|
||||
if (!props.project?.chapters) return false
|
||||
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
return chapter && chapter.generation_status === 'generating'
|
||||
}
|
||||
|
||||
const isChapterFailed = (chapterNumber: number) => {
|
||||
if (!props.project?.chapters) return false
|
||||
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
return chapter && chapter.generation_status === 'failed'
|
||||
}
|
||||
|
||||
const isChapterEvaluationFailed = (chapterNumber: number) => {
|
||||
if (!props.project?.chapters) return false
|
||||
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
return chapter && chapter.generation_status === 'evaluation_failed'
|
||||
}
|
||||
|
||||
const canGenerateChapter = (chapterNumber: number | null) => {
|
||||
if (chapterNumber === null || !props.project?.blueprint?.chapter_outline) return false
|
||||
|
||||
const outlines = props.project.blueprint.chapter_outline.sort((a, b) => a.chapter_number - b.chapter_number)
|
||||
|
||||
for (const outline of outlines) {
|
||||
if (outline.chapter_number >= chapterNumber) break
|
||||
|
||||
const chapter = props.project?.chapters.find(ch => ch.chapter_number === outline.chapter_number)
|
||||
if (!chapter || chapter.generation_status !== 'successful') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const currentChapter = props.project?.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
if (currentChapter && currentChapter.generation_status === 'successful') {
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const currentComponent = computed(() => {
|
||||
if (!props.selectedChapterNumber) {
|
||||
return WorkspaceInitial
|
||||
}
|
||||
|
||||
const status = selectedChapter.value?.generation_status
|
||||
if (status === 'generating' || status === 'evaluating' || status === 'selecting') {
|
||||
return ChapterGenerating // Use a generic "in-progress" component
|
||||
}
|
||||
|
||||
if (status === 'waiting_for_confirm' || status === 'evaluation_failed') {
|
||||
return VersionSelector
|
||||
}
|
||||
|
||||
if (selectedChapter.value?.content) {
|
||||
return ChapterContent
|
||||
}
|
||||
if (isChapterFailed(props.selectedChapterNumber)) {
|
||||
return ChapterFailed
|
||||
}
|
||||
return ChapterEmpty
|
||||
})
|
||||
|
||||
// Polling for chapter status updates
|
||||
const pollingTimer = ref<number | null>(null)
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollingTimer.value = window.setInterval(() => {
|
||||
emit('fetchChapterStatus')
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollingTimer.value) {
|
||||
clearInterval(pollingTimer.value)
|
||||
pollingTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [selectedChapter.value?.generation_status, props.evaluatingChapter, props.isSelectingVersion, props.selectedChapterNumber],
|
||||
([status, evaluating, selecting, chapterNumber]) => {
|
||||
if (chapterNumber === null) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
|
||||
const isEvaluating = evaluating === chapterNumber
|
||||
// Poll when generating, evaluating, or selecting a version
|
||||
const needsPolling = status === 'generating' || status === 'evaluating' || status === 'selecting'
|
||||
|
||||
if (needsPolling) {
|
||||
startPolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
const currentComponentProps = computed(() => {
|
||||
if (!props.selectedChapterNumber) {
|
||||
return {}
|
||||
}
|
||||
const status = selectedChapter.value?.generation_status
|
||||
if (status === 'generating' || status === 'evaluating' || status === 'selecting') {
|
||||
return {
|
||||
chapterNumber: props.selectedChapterNumber,
|
||||
status: status
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'waiting_for_confirm' || status === 'evaluation_failed') {
|
||||
return {
|
||||
selectedChapter: selectedChapter.value,
|
||||
chapterGenerationResult: props.chapterGenerationResult,
|
||||
availableVersions: props.availableVersions,
|
||||
selectedVersionIndex: props.selectedVersionIndex,
|
||||
isSelectingVersion: props.isSelectingVersion,
|
||||
evaluatingChapter: props.evaluatingChapter,
|
||||
isEvaluationFailed: isChapterEvaluationFailed(props.selectedChapterNumber)
|
||||
}
|
||||
}
|
||||
if (selectedChapter.value?.content) {
|
||||
return { selectedChapter: selectedChapter.value }
|
||||
}
|
||||
if (isChapterFailed(props.selectedChapterNumber)) {
|
||||
return {
|
||||
chapterNumber: props.selectedChapterNumber,
|
||||
generatingChapter: props.generatingChapter
|
||||
}
|
||||
}
|
||||
return {
|
||||
chapterNumber: props.selectedChapterNumber,
|
||||
generatingChapter: props.generatingChapter,
|
||||
canGenerate: canGenerateChapter(props.selectedChapterNumber)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="bg-green-50 border border-green-200 rounded-xl p-4 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-green-800">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="font-medium">这个章节已经完成</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="selectedChapter.versions && selectedChapter.versions.length > 0"
|
||||
@click="$emit('showVersionSelector', true)"
|
||||
class="text-green-700 hover:text-green-800 text-sm font-medium flex items-center gap-1"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
查看所有版本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-xl p-6">
|
||||
<div class="flex items-center justify-between mb-4 gap-3">
|
||||
<h4 class="font-semibold text-gray-800">章节内容</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-sm text-gray-500">
|
||||
约 {{ Math.round(cleanVersionContent(selectedChapter.content || '').length / 100) * 100 }} 字
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg border transition-colors duration-200"
|
||||
:class="selectedChapter.content ? 'border-indigo-200 text-indigo-600 hover:bg-indigo-50' : 'border-gray-200 text-gray-400 cursor-not-allowed'"
|
||||
:disabled="!selectedChapter.content"
|
||||
@click="exportChapterAsTxt(selectedChapter)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v16h16V4m-4 4l-4-4-4 4m4-4v12" />
|
||||
</svg>
|
||||
导出TXT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prose max-w-none">
|
||||
<div class="whitespace-pre-wrap text-gray-700 leading-relaxed">{{ cleanVersionContent(selectedChapter.content || '') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Chapter } from '@/api/novel'
|
||||
|
||||
interface Props {
|
||||
selectedChapter: Chapter
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits(['showVersionSelector'])
|
||||
|
||||
const cleanVersionContent = (content: string): string => {
|
||||
if (!content) return ''
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed && typeof parsed === 'object' && parsed.content) {
|
||||
content = parsed.content
|
||||
}
|
||||
} catch (error) {
|
||||
// not a json
|
||||
}
|
||||
let cleaned = content.replace(/^"|"$/g, '')
|
||||
cleaned = cleaned.replace(/\\n/g, '\n')
|
||||
cleaned = cleaned.replace(/\\"/g, '"')
|
||||
cleaned = cleaned.replace(/\\t/g, '\t')
|
||||
cleaned = cleaned.replace(/\\\\/g, '\\')
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const sanitizeFileName = (name: string): string => {
|
||||
return name.replace(/[\\/:*?"<>|]/g, '_')
|
||||
}
|
||||
|
||||
const exportChapterAsTxt = (chapter?: Chapter | null) => {
|
||||
if (!chapter) return
|
||||
|
||||
const title = chapter.title?.trim() || `第${chapter.chapter_number}章`
|
||||
const safeTitle = sanitizeFileName(title) || `chapter-${chapter.chapter_number}`
|
||||
const content = cleanVersionContent(chapter.content || '')
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${safeTitle}.txt`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-2">开始创作</h3>
|
||||
|
||||
<div v-if="canGenerate">
|
||||
<p class="text-gray-500 mb-4">点击"开始创作"按钮生成这个章节</p>
|
||||
<button
|
||||
@click="$emit('generateChapter', chapterNumber)"
|
||||
:disabled="generatingChapter === chapterNumber"
|
||||
class="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<svg v-if="generatingChapter === chapterNumber" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
|
||||
</svg>
|
||||
{{ generatingChapter === chapterNumber ? '生成中...' : '开始创作' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p class="text-gray-500 mb-4">请先完成前面的章节,才能生成此章节</p>
|
||||
<div class="inline-flex items-center px-4 py-2 bg-gray-100 text-gray-600 rounded-lg">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
按顺序生成
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
chapterNumber: number
|
||||
generatingChapter: number | null
|
||||
canGenerate: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits(['generateChapter'])
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="w-24 h-24 bg-red-100 rounded-full mx-auto flex items-center justify-center mb-6 shadow-lg">
|
||||
<svg class="w-12 h-12 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-gray-800 mb-3">第{{ chapterNumber }}章生成失败</h3>
|
||||
<p class="text-gray-600 mb-6">很抱歉,AI在生成这个章节时遇到了问题。请点击重试按钮重新生成。</p>
|
||||
<button
|
||||
@click="$emit('generateChapter', chapterNumber)"
|
||||
:disabled="generatingChapter === chapterNumber"
|
||||
class="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<svg v-if="generatingChapter === chapterNumber" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ generatingChapter === chapterNumber ? '重试中...' : '重试生成' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
chapterNumber: number
|
||||
generatingChapter: number | null
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits(['generateChapter'])
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="relative mb-8">
|
||||
<div class="w-24 h-24 bg-gradient-to-r from-blue-400 to-indigo-500 rounded-full mx-auto flex items-center justify-center animate-pulse shadow-lg">
|
||||
<svg class="w-12 h-12 text-white animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute inset-0 w-24 h-24 bg-gradient-to-r from-blue-400 to-indigo-500 rounded-full mx-auto animate-ping opacity-20"></div>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-gray-800 mb-3">{{ statusText.title }}</h3>
|
||||
<div class="space-y-2 text-gray-600 mb-6">
|
||||
<p class="animate-pulse">{{ statusText.line1 }}</p>
|
||||
<p class="animate-pulse" style="animation-delay: 0.5s">{{ statusText.line2 }}</p>
|
||||
<p class="animate-pulse" style="animation-delay: 1s">🎨 描绘生动场景...</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<p class="text-blue-800 text-sm">
|
||||
<svg class="w-4 h-4 inline mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
生成过程通常需要2分钟以上,请耐心等待。您可以随时离开此页面,生成完成后再回来查看。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Chapter } from '@/api/novel'
|
||||
|
||||
interface Props {
|
||||
chapterNumber: number | null
|
||||
status: Chapter['generation_status'] | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'generating':
|
||||
return {
|
||||
title: `AI 正在为您创作第${props.chapterNumber}章`,
|
||||
line1: '✨ 构思情节发展...',
|
||||
line2: '📝 编织精彩对话...'
|
||||
}
|
||||
case 'evaluating':
|
||||
return {
|
||||
title: `AI 正在评审第${props.chapterNumber}章的多个版本`,
|
||||
line1: '🧐 分析故事结构...',
|
||||
line2: '⚖️ 比较版本优劣...'
|
||||
}
|
||||
case 'selecting':
|
||||
return {
|
||||
title: `正在确认第${props.chapterNumber}章的最终版本`,
|
||||
line1: '💾 保存您的选择...',
|
||||
line2: '✍️ 生成最终摘要...'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
title: '请稍候...',
|
||||
line1: '正在处理您的请求...',
|
||||
line2: '...'
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- AI 评审提示 -->
|
||||
<div v-if="isEvaluationFailed" class="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-red-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-red-900">AI 评审失败</h4>
|
||||
<p class="text-sm text-red-700">AI 评审时遇到问题,请重试。</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('evaluateChapter')"
|
||||
:disabled="evaluatingChapter === selectedChapter?.chapter_number"
|
||||
class="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
<svg v-if="evaluatingChapter === selectedChapter?.chapter_number" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ evaluatingChapter === selectedChapter?.chapter_number ? '重试中...' : '重新评审' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="selectedChapter?.evaluation" class="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a6 6 0 00-6 6v3.586l-1.707 1.707A1 1 0 003 15v1a1 1 0 001 1h12a1 1 0 001-1v-1a1 1 0 00-.293-.707L16 11.586V8a6 6 0 00-6-6zM8.05 17a2 2 0 103.9 0H8.05z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-purple-900">AI 评审已完成</h4>
|
||||
<p class="text-sm text-purple-700">AI 已对所有版本进行评估,点击查看详细结果。</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="$emit('showEvaluationDetail')" class="px-4 py-2 bg-purple-600 text-white hover:bg-purple-700 rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap">
|
||||
查看 AI 评审
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI消息 (仅对新生成的内容显示) -->
|
||||
<div v-if="chapterGenerationResult?.ai_message" class="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="prose prose-sm max-w-none prose-headings:mt-2 prose-headings:mb-1 prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 text-blue-800"
|
||||
v-html="parseMarkdown(chapterGenerationResult.ai_message)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态提示 -->
|
||||
<div v-if="selectedChapter?.content" class="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div class="flex items-center gap-2 text-amber-800">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="font-medium">您可以查看所有版本并选择不同的版本</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div class="flex items-center gap-2 text-blue-800">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="font-medium">请选择一个版本来完成这个章节</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 版本选择器 -->
|
||||
<div class="bg-gray-50 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="font-semibold text-gray-800">
|
||||
{{ availableVersions.length > 1 ? '选择版本' : '生成内容' }}
|
||||
<span class="text-sm font-normal text-gray-600 ml-2">({{ availableVersions.length }} 个版本)</span>
|
||||
</h4>
|
||||
<!-- <button
|
||||
@click="$emit('confirmVersionSelection')"
|
||||
:disabled="!availableVersions?.[selectedVersionIndex]?.content || isCurrentVersion(selectedVersionIndex) || isSelectingVersion"
|
||||
class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<svg v-if="isSelectingVersion" class="w-5 h-5 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span v-else>
|
||||
{{ isCurrentVersion(selectedVersionIndex) ? '当前版本' : '确认选择此版本' }}
|
||||
</span>
|
||||
</button> -->
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
v-for="(version, index) in availableVersions"
|
||||
:key="index"
|
||||
@click="$emit('update:selectedVersionIndex', index)"
|
||||
:class="[
|
||||
'cursor-pointer border-2 rounded-lg p-4 transition-all duration-200',
|
||||
selectedVersionIndex === index
|
||||
? 'border-indigo-300 bg-indigo-50 shadow-md'
|
||||
: isCurrentVersion(index)
|
||||
? 'border-green-300 bg-green-50'
|
||||
: 'border-gray-200 hover:border-indigo-200 hover:bg-white'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
|
||||
selectedVersionIndex === index
|
||||
? 'bg-indigo-500 text-white'
|
||||
: isCurrentVersion(index)
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-300 text-gray-600'
|
||||
]"
|
||||
>
|
||||
<svg v-if="isCurrentVersion(index)" class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-700 line-clamp-3">
|
||||
{{ cleanVersionContent(version.content).substring(0, 150) }}...
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>约 {{ Math.round(cleanVersionContent(version.content).length / 100) * 100 }} 字</span>
|
||||
<span>•</span>
|
||||
<span>{{ version.style || '标准' }}风格</span>
|
||||
<span v-if="isCurrentVersion(index)" class="text-green-600 font-medium">• 当前选中</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button
|
||||
@click.stop="$emit('showVersionDetail', index)"
|
||||
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-1"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end items-center gap-4">
|
||||
<button
|
||||
@click="$emit('evaluateChapter')"
|
||||
:disabled="evaluatingChapter === selectedChapter?.chapter_number || availableVersions.length < 2"
|
||||
class="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<svg v-if="evaluatingChapter === selectedChapter?.chapter_number" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ evaluatingChapter === selectedChapter?.chapter_number ? '评审中...' : 'AI 评审' }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('confirmVersionSelection')"
|
||||
:disabled="!availableVersions?.[selectedVersionIndex]?.content || isCurrentVersion(selectedVersionIndex) || isSelectingVersion"
|
||||
class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<svg v-if="isSelectingVersion" class="w-5 h-5 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span v-else>
|
||||
{{ isCurrentVersion(selectedVersionIndex) ? '当前版本' : '确认选择此版本' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Chapter, ChapterGenerationResponse, ChapterVersion } from '@/api/novel'
|
||||
|
||||
interface Props {
|
||||
selectedChapter: Chapter | null
|
||||
chapterGenerationResult: ChapterGenerationResponse | null
|
||||
availableVersions: ChapterVersion[]
|
||||
selectedVersionIndex: number
|
||||
evaluatingChapter: number | null
|
||||
isSelectingVersion?: boolean
|
||||
isEvaluationFailed?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits(['hideVersionSelector', 'update:selectedVersionIndex', 'showVersionDetail', 'confirmVersionSelection', 'evaluateChapter', 'showEvaluationDetail'])
|
||||
|
||||
|
||||
const isCurrentVersion = (versionIndex: number) => {
|
||||
if (!props.selectedChapter?.content || !props.availableVersions?.[versionIndex]?.content) return false
|
||||
const cleanCurrentContent = cleanVersionContent(props.selectedChapter.content)
|
||||
const cleanVersionContentStr = cleanVersionContent(props.availableVersions[versionIndex].content)
|
||||
return cleanCurrentContent === cleanVersionContentStr
|
||||
}
|
||||
|
||||
const cleanVersionContent = (content: string): string => {
|
||||
if (!content) return ''
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed && typeof parsed === 'object' && parsed.content) {
|
||||
content = parsed.content
|
||||
}
|
||||
} catch (error) {
|
||||
// not a json
|
||||
}
|
||||
let cleaned = content.replace(/^"|"$/g, '')
|
||||
cleaned = cleaned.replace(/\\n/g, '\n')
|
||||
cleaned = cleaned.replace(/\\"/g, '"')
|
||||
cleaned = cleaned.replace(/\\t/g, '\t')
|
||||
cleaned = cleaned.replace(/\\\\/g, '\\')
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const parseMarkdown = (text: string): string => {
|
||||
if (!text) return ''
|
||||
let parsed = text
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\\\/g, '\\')
|
||||
parsed = parsed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
parsed = parsed.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
|
||||
parsed = parsed.replace(/^([A-Z])\)\s*\*\*(.*?)\*\*(.*)/gm, '<div class="mb-2"><span class="inline-flex items-center justify-center w-6 h-6 bg-indigo-100 text-indigo-600 text-sm font-bold rounded-full mr-2">$1</span><strong>$2</strong>$3</div>')
|
||||
parsed = parsed.replace(/\n/g, '<br>')
|
||||
parsed = parsed.replace(/(<br\s*\/?>\s*){2,}/g, '</p><p class="mt-2">')
|
||||
if (!parsed.includes('<p>')) {
|
||||
parsed = `<p>${parsed}</p>`
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4zM18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-2">选择章节开始创作</h3>
|
||||
<p class="text-gray-500">从左侧章节列表中选择一个章节,开始您的创作之旅</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No script needed for this simple component
|
||||
</script>
|
||||
88
frontend/src/composables/useAlert.ts
Normal file
88
frontend/src/composables/useAlert.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
type AlertType = 'success' | 'error' | 'info' | 'confirmation'
|
||||
|
||||
interface Alert {
|
||||
id: number
|
||||
visible: boolean
|
||||
type: AlertType
|
||||
title: string
|
||||
message: string
|
||||
showCancel: boolean
|
||||
confirmText: string
|
||||
cancelText: string
|
||||
onConfirm: (result: boolean) => void
|
||||
}
|
||||
|
||||
const alerts = ref<Alert[]>([])
|
||||
let alertId = 0
|
||||
|
||||
const closeAlert = (id: number, result: boolean) => {
|
||||
const index = alerts.value.findIndex((a) => a.id === id)
|
||||
if (index !== -1) {
|
||||
// First, call the onConfirm callback to resolve the promise.
|
||||
alerts.value[index].onConfirm(result)
|
||||
// Then, remove the alert from the array to hide it.
|
||||
alerts.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const showAlert = (
|
||||
message: string,
|
||||
type: AlertType = 'info',
|
||||
title: string = '',
|
||||
options: Partial<Omit<Alert, 'id' | 'visible' | 'message' | 'type' | 'title'>> = {}
|
||||
) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const id = alertId++
|
||||
|
||||
const newAlert: Alert = {
|
||||
id,
|
||||
visible: true,
|
||||
type,
|
||||
title: title || (type === 'success' ? '成功' : type === 'error' ? '错误' : '提示'),
|
||||
message,
|
||||
showCancel: options.showCancel || false,
|
||||
confirmText: options.confirmText || '确定',
|
||||
cancelText: options.cancelText || '取消',
|
||||
// The onConfirm callback is simply the resolve function of the promise.
|
||||
// This breaks the recursive loop.
|
||||
onConfirm: resolve,
|
||||
}
|
||||
alerts.value.push(newAlert)
|
||||
|
||||
// For simple notifications (not confirmation dialogs), auto-close after 3 seconds.
|
||||
if ((type === 'success' || type === 'info') && !newAlert.showCancel) {
|
||||
setTimeout(() => {
|
||||
closeAlert(id, false) // Auto-close and resolve promise with false
|
||||
}, 3000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const showSuccess = (message: string, title: string = '成功') => {
|
||||
return showAlert(message, 'success', title);
|
||||
};
|
||||
|
||||
const showError = (message: string, title: string = '错误') => {
|
||||
return showAlert(message, 'error', title);
|
||||
};
|
||||
|
||||
const showConfirm = (message: string, title: string = '请确认') => {
|
||||
return showAlert(message, 'confirmation', title, { showCancel: true });
|
||||
};
|
||||
|
||||
export const globalAlert = {
|
||||
alerts,
|
||||
showAlert,
|
||||
closeAlert,
|
||||
showSuccess,
|
||||
showError,
|
||||
showConfirm,
|
||||
}
|
||||
|
||||
export function useAlert() {
|
||||
return {
|
||||
showAlert: globalAlert.showAlert,
|
||||
}
|
||||
}
|
||||
34
frontend/src/main.ts
Normal file
34
frontend/src/main.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import '@fontsource/noto-sans-sc/300.css';
|
||||
import '@fontsource/noto-sans-sc/400.css';
|
||||
import '@fontsource/noto-sans-sc/500.css';
|
||||
import '@fontsource/noto-sans-sc/700.css';
|
||||
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// Handle token from URL
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const token = urlParams.get('token')
|
||||
|
||||
if (token) {
|
||||
const authStore = useAuthStore()
|
||||
authStore.token = token
|
||||
localStorage.setItem('token', token)
|
||||
// Clean the URL
|
||||
window.history.replaceState({}, document.title, "/")
|
||||
authStore.fetchUser()
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
109
frontend/src/router/index.ts
Normal file
109
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import WorkspaceEntry from '../views/WorkspaceEntry.vue'
|
||||
import NovelWorkspace from '../views/NovelWorkspace.vue'
|
||||
import InspirationMode from '../views/InspirationMode.vue'
|
||||
import WritingDesk from '../views/WritingDesk.vue'
|
||||
import NovelDetail from '../views/NovelDetail.vue'
|
||||
import Login from '../views/Login.vue'
|
||||
import Register from '../views/Register.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'workspace-entry',
|
||||
component: WorkspaceEntry,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/workspace',
|
||||
name: 'novel-workspace',
|
||||
component: NovelWorkspace,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/inspiration',
|
||||
name: 'inspiration-mode',
|
||||
component: InspirationMode,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/detail/:id',
|
||||
name: 'novel-detail',
|
||||
component: NovelDetail,
|
||||
props: true,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/novel/:id',
|
||||
name: 'writing-desk',
|
||||
component: WritingDesk,
|
||||
props: true,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: Login,
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: Register,
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: () => import('../views/AdminView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/novel/:id',
|
||||
name: 'admin-novel-detail',
|
||||
component: () => import('../views/AdminNovelDetail.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Attempt to fetch user info if token exists but user info is not loaded
|
||||
if (authStore.token && !authStore.user) {
|
||||
await authStore.fetchUser()
|
||||
}
|
||||
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
||||
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
|
||||
const isAuthenticated = authStore.isAuthenticated
|
||||
const isAdmin = authStore.user?.is_admin
|
||||
|
||||
const mustChangePassword = authStore.user?.is_admin && authStore.mustChangePassword
|
||||
|
||||
if (requiresAuth && !isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (requiresAdmin && !isAdmin) {
|
||||
next('/') // Redirect to a non-admin page if not an admin
|
||||
} else if (isAuthenticated && mustChangePassword) {
|
||||
if (to.name !== 'admin' || to.query.tab !== 'password') {
|
||||
next({ name: 'admin', query: { tab: 'password' } })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
143
frontend/src/stores/auth.ts
Normal file
143
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { API_BASE_URL } from '@/api/novel';
|
||||
|
||||
const API_URL = `${API_BASE_URL}/api/auth`;
|
||||
|
||||
interface AuthOptions {
|
||||
// 是否允许用户自助注册
|
||||
allow_registration: boolean;
|
||||
// 是否启用 Linux.do 登录
|
||||
enable_linuxdo_login: boolean;
|
||||
}
|
||||
|
||||
// Helper function to handle fetch requests and token refreshing
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
const authStore = useAuthStore();
|
||||
const headers = new Headers(options.headers || {});
|
||||
|
||||
if (authStore.token) {
|
||||
headers.set('Authorization', `Bearer ${authStore.token}`);
|
||||
}
|
||||
|
||||
options.headers = headers;
|
||||
const response = await fetch(url, options);
|
||||
|
||||
const refreshedToken = response.headers.get('X-Token-Refresh');
|
||||
if (refreshedToken) {
|
||||
authStore.token = refreshedToken;
|
||||
localStorage.setItem('token', refreshedToken);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
must_change_password: boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('token') || null as string | null,
|
||||
user: null as User | null,
|
||||
authOptions: null as AuthOptions | null,
|
||||
authOptionsLoaded: false,
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state) => !!state.token,
|
||||
allowRegistration: (state) => state.authOptions?.allow_registration ?? true,
|
||||
enableLinuxdoLogin: (state) => state.authOptions?.enable_linuxdo_login ?? false,
|
||||
mustChangePassword: (state) => state.user?.must_change_password ?? false,
|
||||
},
|
||||
actions: {
|
||||
async fetchAuthOptions(force = false) {
|
||||
// 拉取后端认证相关开关,供前端动态渲染
|
||||
if (this.authOptionsLoaded && !force) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/options`);
|
||||
if (!response.ok) {
|
||||
throw new Error('读取认证开关失败');
|
||||
}
|
||||
const data = await response.json() as AuthOptions;
|
||||
this.authOptions = data;
|
||||
} catch (error) {
|
||||
console.error('获取认证配置失败,将使用默认值', error);
|
||||
this.authOptions = {
|
||||
allow_registration: true,
|
||||
enable_linuxdo_login: false,
|
||||
};
|
||||
} finally {
|
||||
this.authOptionsLoaded = true;
|
||||
}
|
||||
},
|
||||
async login(username: string, password: string): Promise<boolean> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('username', username);
|
||||
params.append('password', password);
|
||||
|
||||
const response = await fetchWithAuth(`${API_URL}/token`, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to login');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.token = data.access_token;
|
||||
if (this.token) {
|
||||
localStorage.setItem('token', this.token);
|
||||
}
|
||||
const mustChangePassword = Boolean(data.must_change_password);
|
||||
await this.fetchUser();
|
||||
if (this.user) {
|
||||
this.user.must_change_password = mustChangePassword || this.user.must_change_password;
|
||||
}
|
||||
return mustChangePassword;
|
||||
},
|
||||
// 当前注册流程在 Register.vue 中实现,此处预留方法以兼容旧逻辑
|
||||
async register(payload: { username: string; email: string; password: string; verification_code: string }) {
|
||||
const response = await fetch(`${API_URL}/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const detail = errorData.detail || 'Failed to register';
|
||||
throw new Error(detail);
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
this.token = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
async fetchUser() {
|
||||
if (this.token) {
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_URL}/users/me`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch user');
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
this.user = {
|
||||
id: userData.id,
|
||||
username: userData.username,
|
||||
is_admin: userData.is_admin || false,
|
||||
must_change_password: userData.must_change_password || false,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logout();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
322
frontend/src/stores/novel.ts
Normal file
322
frontend/src/stores/novel.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { NovelProject, NovelProjectSummary, ConverseResponse, BlueprintGenerationResponse, Blueprint, DeleteNovelsResponse, ChapterOutline } from '@/api/novel'
|
||||
import { NovelAPI } from '@/api/novel'
|
||||
|
||||
export const useNovelStore = defineStore('novel', () => {
|
||||
// State
|
||||
const projects = ref<NovelProjectSummary[]>([])
|
||||
const currentProject = ref<NovelProject | null>(null)
|
||||
const currentConversationState = ref<any>({})
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const projectsCount = computed(() => projects.value.length)
|
||||
const hasCurrentProject = computed(() => currentProject.value !== null)
|
||||
|
||||
// Actions
|
||||
async function loadProjects() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
projects.value = await NovelAPI.getAllNovels()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '加载项目失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(title: string, initialPrompt: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const project = await NovelAPI.createNovel(title, initialPrompt)
|
||||
currentProject.value = project
|
||||
currentConversationState.value = {}
|
||||
return project
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '创建项目失败'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject(projectId: string, silent: boolean = false) {
|
||||
if (!silent) {
|
||||
isLoading.value = true
|
||||
}
|
||||
error.value = null
|
||||
try {
|
||||
currentProject.value = await NovelAPI.getNovel(projectId)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '加载项目失败'
|
||||
} finally {
|
||||
if (!silent) {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChapter(chapterNumber: number) {
|
||||
error.value = null
|
||||
try {
|
||||
if (!currentProject.value) {
|
||||
throw new Error('没有当前项目')
|
||||
}
|
||||
const chapter = await NovelAPI.getChapter(currentProject.value.id, chapterNumber)
|
||||
const project = currentProject.value
|
||||
if (!Array.isArray(project.chapters)) {
|
||||
project.chapters = []
|
||||
}
|
||||
const index = project.chapters.findIndex(ch => ch.chapter_number === chapterNumber)
|
||||
if (index >= 0) {
|
||||
project.chapters.splice(index, 1, chapter)
|
||||
} else {
|
||||
project.chapters.push(chapter)
|
||||
}
|
||||
project.chapters.sort((a, b) => a.chapter_number - b.chapter_number)
|
||||
return chapter
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '加载章节失败'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function sendConversation(userInput: any): Promise<ConverseResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (!currentProject.value) {
|
||||
throw new Error('没有当前项目')
|
||||
}
|
||||
const response = await NovelAPI.converseConcept(
|
||||
currentProject.value.id,
|
||||
userInput,
|
||||
currentConversationState.value
|
||||
)
|
||||
currentConversationState.value = response.conversation_state
|
||||
return response
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '对话失败'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBlueprint(): Promise<BlueprintGenerationResponse> {
|
||||
// Generate blueprint from conversation history
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (!currentProject.value) {
|
||||
throw new Error('没有当前项目')
|
||||
}
|
||||
return await NovelAPI.generateBlueprint(currentProject.value.id)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '生成蓝图失败'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBlueprint(blueprint: Blueprint) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (!currentProject.value) {
|
||||
throw new Error('没有当前项目')
|
||||
}
|
||||
if (!blueprint) {
|
||||
throw new Error('缺少蓝图数据')
|
||||
}
|
||||
currentProject.value = await NovelAPI.saveBlueprint(currentProject.value.id, blueprint)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '保存蓝图失败'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generateChapter(chapterNumber: number): Promise<NovelProject> {
|
||||
// 注意:这里不设置全局 isLoading,因为 WritingDesk.vue 有自己的局部加载状态
|
||||
error.value = null
|
||||
try {
|
||||
if (!currentProject.value) {
|
||||
throw new Error('没有当前项目')
|
||||
}
|
||||
const updatedProject = await NovelAPI.generateChapter(currentProject.value.id, chapterNumber)
|
||||
currentProject.value = updatedProject // 更新 store 中的当前项目
|
||||
return updatedProject
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '生成章节失败'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluateChapter(chapterNumber: number): Promise<NovelProject> {
|
||||
error.value = null
|
||||
try {
|
||||
if (!currentProject.value) {
|
||||
throw new Error('没有当前项目')
|
||||
}
|
||||
const updatedProject = await NovelAPI.evaluateChapter(currentProject.value.id, chapterNumber)
|
||||
currentProject.value = updatedProject
|
||||
return updatedProject
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '评估章节失败'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function selectChapterVersion(chapterNumber: number, versionIndex: number) {
|
||||
// 不设置全局 isLoading,让调用方处理局部加载状态
|
||||
error.value = null
|
||||
try {
|
||||
if (!currentProject.value) {
|
||||
throw new Error('没有当前项目')
|
||||
}
|
||||
const updatedProject = await NovelAPI.selectChapterVersion(
|
||||
currentProject.value.id,
|
||||
chapterNumber,
|
||||
versionIndex
|
||||
)
|
||||
currentProject.value = updatedProject // 更新 store
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '选择章节版本失败'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProjects(projectIds: string[]): Promise<DeleteNovelsResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await NovelAPI.deleteNovels(projectIds)
|
||||
|
||||
// 从本地项目列表中移除已删除的项目
|
||||
projects.value = projects.value.filter(project => !projectIds.includes(project.id))
|
||||
|
||||
// 如果当前项目被删除,清空当前项目
|
||||
if (currentProject.value && projectIds.includes(currentProject.value.id)) {
|
||||
currentProject.value = null
|
||||
currentConversationState.value = {}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '删除项目失败'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateChapterOutline(chapterOutline: ChapterOutline) {
|
||||
// 不设置全局 isLoading,让调用方处理局部加载状态
|
||||
error.value = null
|
||||
try {
|
||||
if (!currentProject.value) {
|
||||
throw new Error('没有当前项目')
|
||||
}
|
||||
const updatedProject = await NovelAPI.updateChapterOutline(
|
||||
currentProject.value.id,
|
||||
chapterOutline
|
||||
)
|
||||
currentProject.value = updatedProject // 更新 store
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '更新章节大纲失败'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChapter(chapterNumbers: number | number[]) {
|
||||
error.value = null
|
||||
try {
|
||||
if (!currentProject.value) {
|
||||
throw new Error('没有当前项目')
|
||||
}
|
||||
const numbersToDelete = Array.isArray(chapterNumbers) ? chapterNumbers : [chapterNumbers]
|
||||
const updatedProject = await NovelAPI.deleteChapter(
|
||||
currentProject.value.id,
|
||||
numbersToDelete
|
||||
)
|
||||
currentProject.value = updatedProject // 更新 store
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '删除章节失败'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function generateChapterOutline(startChapter: number, numChapters: number) {
|
||||
error.value = null
|
||||
try {
|
||||
if (!currentProject.value) {
|
||||
throw new Error('没有当前项目')
|
||||
}
|
||||
const updatedProject = await NovelAPI.generateChapterOutline(
|
||||
currentProject.value.id,
|
||||
startChapter,
|
||||
numChapters
|
||||
)
|
||||
currentProject.value = updatedProject // 更新 store
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '生成大纲失败'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function editChapterContent(projectId: string, chapterNumber: number, content: string) {
|
||||
error.value = null
|
||||
try {
|
||||
const updatedProject = await NovelAPI.editChapterContent(projectId, chapterNumber, content)
|
||||
currentProject.value = updatedProject // 更新 store
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '编辑章节内容失败'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function setCurrentProject(project: NovelProject | null) {
|
||||
currentProject.value = project
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
projects,
|
||||
currentProject,
|
||||
currentConversationState,
|
||||
isLoading,
|
||||
error,
|
||||
// Getters
|
||||
projectsCount,
|
||||
hasCurrentProject,
|
||||
// Actions
|
||||
loadProjects,
|
||||
createProject,
|
||||
loadProject,
|
||||
loadChapter,
|
||||
sendConversation,
|
||||
generateBlueprint,
|
||||
saveBlueprint,
|
||||
generateChapter,
|
||||
evaluateChapter,
|
||||
selectChapterVersion,
|
||||
deleteProjects,
|
||||
updateChapterOutline,
|
||||
deleteChapter,
|
||||
generateChapterOutline,
|
||||
editChapterContent,
|
||||
clearError,
|
||||
setCurrentProject
|
||||
}
|
||||
})
|
||||
15
frontend/src/views/AboutView.vue
Normal file
15
frontend/src/views/AboutView.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.about {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
frontend/src/views/AdminNovelDetail.vue
Normal file
7
frontend/src/views/AdminNovelDetail.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<NovelDetailShell :is-admin="true" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NovelDetailShell from '@/components/shared/NovelDetailShell.vue'
|
||||
</script>
|
||||
251
frontend/src/views/AdminView.vue
Normal file
251
frontend/src/views/AdminView.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<n-layout has-sider class="admin-layout">
|
||||
<n-layout-sider
|
||||
collapse-mode="width"
|
||||
:collapsed="collapsed"
|
||||
:collapsed-width="64"
|
||||
:width="240"
|
||||
bordered
|
||||
show-trigger
|
||||
@collapse="collapsed = true"
|
||||
@expand="collapsed = false"
|
||||
>
|
||||
<div class="sider-header">
|
||||
<span class="logo" v-if="!collapsed">Arboris 管理台</span>
|
||||
<span class="logo-small" v-else>管理</span>
|
||||
</div>
|
||||
<n-menu
|
||||
:value="activeKey"
|
||||
:options="menuOptions"
|
||||
:collapsed="collapsed"
|
||||
:collapsed-width="64"
|
||||
:accordion="true"
|
||||
@update:value="handleMenuSelect"
|
||||
/>
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout>
|
||||
<n-layout-header bordered class="admin-header">
|
||||
<n-space align="center" justify="space-between" class="header-content">
|
||||
<n-space align="center" :size="12">
|
||||
<n-button
|
||||
class="mobile-trigger"
|
||||
quaternary
|
||||
circle
|
||||
size="small"
|
||||
@click="collapsed = !collapsed"
|
||||
>
|
||||
<template #icon>
|
||||
<span class="icon">☰</span>
|
||||
</template>
|
||||
</n-button>
|
||||
<span class="header-title">{{ currentMenuLabel }}</span>
|
||||
</n-space>
|
||||
<n-space align="center" :size="10">
|
||||
<span class="header-subtitle">高效掌控平台运行状态</span>
|
||||
<n-button size="small" type="primary" ghost @click="goBack">
|
||||
返回业务系统
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-layout-header>
|
||||
<n-layout-content class="admin-content">
|
||||
<n-scrollbar class="content-scroll">
|
||||
<component :is="activeComponent" />
|
||||
</n-scrollbar>
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NLayout,
|
||||
NLayoutContent,
|
||||
NLayoutHeader,
|
||||
NLayoutSider,
|
||||
NMenu,
|
||||
NScrollbar,
|
||||
NSpace,
|
||||
type MenuOption
|
||||
} from 'naive-ui'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const collapsed = ref(false)
|
||||
const activeKey = ref<MenuKey>('statistics')
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
type MenuKey =
|
||||
| 'statistics'
|
||||
| 'users'
|
||||
| 'prompts'
|
||||
| 'novels'
|
||||
| 'logs'
|
||||
| 'settings'
|
||||
| 'password'
|
||||
|
||||
const components: Record<MenuKey, ReturnType<typeof defineAsyncComponent>> = {
|
||||
statistics: defineAsyncComponent(() => import('../components/admin/Statistics.vue')),
|
||||
users: defineAsyncComponent(() => import('../components/admin/UserManagement.vue')),
|
||||
prompts: defineAsyncComponent(() => import('../components/admin/PromptManagement.vue')),
|
||||
novels: defineAsyncComponent(() => import('../components/admin/NovelManagement.vue')),
|
||||
logs: defineAsyncComponent(() => import('../components/admin/UpdateLogManagement.vue')),
|
||||
settings: defineAsyncComponent(() => import('../components/admin/SettingsManagement.vue')),
|
||||
password: defineAsyncComponent(() => import('../components/admin/PasswordManagement.vue'))
|
||||
}
|
||||
|
||||
const iconRenderers: Record<MenuKey, () => any> = {
|
||||
statistics: () => h('span', { class: 'menu-icon' }, '📊'),
|
||||
users: () => h('span', { class: 'menu-icon' }, '👤'),
|
||||
prompts: () => h('span', { class: 'menu-icon' }, '🗒️'),
|
||||
novels: () => h('span', { class: 'menu-icon' }, '📚'),
|
||||
logs: () => h('span', { class: 'menu-icon' }, '📝'),
|
||||
settings: () => h('span', { class: 'menu-icon' }, '⚙️'),
|
||||
password: () => h('span', { class: 'menu-icon' }, '🔒')
|
||||
}
|
||||
|
||||
const menuOptions: MenuOption[] = [
|
||||
{ key: 'statistics', label: '数据总览', icon: iconRenderers.statistics },
|
||||
{ key: 'users', label: '用户管理', icon: iconRenderers.users },
|
||||
{ key: 'prompts', label: '提示词管理', icon: iconRenderers.prompts },
|
||||
{ key: 'novels', label: '小说项目', icon: iconRenderers.novels },
|
||||
{ key: 'logs', label: '更新日志', icon: iconRenderers.logs },
|
||||
{ key: 'settings', label: '系统配置', icon: iconRenderers.settings },
|
||||
{ key: 'password', label: '安全中心', icon: iconRenderers.password }
|
||||
]
|
||||
|
||||
const isMenuKey = (key: string): key is MenuKey => key in components
|
||||
|
||||
const syncActiveKeyWithRoute = () => {
|
||||
const tab = route.query.tab
|
||||
if (typeof tab === 'string' && isMenuKey(tab)) {
|
||||
activeKey.value = tab
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuSelect = (key: string) => {
|
||||
if (!isMenuKey(key)) {
|
||||
return
|
||||
}
|
||||
activeKey.value = key
|
||||
router.replace({ name: 'admin', query: { tab: key } })
|
||||
}
|
||||
|
||||
const activeComponent = computed(() => components[activeKey.value])
|
||||
const currentMenuLabel = computed(() => {
|
||||
const match = menuOptions.find((option) => option.key === activeKey.value)
|
||||
return match ? (match.label as string) : ''
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const updateCollapsedByWidth = () => {
|
||||
collapsed.value = window.innerWidth < 992
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateCollapsedByWidth()
|
||||
window.addEventListener('resize', updateCollapsedByWidth)
|
||||
syncActiveKeyWithRoute()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateCollapsedByWidth)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => {
|
||||
syncActiveKeyWithRoute()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sider-header {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.logo-small {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.content-scroll {
|
||||
height: calc(100vh - 64px);
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.mobile-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.content-scroll {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mobile-trigger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
frontend/src/views/HomeView.vue
Normal file
9
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
</template>
|
||||
362
frontend/src/views/InspirationMode.vue
Normal file
362
frontend/src/views/InspirationMode.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="w-full max-w-6xl mx-auto">
|
||||
<!-- 灵感模式入口界面 -->
|
||||
<div v-if="!conversationStarted" class="text-center p-8 bg-white/70 backdrop-blur-xl rounded-2xl shadow-lg fade-in">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-gray-800">小说家的新篇章</h1>
|
||||
<p class="text-lg text-gray-600 mt-4 mb-8">
|
||||
准备好释放你的创造力了吗?让AI引导你,一步步构建出独一无二的故事世界。
|
||||
</p>
|
||||
<button
|
||||
@click="startConversation"
|
||||
:disabled="novelStore.isLoading"
|
||||
class="bg-indigo-500 text-white font-bold py-3 px-8 rounded-full hover:bg-indigo-600 transition-all duration-300 transform hover:scale-105 shadow-lg focus:outline-none focus:ring-4 focus:ring-indigo-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ novelStore.isLoading ? '正在准备...' : '开启灵感模式' }}
|
||||
</button>
|
||||
<button
|
||||
@click="goBack"
|
||||
class="mt-4 block mx-auto text-gray-500 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 灵感模式交互界面 -->
|
||||
<div
|
||||
v-else-if="!showBlueprintConfirmation && !showBlueprint"
|
||||
class="h-[90vh] max-h-[950px] flex flex-col bg-white rounded-2xl shadow-2xl overflow-hidden fade-in"
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="relative flex h-3 w-3">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 bg-indigo-500"></span>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-indigo-600">与“文思”对话中...</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span v-if="currentTurn > 0" class="text-sm font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-md">
|
||||
第 {{ currentTurn }} 轮
|
||||
</span>
|
||||
<button
|
||||
@click="handleRestart"
|
||||
title="重新开始"
|
||||
class="text-gray-400 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="exitConversation"
|
||||
title="返回首页"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天区域 -->
|
||||
<div class="flex-1 p-6 overflow-y-auto space-y-6 relative" ref="chatArea">
|
||||
<transition name="fade">
|
||||
<InspirationLoading v-if="isInitialLoading" />
|
||||
</transition>
|
||||
<ChatBubble
|
||||
v-for="(message, index) in chatMessages"
|
||||
:key="index"
|
||||
:message="message.content"
|
||||
:type="message.type"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<ConversationInput
|
||||
:ui-control="currentUIControl"
|
||||
:loading="novelStore.isLoading"
|
||||
@submit="handleUserInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 蓝图确认界面 -->
|
||||
<BlueprintConfirmation
|
||||
v-if="showBlueprintConfirmation"
|
||||
:ai-message="confirmationMessage"
|
||||
@blueprint-generated="handleBlueprintGenerated"
|
||||
@back="backToConversation"
|
||||
/>
|
||||
|
||||
<!-- 大纲展示界面 -->
|
||||
<BlueprintDisplay
|
||||
v-if="showBlueprint"
|
||||
:blueprint="completedBlueprint"
|
||||
:ai-message="blueprintMessage"
|
||||
@confirm="handleConfirmBlueprint"
|
||||
@regenerate="handleRegenerateBlueprint"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useNovelStore } from '@/stores/novel'
|
||||
import type { UIControl, Blueprint } from '@/api/novel'
|
||||
import ChatBubble from '@/components/ChatBubble.vue'
|
||||
import ConversationInput from '@/components/ConversationInput.vue'
|
||||
import BlueprintConfirmation from '@/components/BlueprintConfirmation.vue'
|
||||
import BlueprintDisplay from '@/components/BlueprintDisplay.vue'
|
||||
import InspirationLoading from '@/components/InspirationLoading.vue'
|
||||
import { globalAlert } from '@/composables/useAlert'
|
||||
|
||||
interface ChatMessage {
|
||||
content: string
|
||||
type: 'user' | 'ai'
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const novelStore = useNovelStore()
|
||||
|
||||
const conversationStarted = ref(false)
|
||||
const isInitialLoading = ref(false)
|
||||
const showBlueprintConfirmation = ref(false)
|
||||
const showBlueprint = ref(false)
|
||||
const chatMessages = ref<ChatMessage[]>([])
|
||||
const currentUIControl = ref<UIControl | null>(null)
|
||||
const currentTurn = ref(0)
|
||||
const completedBlueprint = ref<Blueprint | null>(null)
|
||||
const confirmationMessage = ref('')
|
||||
const blueprintMessage = ref('')
|
||||
const chatArea = ref<HTMLElement>()
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// 清空所有状态,开始新的灵感对话
|
||||
const resetInspirationMode = () => {
|
||||
conversationStarted.value = false
|
||||
isInitialLoading.value = false
|
||||
showBlueprintConfirmation.value = false
|
||||
showBlueprint.value = false
|
||||
chatMessages.value = []
|
||||
currentUIControl.value = null
|
||||
currentTurn.value = 0
|
||||
completedBlueprint.value = null
|
||||
confirmationMessage.value = ''
|
||||
blueprintMessage.value = ''
|
||||
|
||||
// 清空 store 中的当前项目和对话状态
|
||||
novelStore.setCurrentProject(null)
|
||||
novelStore.currentConversationState = {}
|
||||
}
|
||||
|
||||
const exitConversation = async () => {
|
||||
const confirmed = await globalAlert.showConfirm('确定要退出灵感模式吗?当前进度可能会丢失。', '退出确认')
|
||||
if (confirmed) {
|
||||
resetInspirationMode()
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestart = async () => {
|
||||
const confirmed = await globalAlert.showConfirm('确定要重新开始吗?当前对话内容将会丢失。', '重新开始确认')
|
||||
if (confirmed) {
|
||||
await startConversation()
|
||||
}
|
||||
}
|
||||
|
||||
const backToConversation = () => {
|
||||
showBlueprintConfirmation.value = false
|
||||
}
|
||||
|
||||
const startConversation = async () => {
|
||||
// 重置所有状态,开始全新的对话
|
||||
resetInspirationMode()
|
||||
conversationStarted.value = true
|
||||
isInitialLoading.value = true
|
||||
|
||||
try {
|
||||
await novelStore.createProject('未命名灵感', '开始灵感模式')
|
||||
|
||||
// 发起第一次对话
|
||||
await handleUserInput(null)
|
||||
} catch (error) {
|
||||
console.error('启动灵感模式失败:', error)
|
||||
globalAlert.showError(`无法开始灵感模式: ${error instanceof Error ? error.message : '未知错误'}`, '启动失败')
|
||||
resetInspirationMode() // 失败时重置回初始状态
|
||||
}
|
||||
}
|
||||
|
||||
const restoreConversation = async (projectId: string) => {
|
||||
try {
|
||||
await novelStore.loadProject(projectId)
|
||||
const project = novelStore.currentProject
|
||||
if (project && project.conversation_history) {
|
||||
conversationStarted.value = true
|
||||
chatMessages.value = project.conversation_history.map((item): ChatMessage | null => {
|
||||
if (item.role === 'user') {
|
||||
try {
|
||||
const userInput = JSON.parse(item.content)
|
||||
return { content: userInput.value, type: 'user' }
|
||||
} catch {
|
||||
return { content: item.content, type: 'user' }
|
||||
}
|
||||
} else { // assistant
|
||||
try {
|
||||
const assistantOutput = JSON.parse(item.content)
|
||||
return { content: assistantOutput.ai_message, type: 'ai' }
|
||||
} catch {
|
||||
return { content: item.content, type: 'ai' }
|
||||
}
|
||||
}
|
||||
}).filter((msg): msg is ChatMessage => msg !== null && msg.content !== null) // 过滤掉空的 user message
|
||||
|
||||
const lastAssistantMsgStr = project.conversation_history.filter(m => m.role === 'assistant').pop()?.content
|
||||
if (lastAssistantMsgStr) {
|
||||
const lastAssistantMsg = JSON.parse(lastAssistantMsgStr)
|
||||
|
||||
if (lastAssistantMsg.is_complete) {
|
||||
// 如果对话已完成,直接显示蓝图确认界面
|
||||
confirmationMessage.value = lastAssistantMsg.ai_message
|
||||
showBlueprintConfirmation.value = true
|
||||
} else {
|
||||
// 否则,恢复对话
|
||||
currentUIControl.value = lastAssistantMsg.ui_control
|
||||
}
|
||||
}
|
||||
// 计算当前轮次
|
||||
currentTurn.value = project.conversation_history.filter(m => m.role === 'assistant').length
|
||||
await scrollToBottom()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('恢复对话失败:', error)
|
||||
globalAlert.showError(`无法恢复对话: ${error instanceof Error ? error.message : '未知错误'}`, '加载失败')
|
||||
resetInspirationMode()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserInput = async (userInput: any) => {
|
||||
try {
|
||||
// 如果有用户输入,添加到聊天记录
|
||||
if (userInput && userInput.value) {
|
||||
chatMessages.value.push({
|
||||
content: userInput.value,
|
||||
type: 'user'
|
||||
})
|
||||
await scrollToBottom()
|
||||
}
|
||||
|
||||
const response = await novelStore.sendConversation(userInput)
|
||||
|
||||
// 首次加载完成后,关闭加载动画
|
||||
if (isInitialLoading.value) {
|
||||
isInitialLoading.value = false
|
||||
}
|
||||
|
||||
// 添加AI回复到聊天记录
|
||||
chatMessages.value.push({
|
||||
content: response.ai_message,
|
||||
type: 'ai'
|
||||
})
|
||||
currentTurn.value++
|
||||
|
||||
await scrollToBottom()
|
||||
|
||||
if (response.is_complete && response.ready_for_blueprint) {
|
||||
// 对话完成,显示蓝图确认界面
|
||||
confirmationMessage.value = response.ai_message
|
||||
showBlueprintConfirmation.value = true
|
||||
} else if (response.is_complete) {
|
||||
// 向后兼容:直接生成蓝图(如果后端还没更新)
|
||||
await handleGenerateBlueprint()
|
||||
} else {
|
||||
// 继续对话
|
||||
currentUIControl.value = response.ui_control
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('对话失败:', error)
|
||||
// 确保在出错时也停止初始加载状态
|
||||
if (isInitialLoading.value) {
|
||||
isInitialLoading.value = false
|
||||
}
|
||||
globalAlert.showError(`抱歉,与AI连接时遇到问题: ${error instanceof Error ? error.message : '未知错误'}`, '通信失败')
|
||||
// 停止加载并返回初始界面
|
||||
resetInspirationMode()
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateBlueprint = async () => {
|
||||
try {
|
||||
const response = await novelStore.generateBlueprint()
|
||||
handleBlueprintGenerated(response)
|
||||
} catch (error) {
|
||||
console.error('生成蓝图失败:', error)
|
||||
globalAlert.showError(`生成蓝图失败: ${error instanceof Error ? error.message : '未知错误'}`, '生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlueprintGenerated = (response: any) => {
|
||||
console.log('收到蓝图生成完成事件:', response)
|
||||
completedBlueprint.value = response.blueprint
|
||||
blueprintMessage.value = response.ai_message
|
||||
showBlueprintConfirmation.value = false
|
||||
showBlueprint.value = true
|
||||
}
|
||||
|
||||
const handleRegenerateBlueprint = () => {
|
||||
showBlueprint.value = false
|
||||
showBlueprintConfirmation.value = true
|
||||
}
|
||||
|
||||
const handleConfirmBlueprint = async () => {
|
||||
if (!completedBlueprint.value) {
|
||||
globalAlert.showError('蓝图数据缺失,请重新生成或稍后重试。', '保存失败')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await novelStore.saveBlueprint(completedBlueprint.value)
|
||||
// 跳转到写作工作台
|
||||
if (novelStore.currentProject) {
|
||||
router.push(`/novel/${novelStore.currentProject.id}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存蓝图失败:', error)
|
||||
globalAlert.showError(`保存蓝图失败: ${error instanceof Error ? error.message : '未知错误'}`, '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
if (chatArea.value) {
|
||||
chatArea.value.scrollTop = chatArea.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const projectId = route.query.project_id as string
|
||||
if (projectId) {
|
||||
restoreConversation(projectId)
|
||||
} else {
|
||||
// 每次进入灵感模式都重置状态,确保没有缓存
|
||||
resetInspirationMode()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
108
frontend/src/views/Login.vue
Normal file
108
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center min-h-screen p-4">
|
||||
<div class="mb-12">
|
||||
<TypewriterEffect text="拯 救 小 说 家" />
|
||||
</div>
|
||||
<div class="w-full max-w-sm p-8 space-y-8 bg-white/70 backdrop-blur-xl rounded-2xl shadow-xl">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-center text-gray-800">
|
||||
欢迎回来
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-center text-gray-500">
|
||||
登录以继续您的创作之旅
|
||||
</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleLogin" class="mt-8 space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="sr-only">用户名</label>
|
||||
<input v-model="username" id="username" name="username" type="text" required
|
||||
class="w-full px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
|
||||
placeholder="用户名" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="sr-only">密码</label>
|
||||
<input v-model="password" id="password" name="password" type="password" required
|
||||
class="w-full px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
|
||||
placeholder="密码" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm font-medium text-center text-red-500">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
:disabled="isLoading"
|
||||
class="w-full px-4 py-3 text-sm font-bold text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-60 transition-all duration-300">
|
||||
<span v-if="isLoading">正在登录...</span>
|
||||
<span v-else>登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="relative flex items-center justify-center my-6">
|
||||
<div class="w-full border-t border-gray-200"></div>
|
||||
<span class="absolute px-3 text-sm text-gray-400 bg-white">或</span>
|
||||
</div>
|
||||
|
||||
<div v-if="enableLinuxdoLogin">
|
||||
<a href="/api/auth/linuxdo/login"
|
||||
class="flex items-center justify-center w-full px-4 py-3 text-sm font-bold text-gray-700 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-gray-200 transition-all duration-300">
|
||||
<svg class="w-5 h-5 mr-2" aria-hidden="true" focusable="false" data-prefix="fab" data-icon="linux" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-110.5 0-200-89.5-200-200S137.5 56 248 56s200 89.5 200 200-89.5 200-200 200z"></path></svg>
|
||||
使用 Linux DO 登录
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p v-if="allowRegistration" class="mt-8 text-sm text-center text-gray-500">
|
||||
还没有账户?
|
||||
<router-link to="/register" class="font-medium text-blue-600 hover:underline">
|
||||
立即注册
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import TypewriterEffect from '@/components/TypewriterEffect.vue';
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const error = ref('');
|
||||
const isLoading = ref(false);
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const allowRegistration = computed(() => authStore.allowRegistration);
|
||||
const enableLinuxdoLogin = computed(() => authStore.enableLinuxdoLogin);
|
||||
|
||||
// 首屏自动拉取认证配置,确保登录页动态展示开关
|
||||
onMounted(() => {
|
||||
authStore.fetchAuthOptions().catch((error) => {
|
||||
console.error('初始化认证配置失败', error);
|
||||
});
|
||||
});
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = '';
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const mustChange = await authStore.login(username.value, password.value);
|
||||
const user = authStore.user;
|
||||
if (user?.is_admin && (authStore.mustChangePassword || mustChange)) {
|
||||
router.push({ name: 'admin', query: { tab: 'password' } });
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = '登录失败,请检查您的用户名和密码。';
|
||||
console.error(err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
7
frontend/src/views/NovelDetail.vue
Normal file
7
frontend/src/views/NovelDetail.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<NovelDetailShell :is-admin="false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NovelDetailShell from '@/components/shared/NovelDetailShell.vue'
|
||||
</script>
|
||||
229
frontend/src/views/NovelWorkspace.vue
Normal file
229
frontend/src/views/NovelWorkspace.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<!-- 删除提示消息 -->
|
||||
<div v-if="deleteMessage"
|
||||
:class="[
|
||||
'fixed top-4 right-4 z-60 px-4 py-3 rounded-lg shadow-lg transition-all duration-300',
|
||||
deleteMessage.type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'
|
||||
]">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg v-if="deleteMessage.type === 'success'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>{{ deleteMessage.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-7xl mx-auto">
|
||||
<div class="p-8 bg-white/95 backdrop-blur-sm rounded-2xl shadow-2xl fade-in">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h2 class="text-3xl font-bold text-gray-800">我的小说项目</h2>
|
||||
<router-link v-if="authStore.user?.is_admin" to="/admin" class="text-sm text-indigo-600 hover:text-indigo-800">管理后台</router-link>
|
||||
</div>
|
||||
<button
|
||||
@click="goBack"
|
||||
class="text-gray-500 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="novelStore.isLoading" class="flex justify-center items-center py-8">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="novelStore.error" class="text-red-500 text-center py-8">
|
||||
{{ novelStore.error }}
|
||||
<button
|
||||
@click="loadProjects"
|
||||
class="block mt-4 mx-auto px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- 空状态 -->
|
||||
<div v-if="novelStore.projects.length === 0" class="col-span-full text-center py-8">
|
||||
<p class="text-gray-500 mb-4">还没有项目,快去开启灵感模式创建一个吧!</p>
|
||||
<button
|
||||
@click="goToInspiration"
|
||||
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
开始创作
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 项目卡片 -->
|
||||
<ProjectCard
|
||||
v-for="project in novelStore.projects"
|
||||
:key="project.id"
|
||||
:project="project"
|
||||
@click="enterProject(project)"
|
||||
@detail="viewProjectDetail"
|
||||
@continue="enterProject"
|
||||
@delete="handleDeleteProject"
|
||||
/>
|
||||
|
||||
<!-- 创建新项目卡片 -->
|
||||
<div
|
||||
@click="goToInspiration"
|
||||
class="flex items-center justify-center p-5 bg-transparent border-2 border-dashed border-gray-300 rounded-xl hover:bg-gray-50 hover:border-indigo-400 transition-colors duration-300 cursor-pointer group min-h-[180px]"
|
||||
>
|
||||
<div class="text-center text-gray-500 group-hover:text-indigo-500 transition-colors">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mx-auto h-8 w-8 mb-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span class="font-semibold">创建新项目</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<div v-if="showDeleteDialog" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clip-rule="evenodd"></path>
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">确认删除</h3>
|
||||
<p class="text-sm text-gray-600">此操作无法撤销</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-700 mb-6">
|
||||
确定要删除项目 "{{ projectToDelete?.title }}" 吗?所有相关数据将被永久删除。
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
@click="cancelDelete"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete"
|
||||
:disabled="isDeleting"
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<svg v-if="isDeleting" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ isDeleting ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNovelStore } from '@/stores/novel'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ProjectCard from '@/components/ProjectCard.vue'
|
||||
import type { NovelProject, NovelProjectSummary } from '@/api/novel'
|
||||
|
||||
const router = useRouter()
|
||||
const novelStore = useNovelStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 删除相关状态
|
||||
const showDeleteDialog = ref(false)
|
||||
const projectToDelete = ref<NovelProjectSummary | null>(null)
|
||||
const isDeleting = ref(false)
|
||||
const deleteMessage = ref<{type: 'success' | 'error', text: string} | null>(null)
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const goToInspiration = () => {
|
||||
router.push('/inspiration')
|
||||
}
|
||||
|
||||
const viewProjectDetail = (projectId: string) => {
|
||||
router.push(`/detail/${projectId}`)
|
||||
}
|
||||
|
||||
const enterProject = (project: NovelProjectSummary) => {
|
||||
if (project.title === '未命名灵感') {
|
||||
router.push(`/inspiration?project_id=${project.id}`)
|
||||
} else {
|
||||
router.push(`/novel/${project.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const loadProjects = async () => {
|
||||
await novelStore.loadProjects()
|
||||
}
|
||||
|
||||
// 删除相关方法
|
||||
const handleDeleteProject = (projectId: string) => {
|
||||
const project = novelStore.projects.find(p => p.id === projectId)
|
||||
if (project) {
|
||||
projectToDelete.value = project
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const cancelDelete = () => {
|
||||
showDeleteDialog.value = false
|
||||
projectToDelete.value = null
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!projectToDelete.value) return
|
||||
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await novelStore.deleteProjects([projectToDelete.value.id])
|
||||
deleteMessage.value = { type: 'success', text: `项目 "${projectToDelete.value.title}" 已成功删除` }
|
||||
showDeleteDialog.value = false
|
||||
projectToDelete.value = null
|
||||
|
||||
// 3秒后清除消息
|
||||
setTimeout(() => {
|
||||
deleteMessage.value = null
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error('删除项目失败:', error)
|
||||
deleteMessage.value = { type: 'error', text: '删除项目失败,请重试' }
|
||||
|
||||
// 3秒后清除消息
|
||||
setTimeout(() => {
|
||||
deleteMessage.value = null
|
||||
}, 3000)
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProjects()
|
||||
})
|
||||
</script>
|
||||
219
frontend/src/views/Register.vue
Normal file
219
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center min-h-screen p-4">
|
||||
<div class="mb-12">
|
||||
<TypewriterEffect text="拯救小说家" />
|
||||
</div>
|
||||
<div v-if="allowRegistration" class="w-full max-w-md p-8 space-y-8 bg-white/70 backdrop-blur-xl rounded-2xl shadow-xl">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-center text-gray-800">
|
||||
加入我们
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-center text-gray-500">
|
||||
开启您的创作新篇章
|
||||
</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleRegister" class="mt-8 space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="sr-only">用户名</label>
|
||||
<input v-model="username" id="username" name="username" type="text" required
|
||||
class="w-full px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
|
||||
placeholder="用户名" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="sr-only">邮箱</label>
|
||||
<input v-model="email" id="email" name="email" type="email" required
|
||||
class="w-full px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
|
||||
placeholder="邮箱" />
|
||||
</div>
|
||||
<div class="flex space-x-2 items-center">
|
||||
<input v-model="verificationCode" id="verificationCode" name="verificationCode" type="text" required
|
||||
class="flex-1 px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
|
||||
placeholder="验证码" />
|
||||
<button type="button" @click="sendCode" :disabled="countdown > 0 || sending"
|
||||
class="px-4 py-3 text-sm font-bold text-white bg-green-600 rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 disabled:opacity-60 transition-all duration-300">
|
||||
<span v-if="sending">发送中...</span>
|
||||
<span v-else>{{ countdown > 0 ? countdown + '秒后重试' : '发送验证码' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="sr-only">密码</label>
|
||||
<input v-model="password" id="password" name="password" type="password" required
|
||||
class="w-full px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
|
||||
placeholder="密码" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm font-medium text-center text-red-500">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-if="success" class="text-sm font-medium text-center text-green-500">
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="w-full px-4 py-3 text-sm font-bold text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-60 transition-all duration-300">
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="mt-8 text-sm text-center text-gray-500">
|
||||
已有账户?
|
||||
<router-link to="/login" class="font-medium text-blue-600 hover:underline">
|
||||
立即登录
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="w-full max-w-md p-8 space-y-6 text-center bg-white/70 backdrop-blur-xl rounded-2xl shadow-xl">
|
||||
<h2 class="text-xl font-bold text-gray-800">暂未开放注册</h2>
|
||||
<p class="text-sm text-gray-500">请联系管理员或稍后再试。</p>
|
||||
<router-link to="/login" class="inline-block px-4 py-2 text-sm font-medium text-blue-600 hover:underline">
|
||||
返回登录
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import TypewriterEffect from '@/components/TypewriterEffect.vue';
|
||||
|
||||
const username = ref('');
|
||||
const email = ref('');
|
||||
const verificationCode = ref('');
|
||||
const password = ref('');
|
||||
const countdown = ref(0);
|
||||
const sending = ref(false);
|
||||
const error = ref('');
|
||||
const success = ref('');
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const allowRegistration = computed(() => authStore.allowRegistration);
|
||||
|
||||
// 进入页面即拉取认证开关,避免展示无效注册表单
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await authStore.fetchAuthOptions();
|
||||
} catch (error) {
|
||||
console.error('加载认证开关失败', error);
|
||||
}
|
||||
if (!allowRegistration.value) {
|
||||
success.value = '';
|
||||
error.value = '当前已关闭注册,请稍后再试。';
|
||||
}
|
||||
});
|
||||
|
||||
const validateInput = () => {
|
||||
// Password validation
|
||||
if (password.value.length < 8) {
|
||||
return '密码必须至少8个字符';
|
||||
}
|
||||
|
||||
// Username validation
|
||||
const usernameVal = username.value;
|
||||
const hasChinese = /[\u4e00-\u9fa5]/.test(usernameVal);
|
||||
const isNumeric = /^\d+$/.test(usernameVal);
|
||||
const isAlphanumeric = /^[a-zA-Z0-9]+$/.test(usernameVal);
|
||||
|
||||
if (isNumeric) {
|
||||
return '用户名不能是纯数字';
|
||||
}
|
||||
|
||||
if (hasChinese && usernameVal.length <= 1) {
|
||||
return '户名长度必须大于2个汉字';
|
||||
}
|
||||
|
||||
if (isAlphanumeric && !hasChinese && usernameVal.length <= 6) {
|
||||
return '用户名长度必须大于6个字母或数字';
|
||||
}
|
||||
|
||||
return null; // No validation errors
|
||||
};
|
||||
|
||||
const sendCode = async () => {
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
|
||||
if (!allowRegistration.value) {
|
||||
error.value = '当前已关闭注册,请联系管理员。';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.value) {
|
||||
error.value = '请输入邮箱';
|
||||
return;
|
||||
}
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email.value)) {
|
||||
error.value = '邮箱格式不正确';
|
||||
return;
|
||||
}
|
||||
|
||||
sending.value = true;
|
||||
try {
|
||||
const res = await fetch(`/api/auth/send-code?email=${encodeURIComponent(email.value)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errMsg = await res.json();
|
||||
throw new Error(errMsg.detail || '发送验证码失败');
|
||||
}
|
||||
success.value = '验证码已发送,请查收邮箱';
|
||||
// 等接口返回成功后再开始倒计时
|
||||
countdown.value = 60;
|
||||
const timer = setInterval(() => {
|
||||
countdown.value--;
|
||||
if (countdown.value <= 0) clearInterval(timer);
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
|
||||
const validationError = validateInput();
|
||||
if (validationError) {
|
||||
error.value = validationError;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowRegistration.value) {
|
||||
error.value = '当前已关闭注册,请联系管理员。';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
verification_code: verificationCode.value
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errMsg = await res.json();
|
||||
throw new Error(errMsg.detail || '注册失败');
|
||||
}
|
||||
success.value = '注册成功!正在跳转到登录页面...';
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '注册失败,请稍后再试。';
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
35
frontend/src/views/SettingsView.vue
Normal file
35
frontend/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="min-h-screen p-4 relative">
|
||||
<div class="absolute top-4 left-4">
|
||||
<router-link
|
||||
to="/"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
← 返回
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row max-w-6xl mx-auto mt-16">
|
||||
<!-- Sidebar -->
|
||||
<div class="w-full md:w-64 bg-white/70 backdrop-blur-xl rounded-2xl shadow-lg p-4 mb-4 md:mb-0 md:mr-8">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-4">设置</h2>
|
||||
<nav>
|
||||
<ul>
|
||||
<li class="px-4 py-2 bg-indigo-100 text-indigo-700 rounded-lg cursor-pointer">
|
||||
LLM 配置
|
||||
</li>
|
||||
<!-- Add other settings links here in the future -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1">
|
||||
<LLMSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LLMSettings from '@/components/LLMSettings.vue';
|
||||
</script>
|
||||
170
frontend/src/views/WorkspaceEntry.vue
Normal file
170
frontend/src/views/WorkspaceEntry.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen p-4 relative">
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm" @click.self="closeModal">
|
||||
<div class="w-full max-w-4xl bg-white rounded-2xl shadow-xl overflow-hidden transform transition-all duration-300 flex flex-col max-h-[90vh]">
|
||||
<div class="p-8 sm:p-10 pb-4">
|
||||
<!-- 头部标题 -->
|
||||
<header class="text-center mb-6">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-slate-800">更新日志</h1>
|
||||
</header>
|
||||
|
||||
<!-- 加入交流群部分 (动态) -->
|
||||
<div v-if="communityLog" class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<div v-html="renderMarkdown(communityLog.content)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 sm:px-10 pb-8 sm:pb-10 overflow-y-auto flex-1">
|
||||
<!-- 日志条目列表 - 时间线样式 -->
|
||||
<div class="flow-root">
|
||||
<ul role="list" class="-mb-8">
|
||||
|
||||
<!-- Timeline Item -->
|
||||
<li v-for="(log, index) in filteredUpdateLogs" :key="log.id">
|
||||
<div class="relative pb-8">
|
||||
<!-- 连接线 (除了最后一个) -->
|
||||
<span v-if="index < filteredUpdateLogs.length - 1" class="absolute left-2.5 top-4 -ml-px h-full w-0.5 bg-slate-200" aria-hidden="true"></span>
|
||||
<div class="relative flex items-start space-x-4">
|
||||
<!-- 时间线上的圆点 -->
|
||||
<div class="h-5 w-5 bg-blue-500 rounded-full flex items-center justify-center ring-8 ring-white mt-1"></div>
|
||||
<!-- 卡片内容 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="bg-slate-50/80 border border-slate-200/80 rounded-xl p-4">
|
||||
<time class="text-sm font-semibold text-slate-600">{{ new Date(log.created_at).toLocaleDateString() }}</time>
|
||||
<div class="mt-3 prose max-w-none prose-sm prose-slate" v-html="renderMarkdown(log.content)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<footer class="bg-slate-50/70 px-8 py-4 border-t border-slate-200 mt-auto">
|
||||
<div class="flex justify-end items-center space-x-4">
|
||||
<button @click="hideModalToday" class="px-5 py-2 text-sm font-semibold text-slate-600 bg-transparent rounded-lg hover:bg-slate-200 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-400">
|
||||
今日不再显示
|
||||
</button>
|
||||
<button @click="closeModal" class="px-5 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-4 right-4 flex space-x-2">
|
||||
<router-link
|
||||
to="/settings"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
设置
|
||||
</router-link>
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div class="text-center p-8 fade-in">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-gray-800 mb-4">拯救小说家:创作中心</h1>
|
||||
<p class="text-lg text-gray-600 mb-12">从一个新灵感开始,或继续打磨你的世界。</p>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-8 max-w-2xl mx-auto">
|
||||
<!-- 灵感模式卡片 -->
|
||||
<div
|
||||
@click="goToInspiration"
|
||||
class="group p-8 bg-white/70 backdrop-blur-xl rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2 cursor-pointer"
|
||||
>
|
||||
<h2 class="text-2xl font-bold text-indigo-600 mb-3">灵感模式</h2>
|
||||
<p class="text-gray-600">没有头绪?让AI通过对话式引导,帮你构建故事的雏形。</p>
|
||||
</div>
|
||||
|
||||
<!-- 小说工作台卡片 -->
|
||||
<div
|
||||
@click="goToWorkspace"
|
||||
class="group p-8 bg-white/70 backdrop-blur-xl rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2 cursor-pointer"
|
||||
>
|
||||
<h2 class="text-2xl font-bold text-teal-600 mb-3">小说工作台</h2>
|
||||
<p class="text-gray-600">查看、编辑和管理你所有的小说项目工程。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getLatestUpdates } from '../api/updates'
|
||||
import type { UpdateLog } from '../api/updates'
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true, // 启用 GitHub 风格语法(表格/任务列表/自动链接 等)
|
||||
breaks: true // 将单个换行视为 <br>(常见于后端返回的段落)
|
||||
})
|
||||
|
||||
const renderMarkdown = (md: string) => marked.parse(md)
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const showModal = ref(false)
|
||||
const updateLogs = ref<UpdateLog[]>([])
|
||||
|
||||
// 查找包含“交流群”的日志
|
||||
const communityLog = computed(() => {
|
||||
return updateLogs.value.find(log => /交流群/.test(log.content))
|
||||
})
|
||||
|
||||
// 过滤掉包含“交流群”的日志,用于时间线显示
|
||||
const filteredUpdateLogs = computed(() => {
|
||||
if (!communityLog.value) {
|
||||
return updateLogs.value
|
||||
}
|
||||
return updateLogs.value.filter(log => log.id !== communityLog.value!.id)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const hideUntil = localStorage.getItem('hideAnnouncement')
|
||||
if (hideUntil !== new Date().toDateString()) {
|
||||
try {
|
||||
updateLogs.value = await getLatestUpdates()
|
||||
if (updateLogs.value.length > 0) {
|
||||
showModal.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch update logs:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
const hideModalToday = () => {
|
||||
localStorage.setItem('hideAnnouncement', new Date().toDateString())
|
||||
closeModal()
|
||||
}
|
||||
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const goToInspiration = () => {
|
||||
router.push('/inspiration')
|
||||
}
|
||||
|
||||
const goToWorkspace = () => {
|
||||
router.push('/workspace')
|
||||
}
|
||||
</script>
|
||||
647
frontend/src/views/WritingDesk.vue
Normal file
647
frontend/src/views/WritingDesk.vue
Normal file
@@ -0,0 +1,647 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
|
||||
<WDHeader
|
||||
:project="project"
|
||||
:progress="progress"
|
||||
:completed-chapters="completedChapters"
|
||||
:total-chapters="totalChapters"
|
||||
@go-back="goBack"
|
||||
@view-project-detail="viewProjectDetail"
|
||||
@toggle-sidebar="toggleSidebar"
|
||||
/>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="flex-1 w-full px-4 sm:px-6 lg:px-8 py-6 overflow-hidden">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="novelStore.isLoading" class="h-full flex justify-center items-center">
|
||||
<div class="text-center">
|
||||
<div class="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">正在加载项目数据...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="novelStore.error" class="text-center py-20">
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-8 max-w-md mx-auto">
|
||||
<svg class="w-12 h-12 text-red-400 mx-auto mb-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-red-900 mb-2">加载失败</h3>
|
||||
<p class="text-red-700 mb-4">{{ novelStore.error }}</p>
|
||||
<button
|
||||
@click="loadProject"
|
||||
class="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div v-else-if="project" class="h-full flex gap-6">
|
||||
<WDSidebar
|
||||
:project="project"
|
||||
:sidebar-open="sidebarOpen"
|
||||
:selected-chapter-number="selectedChapterNumber"
|
||||
:generating-chapter="generatingChapter"
|
||||
:evaluating-chapter="evaluatingChapter"
|
||||
:is-generating-outline="isGeneratingOutline"
|
||||
@close-sidebar="closeSidebar"
|
||||
@select-chapter="selectChapter"
|
||||
@generate-chapter="generateChapter"
|
||||
@edit-chapter="openEditChapterModal"
|
||||
@delete-chapter="deleteChapter"
|
||||
@generate-outline="generateOutline"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<WDWorkspace
|
||||
:project="project"
|
||||
:selected-chapter-number="selectedChapterNumber"
|
||||
:generating-chapter="generatingChapter"
|
||||
:evaluating-chapter="evaluatingChapter"
|
||||
:show-version-selector="showVersionSelector"
|
||||
:chapter-generation-result="chapterGenerationResult"
|
||||
:selected-version-index="selectedVersionIndex"
|
||||
:available-versions="availableVersions"
|
||||
:is-selecting-version="isSelectingVersion"
|
||||
@regenerate-chapter="regenerateChapter"
|
||||
@evaluate-chapter="evaluateChapter"
|
||||
@hide-version-selector="hideVersionSelector"
|
||||
@update:selected-version-index="selectedVersionIndex = $event"
|
||||
@show-version-detail="showVersionDetail"
|
||||
@confirm-version-selection="confirmVersionSelection"
|
||||
@generate-chapter="generateChapter"
|
||||
@show-evaluation-detail="showEvaluationDetailModal = true"
|
||||
@fetch-chapter-status="fetchChapterStatus"
|
||||
@edit-chapter="editChapterContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<WDVersionDetailModal
|
||||
:show="showVersionDetailModal"
|
||||
:detail-version-index="detailVersionIndex"
|
||||
:version="availableVersions[detailVersionIndex]"
|
||||
:is-current="isCurrentVersion(detailVersionIndex)"
|
||||
@close="closeVersionDetail"
|
||||
@select-version="selectVersionFromDetail"
|
||||
/>
|
||||
<WDEvaluationDetailModal
|
||||
:show="showEvaluationDetailModal"
|
||||
:evaluation="selectedChapter?.evaluation || null"
|
||||
@close="showEvaluationDetailModal = false"
|
||||
/>
|
||||
<WDEditChapterModal
|
||||
:show="showEditChapterModal"
|
||||
:chapter="editingChapter"
|
||||
@close="showEditChapterModal = false"
|
||||
@save="saveChapterChanges"
|
||||
/>
|
||||
<WDGenerateOutlineModal
|
||||
:show="showGenerateOutlineModal"
|
||||
@close="showGenerateOutlineModal = false"
|
||||
@generate="handleGenerateOutline"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNovelStore } from '@/stores/novel'
|
||||
import type { Chapter, ChapterOutline, ChapterGenerationResponse, ChapterVersion } from '@/api/novel'
|
||||
import { globalAlert } from '@/composables/useAlert'
|
||||
import Tooltip from '@/components/Tooltip.vue'
|
||||
import WDHeader from '@/components/writing-desk/WDHeader.vue'
|
||||
import WDSidebar from '@/components/writing-desk/WDSidebar.vue'
|
||||
import WDWorkspace from '@/components/writing-desk/WDWorkspace.vue'
|
||||
import WDVersionDetailModal from '@/components/writing-desk/WDVersionDetailModal.vue'
|
||||
import WDEvaluationDetailModal from '@/components/writing-desk/WDEvaluationDetailModal.vue'
|
||||
import WDEditChapterModal from '@/components/writing-desk/WDEditChapterModal.vue'
|
||||
import WDGenerateOutlineModal from '@/components/writing-desk/WDGenerateOutlineModal.vue'
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const router = useRouter()
|
||||
const novelStore = useNovelStore()
|
||||
|
||||
// 状态管理
|
||||
const selectedChapterNumber = ref<number | null>(null)
|
||||
const chapterGenerationResult = ref<ChapterGenerationResponse | null>(null)
|
||||
const selectedVersionIndex = ref<number>(0)
|
||||
const generatingChapter = ref<number | null>(null)
|
||||
const sidebarOpen = ref(false)
|
||||
const showVersionDetailModal = ref(false)
|
||||
const detailVersionIndex = ref<number>(0)
|
||||
const showEvaluationDetailModal = ref(false)
|
||||
const showEditChapterModal = ref(false)
|
||||
const editingChapter = ref<ChapterOutline | null>(null)
|
||||
const isGeneratingOutline = ref(false)
|
||||
const showGenerateOutlineModal = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const project = computed(() => novelStore.currentProject)
|
||||
|
||||
const selectedChapter = computed(() => {
|
||||
if (!project.value || selectedChapterNumber.value === null) return null
|
||||
return project.value.chapters.find(ch => ch.chapter_number === selectedChapterNumber.value) || null
|
||||
})
|
||||
|
||||
const showVersionSelector = computed(() => {
|
||||
if (!selectedChapter.value) return false
|
||||
const status = selectedChapter.value.generation_status
|
||||
return status === 'waiting_for_confirm' || status === 'evaluating' || status === 'evaluation_failed' || status === 'selecting'
|
||||
})
|
||||
|
||||
const evaluatingChapter = computed(() => {
|
||||
if (selectedChapter.value?.generation_status === 'evaluating') {
|
||||
return selectedChapter.value.chapter_number
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isSelectingVersion = computed(() => {
|
||||
return selectedChapter.value?.generation_status === 'selecting'
|
||||
})
|
||||
|
||||
const selectedChapterOutline = computed(() => {
|
||||
if (!project.value?.blueprint?.chapter_outline || selectedChapterNumber.value === null) return null
|
||||
return project.value.blueprint.chapter_outline.find(ch => ch.chapter_number === selectedChapterNumber.value) || null
|
||||
})
|
||||
|
||||
const progress = computed(() => {
|
||||
if (!project.value?.blueprint?.chapter_outline) return 0
|
||||
const totalChapters = project.value.blueprint.chapter_outline.length
|
||||
const completedChapters = project.value.chapters.filter(ch => ch.content).length
|
||||
return Math.round((completedChapters / totalChapters) * 100)
|
||||
})
|
||||
|
||||
const totalChapters = computed(() => {
|
||||
return project.value?.blueprint?.chapter_outline?.length || 0
|
||||
})
|
||||
|
||||
const completedChapters = computed(() => {
|
||||
return project.value?.chapters?.filter(ch => ch.content)?.length || 0
|
||||
})
|
||||
|
||||
const isCurrentVersion = (versionIndex: number) => {
|
||||
if (!selectedChapter.value?.content || !availableVersions.value?.[versionIndex]?.content) return false
|
||||
|
||||
// 使用cleanVersionContent函数清理内容进行比较
|
||||
const cleanCurrentContent = cleanVersionContent(selectedChapter.value.content)
|
||||
const cleanVersionContentStr = cleanVersionContent(availableVersions.value[versionIndex].content)
|
||||
|
||||
return cleanCurrentContent === cleanVersionContentStr
|
||||
}
|
||||
|
||||
const cleanVersionContent = (content: string): string => {
|
||||
if (!content) return ''
|
||||
|
||||
// 尝试解析JSON,看是否是完整的章节对象
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed && typeof parsed === 'object' && parsed.content) {
|
||||
// 如果是章节对象,提取content字段
|
||||
content = parsed.content
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果不是JSON,继续处理字符串
|
||||
}
|
||||
|
||||
// 去掉开头和结尾的引号
|
||||
let cleaned = content.replace(/^"|"$/g, '')
|
||||
|
||||
// 处理转义字符
|
||||
cleaned = cleaned.replace(/\\n/g, '\n') // 换行符
|
||||
cleaned = cleaned.replace(/\\"/g, '"') // 引号
|
||||
cleaned = cleaned.replace(/\\t/g, '\t') // 制表符
|
||||
cleaned = cleaned.replace(/\\\\/g, '\\') // 反斜杠
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const canGenerateChapter = (chapterNumber: number) => {
|
||||
if (!project.value?.blueprint?.chapter_outline) return false
|
||||
|
||||
// 检查前面所有章节是否都已成功生成
|
||||
const outlines = project.value.blueprint.chapter_outline.sort((a, b) => a.chapter_number - b.chapter_number)
|
||||
|
||||
for (const outline of outlines) {
|
||||
if (outline.chapter_number >= chapterNumber) break
|
||||
|
||||
const chapter = project.value?.chapters.find(ch => ch.chapter_number === outline.chapter_number)
|
||||
if (!chapter || chapter.generation_status !== 'successful') {
|
||||
return false // 前面有章节未完成
|
||||
}
|
||||
}
|
||||
|
||||
// 检查当前章节是否已经完成
|
||||
const currentChapter = project.value?.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
if (currentChapter && currentChapter.generation_status === 'successful') {
|
||||
return true // 已完成的章节可以重新生成
|
||||
}
|
||||
|
||||
return true // 前面章节都完成了,可以生成当前章节
|
||||
}
|
||||
|
||||
const isChapterFailed = (chapterNumber: number) => {
|
||||
if (!project.value?.chapters) return false
|
||||
const chapter = project.value.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
return chapter && chapter.generation_status === 'failed'
|
||||
}
|
||||
|
||||
const hasChapterInProgress = (chapterNumber: number) => {
|
||||
if (!project.value?.chapters) return false
|
||||
const chapter = project.value.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
// waiting_for_confirm状态表示等待选择版本 = 进行中状态
|
||||
return chapter && chapter.generation_status === 'waiting_for_confirm'
|
||||
}
|
||||
|
||||
// 可用版本列表 (合并生成结果和已有版本)
|
||||
const availableVersions = computed(() => {
|
||||
// 优先使用新生成的版本(对象数组格式)
|
||||
if (chapterGenerationResult.value?.versions) {
|
||||
console.log('使用生成结果版本:', chapterGenerationResult.value.versions)
|
||||
return chapterGenerationResult.value.versions
|
||||
}
|
||||
|
||||
// 使用章节已有的版本(字符串数组格式,需要转换为对象数组)
|
||||
if (selectedChapter.value?.versions && Array.isArray(selectedChapter.value.versions)) {
|
||||
console.log('原始章节版本 (字符串数组):', selectedChapter.value.versions)
|
||||
|
||||
// 将字符串数组转换为ChapterVersion对象数组
|
||||
const convertedVersions = selectedChapter.value.versions.map((versionString, index) => {
|
||||
console.log(`版本 ${index} 原始字符串:`, versionString)
|
||||
|
||||
try {
|
||||
// 解析JSON字符串
|
||||
const versionObj = JSON.parse(versionString)
|
||||
console.log(`版本 ${index} 解析后的对象:`, versionObj)
|
||||
|
||||
// 提取content字段作为实际内容
|
||||
const actualContent = versionObj.content || versionString
|
||||
|
||||
console.log(`版本 ${index} 实际内容:`, actualContent.substring(0, 100) + '...')
|
||||
|
||||
return {
|
||||
content: actualContent,
|
||||
style: '标准' // 默认风格
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果JSON解析失败,直接使用原始字符串
|
||||
console.log(`版本 ${index} JSON解析失败,使用原始字符串:`, error)
|
||||
return {
|
||||
content: versionString,
|
||||
style: '标准'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('转换后的版本对象:', convertedVersions)
|
||||
return convertedVersions
|
||||
}
|
||||
|
||||
console.log('没有可用版本,selectedChapter:', selectedChapter.value)
|
||||
return []
|
||||
})
|
||||
|
||||
|
||||
// 方法
|
||||
const goBack = () => {
|
||||
router.push('/workspace')
|
||||
}
|
||||
|
||||
const viewProjectDetail = () => {
|
||||
if (project.value) {
|
||||
router.push(`/detail/${project.value.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
const closeSidebar = () => {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
await novelStore.loadProject(props.id)
|
||||
} catch (error) {
|
||||
console.error('加载项目失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchChapterStatus = async () => {
|
||||
if (selectedChapterNumber.value === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await novelStore.loadChapter(selectedChapterNumber.value)
|
||||
console.log('Chapter status polled and updated.')
|
||||
} catch (error) {
|
||||
console.error('轮询章节状态失败:', error)
|
||||
// 在这里可以决定是否要通知用户轮询失败
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 显示版本详情
|
||||
const showVersionDetail = (versionIndex: number) => {
|
||||
detailVersionIndex.value = versionIndex
|
||||
showVersionDetailModal.value = true
|
||||
}
|
||||
|
||||
// 关闭版本详情弹窗
|
||||
const closeVersionDetail = () => {
|
||||
showVersionDetailModal.value = false
|
||||
}
|
||||
|
||||
// 隐藏版本选择器,返回内容视图
|
||||
const hideVersionSelector = () => {
|
||||
// Now controlled by computed property, but we can clear the generation result
|
||||
chapterGenerationResult.value = null
|
||||
selectedVersionIndex.value = 0
|
||||
}
|
||||
|
||||
const selectChapter = (chapterNumber: number) => {
|
||||
selectedChapterNumber.value = chapterNumber
|
||||
chapterGenerationResult.value = null
|
||||
selectedVersionIndex.value = 0
|
||||
closeSidebar()
|
||||
}
|
||||
|
||||
const generateChapter = async (chapterNumber: number) => {
|
||||
// 检查是否可以生成该章节
|
||||
if (!canGenerateChapter(chapterNumber) && !isChapterFailed(chapterNumber) && !hasChapterInProgress(chapterNumber)) {
|
||||
globalAlert.showError('请按顺序生成章节,先完成前面的章节', '生成受限')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
generatingChapter.value = chapterNumber
|
||||
selectedChapterNumber.value = chapterNumber
|
||||
|
||||
// 在本地更新章节状态为generating
|
||||
if (project.value?.chapters) {
|
||||
const chapter = project.value.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
if (chapter) {
|
||||
chapter.generation_status = 'generating'
|
||||
} else {
|
||||
// If chapter does not exist, create a temporary one to show generating state
|
||||
const outline = project.value.blueprint?.chapter_outline?.find(o => o.chapter_number === chapterNumber)
|
||||
project.value.chapters.push({
|
||||
chapter_number: chapterNumber,
|
||||
title: outline?.title || '加载中...',
|
||||
summary: outline?.summary || '',
|
||||
content: '',
|
||||
versions: [],
|
||||
evaluation: null,
|
||||
generation_status: 'generating'
|
||||
} as Chapter)
|
||||
}
|
||||
}
|
||||
|
||||
await novelStore.generateChapter(chapterNumber)
|
||||
|
||||
// store 中的 project 已经被更新,所以我们不需要手动修改本地状态
|
||||
// chapterGenerationResult 也不再需要,因为 availableVersions 会从更新后的 project.chapters 中获取数据
|
||||
// showVersionSelector is now a computed property and will update automatically.
|
||||
chapterGenerationResult.value = null
|
||||
selectedVersionIndex.value = 0
|
||||
} catch (error) {
|
||||
console.error('生成章节失败:', error)
|
||||
|
||||
// 错误状态的本地更新仍然是必要的,以立即反映UI
|
||||
if (project.value?.chapters) {
|
||||
const chapter = project.value.chapters.find(ch => ch.chapter_number === chapterNumber)
|
||||
if (chapter) {
|
||||
chapter.generation_status = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
globalAlert.showError(`生成章节失败: ${error instanceof Error ? error.message : '未知错误'}`, '生成失败')
|
||||
} finally {
|
||||
generatingChapter.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const regenerateChapter = async () => {
|
||||
if (selectedChapterNumber.value !== null) {
|
||||
await generateChapter(selectedChapterNumber.value)
|
||||
}
|
||||
}
|
||||
|
||||
const selectVersion = async (versionIndex: number) => {
|
||||
if (selectedChapterNumber.value === null || !availableVersions.value?.[versionIndex]?.content) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 在本地立即更新状态以反映UI
|
||||
if (project.value?.chapters) {
|
||||
const chapter = project.value.chapters.find(ch => ch.chapter_number === selectedChapterNumber.value)
|
||||
if (chapter) {
|
||||
chapter.generation_status = 'selecting'
|
||||
}
|
||||
}
|
||||
|
||||
selectedVersionIndex.value = versionIndex
|
||||
await novelStore.selectChapterVersion(selectedChapterNumber.value, versionIndex)
|
||||
|
||||
// 状态更新将由 store 自动触发,本地无需手动更新
|
||||
// 轮询机制会处理状态变更,成功后会自动隐藏选择器
|
||||
// showVersionSelector.value = false
|
||||
chapterGenerationResult.value = null
|
||||
globalAlert.showSuccess('版本已确认', '操作成功')
|
||||
} catch (error) {
|
||||
console.error('选择章节版本失败:', error)
|
||||
// 错误状态下恢复章节状态
|
||||
if (project.value?.chapters) {
|
||||
const chapter = project.value.chapters.find(ch => ch.chapter_number === selectedChapterNumber.value)
|
||||
if (chapter) {
|
||||
chapter.generation_status = 'waiting_for_confirm' // Or the previous state
|
||||
}
|
||||
}
|
||||
globalAlert.showError(`选择章节版本失败: ${error instanceof Error ? error.message : '未知错误'}`, '选择失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 从详情弹窗中选择版本
|
||||
const selectVersionFromDetail = async () => {
|
||||
selectedVersionIndex.value = detailVersionIndex.value
|
||||
await selectVersion(detailVersionIndex.value)
|
||||
closeVersionDetail()
|
||||
}
|
||||
|
||||
const confirmVersionSelection = async () => {
|
||||
await selectVersion(selectedVersionIndex.value)
|
||||
}
|
||||
|
||||
const openEditChapterModal = (chapter: ChapterOutline) => {
|
||||
editingChapter.value = chapter
|
||||
showEditChapterModal.value = true
|
||||
}
|
||||
|
||||
const saveChapterChanges = async (updatedChapter: ChapterOutline) => {
|
||||
try {
|
||||
await novelStore.updateChapterOutline(updatedChapter)
|
||||
globalAlert.showSuccess('章节大纲已更新', '保存成功')
|
||||
} catch (error) {
|
||||
console.error('更新章节大纲失败:', error)
|
||||
globalAlert.showError(`更新章节大纲失败: ${error instanceof Error ? error.message : '未知错误'}`, '保存失败')
|
||||
} finally {
|
||||
showEditChapterModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const evaluateChapter = async () => {
|
||||
if (selectedChapterNumber.value !== null) {
|
||||
try {
|
||||
// 在本地更新章节状态为evaluating以立即反映在UI上
|
||||
if (project.value?.chapters) {
|
||||
const chapter = project.value.chapters.find(ch => ch.chapter_number === selectedChapterNumber.value)
|
||||
if (chapter) {
|
||||
chapter.generation_status = 'evaluating'
|
||||
}
|
||||
}
|
||||
await novelStore.evaluateChapter(selectedChapterNumber.value)
|
||||
|
||||
// 评审完成后,状态会通过store和轮询更新,这里不需要额外操作
|
||||
globalAlert.showSuccess('章节评审结果已生成', '评审成功')
|
||||
} catch (error) {
|
||||
console.error('评审章节失败:', error)
|
||||
|
||||
// 错误状态下恢复章节状态
|
||||
if (project.value?.chapters) {
|
||||
const chapter = project.value.chapters.find(ch => ch.chapter_number === selectedChapterNumber.value)
|
||||
if (chapter) {
|
||||
chapter.generation_status = 'successful' // 恢复为成功状态
|
||||
}
|
||||
}
|
||||
|
||||
globalAlert.showError(`评审章节失败: ${error instanceof Error ? error.message : '未知错误'}`, '评审失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteChapter = async (chapterNumbers: number | number[]) => {
|
||||
const numbersToDelete = Array.isArray(chapterNumbers) ? chapterNumbers : [chapterNumbers]
|
||||
const confirmationMessage = numbersToDelete.length > 1
|
||||
? `您确定要删除选中的 ${numbersToDelete.length} 个章节吗?这个操作无法撤销。`
|
||||
: `您确定要删除第 ${numbersToDelete[0]} 章吗?这个操作无法撤销。`
|
||||
|
||||
if (window.confirm(confirmationMessage)) {
|
||||
try {
|
||||
await novelStore.deleteChapter(numbersToDelete)
|
||||
globalAlert.showSuccess('章节已删除', '操作成功')
|
||||
// If the currently selected chapter was deleted, unselect it
|
||||
if (selectedChapterNumber.value && numbersToDelete.includes(selectedChapterNumber.value)) {
|
||||
selectedChapterNumber.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除章节失败:', error)
|
||||
globalAlert.showError(`删除章节失败: ${error instanceof Error ? error.message : '未知错误'}`, '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generateOutline = async () => {
|
||||
showGenerateOutlineModal.value = true
|
||||
}
|
||||
|
||||
const editChapterContent = async (data: { chapterNumber: number, content: string }) => {
|
||||
if (!project.value) return
|
||||
|
||||
try {
|
||||
await novelStore.editChapterContent(project.value.id, data.chapterNumber, data.content)
|
||||
globalAlert.showSuccess('章节内容已更新', '保存成功')
|
||||
} catch (error) {
|
||||
console.error('编辑章节内容失败:', error)
|
||||
globalAlert.showError(`编辑章节内容失败: ${error instanceof Error ? error.message : '未知错误'}`, '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateOutline = async (numChapters: number) => {
|
||||
if (!project.value) return
|
||||
isGeneratingOutline.value = true
|
||||
try {
|
||||
const startChapter = (project.value.blueprint?.chapter_outline?.length || 0) + 1
|
||||
await novelStore.generateChapterOutline(startChapter, numChapters)
|
||||
globalAlert.showSuccess('新的章节大纲已生成', '操作成功')
|
||||
} catch (error) {
|
||||
console.error('生成大纲失败:', error)
|
||||
globalAlert.showError(`生成大纲失败: ${error instanceof Error ? error.message : '未知错误'}`, '生成失败')
|
||||
} finally {
|
||||
isGeneratingOutline.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProject()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义样式 */
|
||||
.line-clamp-1 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user