Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 95 additions & 22 deletions frontend/src/components/StoryStructureTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
</template>

<script setup lang="ts">
import { ref, computed, h, onMounted, watch } from 'vue'
import { ref, computed, h, onMounted, onUnmounted, watch } from 'vue'
import { NTree, NEmpty, NSpin, NTag, NButton, NSpace, NDropdown, NModal, NInput, useMessage, useDialog } from 'naive-ui'
import { structureApi, type StoryNode } from '@/api/structure'
import { chapterApi } from '@/api/chapter'
Expand All @@ -100,6 +100,10 @@ const emit = defineEmits<{
const message = useMessage()
const dialog = useDialog()

// 容器宽度响应式
const containerWidth = ref(558)
const isNarrow = computed(() => containerWidth.value < 436)

const loading = ref(false)
/** 全托管时空侧栏提示:避免与「启动结构规划」主按钮混淆 */
const autopilotEmptyMode = ref<null | 'planning' | 'review'>(null)
Expand Down Expand Up @@ -201,10 +205,20 @@ const convertToTreeNode = (node: StoryNode): any => {
chapter: '📄',
}
const n = node.number
const displayName =
node.node_type === 'chapter' && typeof n === 'number' && n >= 1
? `第${n}章 ${node.title || ''}`.trim()
: node.title
const isNarrow = containerWidth.value < 238
let displayName = node.title

if (node.node_type === 'chapter' && typeof n === 'number' && n >= 1) {
// 窄屏模式(<238px)章节只显示"第X章"
displayName = isNarrow ? `第${n}章` : `第${n}章 ${node.title || ''}`.trim()
} else if (isNarrow && node.node_type === 'act' && typeof n === 'number') {
// 窄屏模式(<238px)幕只显示"第X幕"
displayName = `第${n}幕`
} else if (isNarrow && node.node_type === 'part' && typeof n === 'number') {
// 窄屏模式(<238px)部只显示"第X部"
displayName = `第${n}部`
}

return {
key: node.id,
label: displayName,
Expand Down Expand Up @@ -269,6 +283,9 @@ const loadTree = async () => {
const hasData = treeData.value.length > 0
emit('treeLoaded', hasData)
await syncAutopilotEmptyHint(hasData)

// 初始化容器宽度
updateContainerWidth()
} catch (e: any) {
message.error(e?.response?.data?.detail || '加载结构失败')
emit('treeLoaded', false)
Expand All @@ -278,6 +295,14 @@ const loadTree = async () => {
}
}

// 更新容器宽度
const updateContainerWidth = () => {
const el = document.querySelector('.story-structure')
if (el) {
containerWidth.value = el.clientWidth
}
}

/** 从结构树章节节点解析「全书章节号」(与 GET .../chapters/{chapter_number} 一致) */
function resolveBookChapterNumber(node: StoryNode): number | null {
if (node.node_type !== 'chapter') return null
Expand Down Expand Up @@ -403,13 +428,14 @@ const doAddChild = async () => {
}
}

// 渲染节点标签
// 渲染节点标签(带 tooltip 显示摘要)
const renderLabel = ({ option }: { option: any }) => {
const elements: any[] = [
h('span', { class: 'node-icon' }, option.icon),
h('span', { class: 'node-title' }, option.display_name),
]
if (option.node_type === 'chapter') {
// 窄屏模式下隐藏 "已收稿" 标签
if (option.node_type === 'chapter' && !isNarrow.value) {
const st = (option as StoryNode & { status?: string }).status
const hasContent =
(option.word_count && option.word_count > 0) || st === 'completed'
Expand All @@ -422,24 +448,27 @@ const renderLabel = ({ option }: { option: any }) => {
}, () => (hasContent ? '已收稿' : '未收稿'))
)
}
return h('span', { class: 'node-label' }, elements)
const node = option as StoryNode
const hasSummary = node.description && ['part', 'volume', 'act'].includes(node.node_type)
return h('span', {
class: 'node-label',
title: hasSummary ? node.description : undefined,
}, elements)
}

// 渲染节点后缀
// 渲染节点后缀(仅显示章节范围/字数,摘要通过 tooltip 显示)
const renderSuffix = ({ option }: { option: any }) => {
const elements: any[] = []
const node = option as StoryNode
if (node.description && ['part', 'volume', 'act'].includes(node.node_type)) {
elements.push(
h('span', {
class: 'node-description',
style: { color: '#999', fontSize: '12px', marginLeft: '8px' },
}, node.description)
)
// 窄屏模式下隐藏 "幕" 和 "章" 后面的统计信息
if (isNarrow.value && (node.node_type === 'act' || node.node_type === 'chapter')) {
return null
}
// 章节节点显示字数
if (node.node_type === 'chapter' && node.word_count) {
elements.push(h('span', { class: 'node-range' }, `${node.word_count}字`))
}
// 部/卷/幕显示章节范围
if (node.chapter_start && node.chapter_end) {
elements.push(
h('span', { class: 'node-range' }, `${node.chapter_start}-${node.chapter_end}章 (${node.chapter_count})`)
Expand All @@ -457,7 +486,43 @@ const nodeProps = ({ option }: { option: any }) => {
}
}

onMounted(() => { loadTree() })
let resizeObserver: ResizeObserver | null = null

onMounted(() => {
loadTree()
// 监听容器宽度变化
const el = document.querySelector('.story-structure')
if (el) {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
containerWidth.value = entry.contentRect.width
}
})
resizeObserver.observe(el)
}
})

// 监听宽度变化,窄屏模式切换时重新渲染树
watch(containerWidth, (newWidth, oldWidth) => {
const crossedThreshold = (newWidth < 238 && oldWidth >= 238) || (newWidth >= 238 && oldWidth < 238)
if (crossedThreshold && treeData.value.length > 0) {
// 重新转换树节点以更新显示名称
const refreshTree = (nodes: StoryNode[]): any[] => nodes.map(node => ({
...convertToTreeNode(node),
children: node.children ? refreshTree(node.children) : []
}))
// 从原始数据重新转换
const originalNodes = treeData.value.map(n => ({ ...n, children: n.children }))
treeData.value = refreshTree(originalNodes)
}
})

onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
})

