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 @@ + + + + + diff --git a/web/src/stores/model.ts b/web/src/stores/model.ts index 59e4231..e2ea128 100644 --- a/web/src/stores/model.ts +++ b/web/src/stores/model.ts @@ -12,6 +12,7 @@ export const useModelStore = defineStore('model', () => { async function update(id: number, data: any) { await modelApi.update(id, data); await fetchList() } async function remove(id: number) { await modelApi.delete(id); await fetchList() } async function test(id: number) { return await modelApi.test(id) } + async function balance(id: number) { return await modelApi.balance(id) } - return { models, loading, fetchList, create, update, remove, test } + return { models, loading, fetchList, create, update, remove, test, balance } }) diff --git a/web/src/stores/template.ts b/web/src/stores/template.ts index 36db4d6..12af6b6 100644 --- a/web/src/stores/template.ts +++ b/web/src/stores/template.ts @@ -12,7 +12,7 @@ export const useTemplateStore = defineStore('template', () => { async function fetchList() { loading.value = true; try { const r: any = await templateApi.list(); templates.value = r.data?.items || r.data || [] } finally { loading.value = false } } async function fetchOne(id: number) { const r: any = await templateApi.get(id); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data } async function upload(file: File) { const fd = new FormData(); fd.append('file', file); const r: any = await templateApi.upload(fd); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data } - async function save(id: number) { const data = paragraphs.value.map(p => ({ id: p.id, sort_index: p.sort_index, title: p.title, edit_mode: p.edit_mode, model_id: p.model_id, need_prompt: p.need_prompt, prompt_text: p.prompt_text, need_file: p.need_file, file_note: p.file_note, output_format: p.output_format })); await templateApi.saveParagraphs(id, { paragraphs: data }) } + async function save(id: number) { const data = paragraphs.value.map((p, index) => ({ id: p.id, sort_index: index + 1, anchor_title: p.anchor_title, title: p.title, content: p.content, edit_mode: p.edit_mode, write_mode: p.write_mode, model_id: p.model_id, need_prompt: p.need_prompt, prompt_text: p.prompt_text, need_file: p.need_file, file_note: p.file_note, output_format: p.output_format })); await templateApi.saveParagraphs(id, { paragraphs: data }) } async function remove(id: number) { await templateApi.delete(id); await fetchList() } return { templates, currentTemplate, paragraphs, loading, fetchList, fetchOne, upload, save, remove } diff --git a/web/src/types/index.ts b/web/src/types/index.ts index c6a04a6..95f0241 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -5,8 +5,10 @@ export interface Template { export interface Paragraph { id: number; template_id: number; sort_index: number; title: string; content: string + anchor_title: string style_json: string; is_table: boolean; table_json: string edit_mode: 'manual' | 'ai'; model_id: number | null + write_mode: 'replace_section' | 'append_after_heading' | 'replace_heading_only' need_prompt: boolean; prompt_text: string; need_file: boolean; file_note: string output_format: 'text' | 'table' | 'mixed' | 'chart' } diff --git a/web/src/views/GeneratePage.vue b/web/src/views/GeneratePage.vue index 2ef0d07..a1cdcaa 100644 --- a/web/src/views/GeneratePage.vue +++ b/web/src/views/GeneratePage.vue @@ -64,15 +64,10 @@ {{ paragraph.modelName || '默认' }}
- - {{ uploadedFiles[paragraph.id]?.length ? '继续上传' : '上传文件' }} - -
- - {{ item.file_name }} - - -
+
无需上传 @@ -89,12 +84,12 @@ diff --git a/web/src/views/PreviewEdit.vue b/web/src/views/PreviewEdit.vue index 8b68697..11d37e2 100644 --- a/web/src/views/PreviewEdit.vue +++ b/web/src/views/PreviewEdit.vue @@ -242,6 +242,11 @@ onBeforeUnmount(() => {