完善模板编辑与模型管理体验

This commit is contained in:
zwt13703
2026-07-03 11:14:24 +08:00
parent 52eb058070
commit a796301ae8
19 changed files with 1065 additions and 246 deletions
+8
View File
@@ -45,3 +45,11 @@ async def init_db():
) )
if "request_payload_json" not in document_columns: if "request_payload_json" not in document_columns:
await conn.execute(text("ALTER TABLE documents ADD COLUMN request_payload_json TEXT")) 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) id = Column(Integer, primary_key=True, autoincrement=True)
template_id = Column(Integer, ForeignKey("templates.id"), nullable=False) template_id = Column(Integer, ForeignKey("templates.id"), nullable=False)
sort_index = Column(Integer, default=0, comment="排序") sort_index = Column(Integer, default=0, comment="排序")
anchor_title = Column(String(500), default="", comment="原始标题锚点")
title = Column(String(500), default="", comment="段落标题") title = Column(String(500), default="", comment="段落标题")
content = Column(Text, default="", comment="正文内容/上下文") content = Column(Text, default="", comment="正文内容/上下文")
style_json = Column(Text, default="{}", comment="段落样式定义JSON") style_json = Column(Text, default="{}", comment="段落样式定义JSON")
is_table = Column(Boolean, default=False, comment="是否为表格") is_table = Column(Boolean, default=False, comment="是否为表格")
table_json = Column(Text, default="{}", comment="表格结构JSON") 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="指定模型") model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型")
need_prompt = Column(Boolean, default=True, comment="是否需要提示词") need_prompt = Column(Boolean, default=True, comment="是否需要提示词")
prompt_text = Column(Text, default="", 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(): for log, paragraph in result.all():
logs.append( logs.append(
{ {
"anchor_title": paragraph.anchor_title or paragraph.title,
"title": paragraph.title, "title": paragraph.title,
"write_mode": paragraph.write_mode,
"content": json.loads(log.content) if log.content else {"content": []}, "content": json.loads(log.content) if log.content else {"content": []},
} }
) )
+39
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import httpx
from database import get_db from database import get_db
from models.ai_model import AiModel from models.ai_model import AiModel
@@ -11,6 +12,11 @@ from services.security import decrypt_text, encrypt_text, mask_secret
router = APIRouter() router = APIRouter()
def _is_deepseek_model(model: AiModel) -> bool:
provider = (model.provider or "").strip().lower()
return provider == "deepseek"
def _serialize_model(model: AiModel) -> dict: def _serialize_model(model: AiModel) -> dict:
api_key = decrypt_text(model.api_key_encrypted) api_key = decrypt_text(model.api_key_encrypted)
return { return {
@@ -121,3 +127,36 @@ async def test_model(model_id: int, db: AsyncSession = Depends(get_db)):
message=str(error), message=str(error),
data={"id": model.id, "success": False}, 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", []),
}
)
+27 -6
View File
@@ -32,12 +32,14 @@ def _serialize_paragraph(paragraph: Paragraph) -> dict:
"id": paragraph.id, "id": paragraph.id,
"template_id": paragraph.template_id, "template_id": paragraph.template_id,
"sort_index": paragraph.sort_index, "sort_index": paragraph.sort_index,
"anchor_title": paragraph.anchor_title,
"title": paragraph.title, "title": paragraph.title,
"content": paragraph.content, "content": paragraph.content,
"style_json": paragraph.style_json, "style_json": paragraph.style_json,
"is_table": paragraph.is_table, "is_table": paragraph.is_table,
"table_json": paragraph.table_json, "table_json": paragraph.table_json,
"edit_mode": paragraph.edit_mode, "edit_mode": paragraph.edit_mode,
"write_mode": paragraph.write_mode,
"model_id": paragraph.model_id, "model_id": paragraph.model_id,
"need_prompt": paragraph.need_prompt, "need_prompt": paragraph.need_prompt,
"prompt_text": paragraph.prompt_text, "prompt_text": paragraph.prompt_text,
@@ -151,11 +153,14 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
paragraph = Paragraph( paragraph = Paragraph(
template_id=template.id, template_id=template.id,
sort_index=item.sort_index, sort_index=item.sort_index,
anchor_title=item.anchor_title,
title=item.title, title=item.title,
content=item.content, content=item.content,
style_json=item.style_json, style_json=item.style_json,
is_table=item.is_table, is_table=item.is_table,
table_json=item.table_json, table_json=item.table_json,
edit_mode="manual",
write_mode=item.write_mode,
) )
db.add(paragraph) db.add(paragraph)
paragraph_rows.append(paragraph) paragraph_rows.append(paragraph)
@@ -180,16 +185,30 @@ async def save_template_paragraphs(
if template is None: if template is None:
raise HTTPException(status_code=404, detail="模板不存在") raise HTTPException(status_code=404, detail="模板不存在")
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id)) result = await db.execute(
paragraph_map = {item.id: item for item in result.scalars().all()} 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: for paragraph in existing_paragraphs:
paragraph = paragraph_map.get(config.id) 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: if paragraph is None:
continue paragraph = Paragraph(template_id=template_id)
paragraph.sort_index = config.sort_index 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.title = config.title
paragraph.content = config.content
paragraph.edit_mode = config.edit_mode paragraph.edit_mode = config.edit_mode
paragraph.write_mode = config.write_mode
paragraph.model_id = config.model_id paragraph.model_id = config.model_id
paragraph.need_prompt = config.need_prompt paragraph.need_prompt = config.need_prompt
paragraph.prompt_text = config.prompt_text paragraph.prompt_text = config.prompt_text
@@ -197,6 +216,8 @@ async def save_template_paragraphs(
paragraph.file_note = config.file_note paragraph.file_note = config.file_note
paragraph.output_format = config.output_format paragraph.output_format = config.output_format
await db.commit()
template.paragraph_count = len(body.paragraphs)
await db.commit() await db.commit()
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)}) return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
+4 -1
View File
@@ -31,8 +31,11 @@ class TemplateOut(BaseModel):
class ParagraphConfig(BaseModel): class ParagraphConfig(BaseModel):
id: int = 0 id: int = 0
sort_index: int = 0 sort_index: int = 0
anchor_title: str = ""
title: str = "" title: str = ""
edit_mode: str = "ai" content: str = ""
edit_mode: str = "manual"
write_mode: str = "replace_section"
model_id: Optional[int] = None model_id: Optional[int] = None
need_prompt: bool = True need_prompt: bool = True
prompt_text: str = "" prompt_text: str = ""
+129 -22
View File
@@ -56,6 +56,34 @@ def _copy_run_format(target_run, source_paragraph: Paragraph | None):
break 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( def _append_paragraph_after(
paragraph: Paragraph, paragraph: Paragraph,
text: str, text: str,
@@ -166,16 +194,12 @@ def _find_heading_paragraph(document: DocumentObject, heading_text: str, after_e
return None return None
def _replace_section_content(document: DocumentObject, heading_title: str, content: dict, after_element=None): def _collect_section_templates(heading: Paragraph):
heading = _find_heading_paragraph(document, heading_title, after_element)
if heading is None:
return after_element
first_body_style = None first_body_style = None
paragraph_template = None paragraph_template = None
table_template = None table_template = None
blocks = []
current = heading._element.getnext() current = heading._element.getnext()
blocks_to_remove = []
while current is not None: while current is not None:
if isinstance(current, CT_P): if isinstance(current, CT_P):
current_paragraph = Paragraph(current, heading._parent) 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 first_body_style = current_paragraph.style.name
if paragraph_template is None: if paragraph_template is None:
paragraph_template = current_paragraph paragraph_template = current_paragraph
blocks_to_remove.append(current_paragraph) blocks.append(current_paragraph)
elif isinstance(current, CT_Tbl): elif isinstance(current, CT_Tbl):
current_table = Table(current, heading._parent) current_table = Table(current, heading._parent)
if table_template is None: if table_template is None:
table_template = current_table table_template = current_table
blocks_to_remove.append(current_table) blocks.append(current_table)
current = current.getnext() 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", []) content_blocks = content.get("content", [])
for block in content_blocks: for block in content_blocks:
block_type = block.get("type") block_type = block.get("type")
if block_type == "table": if block_type == "table":
rows = [list(row) for row in block.get("rows", [])] rows = [list(row) for row in block.get("rows", [])]
headers = block.get("headers") or [] headers = block.get("headers") or []
table = _append_table_after(insert_after, rows, headers, table_template) table = _append_table_after(current_anchor, rows, headers, table_template)
insert_after = _append_empty_paragraph_after_table(table, first_body_style) current_anchor = _append_empty_paragraph_after_table(table, first_body_style)
else: else:
text = block.get("text", "") text = block.get("text", "")
text_parts = [item for item in text.split("\n") if item] or [text] text_parts = [item for item in text.split("\n") if item] or [text]
for text_part in text_parts: for text_part in text_parts:
insert_after = _append_paragraph_after( current_anchor = _append_paragraph_after(
insert_after, current_anchor,
text_part, text_part,
first_body_style, first_body_style,
paragraph_template, 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 return heading._element
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes: def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
document = Document(BytesIO(template_bytes)) document = Document(BytesIO(template_bytes))
last_heading_element = None last_heading_element = None
for item in logs: for group in _group_logs(logs):
last_heading_element = _replace_section_content( last_heading_element = _replace_section_group(document, group, last_heading_element)
document,
item["title"],
item["content"],
last_heading_element,
)
output = BytesIO() output = BytesIO()
document.save(output) document.save(output)
+8
View File
@@ -15,11 +15,13 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH
@dataclass @dataclass
class ParsedParagraph: class ParsedParagraph:
sort_index: int sort_index: int
anchor_title: str
title: str title: str
content: str content: str
style_json: str style_json: str
is_table: bool is_table: bool
table_json: str table_json: str
write_mode: str
def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]: 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: if level is not None:
current_item = ParsedParagraph( current_item = ParsedParagraph(
sort_index=len(parsed) + 1, sort_index=len(parsed) + 1,
anchor_title=text,
title=text, title=text,
content="", content="",
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False), style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False),
is_table=False, is_table=False,
table_json="{}", table_json="{}",
write_mode="replace_section",
) )
parsed.append(current_item) parsed.append(current_item)
continue continue
@@ -185,11 +189,13 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
if current_item is None: if current_item is None:
current_item = ParsedParagraph( current_item = ParsedParagraph(
sort_index=len(parsed) + 1, sort_index=len(parsed) + 1,
anchor_title="未命名段落",
title="未命名段落", title="未命名段落",
content=text, content=text,
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False), style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
is_table=False, is_table=False,
table_json="{}", table_json="{}",
write_mode="replace_section",
) )
parsed.append(current_item) parsed.append(current_item)
else: else:
@@ -201,11 +207,13 @@ def parse_template(file_path: str) -> list[ParsedParagraph]:
loose_table_count += 1 loose_table_count += 1
current_item = ParsedParagraph( current_item = ParsedParagraph(
sort_index=len(parsed) + 1, sort_index=len(parsed) + 1,
anchor_title=f"表格_{loose_table_count}",
title=f"表格_{loose_table_count}", title=f"表格_{loose_table_count}",
content=table_text, content=table_text,
style_json="{}", style_json="{}",
is_table=True, is_table=True,
table_json=json.dumps(table_data, ensure_ascii=False), table_json=json.dumps(table_data, ensure_ascii=False),
write_mode="replace_section",
) )
parsed.append(current_item) parsed.append(current_item)
else: else:
+140
View File
@@ -8,3 +8,143 @@
2. 整理并暂存相关文件,排除未跟踪的原型目录。 2. 整理并暂存相关文件,排除未跟踪的原型目录。
3. 准备使用中文提交信息完成本次代码提交。 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, id INT AUTO_INCREMENT PRIMARY KEY,
template_id INT NOT NULL, template_id INT NOT NULL,
sort_index INT DEFAULT 0 COMMENT '排序', sort_index INT DEFAULT 0 COMMENT '排序',
anchor_title VARCHAR(500) DEFAULT '' COMMENT '原始标题锚点',
title VARCHAR(500) DEFAULT '' COMMENT '段落标题', title VARCHAR(500) DEFAULT '' COMMENT '段落标题',
content TEXT DEFAULT '' COMMENT '正文内容', content TEXT DEFAULT '' COMMENT '正文内容',
style_json TEXT DEFAULT '{}' COMMENT '样式 JSON', style_json TEXT DEFAULT '{}' COMMENT '样式 JSON',
is_table TINYINT(1) DEFAULT 0 COMMENT '是否为表格', is_table TINYINT(1) DEFAULT 0 COMMENT '是否为表格',
table_json TEXT DEFAULT '{}' COMMENT '表格结构 JSON', 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 '指定模型', model_id INT DEFAULT NULL COMMENT '指定模型',
need_prompt TINYINT(1) DEFAULT 1 COMMENT '是否需要提示词', need_prompt TINYINT(1) DEFAULT 1 COMMENT '是否需要提示词',
prompt_text TEXT DEFAULT '' 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), update: (id: number, data: any) => http.put(`/models/${id}`, data),
delete: (id: number) => http.delete(`/models/${id}`), delete: (id: number) => http.delete(`/models/${id}`),
test: (id: number) => http.post(`/models/${id}/test`), 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 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 remove(id: number) { await modelApi.delete(id); await fetchList() }
async function test(id: number) { return await modelApi.test(id) } 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 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 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 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() } async function remove(id: number) { await templateApi.delete(id); await fetchList() }
return { templates, currentTemplate, paragraphs, loading, fetchList, fetchOne, upload, save, remove } return { templates, currentTemplate, paragraphs, loading, fetchList, fetchOne, upload, save, remove }
+2
View File
@@ -5,8 +5,10 @@ export interface Template {
export interface Paragraph { export interface Paragraph {
id: number; template_id: number; sort_index: number; title: string; content: string id: number; template_id: number; sort_index: number; title: string; content: string
anchor_title: string
style_json: string; is_table: boolean; table_json: string style_json: string; is_table: boolean; table_json: string
edit_mode: 'manual' | 'ai'; model_id: number | null 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 need_prompt: boolean; prompt_text: string; need_file: boolean; file_note: string
output_format: 'text' | 'table' | 'mixed' | 'chart' output_format: 'text' | 'table' | 'mixed' | 'chart'
} }
+19 -36
View File
@@ -64,15 +64,10 @@
<a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag> <a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag>
</div> </div>
<div v-if="paragraph.need_file" class="file-info"> <div v-if="paragraph.need_file" class="file-info">
<a-upload :multiple="true" :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false"> <ReferenceFileSelector
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id]?.length ? '继续上传' : '上传文件' }}</a-button> v-model="uploadedFiles[paragraph.id]"
</a-upload> variant="compact"
<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>
</div> </div>
<span v-else class="no-file-tag">无需上传</span> <span v-else class="no-file-tag">无需上传</span>
</div> </div>
@@ -89,12 +84,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { useTemplateStore } from '@/stores/template' import { useTemplateStore } from '@/stores/template'
import { useDocumentStore } from '@/stores/document' import { useDocumentStore } from '@/stores/document'
import { generateApi } from '@/api/generate' import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -105,8 +100,6 @@ const templates = ref<any[]>([])
const paragraphs = ref<any[]>([]) const paragraphs = ref<any[]>([])
const selectedTplId = ref<number | undefined>(undefined) const selectedTplId = ref<number | undefined>(undefined)
const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: string }>>>({}) 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 generating = ref(false)
const tplInfo = ref<any>({}) 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 fileCount = computed(() => Object.values(uploadedFiles.value).filter((item) => item?.length).length)
const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '') 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() { async function refreshTemplates() {
await tplStore.fetchList() await tplStore.fetchList()
templates.value = tplStore.templates as any templates.value = tplStore.templates as any
@@ -140,29 +146,6 @@ async function onTplChange(id: number) {
uploadedFilePaths.value = {} 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() { async function startGen() {
if (!selectedTplId.value) { if (!selectedTplId.value) {
message.warning('请先选择模板') message.warning('请先选择模板')
+75 -9
View File
@@ -20,12 +20,21 @@
<div class="mc-provider">密钥{{ item.api_key_preview || '未设置' }}</div> <div class="mc-provider">密钥{{ item.api_key_preview || '未设置' }}</div>
<div class="mc-provider">流式传输{{ item.supports_streaming ? '支持' : '关闭' }}</div> <div class="mc-provider">流式传输{{ item.supports_streaming ? '支持' : '关闭' }}</div>
<div class="mc-provider">思考模式{{ item.enable_reasoning ? '开启' : '关闭' }}</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>
<div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']"> <div :class="['mc-status', item.status === 'enabled' ? 'on' : 'off']">
{{ item.status === 'enabled' ? '已启用' : '已禁用' }} {{ item.status === 'enabled' ? '已启用' : '已禁用' }}
</div> </div>
<div class="mc-actions"> <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="openEdit(item)">编辑</a-button>
<a-button size="small" @click="toggleStatus(item)">{{ item.status === 'enabled' ? '禁用' : '启用' }}</a-button> <a-button size="small" @click="toggleStatus(item)">{{ item.status === 'enabled' ? '禁用' : '启用' }}</a-button>
</div> </div>
@@ -39,7 +48,13 @@
<a-input v-model:value="form.name" /> <a-input v-model:value="form.name" />
</a-form-item> </a-form-item>
<a-form-item label="供应厂商"> <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>
<a-form-item label="API 格式"> <a-form-item label="API 格式">
<a-select v-model:value="form.api_format"> <a-select v-model:value="form.api_format">
@@ -66,7 +81,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' 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' import { useModelStore } from '@/stores/model'
const store = useModelStore() const store = useModelStore()
@@ -75,6 +91,41 @@ const modalOpen = ref(false)
const isEdit = ref(false) const isEdit = ref(false)
const editId = ref(0) const editId = ref(0)
const form = ref({ name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: false }) 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() { async function refreshList() {
await store.fetchList() await store.fetchList()
@@ -88,6 +139,8 @@ onMounted(async () => {
function openAdd() { function openAdd() {
isEdit.value = false isEdit.value = false
form.value = { name: '', provider: '', api_format: 'openai', api_endpoint: '', api_key: '', supports_streaming: false, enable_reasoning: 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 modalOpen.value = true
} }
@@ -103,6 +156,7 @@ function openEdit(item: any) {
supports_streaming: !!item.supports_streaming, supports_streaming: !!item.supports_streaming,
enable_reasoning: !!item.enable_reasoning, enable_reasoning: !!item.enable_reasoning,
} }
inferProviderPreset(item)
modalOpen.value = true modalOpen.value = true
} }
@@ -124,15 +178,27 @@ async function toggleStatus(item: any) {
} }
async function runTest(item: any) { async function runTest(item: any) {
testingMap.value[item.id] = true
try { try {
const result: any = await store.test(item.id) const result: any = await store.test(item.id)
Modal.info({ message.success(result.data?.message || `模型 ${item.name} 测试成功`, 2)
title: '连接测试结果',
width: 680,
content: JSON.stringify(result.data, null, 2),
})
} catch (error: any) { } 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> </script>
+29 -1
View File
@@ -242,6 +242,11 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.detail-page { .detail-page {
padding: 24px; padding: 24px;
height: calc(100vh - 52px);
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
} }
.detail-head { .detail-head {
@@ -299,26 +304,35 @@ onBeforeUnmount(() => {
.preview-card { .preview-card {
margin-bottom: 16px; margin-bottom: 16px;
border-radius: 18px; border-radius: 18px;
flex-shrink: 0;
} }
.detail-layout { .detail-layout {
display: flex; display: flex;
gap: 16px; gap: 16px;
align-items: flex-start; align-items: flex-start;
min-height: 0;
flex: 1;
overflow: hidden;
} }
.detail-left { .detail-left {
width: 360px; width: 360px;
flex-shrink: 0; flex-shrink: 0;
height: 100%;
min-height: 0;
} }
.detail-right { .detail-right {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
height: 100%;
min-height: 0;
} }
.left-card { .left-card {
border-radius: 18px; border-radius: 18px;
height: 100%;
} }
.status-message { .status-message {
@@ -416,7 +430,7 @@ onBeforeUnmount(() => {
border-radius: 14px; border-radius: 14px;
background: #fff; background: #fff;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
min-height: 480px; min-height: 100%;
} }
.preview-section-head { .preview-section-head {
@@ -444,6 +458,20 @@ onBeforeUnmount(() => {
margin-bottom: 14px; 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) { :deep(.result-text) {
margin: 0 0 12px; margin: 0 0 12px;
line-height: 1.8; line-height: 1.8;
+270 -167
View File
@@ -7,6 +7,10 @@
</a> </a>
<span class="divider">|</span> <span class="divider">|</span>
<span class="editor-title">{{ templateName }}</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" /> <span class="flex-spacer" />
<a-button type="primary" @click="saveTemplate">保存模板</a-button> <a-button type="primary" @click="saveTemplate">保存模板</a-button>
</div> </div>
@@ -25,7 +29,7 @@
@click="selectPara(paragraph.id)" @click="selectPara(paragraph.id)"
> >
<span class="pli-index">{{ paragraph.sort_index }}</span> <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']"> <span :class="['pli-badge', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }} {{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}
</span> </span>
@@ -35,30 +39,78 @@
<section class="editor-center"> <section class="editor-center">
<div class="center-toolbar"> <div class="center-toolbar">
<span class="tb-btn active"><b>B</b></span> <template v-if="editorMode === 'paragraph'">
<span class="tb-btn"><i>I</i></span> <span class="tb-btn active"><b>B</b></span>
<span class="tb-btn"><u>U</u></span> <span class="tb-btn"><i>I</i></span>
<span class="tb-divider" /> <span class="tb-btn"><u>U</u></span>
<span class="tb-btn"><font-size-outlined /></span> <span class="tb-divider" />
<span class="tb-btn"><ordered-list-outlined /></span> <span class="tb-btn"><font-size-outlined /></span>
<span class="tb-btn"><table-outlined /></span> <span class="tb-btn"><ordered-list-outlined /></span>
<span class="tb-divider" /> <span class="tb-btn"><table-outlined /></span>
<span class="tb-btn"><align-left-outlined /></span> <span class="tb-divider" />
<span class="tb-btn"><align-center-outlined /></span> <span class="tb-btn"><align-left-outlined /></span>
<span class="tb-btn"><align-right-outlined /></span> <span class="tb-btn"><align-center-outlined /></span>
<span class="toolbar-hint">点击左侧段落或文档中的段落块查看配置</span> <span class="tb-btn"><align-right-outlined /></span>
</template>
<span class="toolbar-hint">
{{ editorMode === 'paragraph' ? '点击左侧段落或文档中的段落块查看配置' : '可直接编辑标题、正文,并手动拆块插入 AI 内容' }}
</span>
</div> </div>
<div class="center-scroll"> <div class="center-scroll">
<div class="doc-edit-page"> <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)"> <template v-if="editorMode === 'paragraph'">
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']"> <div v-for="paragraph in paragraphs" :key="paragraph.id" :id="`paraBlock${paragraph.id}`" :class="['para-block', { selected: selectedId === paragraph.id }]" @click="selectPara(paragraph.id)">
{{ paragraph.edit_mode === 'ai' ? `AI 生成${paragraph.output_format === 'table' ? ' · 表格' : ''}` : '人工编辑' }} <span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
</span> {{ paragraph.edit_mode === 'ai' ? `AI 生成${paragraph.output_format === 'table' ? ' · 表格' : ''}` : '人工编辑' }}
<div class="sec-title" :style="{ marginTop: paragraph.sort_index === 1 ? '0' : '' }">{{ paragraph.title }}</div> </span>
<p v-if="paragraph.content">{{ paragraph.content }}</p> <div class="sec-title" :style="{ marginTop: paragraph.sort_index === 1 ? '0' : '' }">{{ paragraph.title }}</div>
<p v-else class="empty-text">点击左侧配置此段落</p> <p v-if="paragraph.content">{{ paragraph.content }}</p>
</div> <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" danger :disabled="!canDeleteBlock(paragraph)" @click.stop="removeBlock(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>
</div> </div>
</section> </section>
@@ -78,6 +130,13 @@
<a-select-option value="manual">人工编辑</a-select-option> <a-select-option value="manual">人工编辑</a-select-option>
</a-select> </a-select>
</a-form-item> </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-form-item v-if="selectedPara.edit_mode === 'ai'" label="生成模型">
<a-select v-model:value="selectedPara.model_id" allowClear placeholder="使用默认模型"> <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> <a-select-option v-for="model in models" :key="model.id" :value="model.id">{{ model.name }}</a-select-option>
@@ -94,6 +153,31 @@
</a-form> </a-form>
</div> </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">
<div class="config-section-title">提示词与文件</div> <div class="config-section-title">提示词与文件</div>
<div class="toggle-row"> <div class="toggle-row">
@@ -134,68 +218,12 @@
</a-steps> </a-steps>
<div v-if="testStep === 0"> <div v-if="testStep === 0">
<div class="test-paragraph-card"> <ReferenceFileSelector
<div class="test-para-title">{{ selectedPara?.title }}</div> v-model="testSelectedFiles"
<div class="test-para-desc">{{ selectedPara?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。' }}</div> :title="selectedPara?.title || ''"
<a-upload-dragger :multiple="true" :beforeUpload="beforeTestUpload" :showUploadList="false"> :description="selectedPara?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。'"
<p class="ant-upload-drag-icon"><upload-outlined /></p> variant="full"
<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>
<a-button type="primary" block :loading="testing" @click="startTest">开始测试支持多文件</a-button> <a-button type="primary" block :loading="testing" @click="startTest">开始测试支持多文件</a-button>
</div> </div>
@@ -228,7 +256,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { import {
@@ -239,18 +267,11 @@ import {
FontSizeOutlined, FontSizeOutlined,
OrderedListOutlined, OrderedListOutlined,
TableOutlined, TableOutlined,
UploadOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { useTemplateStore } from '@/stores/template' import { useTemplateStore } from '@/stores/template'
import { useModelStore } from '@/stores/model' import { useModelStore } from '@/stores/model'
import { generateApi } from '@/api/generate' import { generateApi } from '@/api/generate'
import type { ReferenceFile } from '@/types' import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
interface LocalUploadFile {
uid: string
name: string
raw: File
}
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -261,33 +282,112 @@ const paragraphs = ref<any[]>([])
const models = ref<any[]>([]) const models = ref<any[]>([])
const selectedId = ref(0) const selectedId = ref(0)
const templateName = ref('模板编辑') const templateName = ref('模板编辑')
const editorMode = ref<'paragraph' | 'manual'>('paragraph')
const testOpen = ref(false) const testOpen = ref(false)
const testStep = ref(0) const testStep = ref(0)
const testing = ref(false) const testing = ref(false)
const testStatusText = ref('正在解析文件内容并请求 AI 模型...') const testStatusText = ref('正在解析文件内容并请求 AI 模型...')
const testFiles = ref<LocalUploadFile[]>([]) const testSelectedFiles = ref<Array<{ file_name: string; file_path: string }>>([])
const testResultHtml = ref('') const testResultHtml = ref('')
const testResultMessage = ref('') const testResultMessage = ref('')
const testFileSummaries = ref<any[]>([]) const testFileSummaries = ref<any[]>([])
const streamedText = ref('') 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 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) { function selectPara(id: number) {
selectedId.value = id 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.filter((item) => item.anchor_title === paragraph.anchor_title).length > 1
}
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
}
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() { async function saveTemplate() {
const templateId = Number(route.params.id) const templateId = Number(route.params.id)
await store.save(templateId) 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('模板配置已保存') message.success('模板配置已保存')
} }
@@ -295,71 +395,21 @@ function openTestModal() {
testOpen.value = true testOpen.value = true
testStep.value = 0 testStep.value = 0
testing.value = false testing.value = false
testFiles.value = [] testSelectedFiles.value = []
testResultHtml.value = '' testResultHtml.value = ''
testResultMessage.value = '' testResultMessage.value = ''
testFileSummaries.value = [] testFileSummaries.value = []
selectedHistoryPaths.value = []
fetchReferenceHistory()
} }
function resetTestModal() { function resetTestModal() {
testOpen.value = false testOpen.value = false
testStep.value = 0 testStep.value = 0
testing.value = false testing.value = false
testFiles.value = [] testSelectedFiles.value = []
testResultHtml.value = '' testResultHtml.value = ''
testResultMessage.value = '' testResultMessage.value = ''
testFileSummaries.value = [] testFileSummaries.value = []
streamedText.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) { function renderTestResult(content: any) {
@@ -388,29 +438,17 @@ function renderTestResult(content: any) {
async function startTest() { async function startTest() {
if (!selectedPara.value) return 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('请先上传至少一个参考文件') message.warning('请先上传至少一个参考文件')
return return
} }
testStep.value = 1 testStep.value = 1
testing.value = true testing.value = true
testStatusText.value = '正在上传文件...' testStatusText.value = '正在整理参考文件...'
try { try {
const filePaths: string[] = [] const filePaths = testSelectedFiles.value.map((item) => item.file_path)
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()
testStatusText.value = '正在解析文件内容并请求 AI 模型...' testStatusText.value = '正在解析文件内容并请求 AI 模型...'
const templateId = Number(route.params.id) const templateId = Number(route.params.id)
@@ -644,6 +682,7 @@ onMounted(async () => {
padding: 24px; padding: 24px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: flex-start;
} }
.doc-edit-page { .doc-edit-page {
@@ -652,6 +691,7 @@ onMounted(async () => {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 80px 72px 120px; padding: 80px 72px 120px;
min-height: 500px; min-height: 500px;
flex-shrink: 0;
} }
.para-block { .para-block {
@@ -705,6 +745,65 @@ onMounted(async () => {
color: #9aa1ad; 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 { .editor-right {
width: 380px; width: 380px;
flex-shrink: 0; flex-shrink: 0;
@@ -739,6 +838,10 @@ onMounted(async () => {
margin-top: 16px; margin-top: 16px;
} }
.template-alert {
margin-bottom: 12px;
}
.para-list-item { .para-list-item {
display: flex; display: flex;
align-items: center; align-items: center;