defineExpose({ loadTree })
</script>
Expand All @@ -484,17 +549,25 @@ defineExpose({ loadTree })
.node-label {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
}
.node-icon { font-size: 16px; }
.node-title { font-size: 13px; }
.node-icon { font-size: 16px; width: 18px; text-align: center; }
.node-title { font-size: 14px; }
.node-range {
font-size: 12px;
font-size: 11px;
color: #999;
margin-left: 8px;
margin-left: 6px;
}
.node-level-1 { font-weight: 600; }
.node-level-2 { font-weight: 500; }
.node-level-3 { font-weight: normal; }
.node-level-4 { font-weight: normal; font-size: 13px; }

/* 紧凑布局 */
:deep(.n-tree-node-content) {
padding: 2px 0;
}
:deep(.n-tree-node-wrapper) {
padding: 1px 0;
}
</style>
27 changes: 18 additions & 9 deletions frontend/src/components/autopilot/AutopilotDashboard.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
<template>
<div class="autopilot-dashboard">
<n-alert type="default" :show-icon="false" class="monitor-copy-hint">
<n-text depth="3" style="font-size: 12px; line-height: 1.5">
<strong>监控说明</strong>:「文风」卡片为按<strong>角色声线</strong>的偏离监测。全书<strong>作者文风指纹</strong>与侧栏「剧本基建」规划为不同能力,与此处互补。
</n-text>
</n-alert>
<!-- 监控网格 -->
<div class="monitor-grid">
<!-- 第一行:张力图表 + 实时日志 -->
Expand Down Expand Up @@ -74,8 +69,7 @@ function handleBreakerReset() {

<style scoped>
.autopilot-dashboard {
height: 100%;
overflow-y: auto;
/* 高度由内容自然撑开,配合 WorkArea 整体滚动 */
}

.monitor-copy-hint {
Expand All @@ -86,12 +80,27 @@ function handleBreakerReset() {
.monitor-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: minmax(200px, auto); /* 第一行最小200px,自动拉伸 */
align-items: stretch; /* 单元格垂直拉伸对齐 */
gap: 16px;
padding: 4px;
padding: 8px;
}

.grid-cell {
min-height: 280px;
min-height: auto;
display: flex;
flex-direction: column;
}

.grid-cell > * {
flex: 1 1 auto; /* 允许子元素拉伸填充 */
min-height: 0;
}

/* 第一行单元格高度一致 */
.grid-cell.span-1,
.grid-cell.span-2 {
height: 100%;
}

.grid-cell.span-1 {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/autopilot/AutopilotPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,8 @@ onUnmounted(() => {
background: linear-gradient(135deg, rgba(24, 160, 88, 0.05) 0%, rgba(24, 160, 88, 0.02) 100%);
border: 1px solid rgba(24, 160, 88, 0.15);
border-radius: 12px;
padding: 16px 18px;
padding: 10px 8px;
/* padding: 14px 16px; */
display: flex;
flex-direction: column;
gap: 12px;
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/components/autopilot/RealtimeLogStream.vue
Original file line number Diff line number Diff line change
Expand Up @@ -520,9 +520,23 @@ onUnmounted(() => {

<style scoped>
.realtime-log-stream {
height: 100%;
min-width: 368px;
Comment on lines 522 to +524

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Drop the hard min-width here.

Below 900px, frontend/src/components/autopilot/AutopilotDashboard.vue switches to a single-column grid, and frontend/src/views/Workbench.vue now reserves ~900px for the two side panes by default. This min-width: 368px forces the log card to overflow/clamp as soon as the center pane is narrower than 368px, which is easy to hit on smaller windows.

Suggested fix
 .realtime-log-stream {
   height: 100%;
-  min-width: 368px;
+  min-width: 0;
+  width: 100%;
   position: relative;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.realtime-log-stream {
height: 100%;
min-width: 368px;
.realtime-log-stream {
height: 100%;
min-width: 0;
width: 100%;
position: relative;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/autopilot/RealtimeLogStream.vue` around lines 522 -
524, The CSS rule in RealtimeLogStream.vue sets a hard min-width on the
.realtime-log-stream class which causes overflow on narrow layouts; remove the
min-width: 368px (or replace it with a responsive rule such as max-width, width:
100%, or a media-query that only applies a min-width at larger breakpoints) so
the RealtimeLogStream component can shrink with the center pane (update the
.realtime-log-stream declaration in RealtimeLogStream.vue accordingly).

position: relative;
}

/* 确保卡片高度填满容器 */
:deep(.n-card) {
height: 100%;
display: flex;
flex-direction: column;
}

:deep(.n-card__content) {
flex: 1;
min-height: 0;
}

.stream-wrap {
position: relative;
}
Expand Down Expand Up @@ -700,7 +714,8 @@ onUnmounted(() => {

/* 日志流主体 */
.stream-body {
height: 280px;
height: 200px; /* 固定高度,与张力心电图一致 */
max-height: 200px;
overflow-y: auto;
padding: 12px 16px;
scroll-behavior: smooth;
Expand Down
Loading
Loading