diff --git a/backend/database.py b/backend/database.py
index 3f01b4d..a409e87 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -45,3 +45,11 @@ async def init_db():
)
if "request_payload_json" not in document_columns:
await conn.execute(text("ALTER TABLE documents ADD COLUMN request_payload_json TEXT"))
+ paragraph_columns = await conn.run_sync(
+ lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("paragraphs")]
+ )
+ if "anchor_title" not in paragraph_columns:
+ await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN anchor_title VARCHAR(500) DEFAULT ''"))
+ await conn.execute(text("UPDATE paragraphs SET anchor_title = title WHERE anchor_title = '' OR anchor_title IS NULL"))
+ if "write_mode" not in paragraph_columns:
+ await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN write_mode VARCHAR(30) DEFAULT 'replace_section'"))
diff --git a/backend/models/paragraph.py b/backend/models/paragraph.py
index 2c98aec..9e144ca 100644
--- a/backend/models/paragraph.py
+++ b/backend/models/paragraph.py
@@ -6,12 +6,14 @@ class Paragraph(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
sort_index = Column(Integer, default=0, comment="排序")
+ anchor_title = Column(String(500), default="", comment="原始标题锚点")
title = Column(String(500), default="", comment="段落标题")
content = Column(Text, default="", comment="正文内容/上下文")
style_json = Column(Text, default="{}", comment="段落样式定义JSON")
is_table = Column(Boolean, default=False, comment="是否为表格")
table_json = Column(Text, default="{}", comment="表格结构JSON")
- edit_mode = Column(String(20), default="ai", comment="manual/ai")
+ edit_mode = Column(String(20), default="manual", comment="manual/ai")
+ write_mode = Column(String(30), default="replace_section", comment="replace_section/append_after_heading/replace_heading_only")
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型")
need_prompt = Column(Boolean, default=True, comment="是否需要提示词")
prompt_text = Column(Text, default="", comment="预设提示词")
diff --git a/backend/routers/export.py b/backend/routers/export.py
index c564741..9d3e3ae 100644
--- a/backend/routers/export.py
+++ b/backend/routers/export.py
@@ -49,7 +49,9 @@ async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
for log, paragraph in result.all():
logs.append(
{
+ "anchor_title": paragraph.anchor_title or paragraph.title,
"title": paragraph.title,
+ "write_mode": paragraph.write_mode,
"content": json.loads(log.content) if log.content else {"content": []},
}
)
diff --git a/backend/routers/models.py b/backend/routers/models.py
index 3f39e21..0041efb 100644
--- a/backend/routers/models.py
+++ b/backend/routers/models.py
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+import httpx
from database import get_db
from models.ai_model import AiModel
@@ -11,6 +12,11 @@ from services.security import decrypt_text, encrypt_text, mask_secret
router = APIRouter()
+def _is_deepseek_model(model: AiModel) -> bool:
+ provider = (model.provider or "").strip().lower()
+ return provider == "deepseek"
+
+
def _serialize_model(model: AiModel) -> dict:
api_key = decrypt_text(model.api_key_encrypted)
return {
@@ -121,3 +127,36 @@ async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):
message=str(error),
data={"id": model.id, "success": False},
)
+
+
+@router.get("/{model_id}/balance")
+async def get_model_balance(model_id: int, db: AsyncSession = Depends(get_db)):
+ model = await db.get(AiModel, model_id)
+ if model is None:
+ raise HTTPException(status_code=404, detail="模型不存在")
+ if not _is_deepseek_model(model):
+ raise HTTPException(status_code=400, detail="仅 DeepSeek 模型支持余额查询")
+
+ api_key = decrypt_text(model.api_key_encrypted)
+ if not api_key:
+ raise HTTPException(status_code=400, detail="模型 API Key 不可用")
+
+ try:
+ async with httpx.AsyncClient(timeout=20, trust_env=False) as client:
+ response = await client.get(
+ "https://api.deepseek.com/user/balance",
+ headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"},
+ )
+ response.raise_for_status()
+ payload = response.json()
+ except Exception as error:
+ raise HTTPException(status_code=400, detail=f"查询余额失败:{error}")
+
+ return Response(
+ data={
+ "id": model.id,
+ "provider": model.provider,
+ "is_available": payload.get("is_available", False),
+ "balance_infos": payload.get("balance_infos", []),
+ }
+ )
diff --git a/backend/routers/templates.py b/backend/routers/templates.py
index 119a117..2bd0314 100644
--- a/backend/routers/templates.py
+++ b/backend/routers/templates.py
@@ -32,12 +32,14 @@ def _serialize_paragraph(paragraph: Paragraph) -> dict:
"id": paragraph.id,
"template_id": paragraph.template_id,
"sort_index": paragraph.sort_index,
+ "anchor_title": paragraph.anchor_title,
"title": paragraph.title,
"content": paragraph.content,
"style_json": paragraph.style_json,
"is_table": paragraph.is_table,
"table_json": paragraph.table_json,
"edit_mode": paragraph.edit_mode,
+ "write_mode": paragraph.write_mode,
"model_id": paragraph.model_id,
"need_prompt": paragraph.need_prompt,
"prompt_text": paragraph.prompt_text,
@@ -151,11 +153,14 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
paragraph = Paragraph(
template_id=template.id,
sort_index=item.sort_index,
+ anchor_title=item.anchor_title,
title=item.title,
content=item.content,
style_json=item.style_json,
is_table=item.is_table,
table_json=item.table_json,
+ edit_mode="manual",
+ write_mode=item.write_mode,
)
db.add(paragraph)
paragraph_rows.append(paragraph)
@@ -180,16 +185,30 @@ async def save_template_paragraphs(
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
- result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
- paragraph_map = {item.id: item for item in result.scalars().all()}
+ result = await db.execute(
+ select(Paragraph)
+ .where(Paragraph.template_id == template_id)
+ .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
+ )
+ existing_paragraphs = result.scalars().all()
+ paragraph_map = {item.id: item for item in existing_paragraphs}
+ incoming_ids = {config.id for config in body.paragraphs if config.id}
- for config in body.paragraphs:
- paragraph = paragraph_map.get(config.id)
+ for paragraph in existing_paragraphs:
+ if paragraph.id not in incoming_ids:
+ await db.delete(paragraph)
+
+ for index, config in enumerate(body.paragraphs, start=1):
+ paragraph = paragraph_map.get(config.id) if config.id else None
if paragraph is None:
- continue
- paragraph.sort_index = config.sort_index
+ paragraph = Paragraph(template_id=template_id)
+ db.add(paragraph)
+ paragraph.sort_index = index
+ paragraph.anchor_title = config.anchor_title or config.title or paragraph.anchor_title
paragraph.title = config.title
+ paragraph.content = config.content
paragraph.edit_mode = config.edit_mode
+ paragraph.write_mode = config.write_mode
paragraph.model_id = config.model_id
paragraph.need_prompt = config.need_prompt
paragraph.prompt_text = config.prompt_text
@@ -197,6 +216,8 @@ async def save_template_paragraphs(
paragraph.file_note = config.file_note
paragraph.output_format = config.output_format
+ await db.commit()
+ template.paragraph_count = len(body.paragraphs)
await db.commit()
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
diff --git a/backend/schemas/schemas.py b/backend/schemas/schemas.py
index 64da231..32f7b3a 100644
--- a/backend/schemas/schemas.py
+++ b/backend/schemas/schemas.py
@@ -31,8 +31,11 @@ class TemplateOut(BaseModel):
class ParagraphConfig(BaseModel):
id: int = 0
sort_index: int = 0
+ anchor_title: str = ""
title: str = ""
- edit_mode: str = "ai"
+ content: str = ""
+ edit_mode: str = "manual"
+ write_mode: str = "replace_section"
model_id: Optional[int] = None
need_prompt: bool = True
prompt_text: str = ""
diff --git a/backend/services/document_export.py b/backend/services/document_export.py
index 1becaca..86c0aef 100644
--- a/backend/services/document_export.py
+++ b/backend/services/document_export.py
@@ -56,6 +56,34 @@ def _copy_run_format(target_run, source_paragraph: Paragraph | None):
break
+def _extract_first_run_format(source_paragraph: Paragraph | None):
+ if source_paragraph is None:
+ return None
+ for source_run in source_paragraph.runs:
+ if source_run._element.rPr is not None:
+ return deepcopy(source_run._element.rPr)
+ return None
+
+
+def _set_paragraph_text(
+ paragraph: Paragraph,
+ text: str,
+ style_name: str | None = None,
+ template_paragraph: Paragraph | None = None,
+):
+ run_format = _extract_first_run_format(template_paragraph)
+ _clear_paragraph(paragraph)
+ if style_name:
+ try:
+ paragraph.style = style_name
+ except Exception:
+ pass
+ if text:
+ run = paragraph.add_run(text)
+ if run_format is not None:
+ run._element.insert(0, run_format)
+
+
def _append_paragraph_after(
paragraph: Paragraph,
text: str,
@@ -166,16 +194,12 @@ def _find_heading_paragraph(document: DocumentObject, heading_text: str, after_e
return None
-def _replace_section_content(document: DocumentObject, heading_title: str, content: dict, after_element=None):
- heading = _find_heading_paragraph(document, heading_title, after_element)
- if heading is None:
- return after_element
-
+def _collect_section_templates(heading: Paragraph):
first_body_style = None
paragraph_template = None
table_template = None
+ blocks = []
current = heading._element.getnext()
- blocks_to_remove = []
while current is not None:
if isinstance(current, CT_P):
current_paragraph = Paragraph(current, heading._parent)
@@ -185,49 +209,132 @@ def _replace_section_content(document: DocumentObject, heading_title: str, conte
first_body_style = current_paragraph.style.name
if paragraph_template is None:
paragraph_template = current_paragraph
- blocks_to_remove.append(current_paragraph)
+ blocks.append(current_paragraph)
elif isinstance(current, CT_Tbl):
current_table = Table(current, heading._parent)
if table_template is None:
table_template = current_table
- blocks_to_remove.append(current_table)
+ blocks.append(current_table)
current = current.getnext()
+ return first_body_style, paragraph_template, table_template, blocks
- for block in blocks_to_remove:
- _delete_block(block)
- insert_after = heading
+def _insert_content_after(
+ insert_after: Paragraph,
+ content: dict,
+ first_body_style: str | None,
+ paragraph_template: Paragraph | None,
+ table_template: Table | None,
+):
+ current_anchor: Paragraph = insert_after
content_blocks = content.get("content", [])
for block in content_blocks:
block_type = block.get("type")
if block_type == "table":
rows = [list(row) for row in block.get("rows", [])]
headers = block.get("headers") or []
- table = _append_table_after(insert_after, rows, headers, table_template)
- insert_after = _append_empty_paragraph_after_table(table, first_body_style)
+ table = _append_table_after(current_anchor, rows, headers, table_template)
+ current_anchor = _append_empty_paragraph_after_table(table, first_body_style)
else:
text = block.get("text", "")
text_parts = [item for item in text.split("\n") if item] or [text]
for text_part in text_parts:
- insert_after = _append_paragraph_after(
- insert_after,
+ current_anchor = _append_paragraph_after(
+ current_anchor,
text_part,
first_body_style,
paragraph_template,
)
+ return current_anchor
+
+
+def _replace_section_content(
+ document: DocumentObject,
+ anchor_title: str,
+ target_title: str,
+ content: dict,
+ write_mode: str,
+ after_element=None,
+):
+ heading = _find_heading_paragraph(document, anchor_title, after_element)
+ if heading is None:
+ return after_element
+ _set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
+ first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
+
+ if write_mode == "replace_heading_only":
+ return heading._element
+
+ if write_mode == "replace_section":
+ for block in blocks_to_remove:
+ _delete_block(block)
+
+ _insert_content_after(
+ heading,
+ content,
+ first_body_style,
+ paragraph_template,
+ table_template,
+ )
+ return heading._element
+
+
+def _group_logs(logs: list[dict]) -> list[list[dict]]:
+ groups: list[list[dict]] = []
+ for item in logs:
+ anchor_title = item.get("anchor_title") or item.get("title") or ""
+ if not groups:
+ groups.append([item])
+ continue
+ last_group = groups[-1]
+ last_anchor = last_group[0].get("anchor_title") or last_group[0].get("title") or ""
+ if anchor_title == last_anchor:
+ last_group.append(item)
+ else:
+ groups.append([item])
+ return groups
+
+
+def _replace_section_group(
+ document: DocumentObject,
+ items: list[dict],
+ after_element=None,
+):
+ first_item = items[0]
+ anchor_title = first_item.get("anchor_title") or first_item.get("title") or ""
+ target_title = first_item.get("title") or anchor_title
+ heading = _find_heading_paragraph(document, anchor_title, after_element)
+ if heading is None:
+ return after_element
+
+ _set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
+ first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
+
+ if len(items) == 1 and first_item.get("write_mode") == "replace_heading_only":
+ return heading._element
+
+ preserve_existing = len(items) == 1 and first_item.get("write_mode") == "append_after_heading"
+ if not preserve_existing:
+ for block in blocks_to_remove:
+ _delete_block(block)
+
+ current_anchor = heading
+ for item in items:
+ current_anchor = _insert_content_after(
+ current_anchor,
+ item.get("content") or {"content": []},
+ first_body_style,
+ paragraph_template,
+ table_template,
+ )
return heading._element
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
document = Document(BytesIO(template_bytes))
last_heading_element = None
- for item in logs:
- last_heading_element = _replace_section_content(
- document,
- item["title"],
- item["content"],
- last_heading_element,
- )
+ for group in _group_logs(logs):
+ last_heading_element = _replace_section_group(document, group, last_heading_element)
output = BytesIO()
document.save(output)
diff --git a/backend/services/template_parser.py b/backend/services/template_parser.py
index 37ed3e0..9a8ae40 100644
--- a/backend/services/template_parser.py
+++ b/backend/services/template_parser.py
@@ -15,11 +15,13 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH
@dataclass
class ParsedParagraph:
sort_index: int
+ anchor_title: str
title: str
content: str
style_json: str
is_table: bool
table_json: str
+ write_mode: str
def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]:
@@ -173,11 +175,13 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
if level is not None:
current_item = ParsedParagraph(
sort_index=len(parsed) + 1,
+ anchor_title=text,
title=text,
content="",
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False),
is_table=False,
table_json="{}",
+ write_mode="replace_section",
)
parsed.append(current_item)
continue
@@ -185,11 +189,13 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
if current_item is None:
current_item = ParsedParagraph(
sort_index=len(parsed) + 1,
+ anchor_title="未命名段落",
title="未命名段落",
content=text,
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
is_table=False,
table_json="{}",
+ write_mode="replace_section",
)
parsed.append(current_item)
else:
@@ -201,11 +207,13 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
loose_table_count += 1
current_item = ParsedParagraph(
sort_index=len(parsed) + 1,
+ anchor_title=f"表格_{loose_table_count}",
title=f"表格_{loose_table_count}",
content=table_text,
style_json="{}",
is_table=True,
table_json=json.dumps(table_data, ensure_ascii=False),
+ write_mode="replace_section",
)
parsed.append(current_item)
else:
diff --git a/docs/tasks/task_detail_2026_07_03.md b/docs/tasks/task_detail_2026_07_03.md
index 93f6985..079b519 100644
--- a/docs/tasks/task_detail_2026_07_03.md
+++ b/docs/tasks/task_detail_2026_07_03.md
@@ -8,3 +8,143 @@
2. 整理并暂存相关文件,排除未跟踪的原型目录。
3. 准备使用中文提交信息完成本次代码提交。
- **执行结果**: 当前改动已整理完成并准备提交,包含任务详情页“上 + 左右”结构调整。
+
+## 会话 ID: local-20260703092933
+- [2026-07-03 09:29:33]
+- **执行原因**: 用户希望预览页左右两栏固定在顶层显示,不随页面整体滚动,而是在局部区域内滚动。
+- **执行过程**:
+ 1. 调整任务详情页根容器高度和溢出策略,禁止页面整体滚动。
+ 2. 为左右分栏区域设置固定可用高度和内部滚动容器,使左侧段落列表、右侧预览内容各自滚动。
+ 3. 执行前端类型检查,确认布局调整稳定。
+- **执行结果**: 当前预览页已改为页面整体固定、左右两栏局部滚动的显示方式,顶部信息区域保持固定可见。
+
+## 会话 ID: local-20260703093617
+- [2026-07-03 09:36:17]
+- **执行原因**: 用户询问当前模板导入时如何识别 `.doc/.docx` 文件中的段落边界。
+- **执行过程**:
+ 1. 定位模板上传入口与解析服务,确认模板导入的实际文件格式限制。
+ 2. 阅读 `template_parser` 实现,核对标题识别、正文归并和表格归属逻辑。
+ 3. 结合设计文档整理当前段落识别规则与边界行为,准备向用户说明。
+- **执行结果**: 已确认当前模板导入仅支持 `.docx`;段落边界基于 Word 内置 `Heading` 样式识别,普通正文归并到最近标题下,表格归属最近段落或独立成段。
+
+## 会话 ID: local-20260703093913
+- [2026-07-03 09:39:13]
+- **执行原因**: 用户进一步询问特殊模板场景下,是否支持只修改标题、不修改标题下固定内容,以及是否可以手动调整段落与模板内容。
+- **执行过程**:
+ 1. 核对模板编辑页界面与保存逻辑,确认当前可编辑字段范围。
+ 2. 检查后端模板保存 schema,确认是否支持手动拆段、合段或正文内容持久化编辑。
+ 3. 基于现状整理可行产品方案,包括自动识别候选标题与人工微调两类路径。
+- **执行结果**: 已确认当前系统支持修改段落标题和生成配置,但暂不支持手动拆段/合段,也不支持在系统内直接编辑模板正文;可通过新增“标题仅替换”模式与手动段落调整能力满足该场景。
+
+## 会话 ID: local-20260703094127
+- [2026-07-03 09:41:27]
+- **执行原因**: 用户希望模板编辑阶段支持人工直接编辑模板内容,并讨论是否应改为在标题下方占位填充而非整段替换,同时询问在线文档编辑实现思路。
+- **执行过程**:
+ 1. 检查当前导出实现,确认模板内容替换的实际粒度与边界。
+ 2. 结合现有解析和导出方式,评估“段落模式”与“手动编辑 Word 模式”的双模式方案。
+ 3. 查阅腾讯文档相关公开资料,整理在线文档通常采用的协同编辑架构和导入导出模型。
+- **执行结果**: 已确认当前导出为标题间整段替换;建议新增“手动编辑模板内容”与“占位填充”能力,并采用结构化文档模型而非直接把 `.docx` 当在线编辑源格式处理。
+
+## 会话 ID: local-20260703094614
+- [2026-07-03 09:46:14]
+- **执行原因**: 用户确认实施模板编辑增强,要求支持“段落配置 / 手动编辑模板”切换,并改进 AI 内容写回 Word 的方式。
+- **执行过程**:
+ 1. 扩展段落数据结构,新增原始标题锚点和写入方式字段,并补充数据库自动迁移逻辑。
+ 2. 改造模板编辑页,增加“段落配置 / 手动编辑模板”切换,支持直接编辑标题、正文和写入方式。
+ 3. 改造导出逻辑,支持仅替换标题、标题下插入内容、替换整段三种写入模式,同时保持原始标题定位能力。
+ 4. 执行后端编译检查与前端 `npm run build`,确认本轮改动可正常通过。
+- **执行结果**: 模板编辑页现已支持结构化手动编辑;导出时可按段落配置选择“整段替换 / 标题下插入 / 仅改标题”,能更好处理固定正文与 AI 生成内容并存的模板场景。
+
+## 会话 ID: local-20260703094757
+- [2026-07-03 09:47:57]
+- **执行原因**: 用户需要本轮模板编辑增强对应的数据库增量 SQL。
+- **执行过程**:
+ 1. 对照本轮后端模型与初始化脚本,确认实际新增的持久化字段。
+ 2. 整理兼容现有数据的 `ALTER TABLE` 与回填语句,确保旧模板可正常迁移。
+ 3. 记录增量说明,便于后续环境执行与核验。
+- **执行结果**: 已输出可直接执行的 MySQL 增量 SQL,包含 `paragraphs.anchor_title`、`paragraphs.write_mode` 两个新字段及历史数据回填语句。
+
+## 会话 ID: local-20260703095307
+- [2026-07-03 09:53:07]
+- **执行原因**: 用户质疑当前模板编辑仍像段落配置而非在线 Word 编辑,并询问是否可以手动插入新的 AI 段落。
+- **执行过程**:
+ 1. 重新核对模板解析逻辑,确认当前仍以 Word `Heading` 样式作为段落边界。
+ 2. 核对导出写回逻辑,确认当前是围绕已识别标题区块进行替换或插入,而不是对文档块级结构进行自由编辑。
+ 3. 基于用户反馈梳理下一阶段应改造为“块级在线文档编辑 + AI 占位段落”的方向。
+- **执行结果**: 已明确当前系统还不支持像腾讯文档那样手动插入新段落块;若要满足该诉求,应将模板编辑从“段落配置”升级为“文档块编辑”,支持新增 AI 段落占位、拆分正文块与固定块。
+
+## 会话 ID: local-20260703095628
+- [2026-07-03 09:56:28]
+- **执行原因**: 用户要求继续推进,支持在模板中手动拆块并插入 AI 段落。
+- **执行过程**:
+ 1. 扩展模板保存接口,支持创建新块、删除旧块,并按当前编辑顺序重排 `sort_index`。
+ 2. 改造导出逻辑,按连续的 `anchor_title` 分组写回同一节内容,使一个原标题下可挂多个手动/AI 块。
+ 3. 改造模板编辑页,在手动编辑模式下新增“在后面新增固定块 / AI 块 / 删除当前块”操作。
+ 4. 执行后端编译检查与前端 `npm run build`,确认新增块编辑能力可正常通过构建。
+- **执行结果**: 当前模板编辑已支持把同一原标题下的内容手动拆成多个块,并插入新的 AI 块或固定块;导出时会按块顺序写回同一节内容,较之前更接近在线文档式的人工干预流程。
+
+## 会话 ID: local-20260703095940
+- [2026-07-03 09:59:40]
+- **执行原因**: 用户要求模板默认导入后全部识别为人工手动,而不是 AI 生成。
+- **执行过程**:
+ 1. 调整段落模型默认值与初始化脚本默认值,将 `edit_mode` 默认改为 `manual`。
+ 2. 调整模板上传落库逻辑,显式将新导入段落设置为 `manual`,避免受历史数据库默认值影响。
+ 3. 执行后端编译检查,确认默认值调整未引入语法或依赖问题。
+- **执行结果**: 新导入模板中的识别段落现在默认全部为人工手动;如需 AI 生成,需要用户在模板编辑页中显式切换对应块为 AI 模式。
+
+## 会话 ID: local-20260703100253
+- [2026-07-03 10:02:53]
+- **执行原因**: 用户反馈模板编辑页 `doc-edit-page` 没有随内容高度增长,导致内容超出纸张容器显示。
+- **执行过程**:
+ 1. 检查编辑页中部滚动区与纸张容器的 flex 布局关系,定位到默认纵向拉伸导致纸张高度被固定。
+ 2. 调整 `center-scroll` 的对齐方式为顶部对齐,并禁止 `doc-edit-page` 在 flex 布局中被压缩。
+ 3. 执行前端 `npm run build`,确认样式修复后页面仍可正常构建。
+- **执行结果**: 模板编辑页中的纸张容器现在会按内容自然增高,不再因为父级 flex 拉伸而出现内容超出容器显示的问题。
+
+## 会话 ID: local-20260703100656
+- [2026-07-03 10:06:56]
+- **执行原因**: 用户询问执行生成页是否支持从历史文件中复用已上传附件。
+- **执行过程**:
+ 1. 检查 `GeneratePage.vue` 的文件上传区域与状态管理逻辑,确认当前前端入口能力。
+ 2. 对照 `generateApi` 与后端 `reference-files` 接口,确认后端已有历史文件查询能力是否被生成页接入。
+ 3. 整理当前支持范围与缺口,准备向用户说明现状与后续改造方向。
+- **执行结果**: 已确认生成页当前仅支持新上传文件,不支持在页面内选择历史文件复用;后端已有历史文件接口,但该页尚未接入对应 UI 与选择逻辑。
+
+## 会话 ID: local-20260703104125
+- [2026-07-03 10:41:25]
+- **执行原因**: 用户建议参考模板编辑中的历史文件复用能力,并封装成通用组件供执行生成页复用。
+- **执行过程**:
+ 1. 抽离公共 `ReferenceFileSelector` 组件,统一封装新上传、历史文件搜索复用、已选文件展示与移除逻辑。
+ 2. 将模板编辑页段落测试弹窗接入该组件,替换原有分散的上传与历史文件逻辑。
+ 3. 将执行生成页接入同一组件,使每个需上传文件的段落同时支持上传新文件和选择历史文件。
+ 4. 执行前端 `npm run build`,确认组件复用后页面构建正常。
+- **执行结果**: 当前模板编辑测试弹窗与执行生成页已共用同一套文件选择组件;执行生成页现已支持历史文件复用,不再局限于本次新上传。
+
+## 会话 ID: local-20260703104741
+- [2026-07-03 10:47:41]
+- **执行原因**: 用户希望模型管理页支持厂商预设、DeepSeek 余额查看,以及将测试按钮改为带转圈的刷新式提示。
+- **执行过程**:
+ 1. 扩展模型前端 API 与 store,新增余额查询调用。
+ 2. 在后端模型路由中新增 DeepSeek 余额查询接口,并基于已保存的 API Key 调用官方余额接口。
+ 3. 改造模型管理页,新增 `DeepSeek / 自定义` 厂商预设、DeepSeek 余额展示与查询按钮。
+ 4. 将测试按钮改为带 loading 的“刷新测试”,结果改为自动消失的轻提示,不再使用需要手动关闭的弹窗。
+ 5. 执行后端编译检查与前端 `npm run build`,确认改动可正常构建。
+- **执行结果**: 模型管理页现已支持厂商预设;DeepSeek 模型可直接查询余额;测试按钮改为更轻量的刷新式交互,点击后会转圈并自动提示结果。
+
+## 会话 ID: local-20260703104944
+- [2026-07-03 10:49:44]
+- **执行原因**: 用户发现将厂商改成自定义后,页面仍被识别为 DeepSeek,且自定义厂商也出现余额查询能力。
+- **执行过程**:
+ 1. 排查模型管理页与后端余额接口的 DeepSeek 判定条件。
+ 2. 将判定逻辑从“厂商或 endpoint 命中 DeepSeek”收紧为“仅当 provider 明确为 DeepSeek 时才视为 DeepSeek 模型”。
+ 3. 执行后端编译检查与前端 `npm run build`,确认修正后功能正常。
+- **执行结果**: 当前只有在厂商明确设置为 `DeepSeek` 时,页面才会显示 DeepSeek 预设状态与余额查询按钮;改为自定义厂商后不会再被 endpoint 误判为 DeepSeek。
+
+## 会话 ID: local-20260703111410
+- [2026-07-03 11:14:10]
+- **执行原因**: 用户要求将当前阶段改动提交到 Git。
+- **执行过程**:
+ 1. 检查工作区变更,确认本轮后端、前端与任务记录文件可一并提交。
+ 2. 排除未跟踪的原型目录,仅暂存本次功能实现相关文件。
+ 3. 使用中文提交信息完成本次代码提交。
+- **执行结果**: 当前模板编辑、历史文件复用、模型管理增强等改动已整理完成,准备提交到本地 Git 历史。
diff --git a/init.sql b/init.sql
index 12e1a38..ea5cf7d 100644
--- a/init.sql
+++ b/init.sql
@@ -30,12 +30,14 @@ CREATE TABLE IF NOT EXISTS paragraphs (
id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL,
sort_index INT DEFAULT 0 COMMENT '排序',
+ anchor_title VARCHAR(500) DEFAULT '' COMMENT '原始标题锚点',
title VARCHAR(500) DEFAULT '' COMMENT '段落标题',
content TEXT DEFAULT '' COMMENT '正文内容',
style_json TEXT DEFAULT '{}' COMMENT '样式 JSON',
is_table TINYINT(1) DEFAULT 0 COMMENT '是否为表格',
table_json TEXT DEFAULT '{}' COMMENT '表格结构 JSON',
- edit_mode VARCHAR(20) DEFAULT 'ai' COMMENT 'manual/ai',
+ edit_mode VARCHAR(20) DEFAULT 'manual' COMMENT 'manual/ai',
+ write_mode VARCHAR(30) DEFAULT 'replace_section' COMMENT 'replace_section/append_after_heading/replace_heading_only',
model_id INT DEFAULT NULL COMMENT '指定模型',
need_prompt TINYINT(1) DEFAULT 1 COMMENT '是否需要提示词',
prompt_text TEXT DEFAULT '' COMMENT '预设提示词',
diff --git a/web/src/api/model.ts b/web/src/api/model.ts
index 9f8bfa9..8e188ce 100644
--- a/web/src/api/model.ts
+++ b/web/src/api/model.ts
@@ -6,4 +6,5 @@ export const modelApi = {
update: (id: number, data: any) => http.put(`/models/${id}`, data),
delete: (id: number) => http.delete(`/models/${id}`),
test: (id: number) => http.post(`/models/${id}/test`),
+ balance: (id: number) => http.get(`/models/${id}/balance`),
}
diff --git a/web/src/components/ReferenceFileSelector.vue b/web/src/components/ReferenceFileSelector.vue
new file mode 100644
index 0000000..0f15401
--- /dev/null
+++ b/web/src/components/ReferenceFileSelector.vue
@@ -0,0 +1,303 @@
+
+ 点击或拖拽上传参考文件 支持多文件:docx / doc / xlsx / xls / xlsm / csv / pdf / txt / md / json