From f7d994842a8665c910995e9a63991202a2fb8fd1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com>
Date: Sun, 30 Aug 2026 04:36:28 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20list=E5=8F=8C=E9=87=8D?=
=?UTF-8?q?=E6=A0=87=E8=AE=B0(=E8=87=AA=E5=B8=A6=E7=BC=96=E5=8F=B7?=
=?UTF-8?q?=E9=9A=90=E8=97=8F=E5=9C=86=E7=82=B9)+=E6=B7=B1=E5=BA=95accent?=
=?UTF-8?q?=E5=B0=8F=E5=AD=97=E7=A6=81=E7=94=A8+=E6=97=A5=E6=9C=9F?=
=?UTF-8?q?=E6=97=B6=E6=95=88=E5=9F=BA=E5=87=86+=E6=95=B4=E5=A5=97?=
=?UTF-8?q?=E8=A7=84=E5=88=92=E6=B3=A8=E5=85=A5=E5=8D=95=E9=A1=B5=E7=94=9F?=
=?UTF-8?q?=E6=88=90(=E5=AD=97=E5=8F=B7/=E8=A3=85=E9=A5=B0=E4=B8=80?=
=?UTF-8?q?=E8=87=B4=E6=80=A7,pageInstruction=E5=8F=8C=E9=80=9A=E9=81=93?=
=?UTF-8?q?=E5=85=B1=E4=BA=AB)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/ai/AiPanel.vue | 17 ++++------
src/components/ai/OutlinePanel.vue | 18 +++++-----
src/components/editor/ElementView.vue | 8 ++---
src/core/ai.ts | 31 ++++++++++++++++--
src/core/richtext.ts | 8 +++++
src/styles/editor.css | 3 ++
tests/list-marker.test.ts | 47 +++++++++++++++++++++++++++
7 files changed, 105 insertions(+), 27 deletions(-)
create mode 100644 tests/list-marker.test.ts
diff --git a/src/components/ai/AiPanel.vue b/src/components/ai/AiPanel.vue
index 1d7da5a..18d1489 100644
--- a/src/components/ai/AiPanel.vue
+++ b/src/components/ai/AiPanel.vue
@@ -7,7 +7,7 @@
import { ref, nextTick, computed, watch, onMounted, onUnmounted } from 'vue'
import type { ChatMessage, AiOp, Outline, Slide } from '../../core/types'
import { store } from '../../core/store'
-import { polish, chat, beautifyPage, isConfigured, buildAgentPrompt, parseChatReply } from '../../core/ai'
+import { polish, chat, beautifyPage, isConfigured, buildAgentPrompt, parseChatReply, pageInstruction } from '../../core/ai'
import { relay, type RelayStatus } from '../../core/relay'
import { elementTypes } from '../../core/sample'
import { renderMd } from '../../core/markdown'
@@ -594,18 +594,13 @@ function driveNextOutlinePage() {
if (!panel || !agentOutline.value) return
const next = agentOutline.value.items.findIndex(it => !it.done)
if (next < 0) { toast('大纲全部页已生成'); return }
- panel.requestAgent(pageInstructionOf(next), next)
+ panel.requestAgent(pageInstruction(agentOutline.value!.items[next], next, agentOutline.value!.items.length, curAgentPlan()), next)
agentPendingPageIdx = next
}
-/** 大纲第 idx 条的生成指令(与 OutlinePanel.pageInstruction 同格式) */
-function pageInstructionOf(idx: number): string {
- const items = agentOutline.value!.items
- const it = items[idx]
- return '按大纲生成第 ' + (idx + 1) + '/' + items.length + ' 页幻灯片:\n' +
- '类型:' + it.kind + '\n' +
- '标题:' + it.title + '\n' +
- '要点:' + it.points.map((p, i) => (i + 1) + '. ' + p).join('\n') +
- (it.hint ? '\n补充指令:' + it.hint : '')
+/** agent 通道当前大纲 → 整套规划摘要(注入单页指令,约束套内风格一致) */
+function curAgentPlan() {
+ const o = agentOutline.value
+ return o ? { title: o.title, items: o.items.map(it => ({ kind: it.kind, title: it.title })) } : undefined
}
/* ---------- 一键美化 ---------- */
diff --git a/src/components/ai/OutlinePanel.vue b/src/components/ai/OutlinePanel.vue
index f1c6cd0..f396bee 100644
--- a/src/components/ai/OutlinePanel.vue
+++ b/src/components/ai/OutlinePanel.vue
@@ -6,7 +6,7 @@
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import type { Outline, OutlineItem, Slide } from '../../core/types'
import { store } from '../../core/store'
-import { outline as genOutline, generatePage, isConfigured } from '../../core/ai'
+import { outline as genOutline, generatePage, pageInstruction, isConfigured } from '../../core/ai'
import Icon from '../common/Icon.vue'
const props = defineProps<{ visible: boolean; initialOutline?: Outline | null; agentChannel?: boolean; agentBusy?: boolean }>()
@@ -154,13 +154,9 @@ 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 : '')
+/** 当前大纲 → 整套规划摘要(注入单页生成,约束套内风格一致) */
+function curPlan() {
+ return outline.value ? { title: outline.value.title, items: outline.value.items.map(it => ({ kind: it.kind, title: it.title })) } : undefined
}
/* ---------- 生成单页 ---------- */
@@ -169,7 +165,7 @@ async function onGenOne(idx: number) {
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), idx); return }
+ if (props.agentChannel) { emit('request-agent', pageInstruction(item, idx, outline.value.items.length, curPlan()), idx); return }
setBusy(true)
abortCtrl = new AbortController()
try {
@@ -177,6 +173,7 @@ async function onGenOne(idx: number) {
item,
index: idx,
total: outline.value.items.length,
+ plan: curPlan(),
signal: abortCtrl.signal
})
// 保持 generatedSlides 与 items 顺序对齐
@@ -202,7 +199,7 @@ async function onGenAll() {
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), next)
+ emit('request-agent', pageInstruction(items[next], next, items.length, curPlan()), next)
return
}
setBusy(true)
@@ -217,6 +214,7 @@ async function onGenAll() {
item: items[i],
index: i,
total: items.length,
+ plan: curPlan(),
signal: abortCtrl.signal
})
const isFirst = committedCount === 0
diff --git a/src/components/editor/ElementView.vue b/src/components/editor/ElementView.vue
index a06b7f1..c546b7b 100644
--- a/src/components/editor/ElementView.vue
+++ b/src/components/editor/ElementView.vue
@@ -7,7 +7,7 @@ import { computed } from 'vue'
import type { SlideElement, BgKey } from '../../core/types'
import { store, resolveColor, isDarkBg } from '../../core/store'
import { resolveRef } from '../../core/assets'
-import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext'
+import { segmentsToHtml, markdownToSegments, hasFormatting, hasLineMarker } from '../../core/richtext'
import ChartView from './ChartView.vue'
import Icon from '../common/Icon.vue'
@@ -240,15 +240,15 @@ function onBlur(e: Event, field: string) {
-
{{ line }}
+
{{ line }}
-
{{ line }}
+
{{ line }}
diff --git a/src/core/ai.ts b/src/core/ai.ts
index 6f7cc8d..49061c8 100644
--- a/src/core/ai.ts
+++ b/src/core/ai.ts
@@ -34,8 +34,13 @@ const ERROR_HINTS_BY_STATUS: Record = {
/* ============================================================
* Prompt 模板
* ============================================================ */
+/** 时效基准:本地日期 y-m-d(toISOString 走 UTC,跨时区会偏一天,故本地拼装) */
+const now = new Date()
+const TODAY = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0')
+
const SYS_BASE =
'你是「u-ppt」的内容创作助手,擅长把主题变成结构清晰、视觉现代、带入场动效的中文演示稿。\n' +
+ '时效基准:当前日期是 ' + TODAY + '。内容中的年份、日期、季度必须与此一致,禁止臆造过时或未来的年份。\n' +
'输出必须严格遵循下面的数据模型,坐标用百分比(0-100),字号为数字。\n\n' +
'幻灯片模型:\n' +
'{ "slides": [ { "background": "bg|panel|primary|accent|g-primary|g-deep|g-soft", "elements": [ 元素, ... ] } ] }\n' +
@@ -76,6 +81,8 @@ const SYS_BASE =
'- 装饰克制:禁止用多个形状拼组合图案(房子/人物/山丘/图标等);不要用形状当分隔线、进度条、底座;没有明确版式作用就不放形状,宁缺毋滥。\n' +
'- 纵向骨架:内容页标题 y=8 h=10,正文/列表/表格/卡片组从 y≈22-26 开始,按内容量给 h——列表 h≈6+条数×8,表格 h≈12+行数×9,卡片组下缘到 y≈85 收底。\n' +
'- 内容少时缩小 h 并整体上移,空白留在页面底部;标题与正文间不留大空档。\n' +
+ '- list 渲染层每行自带圆点,content 行首不要再写「•」「-」「①」等编号或符号前缀。\n' +
+ '- 深底页(g-primary/g-deep/primary/accent 背景)上,正文/脚注/小字不要用 accent(与渐变背景混同),用默认 muted;accent 仅用于大号元素(大数字/大标题)。\n' +
'- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' +
'内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' +
'- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' +
@@ -746,9 +753,28 @@ const SYS_OUTLINE =
const SYS_GEN_PAGE =
SYS_BASE +
'\n任务:根据大纲中的一条,生成「一页」幻灯片。严格遵循要点与 hint,不要偏离主题。\n' +
+ '若提供整套规划,标题字号与装饰密度须与同套其他页一致。\n' +
'严格输出:{ "background":"...", "elements":[ ... ] }(单个 slide 对象,不要数组)。\n' +
'kind=cover 用 g-primary 背景;kind=quote 用 g-deep;kind=end 用 g-primary;kind=content 数据页用 stat+chart。'
+/** 整套规划摘要:注入每次单页生成的提示词,约束套内风格一致 */
+export function planDigest(plan: { title: string; items: { kind: string; title: string }[] }): string {
+ const list = plan.items.map((it, i) => (i + 1) + '.[' + it.kind + '] ' + it.title).join(' / ')
+ return '整套规划「' + plan.title + '」共 ' + plan.items.length + ' 页:' + list +
+ '(一致性要求:内容页标题 fontSize 统一 44,正文 20-24;背景惯例 cover/end=g-primary、quote=g-deep、content=bg/panel)'
+}
+
+/** 大纲条目 → 自然语言生成指令(本地直连与 agent 通道共用,格式对齐 generatePage 的 user 内容);
+ * 有整套规划时在末尾追加 planDigest,约束套内风格一致 */
+export function pageInstruction(item: OutlineItem, idx: number, total: number, plan?: { title: string; items: { kind: string; title: string }[] }): 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 : '') +
+ (plan ? '\n' + planDigest(plan) : '')
+}
+
const SYS_THEME =
'你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色(primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' +
'要求:\n' +
@@ -789,13 +815,14 @@ export async function outline(opts: { topic: string; count?: number | 'auto'; si
/* ---------- 2. 按大纲条目生成单页 ---------- */
-export async function generatePage(opts: { item: OutlineItem; index: number; total: number; signal?: AbortSignal }): Promise {
+export async function generatePage(opts: { item: OutlineItem; index: number; total: number; plan?: { title: string; items: { kind: string; title: string }[] }; signal?: AbortSignal }): Promise {
const user =
'大纲第 ' + (opts.index + 1) + '/' + opts.total + ' 页:\n' +
'类型:' + opts.item.kind + '\n' +
'标题:' + opts.item.title + '\n' +
'要点:' + opts.item.points.map((p, i) => (i + 1) + '. ' + p).join('\n') +
- (opts.item.hint ? '\n补充指令:' + opts.item.hint : '')
+ (opts.item.hint ? '\n补充指令:' + opts.item.hint : '') +
+ (opts.plan ? '\n' + planDigest(opts.plan) : '')
const messages: Message[] = [
{ role: 'system', content: SYS_GEN_PAGE },
{ role: 'user', content: user }
diff --git a/src/core/richtext.ts b/src/core/richtext.ts
index bec24e7..eea4806 100644
--- a/src/core/richtext.ts
+++ b/src/core/richtext.ts
@@ -170,6 +170,14 @@ export function hasFormatting(lines: RichLine[]): boolean {
return false
}
+/* ---------- 工具:行首是否自带列表标记(圆点/编号,用于避免渲染层双重标记) ---------- */
+
+/** 检测行首(允许空白)是否自带列表标记:圈号①-⑳ / 阿拉伯数字+点顿 / 圆点符号 / 中文数字+点顿 */
+export function hasLineMarker(line: string): boolean {
+ if (!line) return false
+ return /^[\s]*(?:[①-⑳]|[((]?\d{1,2}[)).、.]|[-•·▪●○*]|[一二三四五六七八九十]+[、..])/.test(line)
+}
+
/* ---------- 工具:安全化(AI 输出或外部数据 → 合法 segments) ---------- */
export function normSegments(input: any): RichLine[] | undefined {
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 4dd2944..b48282a 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -174,6 +174,9 @@
content: ""; position: absolute; left: 0; top: .55em;
width: .35em; height: .35em; border-radius: 50%; background: currentColor;
}
+/* 行首自带编号/符号(①、1.、- 等)时不再画圆点,避免双重标记 */
+.el-list .li.no-marker::before { display: none; }
+.el-list .li.no-marker { padding-left: 0; }
.el-stat { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.el-stat .num { font-weight: 800; line-height: 1; }
.el-stat .label { margin-top: .35em; text-align: center; }
diff --git a/tests/list-marker.test.ts b/tests/list-marker.test.ts
new file mode 100644
index 0000000..02fcb2a
--- /dev/null
+++ b/tests/list-marker.test.ts
@@ -0,0 +1,47 @@
+/* list-marker.test.ts — hasLineMarker 行首列表标记检测验证 */
+import { describe, it, expect } from 'vitest'
+import { hasLineMarker } from '../src/core/richtext'
+
+describe('hasLineMarker 行首列表标记检测', () => {
+ it('圈号 ①-⑳ 识别为标记', () => {
+ expect(hasLineMarker('① 第一点')).toBe(true)
+ expect(hasLineMarker('⑳ 最后一点')).toBe(true)
+ })
+
+ it('阿拉伯数字+点/顿/括号 识别为标记', () => {
+ expect(hasLineMarker('1. 标题')).toBe(true)
+ expect(hasLineMarker('12、要点')).toBe(true)
+ expect(hasLineMarker('3)要点')).toBe(true)
+ expect(hasLineMarker('(4). 要点')).toBe(true)
+ expect(hasLineMarker('5.全角点')).toBe(true)
+ })
+
+ it('圆点类符号识别为标记', () => {
+ expect(hasLineMarker('- 要点')).toBe(true)
+ expect(hasLineMarker('• 要点')).toBe(true)
+ expect(hasLineMarker('· 要点')).toBe(true)
+ expect(hasLineMarker('▪ 要点')).toBe(true)
+ expect(hasLineMarker('● 要点')).toBe(true)
+ expect(hasLineMarker('○ 要点')).toBe(true)
+ expect(hasLineMarker('* 强调')).toBe(true)
+ })
+
+ it('中文数字+点顿 识别为标记', () => {
+ expect(hasLineMarker('一、概述')).toBe(true)
+ expect(hasLineMarker('十二. 展开')).toBe(true)
+ expect(hasLineMarker('十.收尾')).toBe(true)
+ })
+
+ it('行首空白不影响检测', () => {
+ expect(hasLineMarker(' ① 缩进的圈号')).toBe(true)
+ expect(hasLineMarker('\t- 制表符开头的圆点')).toBe(true)
+ })
+
+ it('普通文本返回 false', () => {
+ expect(hasLineMarker('华东 Q3 增长 23%')).toBe(false)
+ expect(hasLineMarker('第一点(无标点编号)')).toBe(false)
+ expect(hasLineMarker('')).toBe(false)
+ expect(hasLineMarker('2026 年趋势')).toBe(false)
+ expect(hasLineMarker('1 比 2 更好')).toBe(false)
+ })
+})