优化: 大纲页数选择重构为标签+一体式分段单选(免下拉一键直达),样式与通道切换统一

This commit is contained in:
lxy
2026-08-30 02:57:22 +08:00
parent 99f38726a2
commit 989d44f5fa
+171 -18
View File
@@ -3,16 +3,18 @@
生成大纲 逐条编辑 逐页/全部生成 应用到文稿
===================================================================== -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import type { Outline, OutlineItem, Slide } from '../../core/types'
import { store } from '../../core/store'
import { outline as genOutline, generatePage, isConfigured } from '../../core/ai'
defineProps<{ visible: boolean }>()
const props = defineProps<{ visible: boolean; initialOutline?: Outline | null; agentChannel?: boolean }>()
const emit = defineEmits<{
(e: 'busy-change', busy: boolean): void
(e: 'toast', msg: string): void
(e: 'open-settings'): void
/** agent 通道:把生成大纲/单页的指令交由 AiPanel 转发给中继 Agent */
(e: 'request-agent', instruction: string): void
}>()
const outline = ref<Outline | null>(null)
@@ -22,8 +24,60 @@ const editingTitle = ref(false)
const busy = ref(false)
const progress = ref({ cur: 0, total: 0 })
const generatedSlides = ref<Slide[]>([])
const pageCount = ref<'auto' | number>('auto')
/** 页数分段选项:自动=AI 按主题信息量裁量,其余为指定页数 */
const pageCountOpts: { value: 'auto' | number; label: string; tip: string }[] = [
{ value: 'auto', label: '自动', tip: '按主题信息量由 AI 裁量(5-12 页)' },
{ value: 5, label: '5', tip: '固定 5 页' },
{ value: 7, label: '7', tip: '固定 7 页' },
{ value: 10, label: '10', tip: '固定 10 页' },
{ value: 12, label: '12', tip: '固定 12 页' }
]
const expanded = ref<Set<string>>(new Set())
let abortCtrl: AbortController | null = null
function toggleExpand(id: string) {
const s = expanded.value
s.has(id) ? s.delete(id) : s.add(id)
expanded.value = new Set(s)
}
/** 首个生成页:整体替换文稿(保留主题),后续页追加;返回写入的页索引 */
function commitSlide(slide: Slide, isFirst: boolean): number {
if (isFirst) {
store.replaceDeck({ theme: store.theme.value, slides: [slide] }, { newChat: true })
return 0
}
store.appendSlide(slide)
return store.getCount() - 1
}
/** 挂载/外部传入大纲变化时(agent 通道返回 outline op),进入编辑态 */
onMounted(() => {
if (props.initialOutline) applyInitialOutline(props.initialOutline)
})
watch(() => props.initialOutline, (o) => { if (o) applyInitialOutline(o) })
function applyInitialOutline(o: Outline) {
outline.value = o
titleInput.value = o.title
generatedSlides.value = []
expanded.value = new Set()
}
/** 接收 agent 通道生成的单页(由 AiPanel 在 op 返回后调用) */
function acceptAgentSlide(slide: Slide) {
// 对齐第一个未完成条目:generatedSlides 与 items 同索引(空位补 undefined 占位,应用时 filter 掉)
const idx = outline.value ? outline.value.items.findIndex(it => !it.done) : -1
const at = idx >= 0 ? idx : generatedSlides.value.length
while (generatedSlides.value.length < at) generatedSlides.value.push(undefined as unknown as Slide)
generatedSlides.value[at] = slide
const isFirst = doneCount.value === 0
if (idx >= 0) outline.value!.items[idx].done = true
const committed = commitSlide(slide, isFirst)
store.setCurrentIndex(committed)
}
/** kind → 徽标文本与颜色 */
const KIND_META: Record<OutlineItem['kind'], { label: string; color: string }> = {
cover: { label: '封面', color: '#4f46e5' },
@@ -47,14 +101,22 @@ async function onGenOutline() {
if (busy.value) return
const topic = topicInput.value.trim()
if (!topic) { emit('toast', '请先输入主题'); return }
// agent 通道:转自然语言指令走中继,大纲由 agent 返回(AiPanel 填充 initialOutline
if (props.agentChannel) {
emit('request-agent', pageCount.value === 'auto'
? '拟定大纲:主题「' + topic + '」,页数按主题信息量自动裁量(5-12页)'
: '拟定大纲:主题「' + topic + '」,约 ' + pageCount.value + ' 页')
return
}
if (!isConfigured()) { emit('open-settings'); return }
setBusy(true)
abortCtrl = new AbortController()
try {
const r = await genOutline({ topic, signal: abortCtrl.signal })
const r = await genOutline({ topic, count: pageCount.value, signal: abortCtrl.signal })
outline.value = r
titleInput.value = r.title
generatedSlides.value = []
expanded.value = new Set()
emit('toast', '已生成 ' + r.items.length + ' 条大纲')
} catch (e: any) {
if (e?.name === 'AbortError') emit('toast', '已停止')
@@ -85,11 +147,22 @@ function removeItem(idx: number) {
outline.value.items.splice(idx, 1)
}
/** 大纲条目 → 自然语言生成指令(agent 通道用,格式对齐 ai.ts generatePage 的 user 内容) */
function pageInstruction(item: OutlineItem, idx: number, total: number): string {
return '按大纲生成第 ' + (idx + 1) + '/' + total + ' 页幻灯片:\n' +
'类型:' + item.kind + '\n' +
'标题:' + item.title + '\n' +
'要点:' + item.points.map((p, i) => (i + 1) + '. ' + p).join('\n') +
(item.hint ? '\n补充指令:' + item.hint : '')
}
/* ---------- 生成单页 ---------- */
async function onGenOne(idx: number) {
if (busy.value || !outline.value) return
const item = outline.value.items[idx]
if (!item) return
// agent 通道:指令交由 AiPanel 转发,结果经 acceptAgentSlide 回收
if (props.agentChannel) { emit('request-agent', pageInstruction(item, idx, outline.value.items.length)); return }
setBusy(true)
abortCtrl = new AbortController()
try {
@@ -100,8 +173,11 @@ async function onGenOne(idx: number) {
signal: abortCtrl.signal
})
// 保持 generatedSlides 与 items 顺序对齐
const isFirst = doneCount.value === 0
generatedSlides.value[idx] = slide
item.done = true
const committed = commitSlide(slide, isFirst)
store.setCurrentIndex(committed)
emit('toast', '已生成第 ' + (idx + 1) + ' 页')
} catch (e: any) {
if (e?.name === 'AbortError') emit('toast', '已停止')
@@ -115,9 +191,17 @@ async function onGenOne(idx: number) {
async function onGenAll() {
if (busy.value || !outline.value) return
const items = outline.value.items
// agent 通道:发首条指令即返回,AiPanel 在每页回收后驱动下一条(见 onOutlineRequestAgent),保证串行与页面顺序对齐
if (props.agentChannel) {
const next = items.findIndex(it => !it.done)
if (next < 0) { emit('toast', '全部条目已生成'); return }
emit('request-agent', pageInstruction(items[next], next, items.length))
return
}
setBusy(true)
abortCtrl = new AbortController()
progress.value = { cur: 0, total: items.length }
let committedCount = doneCount.value
try {
for (let i = 0; i < items.length; i++) {
if (abortCtrl.signal.aborted) break
@@ -128,8 +212,12 @@ async function onGenAll() {
total: items.length,
signal: abortCtrl.signal
})
const isFirst = committedCount === 0
generatedSlides.value[i] = slide
items[i].done = true
committedCount++
const committed = commitSlide(slide, isFirst)
store.setCurrentIndex(committed)
}
emit('toast', '全部生成完成(' + items.filter(it => it.done).length + '/' + items.length + '')
} catch (e: any) {
@@ -146,14 +234,7 @@ function onStop() {
if (abortCtrl) abortCtrl.abort()
}
/* ---------- 应用到文稿 ---------- */
function onApply() {
if (!outline.value) return
const slides = generatedSlides.value.filter(Boolean)
if (!slides.length) { emit('toast', '请先生成至少一页'); return }
store.replaceDeck({ theme: store.theme.value, slides }, { newChat: true })
emit('toast', '已应用 ' + slides.length + ' 页到文稿')
}
defineExpose({ acceptAgentSlide, requestAgent: (s: string) => emit('request-agent', s) })
</script>
<template>
@@ -165,6 +246,22 @@ function onApply() {
<button v-if="busy" class="btn danger" @click="onStop">停止</button>
</div>
<!-- 页数选择一体式分段单选与通道切换 .seg 同语言一键直达免下拉 -->
<div class="outline-count" role="radiogroup" aria-label="生成页数">
<span class="count-label">页数</span>
<div class="count-seg">
<button
v-for="opt in pageCountOpts" :key="String(opt.value)"
type="button" role="radio" :aria-checked="pageCount === opt.value"
class="count-pill" :class="{ active: pageCount === opt.value }"
:title="opt.tip" :disabled="busy"
@click="pageCount = opt.value"
>{{ opt.label }}</button>
</div>
<span class="count-hint" v-if="pageCount === 'auto'">AI 裁量</span>
<span class="count-hint" v-else>固定 {{ pageCount }} </span>
</div>
<!-- 无大纲时的空态 -->
<div v-if="!outline" class="outline-empty">
输入主题后点击生成大纲<br />AI 会先拟定大纲再逐页生成
@@ -181,16 +278,18 @@ function onApply() {
<!-- 条目列表 -->
<div class="outline-items">
<div v-for="(it, i) in outline.items" :key="it.id" class="outline-item">
<div class="item-head">
<div class="item-head" @click="toggleExpand(it.id)">
<span class="fold-arrow" :class="{ open: expanded.has(it.id) }"></span>
<span class="kind-badge" :style="{ background: KIND_META[it.kind].color }">{{ KIND_META[it.kind].label }}</span>
<input class="item-title" type="text" v-model="it.title" :disabled="busy" />
<input class="item-title" type="text" v-model="it.title" :disabled="busy" @click.stop />
<span class="status" :class="{ done: it.done }">{{ it.done ? '✓ 已生成' : '待生成' }}</span>
<button class="btn small" :disabled="busy" @click="onGenOne(i)" :title="'生成第 ' + (i + 1) + ' 页'">生成此页</button>
<button class="btn small danger" :disabled="busy" @click="removeItem(i)" title="删除">🗑</button>
<button class="btn small" :disabled="busy" @click.stop="onGenOne(i)" :title="'生成第 ' + (i + 1) + ' 页'">生成此页</button>
<button class="btn small danger" :disabled="busy" @click.stop="removeItem(i)" title="删除">🗑</button>
</div>
<div class="item-summary" v-if="!expanded.has(it.id) && it.points.length">{{ it.points[0] }}</div>
<!-- 要点列表 -->
<div class="item-points">
<div class="item-points" v-show="expanded.has(it.id)">
<div v-for="(p, pi) in it.points" :key="pi" class="point-row">
<input type="text" v-model="it.points[pi]" :disabled="busy" placeholder="要点内容" />
<button class="btn small ghost" :disabled="busy" @click="removePoint(it, pi)" title="删除要点"></button>
@@ -199,7 +298,7 @@ function onApply() {
</div>
<!-- hint -->
<div class="item-hint">
<div class="item-hint" v-show="expanded.has(it.id)">
<input type="text" v-model="it.hint" :disabled="busy" placeholder="补充指令(可选,如「用对比卡片」「数据页」" />
</div>
</div>
@@ -210,7 +309,6 @@ function onApply() {
<span class="progress-text" v-if="progress.total">{{ progress.cur }}/{{ progress.total }}</span>
<span class="done-count" v-else>{{ doneCount }}/{{ total }} 页已生成</span>
<button class="btn primary" :disabled="busy || allDone" @click="onGenAll">全部生成</button>
<button class="btn" :disabled="busy || !doneCount" @click="onApply">应用到文稿</button>
</div>
</div>
</div>
@@ -233,6 +331,61 @@ function onApply() {
}
.outline-top input { flex: 1; }
/* 页数分段单选:一体式外壳+互斥 pill,与通道切换(.seg)同设计语言 */
.outline-count {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.count-label {
font-size: 11px;
color: var(--ui-muted);
flex-shrink: 0;
}
.count-seg {
display: inline-flex;
border: 1px solid var(--ui-border);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--ui-panel);
}
.count-pill {
height: 24px;
padding: 0 10px;
border: none;
background: none;
cursor: pointer;
font-size: 12px;
color: var(--ui-muted);
transition: background .12s, color .12s;
}
.count-pill + .count-pill { border-left: 1px solid var(--ui-border); }
.count-pill:hover:not(:disabled):not(.active) { background: var(--ui-primary-soft); }
.count-pill.active {
background: var(--ui-primary-soft);
color: var(--ui-primary);
font-weight: 600;
}
.count-pill:disabled { cursor: not-allowed; opacity: .55; }
.count-hint {
font-size: 11px;
color: var(--ui-muted);
opacity: .85;
}
.fold-arrow {
display: inline-block; font-size: 10px; color: var(--ui-muted);
transition: transform .15s; flex-shrink: 0; cursor: pointer;
}
.fold-arrow.open { transform: rotate(90deg); }
.item-head { cursor: pointer; }
.item-head input, .item-head button { cursor: auto; }
.item-summary {
font-size: 11px; color: var(--ui-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
margin: -2px 0 4px 22px;
}
.outline-empty {
color: var(--ui-muted);
font-size: 13px;