优化: 大纲页数选择重构为标签+一体式分段单选(免下拉一键直达),样式与通道切换统一
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user