Compare commits

...

3 Commits

Author SHA1 Message Date
zwt13703 1369d87afb 支持段落删除与移动排序,修复导出残留与外键约束
- 前端:段落列表/配置区/手动编辑区新增上移、下移、删除按钮,hover 显示
- 前端:移动和删除操作后自动保存到后端,修正 canDeleteBlock 判定逻辑
- 后端:保存段落时级联删除关联的 generation_logs 再删段落
- 后端:导出时清理未被引用的标题段落及其内容,避免已删段落残留在 Word 中
- 后端:删除模板时级联清理 generation_logs/documents/paragraphs
2026-07-03 17:13:02 +08:00
zwt13703 a796301ae8 完善模板编辑与模型管理体验 2026-07-03 11:14:24 +08:00
zwt13703 52eb058070 调整任务详情页左右预览布局 2026-07-03 09:28:39 +08:00
20 changed files with 1416 additions and 282 deletions
+8
View File
@@ -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'"))
+3 -1
View File
@@ -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="预设提示词")
+2
View File
@@ -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": []},
}
)
+39
View File
@@ -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", []),
}
)
+53 -8
View File
@@ -6,11 +6,13 @@ from datetime import datetime
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from database import get_db
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from schemas.schemas import Response, TemplateSave
@@ -32,12 +34,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 +155,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 +187,36 @@ 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)
print(f"[SAVE] template_id={template_id}, incoming_ids={incoming_ids}, existing_ids={[p.id for p in existing_paragraphs]}")
for paragraph in existing_paragraphs:
if paragraph.id not in incoming_ids:
print(f"[SAVE] Deleting paragraph id={paragraph.id} title={paragraph.title}")
await db.execute(
delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id)
)
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 +224,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)})
@@ -208,7 +237,23 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=404, detail="模板不存在")
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
for paragraph in result.scalars().all():
paragraphs_to_delete = result.scalars().all()
paragraph_ids = [p.id for p in paragraphs_to_delete]
doc_result = await db.execute(select(Document).where(Document.template_id == template_id))
documents_to_delete = doc_result.scalars().all()
if paragraph_ids:
await db.execute(
delete(GenerationLog).where(GenerationLog.paragraph_id.in_(paragraph_ids))
)
for document in documents_to_delete:
await db.execute(
delete(GenerationLog).where(GenerationLog.document_id == document.id)
)
await db.delete(document)
for paragraph in paragraphs_to_delete:
await db.delete(paragraph)
file_path = template.file_path or ""
+4 -1
View File
@@ -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 = ""
+178 -22
View File
@@ -32,6 +32,43 @@ def _delete_block(block):
parent.remove(element)
def _delete_heading_section(heading: Paragraph):
blocks = [heading]
current = heading._element.getnext()
while current is not None:
if isinstance(current, CT_P):
para = Paragraph(current, heading._parent)
if _is_heading(para):
break
blocks.append(para)
elif isinstance(current, CT_Tbl):
blocks.append(Table(current, heading._parent))
current = current.getnext()
for block in blocks:
_delete_block(block)
def _remove_unreferenced_headings(document: DocumentObject, referenced_anchors: set[str]):
headings_to_remove: list[Paragraph] = []
found_first_heading = False
pre_heading_blocks: list = []
print(f"[EXPORT] referenced_anchors: {referenced_anchors}")
for block in _iter_block_items(document):
if isinstance(block, Paragraph) and _is_heading(block):
found_first_heading = True
text = block.text.strip()
if text not in referenced_anchors:
print(f"[EXPORT] Unreferenced heading found, will remove: '{text}'")
headings_to_remove.append(block)
elif not found_first_heading:
pre_heading_blocks.append(block)
for heading in headings_to_remove:
_delete_heading_section(heading)
if not referenced_anchors:
for block in pre_heading_blocks:
_delete_block(block)
def _clear_paragraph(paragraph: Paragraph):
element = paragraph._element
for child in list(element):
@@ -56,6 +93,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 +231,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 +246,144 @@ 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
referenced_anchors: set[str] = set()
for item in logs:
last_heading_element = _replace_section_content(
document,
item["title"],
item["content"],
last_heading_element,
)
for key in ("anchor_title", "title"):
val = (item.get(key) or "").strip()
if val:
referenced_anchors.add(val)
print(f"[EXPORT] logs count={len(logs)}, anchor_titles={[(l.get('anchor_title'), l.get('title')) for l in logs]}")
last_heading_element = None
for group in _group_logs(logs):
last_heading_element = _replace_section_group(document, group, last_heading_element)
_remove_unreferenced_headings(document, referenced_anchors)
output = BytesIO()
document.save(output)
+8
View File
@@ -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:
+18
View File
@@ -219,3 +219,21 @@
4. 调整导出时的表格回写方式,优先克隆模板原有表格结构并填充新数据,尽量保留表格外观和基础样式。
5. 执行后端语法检查,确认导出服务改动稳定。
- **执行结果**: 当前导出链路已改为“尽量复用模板原始段落/表格样式后写入 AI 内容”,比此前的新建空白内容块方式更接近原模板样式,也更容易把 AI 结果正确写回文档。
## 会话 ID: local-20260702202424
- [2026-07-02 20:24:24]
- **执行原因**: 用户希望用极简一句话概括当前项目功能状态,并说明明天的工作重点。
- **执行过程**:
1. 基于当前已完成能力,压缩总结项目现状。
2. 提炼明日优先事项,聚焦导出准确性与样式保真。
- **执行结果**: 已形成简短状态说明,可作为明日继续开发的工作摘要。
## 会话 ID: local-20260702202807
- [2026-07-02 20:28:07]
- **执行原因**: 用户希望将任务详情页改为“上 + 左右”结构,左侧显示段落,右侧显示生成结果预览。
- **执行过程**:
1. 重构任务详情页布局,将页面调整为顶部任务概览、下方左右分栏的结构。
2. 左侧增加段落列表与段落状态展示,并保留附件映射信息。
3. 右侧改为当前选中段落的结果预览区域,展示段落标题、状态、文件要求和生成内容。
4. 执行前端类型检查,确认本轮布局调整稳定。
- **执行结果**: 当前任务详情页已改为“上 + 左右”结构,用户可在左侧切换段落,在右侧查看对应生成结果预览。
+150
View File
@@ -0,0 +1,150 @@
# 任务执行摘要
## 会话 ID: local-20260703092828
- [2026-07-03 09:28:28]
- **执行原因**: 用户要求提交当前已完成的预览页布局调整代码。
- **执行过程**:
1. 检查当前工作区改动,确认仅包含任务详情页左右布局调整和对应任务记录。
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 历史。
+3 -1
View File
@@ -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 '预设提示词',
+1
View File
@@ -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`),
}
@@ -0,0 +1,303 @@
<template>
<div :class="['reference-selector', variant]">
<div v-if="variant === 'full'" class="upload-card">
<div v-if="title" class="upload-title">{{ title }}</div>
<div v-if="description" class="upload-desc">{{ description }}</div>
<a-upload-dragger :multiple="true" :beforeUpload="beforeUpload" :showUploadList="false">
<p class="ant-upload-drag-icon"><upload-outlined /></p>
<p class="ant-upload-text">点击或拖拽上传参考文件</p>
<p class="ant-upload-hint">支持多文件docx / doc / xlsx / xls / xlsm / csv / pdf / txt / md / json</p>
</a-upload-dragger>
</div>
<div v-else class="compact-toolbar">
<a-upload :multiple="true" :beforeUpload="beforeUpload" :showUploadList="false">
<a-button size="small" :loading="uploading">{{ hasSelection ? '继续上传' : '上传文件' }}</a-button>
</a-upload>
<a-button size="small" @click="toggleHistoryPanel">{{ historyPanelOpen ? '收起历史文件' : '选择历史文件' }}</a-button>
</div>
<div v-if="selectedFiles.length" class="selected-list">
<div class="selected-item" v-for="item in selectedFiles" :key="item.file_path">
<span class="selected-name">{{ item.file_name }}</span>
<a-button type="link" size="small" danger @click="removeSelectedFile(item.file_path)">
{{ variant === 'full' ? '移除' : 'x' }}
</a-button>
</div>
</div>
<div v-if="showHistorySection" class="history-card">
<div class="history-head">
<div>
<div class="history-title">历史文件</div>
<div class="history-desc">已上传过的文件会保存在系统里下次可直接选择复用</div>
</div>
<a-button size="small" @click="fetchReferenceHistory">刷新</a-button>
</div>
<div class="history-search">
<a-input-search
v-model:value="historyKeyword"
placeholder="按文件名搜索历史文件"
allow-clear
@search="fetchReferenceHistory"
/>
</div>
<a-spin :spinning="historyLoading">
<div v-if="referenceHistory.length" class="history-list">
<label v-for="item in referenceHistory" :key="item.id" class="history-item">
<input
type="checkbox"
:checked="isSelected(item.file_path)"
@change="toggleHistoryFile(item)"
/>
<div class="history-item-main">
<div class="history-item-name">{{ item.file_name }}</div>
<div class="history-item-meta">
<span>{{ formatFileSize(item.file_size) }}</span>
<span>{{ formatDateTime(item.created_at) }}</span>
</div>
</div>
</label>
</div>
<a-empty v-else description="暂无历史文件" />
</a-spin>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { UploadOutlined } from '@ant-design/icons-vue'
import { generateApi } from '@/api/generate'
import type { ReferenceFile } from '@/types'
type SelectedFile = { file_name: string; file_path: string }
const props = withDefaults(defineProps<{
modelValue: SelectedFile[]
title?: string
description?: string
variant?: 'full' | 'compact'
}>(), {
title: '',
description: '',
variant: 'full',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: SelectedFile[]): void
}>()
const uploading = ref(false)
const referenceHistory = ref<ReferenceFile[]>([])
const historyKeyword = ref('')
const historyLoading = ref(false)
const historyPanelOpen = ref(false)
const selectedFiles = computed(() => props.modelValue || [])
const hasSelection = computed(() => selectedFiles.value.length > 0)
const showHistorySection = computed(() => props.variant === 'full' || historyPanelOpen.value)
function updateFiles(files: SelectedFile[]) {
emit('update:modelValue', files)
}
function appendSelectedFile(file: SelectedFile) {
if (selectedFiles.value.some((item) => item.file_path === file.file_path)) return
updateFiles([...selectedFiles.value, file])
}
function removeSelectedFile(filePath: string) {
updateFiles(selectedFiles.value.filter((item) => item.file_path !== filePath))
}
function isSelected(filePath: string) {
return selectedFiles.value.some((item) => item.file_path === filePath)
}
function toggleHistoryFile(item: ReferenceFile) {
if (isSelected(item.file_path)) {
removeSelectedFile(item.file_path)
return
}
appendSelectedFile({ file_name: item.file_name, file_path: item.file_path })
}
async function beforeUpload(file: File) {
try {
uploading.value = true
const fd = new FormData()
fd.append('file', file)
const res: any = await generateApi.upload(fd)
appendSelectedFile({ file_name: res.data.file_name, file_path: res.data.file_path })
message.success('文件上传成功')
} catch (error: any) {
message.error(error.message || '文件上传失败')
} finally {
uploading.value = false
}
return false
}
async function fetchReferenceHistory() {
historyLoading.value = true
try {
const response: any = await generateApi.referenceFiles({
page: 1,
page_size: 30,
keyword: historyKeyword.value.trim(),
})
referenceHistory.value = response.data?.items || []
} catch (error: any) {
message.error(error.message || '加载历史文件失败')
} finally {
historyLoading.value = false
}
}
function toggleHistoryPanel() {
historyPanelOpen.value = !historyPanelOpen.value
if (historyPanelOpen.value && !referenceHistory.value.length) {
fetchReferenceHistory()
}
}
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function formatDateTime(value: string) {
return value ? value.replace('T', ' ').slice(0, 19) : ''
}
onMounted(() => {
if (props.variant === 'full') {
fetchReferenceHistory()
}
})
</script>
<style scoped>
.reference-selector {
display: grid;
gap: 16px;
}
.upload-card {
background: #f0f1f3;
border-radius: 8px;
padding: 16px;
}
.upload-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.upload-desc {
font-size: 12px;
color: #5b626e;
margin-bottom: 12px;
}
.compact-toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.history-card {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fafbfc;
}
.history-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.history-title {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.history-desc {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.history-search {
margin: 12px 0;
}
.history-list {
display: grid;
gap: 8px;
max-height: 240px;
overflow-y: auto;
}
.history-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
cursor: pointer;
}
.history-item-main {
min-width: 0;
flex: 1;
}
.history-item-name {
font-size: 13px;
font-weight: 500;
color: #111827;
word-break: break-all;
}
.history-item-meta {
display: flex;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.selected-list {
display: grid;
gap: 8px;
}
.selected-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
background: #f5f6f8;
border-radius: 8px;
padding: 8px 12px;
}
.selected-name {
min-width: 0;
font-size: 13px;
word-break: break-all;
}
</style>
+2 -1
View File
@@ -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 }
})
+1 -1
View File
@@ -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 }
+2
View File
@@ -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'
}
+19 -36
View File
@@ -64,15 +64,10 @@
<a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag>
</div>
<div v-if="paragraph.need_file" class="file-info">
<a-upload :multiple="true" :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false">
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id]?.length ? '继续上传' : '上传文件' }}</a-button>
</a-upload>
<div v-if="uploadedFiles[paragraph.id]?.length" class="uploaded-list">
<span v-for="item in uploadedFiles[paragraph.id]" :key="item.file_path" class="uploaded-name">
{{ item.file_name }}
<button class="remove-file-btn" @click="removeUploadedFile(paragraph.id, item.file_path)">x</button>
</span>
</div>
<ReferenceFileSelector
v-model="uploadedFiles[paragraph.id]"
variant="compact"
/>
</div>
<span v-else class="no-file-tag">无需上传</span>
</div>
@@ -89,12 +84,12 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useTemplateStore } from '@/stores/template'
import { useDocumentStore } from '@/stores/document'
import { generateApi } from '@/api/generate'
import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
const route = useRoute()
const router = useRouter()
@@ -105,8 +100,6 @@ const templates = ref<any[]>([])
const paragraphs = ref<any[]>([])
const selectedTplId = ref<number | undefined>(undefined)
const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: string }>>>({})
const uploadedFilePaths = ref<Record<number, string[]>>({})
const uploadingMap = ref<Record<number, boolean>>({})
const generating = ref(false)
const tplInfo = ref<any>({})
@@ -115,6 +108,19 @@ const autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mod
const fileCount = computed(() => Object.values(uploadedFiles.value).filter((item) => item?.length).length)
const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '')
watch(
uploadedFiles,
(value) => {
const filePaths = Object.fromEntries(
Object.entries(value).map(([key, items]) => [Number(key), (items || []).map((item) => item.file_path)])
)
uploadedFilePaths.value = filePaths
},
{ deep: true }
)
const uploadedFilePaths = ref<Record<number, string[]>>({})
async function refreshTemplates() {
await tplStore.fetchList()
templates.value = tplStore.templates as any
@@ -140,29 +146,6 @@ async function onTplChange(id: number) {
uploadedFilePaths.value = {}
}
async function handleFileUpload(paragraphId: number, file: File) {
try {
uploadingMap.value[paragraphId] = true
const fd = new FormData()
fd.append('file', file)
const res: any = await generateApi.upload(fd)
const nextFile = { file_name: res.data.file_name, file_path: res.data.file_path }
uploadedFiles.value[paragraphId] = [...(uploadedFiles.value[paragraphId] || []), nextFile]
uploadedFilePaths.value[paragraphId] = [...(uploadedFilePaths.value[paragraphId] || []), res.data.file_path]
message.success('文件上传成功')
} catch (error: any) {
message.error(error.message || '文件上传失败')
} finally {
uploadingMap.value[paragraphId] = false
}
return false
}
function removeUploadedFile(paragraphId: number, filePath: string) {
uploadedFiles.value[paragraphId] = (uploadedFiles.value[paragraphId] || []).filter((item) => item.file_path !== filePath)
uploadedFilePaths.value[paragraphId] = (uploadedFilePaths.value[paragraphId] || []).filter((item) => item !== filePath)
}
async function startGen() {
if (!selectedTplId.value) {
message.warning('请先选择模板')
+75 -9
View File
@@ -20,12 +20,21 @@
<div class="mc-provider">密钥{{ item.api_key_preview || '未设置' }}</div>
<div class="mc-provider">流式传输{{ item.supports_streaming ? '支持' : '关闭' }}</div>
<div class="mc-provider">思考模式{{ item.enable_reasoning ? '开启' : '关闭' }}</div>
<div v-if="isDeepSeek(item) && balanceMap[item.id]" class="mc-provider">
余额{{ formatBalanceText(balanceMap[item.id]) }}
</div>
</div>
<div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']">
{{ item.status === 'enabled' ? '已启用' : '已禁用' }}
</div>
<div class="mc-actions">
<a-button size="small" @click="runTest(item)">测试</a-button>
<a-button size="small" :loading="testingMap[item.id]" @click="runTest(item)">
<template #icon><reload-outlined /></template>
刷新测试
</a-button>
<a-button v-if="isDeepSeek(item)" size="small" :loading="balanceLoadingMap[item.id]" @click="fetchBalance(item)">
查看余额
</a-button>
<a-button size="small" @click="openEdit(item)">编辑</a-button>
<a-button size="small" @click="toggleStatus(item)">{{ item.status === 'enabled' ? '禁用' : '启用' }}</a-button>
</div>
@@ -39,7 +48,13 @@
<a-input v-model:value="form.name" />
</a-form-item>
<a-form-item label="供应厂商">
<a-input v-model:value="form.provider" />
<a-select v-model:value="providerPreset" @change="applyProviderPreset">
<a-select-option value="deepseek">DeepSeek</a-select-option>
<a-select-option value="custom">自定义</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="providerPreset === 'custom'" label="自定义厂商名称">
<a-input v-model:value="form.provider" placeholder="例如 OpenAI / Anthropic / 其他" />
</a-form-item>
<a-form-item label="API 格式">
<a-select v-model:value="form.api_format">
@@ -66,7 +81,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Modal, message } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { ReloadOutlined } from '@ant-design/icons-vue'
import { useModelStore } from '@/stores/model'
const store = useModelStore()
@@ -75,6 +91,41 @@ const modalOpen = ref(false)
const isEdit = ref(false)
const editId = ref(0)
const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false })
const providerPreset = ref<'deepseek' | 'custom'>('custom')
const testingMap = ref<Record<number, boolean>>({})
const balanceLoadingMap = ref<Record<number, boolean>>({})
const balanceMap = ref<Record<number, { is_available: boolean; balance_infos: Array<{ currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }> }>>({})
function isDeepSeek(item: any) {
const provider = (item?.provider || '').trim().toLowerCase()
return provider === 'deepseek'
}
function applyProviderPreset(value: 'deepseek' | 'custom') {
if (value === 'deepseek') {
form.value.provider = 'DeepSeek'
form.value.api_format = 'openai'
if (!form.value.api_endpoint || form.value.api_endpoint.includes('deepseek')) {
form.value.api_endpoint = 'https://api.deepseek.com'
}
return
}
if (form.value.provider === 'DeepSeek') {
form.value.provider = ''
}
}
function inferProviderPreset(item?: any) {
providerPreset.value = isDeepSeek(item || form.value) ? 'deepseek' : 'custom'
}
function formatBalanceText(data: { is_available: boolean; balance_infos: Array<{ currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }> }) {
const infos = data?.balance_infos || []
if (!infos.length) return data?.is_available ? '可用' : '不可用'
return infos
.map((item) => `${item.currency} ${item.total_balance}(充值 ${item.topped_up_balance} / 赠送 ${item.granted_balance}`)
.join('')
}
async function refreshList() {
await store.fetchList()
@@ -88,6 +139,8 @@ onMounted(async () => {
function openAdd() {
isEdit.value = false
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false }
providerPreset.value = 'deepseek'
applyProviderPreset('deepseek')
modalOpen.value = true
}
@@ -103,6 +156,7 @@ function openEdit(item: any) {
supports_streaming: !!item.supports_streaming,
enable_reasoning: !!item.enable_reasoning,
}
inferProviderPreset(item)
modalOpen.value = true
}
@@ -124,15 +178,27 @@ async function toggleStatus(item: any) {
}
async function runTest(item: any) {
testingMap.value[item.id] = true
try {
const result: any = await store.test(item.id)
Modal.info({
title: '连接测试结果',
width: 680,
content: JSON.stringify(result.data, null, 2),
})
message.success(result.data?.message || `模型 ${item.name} 测试成功`, 2)
} catch (error: any) {
message.error(error.message || '连接测试失败')
message.error(error.message || '连接测试失败', 2)
} finally {
testingMap.value[item.id] = false
}
}
async function fetchBalance(item: any) {
balanceLoadingMap.value[item.id] = true
try {
const result: any = await store.balance(item.id)
balanceMap.value[item.id] = result.data
message.success(`已刷新 ${item.name} 余额`, 2)
} catch (error: any) {
message.error(error.message || '余额查询失败', 2)
} finally {
balanceLoadingMap.value[item.id] = false
}
}
</script>
+148 -34
View File
@@ -33,37 +33,55 @@
<div class="status-message">{{ progressMessage || documentInfo.error || '任务已创建,等待执行。' }}</div>
</a-card>
<a-card class="mapping-card" title="段落与附件映射">
<div v-if="paragraphMappings.length" class="mapping-list">
<div v-for="item in paragraphMappings" :key="item.paragraph_id" class="mapping-item">
<div class="mapping-top">
<span class="mapping-index">{{ item.sort_index }}</span>
<div class="mapping-main">
<div class="mapping-title">{{ item.title }}</div>
<div v-if="item.file_note" class="mapping-note">{{ item.file_note }}</div>
<div class="detail-layout">
<aside class="detail-left">
<a-card class="left-card" title="段落列表">
<div v-if="paragraphMappings.length" class="mapping-list">
<div
v-for="item in paragraphMappings"
:key="item.paragraph_id"
:class="['mapping-item', { active: selectedParagraphId === item.paragraph_id }]"
@click="selectParagraph(item.paragraph_id)"
>
<div class="mapping-top">
<span class="mapping-index">{{ item.sort_index }}</span>
<div class="mapping-main">
<div class="mapping-title">{{ item.title }}</div>
<div v-if="item.file_note" class="mapping-note">{{ item.file_note }}</div>
</div>
</div>
<div class="mapping-status">
<a-badge :status="statusBadge(logStatusMap[item.paragraph_id] || 'pending')" :text="statusText(logStatusMap[item.paragraph_id] || 'pending')" />
</div>
<div v-if="item.selected_files?.length" class="mapping-files">
<span v-for="file in item.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
</div>
<div v-else class="mapping-empty">未选择附件</div>
</div>
</div>
<div v-if="item.selected_files?.length" class="mapping-files">
<span v-for="file in item.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
</div>
<div v-else class="mapping-empty">未选择附件</div>
</div>
</div>
<a-empty v-else description="当前任务未记录附件映射" />
</a-card>
<a-empty v-else description="当前任务未记录段落信息" />
</a-card>
</aside>
<a-card class="preview-card" title="生成结果预览">
<div v-if="logs.length" class="preview-wrap">
<section v-for="item in logs" :key="item.paragraph_id" :id="`section-${item.paragraph_id}`" class="preview-section">
<div class="preview-section-head">
<h3>{{ item.title }}</h3>
<a-badge :status="statusBadge(item.status)" :text="statusText(item.status)" />
</div>
<div class="preview-block" v-html="renderBlocks(item.content?.content || [])" />
</section>
</div>
<a-empty v-else :description="isRunning ? '任务进行中,已完成段落会逐步出现在这里' : '暂无生成内容'" />
</a-card>
<section class="detail-right">
<a-card class="preview-card" title="生成结果预览">
<template v-if="selectedParagraph">
<div class="preview-section">
<div class="preview-section-head">
<h3>{{ selectedParagraph.title }}</h3>
<a-badge :status="statusBadge(selectedParagraph.status)" :text="statusText(selectedParagraph.status)" />
</div>
<div v-if="selectedMapping?.file_note" class="preview-note">文件要求{{ selectedMapping.file_note }}</div>
<div v-if="selectedMapping?.selected_files?.length" class="preview-files">
<span v-for="file in selectedMapping.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
</div>
<div class="preview-block" v-html="renderBlocks(selectedParagraph.content?.content || [])" />
</div>
</template>
<a-empty v-else :description="isRunning ? '任务进行中,当前段落结果尚未生成' : '当前没有可预览的段落结果'" />
</a-card>
</section>
</div>
</div>
</template>
@@ -96,11 +114,21 @@ const documentInfo = ref<any>({})
const logs = ref<LogItem[]>([])
const progressPercent = ref(0)
const progressMessage = ref('')
const selectedParagraphId = ref<number | null>(null)
let progressSource: EventSource | null = null
const isRunning = computed(() => ['pending', 'generating'].includes(documentInfo.value.status))
const isCompleted = computed(() => documentInfo.value.status === 'completed')
const paragraphMappings = computed(() => documentInfo.value.request_payload?.paragraphs || [])
const logStatusMap = computed(() =>
Object.fromEntries(logs.value.map((item) => [item.paragraph_id, item.status]))
)
const selectedParagraph = computed(() =>
logs.value.find((item) => item.paragraph_id === selectedParagraphId.value) || null
)
const selectedMapping = computed(() =>
paragraphMappings.value.find((item: any) => item.paragraph_id === selectedParagraphId.value) || null
)
function statusText(status: string) {
const map: Record<string, string> = {
@@ -145,6 +173,20 @@ async function loadDocument() {
const response: any = await generateApi.getDocument(id)
documentInfo.value = response.data || {}
logs.value = response.data?.logs || []
if (!selectedParagraphId.value) {
selectedParagraphId.value = logs.value[0]?.paragraph_id || paragraphMappings.value[0]?.paragraph_id || null
}
if (
selectedParagraphId.value &&
!paragraphMappings.value.some((item: any) => item.paragraph_id === selectedParagraphId.value) &&
!logs.value.some((item) => item.paragraph_id === selectedParagraphId.value)
) {
selectedParagraphId.value = logs.value[0]?.paragraph_id || paragraphMappings.value[0]?.paragraph_id || null
}
}
function selectParagraph(paragraphId: number) {
selectedParagraphId.value = paragraphId
}
function bindProgress() {
@@ -200,6 +242,11 @@ onBeforeUnmount(() => {
<style scoped>
.detail-page {
padding: 24px;
height: calc(100vh - 52px);
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.detail-head {
@@ -254,10 +301,38 @@ onBeforeUnmount(() => {
}
.status-card,
.mapping-card,
.preview-card {
margin-bottom: 16px;
border-radius: 18px;
flex-shrink: 0;
}
.detail-layout {
display: flex;
gap: 16px;
align-items: flex-start;
min-height: 0;
flex: 1;
overflow: hidden;
}
.detail-left {
width: 360px;
flex-shrink: 0;
height: 100%;
min-height: 0;
}
.detail-right {
min-width: 0;
flex: 1;
height: 100%;
min-height: 0;
}
.left-card {
border-radius: 18px;
height: 100%;
}
.status-message {
@@ -276,6 +351,18 @@ onBeforeUnmount(() => {
border: 1px solid #e5e7eb;
border-radius: 14px;
background: #fafbfc;
cursor: pointer;
transition: all 0.15s ease;
}
.mapping-item:hover {
border-color: #c7d2fe;
background: #f8faff;
}
.mapping-item.active {
border-color: #4f46e5;
background: #eef2ff;
}
.mapping-top {
@@ -312,6 +399,10 @@ onBeforeUnmount(() => {
color: #6b7280;
}
.mapping-status {
margin-top: 10px;
}
.mapping-files {
display: flex;
flex-wrap: wrap;
@@ -334,16 +425,12 @@ onBeforeUnmount(() => {
color: #9ca3af;
}
.preview-wrap {
display: grid;
gap: 16px;
}
.preview-section {
padding: 16px;
border-radius: 14px;
background: #fff;
border: 1px solid #e5e7eb;
min-height: 100%;
}
.preview-section-head {
@@ -358,6 +445,33 @@ onBeforeUnmount(() => {
margin: 0;
}
.preview-note {
margin-bottom: 12px;
font-size: 13px;
color: #6b7280;
}
.preview-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
:deep(.left-card .ant-card-body) {
height: calc(100% - 57px);
overflow-y: auto;
}
:deep(.preview-card) {
height: 100%;
}
:deep(.preview-card .ant-card-body) {
height: calc(100% - 57px);
overflow-y: auto;
}
:deep(.result-text) {
margin: 0 0 12px;
line-height: 1.8;
+399 -168
View File
@@ -7,6 +7,10 @@
</a>
<span class="divider">|</span>
<span class="editor-title">{{ templateName }}</span>
<a-radio-group v-model:value="editorMode" size="small" button-style="solid">
<a-radio-button value="paragraph">段落配置</a-radio-button>
<a-radio-button value="manual">手动编辑模板</a-radio-button>
</a-radio-group>
<span class="flex-spacer" />
<a-button type="primary" @click="saveTemplate">保存模板</a-button>
</div>
@@ -25,40 +29,112 @@
@click="selectPara(paragraph.id)"
>
<span class="pli-index">{{ paragraph.sort_index }}</span>
<span class="pli-title">{{ paragraph.title || '未命名段落' }}</span>
<span class="pli-title">{{ listTitle(paragraph) }}</span>
<span :class="['pli-badge', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}
</span>
<span class="pli-actions" @click.stop>
<a-button type="text" size="small" :disabled="!canMoveUp(paragraph)" @click="moveUp(paragraph)">
<arrow-up-outlined />
</a-button>
<a-button type="text" size="small" :disabled="!canMoveDown(paragraph)" @click="moveDown(paragraph)">
<arrow-down-outlined />
</a-button>
<a-button type="text" size="small" danger :disabled="!canDeleteBlock(paragraph)" @click="handleDeleteParagraph(paragraph)">
<delete-outlined />
</a-button>
</span>
</div>
</div>
</aside>
<section class="editor-center">
<div class="center-toolbar">
<span class="tb-btn active"><b>B</b></span>
<span class="tb-btn"><i>I</i></span>
<span class="tb-btn"><u>U</u></span>
<span class="tb-divider" />
<span class="tb-btn"><font-size-outlined /></span>
<span class="tb-btn"><ordered-list-outlined /></span>
<span class="tb-btn"><table-outlined /></span>
<span class="tb-divider" />
<span class="tb-btn"><align-left-outlined /></span>
<span class="tb-btn"><align-center-outlined /></span>
<span class="tb-btn"><align-right-outlined /></span>
<span class="toolbar-hint">点击左侧段落或文档中的段落块查看配置</span>
<template v-if="editorMode === 'paragraph'">
<span class="tb-btn active"><b>B</b></span>
<span class="tb-btn"><i>I</i></span>
<span class="tb-btn"><u>U</u></span>
<span class="tb-divider" />
<span class="tb-btn"><font-size-outlined /></span>
<span class="tb-btn"><ordered-list-outlined /></span>
<span class="tb-btn"><table-outlined /></span>
<span class="tb-divider" />
<span class="tb-btn"><align-left-outlined /></span>
<span class="tb-btn"><align-center-outlined /></span>
<span class="tb-btn"><align-right-outlined /></span>
</template>
<span class="toolbar-hint">
{{ editorMode === 'paragraph' ? '点击左侧段落或文档中的段落块查看配置' : '可直接编辑标题、正文,并手动拆块插入 AI 内容' }}
</span>
</div>
<div class="center-scroll">
<div class="doc-edit-page">
<div v-for="paragraph in paragraphs" :key="paragraph.id" :id="`paraBlock${paragraph.id}`" :class="['para-block', { selected: selectedId === paragraph.id }]" @click="selectPara(paragraph.id)">
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? `AI 生成${paragraph.output_format === 'table' ? ' · 表格' : ''}` : '人工编辑' }}
</span>
<div class="sec-title" :style="{ marginTop: paragraph.sort_index === 1 ? '0' : '' }">{{ paragraph.title }}</div>
<p v-if="paragraph.content">{{ paragraph.content }}</p>
<p v-else class="empty-text">点击左侧配置此段落</p>
</div>
<template v-if="editorMode === 'paragraph'">
<div v-for="paragraph in paragraphs" :key="paragraph.id" :id="`paraBlock${paragraph.id}`" :class="['para-block', { selected: selectedId === paragraph.id }]" @click="selectPara(paragraph.id)">
<span class="para-block-actions" @click.stop>
<a-button type="text" size="small" :disabled="!canMoveUp(paragraph)" @click="moveUp(paragraph)">
<arrow-up-outlined />
</a-button>
<a-button type="text" size="small" :disabled="!canMoveDown(paragraph)" @click="moveDown(paragraph)">
<arrow-down-outlined />
</a-button>
<a-button type="text" size="small" danger :disabled="!canDeleteBlock(paragraph)" @click="handleDeleteParagraph(paragraph)">
<delete-outlined />
</a-button>
</span>
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? `AI 生成${paragraph.output_format === 'table' ? ' · 表格' : ''}` : '人工编辑' }}
</span>
<div class="sec-title" :style="{ marginTop: paragraph.sort_index === 1 ? '0' : '' }">{{ paragraph.title }}</div>
<p v-if="paragraph.content">{{ paragraph.content }}</p>
<p v-else class="empty-text">点击左侧配置此段落</p>
</div>
</template>
<template v-else>
<div
v-for="paragraph in paragraphs"
:key="paragraph.id"
:class="['para-block', 'manual-block', { selected: selectedId === paragraph.id }]"
@click="selectPara(paragraph.id)"
>
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? 'AI 段落' : '手动段落' }}
</span>
<div class="manual-block-head">
<span class="manual-block-index">段落 {{ paragraph.sort_index }}</span>
<span class="manual-block-anchor" v-if="paragraph.anchor_title && paragraph.anchor_title !== paragraph.title">
原标题{{ paragraph.anchor_title }}
</span>
</div>
<div class="field-label">标题</div>
<a-input
v-model:value="paragraph.title"
class="manual-title-input"
placeholder="输入导出时使用的标题"
@click.stop
/>
<div class="field-label">正文</div>
<a-textarea
v-model:value="paragraph.content"
class="manual-content-input"
:rows="paragraph.write_mode === 'replace_heading_only' ? 3 : 6"
:placeholder="contentPlaceholder(paragraph)"
@click.stop
/>
<div class="manual-block-actions">
<a-button size="small" @click.stop="insertBlockAfter(paragraph, 'manual')">在后面新增固定块</a-button>
<a-button size="small" type="primary" ghost @click.stop="insertBlockAfter(paragraph, 'ai')">在后面新增 AI </a-button>
<a-button size="small" :disabled="!canMoveUp(paragraph)" @click.stop="moveUp(paragraph)">上移</a-button>
<a-button size="small" :disabled="!canMoveDown(paragraph)" @click.stop="moveDown(paragraph)">下移</a-button>
<a-button size="small" danger :disabled="!canDeleteBlock(paragraph)" @click.stop="handleDeleteParagraph(paragraph)">删除当前块</a-button>
</div>
<div class="manual-block-meta">
<span class="meta-item">编辑方式{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}</span>
<span class="meta-item">写入方式{{ writeModeLabel(paragraph.write_mode) }}</span>
</div>
</div>
</template>
</div>
</div>
</section>
@@ -78,6 +154,13 @@
<a-select-option value="manual">人工编辑</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="写入方式">
<a-select v-model:value="selectedPara.write_mode">
<a-select-option value="replace_section">替换标题下整段</a-select-option>
<a-select-option value="append_after_heading">标题下插入内容</a-select-option>
<a-select-option value="replace_heading_only">仅替换标题</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="selectedPara.edit_mode === 'ai'" label="生成模型">
<a-select v-model:value="selectedPara.model_id" allowClear placeholder="使用默认模型">
<a-select-option v-for="model in models" :key="model.id" :value="model.id">{{ model.name }}</a-select-option>
@@ -94,6 +177,31 @@
</a-form>
</div>
<div class="config-section">
<div class="config-section-title">模板内容</div>
<a-alert
type="info"
show-icon
message="这里的标题和正文会作为模板快照保存。你可以把一个大段拆成多个块,再插入 AI 块。系统会按同一原标题锚点,把这些块顺序写回 Word。"
class="template-alert"
/>
<a-form layout="vertical">
<a-form-item label="原标题锚点">
<a-input :value="selectedPara.anchor_title || selectedPara.title" disabled />
</a-form-item>
<a-form-item label="导出标题">
<a-input v-model:value="selectedPara.title" placeholder="输入导出时使用的标题" />
</a-form-item>
<a-form-item label="模板正文">
<a-textarea
v-model:value="selectedPara.content"
:rows="selectedPara.write_mode === 'replace_heading_only' ? 4 : 8"
:placeholder="contentPlaceholder(selectedPara)"
/>
</a-form-item>
</a-form>
</div>
<div class="config-section">
<div class="config-section-title">提示词与文件</div>
<div class="toggle-row">
@@ -134,68 +242,12 @@
</a-steps>
<div v-if="testStep === 0">
<div class="test-paragraph-card">
<div class="test-para-title">{{ selectedPara?.title }}</div>
<div class="test-para-desc">{{ selectedPara?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。' }}</div>
<a-upload-dragger :multiple="true" :beforeUpload="beforeTestUpload" :showUploadList="false">
<p class="ant-upload-drag-icon"><upload-outlined /></p>
<p class="ant-upload-text">点击或拖拽上传参考文件</p>
<p class="ant-upload-hint">支持多文件docx / doc / xlsx / xls / xlsm / csv / pdf / txt / md / json</p>
</a-upload-dragger>
</div>
<div v-if="testFiles.length" class="uploaded-list">
<div class="uploaded-item" v-for="file in testFiles" :key="file.uid">
<span class="uploaded-name">{{ file.name }}</span>
<a-button type="link" size="small" danger @click="removeTestFile(file.uid)">移除</a-button>
</div>
</div>
<div class="history-card">
<div class="history-head">
<div>
<div class="history-title">历史文件</div>
<div class="history-desc">已上传过的文件会保存在系统里下次可直接选择复用</div>
</div>
<a-button size="small" @click="fetchReferenceHistory">刷新</a-button>
</div>
<div class="history-search">
<a-input-search
v-model:value="historyKeyword"
placeholder="按文件名搜索历史文件"
allow-clear
@search="fetchReferenceHistory"
/>
</div>
<a-spin :spinning="historyLoading">
<div v-if="referenceHistory.length" class="history-list">
<label v-for="item in referenceHistory" :key="item.id" class="history-item">
<input
type="checkbox"
:checked="selectedHistoryPaths.includes(item.file_path)"
@change="toggleHistoryFile(item.file_path)"
/>
<div class="history-item-main">
<div class="history-item-name">{{ item.file_name }}</div>
<div class="history-item-meta">
<span>{{ formatFileSize(item.file_size) }}</span>
<span>{{ formatDateTime(item.created_at) }}</span>
</div>
</div>
</label>
</div>
<a-empty v-else description="暂无历史文件" />
</a-spin>
</div>
<div v-if="selectedHistoryItems.length" class="uploaded-list">
<div class="uploaded-item" v-for="item in selectedHistoryItems" :key="item.file_path">
<span class="uploaded-name">{{ item.file_name }}</span>
<a-button type="link" size="small" danger @click="toggleHistoryFile(item.file_path)">取消选择</a-button>
</div>
</div>
<ReferenceFileSelector
v-model="testSelectedFiles"
:title="selectedPara?.title || ''"
:description="selectedPara?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。'"
variant="full"
/>
<a-button type="primary" block :loading="testing" @click="startTest">开始测试支持多文件</a-button>
</div>
@@ -228,29 +280,26 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { message, Modal } from 'ant-design-vue'
import {
AlignCenterOutlined,
AlignLeftOutlined,
AlignRightOutlined,
ArrowDownOutlined,
ArrowLeftOutlined,
ArrowUpOutlined,
DeleteOutlined,
FontSizeOutlined,
OrderedListOutlined,
TableOutlined,
UploadOutlined,
} from '@ant-design/icons-vue'
import { useTemplateStore } from '@/stores/template'
import { useModelStore } from '@/stores/model'
import { generateApi } from '@/api/generate'
import type { ReferenceFile } from '@/types'
interface LocalUploadFile {
uid: string
name: string
raw: File
}
import { templateApi } from '@/api/template'
import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
const route = useRoute()
const router = useRouter()
@@ -261,33 +310,189 @@ const paragraphs = ref<any[]>([])
const models = ref<any[]>([])
const selectedId = ref(0)
const templateName = ref('模板编辑')
const editorMode = ref<'paragraph' | 'manual'>('paragraph')
const testOpen = ref(false)
const testStep = ref(0)
const testing = ref(false)
const testStatusText = ref('正在解析文件内容并请求 AI 模型...')
const testFiles = ref<LocalUploadFile[]>([])
const testSelectedFiles = ref<Array<{ file_name: string; file_path: string }>>([])
const testResultHtml = ref('')
const testResultMessage = ref('')
const testFileSummaries = ref<any[]>([])
const streamedText = ref('')
const referenceHistory = ref<ReferenceFile[]>([])
const selectedHistoryPaths = ref<string[]>([])
const historyKeyword = ref('')
const historyLoading = ref(false)
const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value))
const selectedHistoryItems = computed(() =>
referenceHistory.value.filter((item) => selectedHistoryPaths.value.includes(item.file_path))
)
function selectPara(id: number) {
selectedId.value = id
}
function normalizeSortIndex() {
paragraphs.value = paragraphs.value.map((item, index) => ({
...item,
sort_index: index + 1,
}))
}
function writeModeLabel(mode: string) {
if (mode === 'append_after_heading') return '标题下插入内容'
if (mode === 'replace_heading_only') return '仅替换标题'
return '替换标题下整段'
}
function listTitle(paragraph: any) {
if (!paragraph?.anchor_title || paragraph.anchor_title === paragraph.title) {
return paragraph?.title || '未命名段落'
}
return `${paragraph.title || '未命名块'}(归属 ${paragraph.anchor_title}`
}
function canDeleteBlock(_paragraph: any) {
return paragraphs.value.length > 1
}
function canMoveUp(paragraph: any) {
const index = paragraphs.value.findIndex((item) => item === paragraph)
return index > 0
}
function canMoveDown(paragraph: any) {
const index = paragraphs.value.findIndex((item) => item === paragraph)
return index >= 0 && index < paragraphs.value.length - 1
}
function moveUp(paragraph: any) {
const index = paragraphs.value.findIndex((item) => item === paragraph)
if (index <= 0) return
const temp = paragraphs.value[index]
paragraphs.value[index] = paragraphs.value[index - 1]
paragraphs.value[index - 1] = temp
normalizeSortIndex()
autoSaveParagraphs()
}
function moveDown(paragraph: any) {
const index = paragraphs.value.findIndex((item) => item === paragraph)
if (index < 0 || index >= paragraphs.value.length - 1) return
const temp = paragraphs.value[index]
paragraphs.value[index] = paragraphs.value[index + 1]
paragraphs.value[index + 1] = temp
normalizeSortIndex()
autoSaveParagraphs()
}
function handleDeleteParagraph(paragraph: any) {
if (!canDeleteBlock(paragraph)) {
message.warning('至少保留一个段落')
return
}
Modal.confirm({
title: '确认删除',
content: `确定要删除段落"${paragraph.title || '未命名'}"吗?`,
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
onOk: () => {
removeBlock(paragraph)
},
})
}
async function autoSaveParagraphs() {
const templateId = Number(route.params.id)
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,
}))
try {
console.log('[autoSave] sending paragraphs:', data.map(p => ({ id: p.id, title: p.title })))
await templateApi.saveParagraphs(templateId, { paragraphs: data })
store.paragraphs = paragraphs.value as any
console.log('[autoSave] save success')
} catch (e: any) {
console.error('[autoSave] save failed:', e)
message.error(e?.message || '自动保存失败')
}
}
function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
const index = paragraphs.value.findIndex((item) => item === sourceParagraph)
if (index < 0) return
const blockTitle = sourceParagraph.title || sourceParagraph.anchor_title || '未命名段落'
const newBlock = {
id: -Date.now() - Math.floor(Math.random() * 1000),
template_id: sourceParagraph.template_id,
sort_index: sourceParagraph.sort_index + 1,
anchor_title: sourceParagraph.anchor_title || blockTitle,
title: blockTitle,
content: '',
style_json: sourceParagraph.style_json || '{}',
is_table: false,
table_json: '{}',
edit_mode: editMode,
write_mode: 'replace_section',
model_id: editMode === 'ai' ? sourceParagraph.model_id ?? null : null,
need_prompt: editMode === 'ai',
prompt_text: editMode === 'ai' ? sourceParagraph.prompt_text || '' : '',
need_file: false,
file_note: '',
output_format: 'text',
}
paragraphs.value.splice(index + 1, 0, newBlock)
normalizeSortIndex()
nextTick(() => {
selectedId.value = newBlock.id
})
}
function removeBlock(paragraph: any) {
if (!canDeleteBlock(paragraph)) {
message.warning('至少保留一个段落')
return
}
const index = paragraphs.value.findIndex((item) => item === paragraph)
if (index < 0) return
paragraphs.value.splice(index, 1)
normalizeSortIndex()
const next = paragraphs.value[index] || paragraphs.value[index - 1] || paragraphs.value[0]
selectedId.value = next?.id || 0
autoSaveParagraphs()
}
function contentPlaceholder(paragraph: any) {
if (paragraph?.edit_mode === 'manual') {
return paragraph?.write_mode === 'replace_heading_only'
? '仅替换标题时,这里的正文仅作为备注保留,不会覆盖原文。'
: '输入人工维护的正文内容,导出时将按写入方式写回 Word。'
}
if (paragraph?.write_mode === 'replace_heading_only') {
return '该段落只更新标题,正文不会被 AI 覆盖。可在这里记录上下文备注。'
}
return '这里可填写模板参考正文、固定说明或给 AI 的上下文。'
}
async function saveTemplate() {
const templateId = Number(route.params.id)
store.paragraphs = paragraphs.value as any
await store.save(templateId)
const template = await store.fetchOne(templateId)
templateName.value = template.name
paragraphs.value = store.paragraphs as any
if (selectedId.value <= 0 && paragraphs.value.length) {
selectedId.value = paragraphs.value[0].id
}
message.success('模板配置已保存')
}
@@ -295,71 +500,21 @@ function openTestModal() {
testOpen.value = true
testStep.value = 0
testing.value = false
testFiles.value = []
testSelectedFiles.value = []
testResultHtml.value = ''
testResultMessage.value = ''
testFileSummaries.value = []
selectedHistoryPaths.value = []
fetchReferenceHistory()
}
function resetTestModal() {
testOpen.value = false
testStep.value = 0
testing.value = false
testFiles.value = []
testSelectedFiles.value = []
testResultHtml.value = ''
testResultMessage.value = ''
testFileSummaries.value = []
streamedText.value = ''
selectedHistoryPaths.value = []
}
function beforeTestUpload(file: File) {
testFiles.value.push({
uid: `${Date.now()}-${Math.random()}`,
name: file.name,
raw: file,
})
return false
}
function removeTestFile(uid: string) {
testFiles.value = testFiles.value.filter((item) => item.uid !== uid)
}
function toggleHistoryFile(filePath: string) {
if (selectedHistoryPaths.value.includes(filePath)) {
selectedHistoryPaths.value = selectedHistoryPaths.value.filter((item) => item !== filePath)
return
}
selectedHistoryPaths.value = [...selectedHistoryPaths.value, filePath]
}
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function formatDateTime(value: string) {
return value ? value.replace('T', ' ').slice(0, 19) : ''
}
async function fetchReferenceHistory() {
historyLoading.value = true
try {
const response: any = await generateApi.referenceFiles({
page: 1,
page_size: 30,
keyword: historyKeyword.value.trim(),
})
referenceHistory.value = response.data?.items || []
} catch (error: any) {
message.error(error.message || '加载历史文件失败')
} finally {
historyLoading.value = false
}
}
function renderTestResult(content: any) {
@@ -388,29 +543,17 @@ function renderTestResult(content: any) {
async function startTest() {
if (!selectedPara.value) return
if (selectedPara.value.need_file && !testFiles.value.length && !selectedHistoryPaths.value.length) {
if (selectedPara.value.need_file && !testSelectedFiles.value.length) {
message.warning('请先上传至少一个参考文件')
return
}
testStep.value = 1
testing.value = true
testStatusText.value = '正在上传文件...'
testStatusText.value = '正在整理参考文件...'
try {
const filePaths: string[] = []
for (const item of testFiles.value) {
const formData = new FormData()
formData.append('file', item.raw)
const uploadRes: any = await generateApi.upload(formData)
filePaths.push(uploadRes.data.file_path)
}
for (const filePath of selectedHistoryPaths.value) {
if (!filePaths.includes(filePath)) {
filePaths.push(filePath)
}
}
await fetchReferenceHistory()
const filePaths = testSelectedFiles.value.map((item) => item.file_path)
testStatusText.value = '正在解析文件内容并请求 AI 模型...'
const templateId = Number(route.params.id)
@@ -644,6 +787,7 @@ onMounted(async () => {
padding: 24px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.doc-edit-page {
@@ -652,6 +796,7 @@ onMounted(async () => {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 80px 72px 120px;
min-height: 500px;
flex-shrink: 0;
}
.para-block {
@@ -692,6 +837,19 @@ onMounted(async () => {
color: #e68a00;
}
.para-block-actions {
position: absolute;
right: 56px;
top: 6px;
display: none;
gap: 2px;
}
.para-block:hover .para-block-actions,
.para-block.selected .para-block-actions {
display: inline-flex;
}
.sec-title {
font-size: 16px;
font-weight: 600;
@@ -705,6 +863,65 @@ onMounted(async () => {
color: #9aa1ad;
}
.manual-block {
padding: 20px;
margin-bottom: 16px;
border: 1px solid #e7e9ee;
border-radius: 12px;
}
.manual-block-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.manual-block-index {
font-size: 12px;
font-weight: 600;
color: #5b5bd6;
}
.manual-block-anchor {
font-size: 12px;
color: #7b8190;
}
.field-label {
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: #30343c;
}
.manual-title-input {
margin-bottom: 12px;
}
.manual-content-input {
margin-bottom: 12px;
}
.manual-block-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.manual-block-meta {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.meta-item {
font-size: 12px;
color: #7b8190;
}
.editor-right {
width: 380px;
flex-shrink: 0;
@@ -739,6 +956,10 @@ onMounted(async () => {
margin-top: 16px;
}
.template-alert {
margin-bottom: 12px;
}
.para-list-item {
display: flex;
align-items: center;
@@ -803,6 +1024,16 @@ onMounted(async () => {
color: #e68a00;
}
.pli-actions {
display: none;
gap: 2px;
flex-shrink: 0;
}
.para-list-item:hover .pli-actions {
display: inline-flex;
}
.test-paragraph-card {
background: #f0f1f3;
border-radius: 8px;