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

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:
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", []),
}
)
+27 -6
View File
@@ -32,12 +32,14 @@ def _serialize_paragraph(paragraph: Paragraph) -> dict:
"id": paragraph.id,
"template_id": paragraph.template_id,
"sort_index": paragraph.sort_index,
"anchor_title": paragraph.anchor_title,
"title": paragraph.title,
"content": paragraph.content,
"style_json": paragraph.style_json,
"is_table": paragraph.is_table,
"table_json": paragraph.table_json,
"edit_mode": paragraph.edit_mode,
"write_mode": paragraph.write_mode,
"model_id": paragraph.model_id,
"need_prompt": paragraph.need_prompt,
"prompt_text": paragraph.prompt_text,
@@ -151,11 +153,14 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
paragraph = Paragraph(
template_id=template.id,
sort_index=item.sort_index,
anchor_title=item.anchor_title,
title=item.title,
content=item.content,
style_json=item.style_json,
is_table=item.is_table,
table_json=item.table_json,
edit_mode="manual",
write_mode=item.write_mode,
)
db.add(paragraph)
paragraph_rows.append(paragraph)
@@ -180,16 +185,30 @@ async def save_template_paragraphs(
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
paragraph_map = {item.id: item for item in result.scalars().all()}
result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
existing_paragraphs = result.scalars().all()
paragraph_map = {item.id: item for item in existing_paragraphs}
incoming_ids = {config.id for config in body.paragraphs if config.id}
for config in body.paragraphs:
paragraph = paragraph_map.get(config.id)
for paragraph in existing_paragraphs:
if paragraph.id not in incoming_ids:
await db.delete(paragraph)
for index, config in enumerate(body.paragraphs, start=1):
paragraph = paragraph_map.get(config.id) if config.id else None
if paragraph is None:
continue
paragraph.sort_index = config.sort_index
paragraph = Paragraph(template_id=template_id)
db.add(paragraph)
paragraph.sort_index = index
paragraph.anchor_title = config.anchor_title or config.title or paragraph.anchor_title
paragraph.title = config.title
paragraph.content = config.content
paragraph.edit_mode = config.edit_mode
paragraph.write_mode = config.write_mode
paragraph.model_id = config.model_id
paragraph.need_prompt = config.need_prompt
paragraph.prompt_text = config.prompt_text
@@ -197,6 +216,8 @@ async def save_template_paragraphs(
paragraph.file_note = config.file_note
paragraph.output_format = config.output_format
await db.commit()
template.paragraph_count = len(body.paragraphs)
await db.commit()
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
+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 = ""
+129 -22
View File
@@ -56,6 +56,34 @@ def _copy_run_format(target_run, source_paragraph: Paragraph | None):
break
def _extract_first_run_format(source_paragraph: Paragraph | None):
if source_paragraph is None:
return None
for source_run in source_paragraph.runs:
if source_run._element.rPr is not None:
return deepcopy(source_run._element.rPr)
return None
def _set_paragraph_text(
paragraph: Paragraph,
text: str,
style_name: str | None = None,
template_paragraph: Paragraph | None = None,
):
run_format = _extract_first_run_format(template_paragraph)
_clear_paragraph(paragraph)
if style_name:
try:
paragraph.style = style_name
except Exception:
pass
if text:
run = paragraph.add_run(text)
if run_format is not None:
run._element.insert(0, run_format)
def _append_paragraph_after(
paragraph: Paragraph,
text: str,
@@ -166,16 +194,12 @@ def _find_heading_paragraph(document: DocumentObject, heading_text: str, after_e
return None
def _replace_section_content(document: DocumentObject, heading_title: str, content: dict, after_element=None):
heading = _find_heading_paragraph(document, heading_title, after_element)
if heading is None:
return after_element
def _collect_section_templates(heading: Paragraph):
first_body_style = None
paragraph_template = None
table_template = None
blocks = []
current = heading._element.getnext()
blocks_to_remove = []
while current is not None:
if isinstance(current, CT_P):
current_paragraph = Paragraph(current, heading._parent)
@@ -185,49 +209,132 @@ def _replace_section_content(document: DocumentObject, heading_title: str, conte
first_body_style = current_paragraph.style.name
if paragraph_template is None:
paragraph_template = current_paragraph
blocks_to_remove.append(current_paragraph)
blocks.append(current_paragraph)
elif isinstance(current, CT_Tbl):
current_table = Table(current, heading._parent)
if table_template is None:
table_template = current_table
blocks_to_remove.append(current_table)
blocks.append(current_table)
current = current.getnext()
return first_body_style, paragraph_template, table_template, blocks
for block in blocks_to_remove:
_delete_block(block)
insert_after = heading
def _insert_content_after(
insert_after: Paragraph,
content: dict,
first_body_style: str | None,
paragraph_template: Paragraph | None,
table_template: Table | None,
):
current_anchor: Paragraph = insert_after
content_blocks = content.get("content", [])
for block in content_blocks:
block_type = block.get("type")
if block_type == "table":
rows = [list(row) for row in block.get("rows", [])]
headers = block.get("headers") or []
table = _append_table_after(insert_after, rows, headers, table_template)
insert_after = _append_empty_paragraph_after_table(table, first_body_style)
table = _append_table_after(current_anchor, rows, headers, table_template)
current_anchor = _append_empty_paragraph_after_table(table, first_body_style)
else:
text = block.get("text", "")
text_parts = [item for item in text.split("\n") if item] or [text]
for text_part in text_parts:
insert_after = _append_paragraph_after(
insert_after,
current_anchor = _append_paragraph_after(
current_anchor,
text_part,
first_body_style,
paragraph_template,
)
return current_anchor
def _replace_section_content(
document: DocumentObject,
anchor_title: str,
target_title: str,
content: dict,
write_mode: str,
after_element=None,
):
heading = _find_heading_paragraph(document, anchor_title, after_element)
if heading is None:
return after_element
_set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
if write_mode == "replace_heading_only":
return heading._element
if write_mode == "replace_section":
for block in blocks_to_remove:
_delete_block(block)
_insert_content_after(
heading,
content,
first_body_style,
paragraph_template,
table_template,
)
return heading._element
def _group_logs(logs: list[dict]) -> list[list[dict]]:
groups: list[list[dict]] = []
for item in logs:
anchor_title = item.get("anchor_title") or item.get("title") or ""
if not groups:
groups.append([item])
continue
last_group = groups[-1]
last_anchor = last_group[0].get("anchor_title") or last_group[0].get("title") or ""
if anchor_title == last_anchor:
last_group.append(item)
else:
groups.append([item])
return groups
def _replace_section_group(
document: DocumentObject,
items: list[dict],
after_element=None,
):
first_item = items[0]
anchor_title = first_item.get("anchor_title") or first_item.get("title") or ""
target_title = first_item.get("title") or anchor_title
heading = _find_heading_paragraph(document, anchor_title, after_element)
if heading is None:
return after_element
_set_paragraph_text(heading, target_title, heading.style.name if heading.style is not None else None, heading)
first_body_style, paragraph_template, table_template, blocks_to_remove = _collect_section_templates(heading)
if len(items) == 1 and first_item.get("write_mode") == "replace_heading_only":
return heading._element
preserve_existing = len(items) == 1 and first_item.get("write_mode") == "append_after_heading"
if not preserve_existing:
for block in blocks_to_remove:
_delete_block(block)
current_anchor = heading
for item in items:
current_anchor = _insert_content_after(
current_anchor,
item.get("content") or {"content": []},
first_body_style,
paragraph_template,
table_template,
)
return heading._element
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
document = Document(BytesIO(template_bytes))
last_heading_element = None
for item in logs:
last_heading_element = _replace_section_content(
document,
item["title"],
item["content"],
last_heading_element,
)
for group in _group_logs(logs):
last_heading_element = _replace_section_group(document, group, last_heading_element)
output = BytesIO()
document.save(output)
+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:
+140
View File
@@ -8,3 +8,143 @@
2. 整理并暂存相关文件,排除未跟踪的原型目录。
3. 准备使用中文提交信息完成本次代码提交。
- **执行结果**: 当前改动已整理完成并准备提交,包含任务详情页“上 + 左右”结构调整。
## 会话 ID: local-20260703092933
- [2026-07-03 09:29:33]
- **执行原因**: 用户希望预览页左右两栏固定在顶层显示,不随页面整体滚动,而是在局部区域内滚动。
- **执行过程**:
1. 调整任务详情页根容器高度和溢出策略,禁止页面整体滚动。
2. 为左右分栏区域设置固定可用高度和内部滚动容器,使左侧段落列表、右侧预览内容各自滚动。
3. 执行前端类型检查,确认布局调整稳定。
- **执行结果**: 当前预览页已改为页面整体固定、左右两栏局部滚动的显示方式,顶部信息区域保持固定可见。
## 会话 ID: local-20260703093617
- [2026-07-03 09:36:17]
- **执行原因**: 用户询问当前模板导入时如何识别 `.doc/.docx` 文件中的段落边界。
- **执行过程**:
1. 定位模板上传入口与解析服务,确认模板导入的实际文件格式限制。
2. 阅读 `template_parser` 实现,核对标题识别、正文归并和表格归属逻辑。
3. 结合设计文档整理当前段落识别规则与边界行为,准备向用户说明。
- **执行结果**: 已确认当前模板导入仅支持 `.docx`;段落边界基于 Word 内置 `Heading` 样式识别,普通正文归并到最近标题下,表格归属最近段落或独立成段。
## 会话 ID: local-20260703093913
- [2026-07-03 09:39:13]
- **执行原因**: 用户进一步询问特殊模板场景下,是否支持只修改标题、不修改标题下固定内容,以及是否可以手动调整段落与模板内容。
- **执行过程**:
1. 核对模板编辑页界面与保存逻辑,确认当前可编辑字段范围。
2. 检查后端模板保存 schema,确认是否支持手动拆段、合段或正文内容持久化编辑。
3. 基于现状整理可行产品方案,包括自动识别候选标题与人工微调两类路径。
- **执行结果**: 已确认当前系统支持修改段落标题和生成配置,但暂不支持手动拆段/合段,也不支持在系统内直接编辑模板正文;可通过新增“标题仅替换”模式与手动段落调整能力满足该场景。
## 会话 ID: local-20260703094127
- [2026-07-03 09:41:27]
- **执行原因**: 用户希望模板编辑阶段支持人工直接编辑模板内容,并讨论是否应改为在标题下方占位填充而非整段替换,同时询问在线文档编辑实现思路。
- **执行过程**:
1. 检查当前导出实现,确认模板内容替换的实际粒度与边界。
2. 结合现有解析和导出方式,评估“段落模式”与“手动编辑 Word 模式”的双模式方案。
3. 查阅腾讯文档相关公开资料,整理在线文档通常采用的协同编辑架构和导入导出模型。
- **执行结果**: 已确认当前导出为标题间整段替换;建议新增“手动编辑模板内容”与“占位填充”能力,并采用结构化文档模型而非直接把 `.docx` 当在线编辑源格式处理。
## 会话 ID: local-20260703094614
- [2026-07-03 09:46:14]
- **执行原因**: 用户确认实施模板编辑增强,要求支持“段落配置 / 手动编辑模板”切换,并改进 AI 内容写回 Word 的方式。
- **执行过程**:
1. 扩展段落数据结构,新增原始标题锚点和写入方式字段,并补充数据库自动迁移逻辑。
2. 改造模板编辑页,增加“段落配置 / 手动编辑模板”切换,支持直接编辑标题、正文和写入方式。
3. 改造导出逻辑,支持仅替换标题、标题下插入内容、替换整段三种写入模式,同时保持原始标题定位能力。
4. 执行后端编译检查与前端 `npm run build`,确认本轮改动可正常通过。
- **执行结果**: 模板编辑页现已支持结构化手动编辑;导出时可按段落配置选择“整段替换 / 标题下插入 / 仅改标题”,能更好处理固定正文与 AI 生成内容并存的模板场景。
## 会话 ID: local-20260703094757
- [2026-07-03 09:47:57]
- **执行原因**: 用户需要本轮模板编辑增强对应的数据库增量 SQL。
- **执行过程**:
1. 对照本轮后端模型与初始化脚本,确认实际新增的持久化字段。
2. 整理兼容现有数据的 `ALTER TABLE` 与回填语句,确保旧模板可正常迁移。
3. 记录增量说明,便于后续环境执行与核验。
- **执行结果**: 已输出可直接执行的 MySQL 增量 SQL,包含 `paragraphs.anchor_title``paragraphs.write_mode` 两个新字段及历史数据回填语句。
## 会话 ID: local-20260703095307
- [2026-07-03 09:53:07]
- **执行原因**: 用户质疑当前模板编辑仍像段落配置而非在线 Word 编辑,并询问是否可以手动插入新的 AI 段落。
- **执行过程**:
1. 重新核对模板解析逻辑,确认当前仍以 Word `Heading` 样式作为段落边界。
2. 核对导出写回逻辑,确认当前是围绕已识别标题区块进行替换或插入,而不是对文档块级结构进行自由编辑。
3. 基于用户反馈梳理下一阶段应改造为“块级在线文档编辑 + AI 占位段落”的方向。
- **执行结果**: 已明确当前系统还不支持像腾讯文档那样手动插入新段落块;若要满足该诉求,应将模板编辑从“段落配置”升级为“文档块编辑”,支持新增 AI 段落占位、拆分正文块与固定块。
## 会话 ID: local-20260703095628
- [2026-07-03 09:56:28]
- **执行原因**: 用户要求继续推进,支持在模板中手动拆块并插入 AI 段落。
- **执行过程**:
1. 扩展模板保存接口,支持创建新块、删除旧块,并按当前编辑顺序重排 `sort_index`
2. 改造导出逻辑,按连续的 `anchor_title` 分组写回同一节内容,使一个原标题下可挂多个手动/AI 块。
3. 改造模板编辑页,在手动编辑模式下新增“在后面新增固定块 / AI 块 / 删除当前块”操作。
4. 执行后端编译检查与前端 `npm run build`,确认新增块编辑能力可正常通过构建。
- **执行结果**: 当前模板编辑已支持把同一原标题下的内容手动拆成多个块,并插入新的 AI 块或固定块;导出时会按块顺序写回同一节内容,较之前更接近在线文档式的人工干预流程。
## 会话 ID: local-20260703095940
- [2026-07-03 09:59:40]
- **执行原因**: 用户要求模板默认导入后全部识别为人工手动,而不是 AI 生成。
- **执行过程**:
1. 调整段落模型默认值与初始化脚本默认值,将 `edit_mode` 默认改为 `manual`
2. 调整模板上传落库逻辑,显式将新导入段落设置为 `manual`,避免受历史数据库默认值影响。
3. 执行后端编译检查,确认默认值调整未引入语法或依赖问题。
- **执行结果**: 新导入模板中的识别段落现在默认全部为人工手动;如需 AI 生成,需要用户在模板编辑页中显式切换对应块为 AI 模式。
## 会话 ID: local-20260703100253
- [2026-07-03 10:02:53]
- **执行原因**: 用户反馈模板编辑页 `doc-edit-page` 没有随内容高度增长,导致内容超出纸张容器显示。
- **执行过程**:
1. 检查编辑页中部滚动区与纸张容器的 flex 布局关系,定位到默认纵向拉伸导致纸张高度被固定。
2. 调整 `center-scroll` 的对齐方式为顶部对齐,并禁止 `doc-edit-page` 在 flex 布局中被压缩。
3. 执行前端 `npm run build`,确认样式修复后页面仍可正常构建。
- **执行结果**: 模板编辑页中的纸张容器现在会按内容自然增高,不再因为父级 flex 拉伸而出现内容超出容器显示的问题。
## 会话 ID: local-20260703100656
- [2026-07-03 10:06:56]
- **执行原因**: 用户询问执行生成页是否支持从历史文件中复用已上传附件。
- **执行过程**:
1. 检查 `GeneratePage.vue` 的文件上传区域与状态管理逻辑,确认当前前端入口能力。
2. 对照 `generateApi` 与后端 `reference-files` 接口,确认后端已有历史文件查询能力是否被生成页接入。
3. 整理当前支持范围与缺口,准备向用户说明现状与后续改造方向。
- **执行结果**: 已确认生成页当前仅支持新上传文件,不支持在页面内选择历史文件复用;后端已有历史文件接口,但该页尚未接入对应 UI 与选择逻辑。
## 会话 ID: local-20260703104125
- [2026-07-03 10:41:25]
- **执行原因**: 用户建议参考模板编辑中的历史文件复用能力,并封装成通用组件供执行生成页复用。
- **执行过程**:
1. 抽离公共 `ReferenceFileSelector` 组件,统一封装新上传、历史文件搜索复用、已选文件展示与移除逻辑。
2. 将模板编辑页段落测试弹窗接入该组件,替换原有分散的上传与历史文件逻辑。
3. 将执行生成页接入同一组件,使每个需上传文件的段落同时支持上传新文件和选择历史文件。
4. 执行前端 `npm run build`,确认组件复用后页面构建正常。
- **执行结果**: 当前模板编辑测试弹窗与执行生成页已共用同一套文件选择组件;执行生成页现已支持历史文件复用,不再局限于本次新上传。
## 会话 ID: local-20260703104741
- [2026-07-03 10:47:41]
- **执行原因**: 用户希望模型管理页支持厂商预设、DeepSeek 余额查看,以及将测试按钮改为带转圈的刷新式提示。
- **执行过程**:
1. 扩展模型前端 API 与 store,新增余额查询调用。
2. 在后端模型路由中新增 DeepSeek 余额查询接口,并基于已保存的 API Key 调用官方余额接口。
3. 改造模型管理页,新增 `DeepSeek / 自定义` 厂商预设、DeepSeek 余额展示与查询按钮。
4. 将测试按钮改为带 loading 的“刷新测试”,结果改为自动消失的轻提示,不再使用需要手动关闭的弹窗。
5. 执行后端编译检查与前端 `npm run build`,确认改动可正常构建。
- **执行结果**: 模型管理页现已支持厂商预设;DeepSeek 模型可直接查询余额;测试按钮改为更轻量的刷新式交互,点击后会转圈并自动提示结果。
## 会话 ID: local-20260703104944
- [2026-07-03 10:49:44]
- **执行原因**: 用户发现将厂商改成自定义后,页面仍被识别为 DeepSeek,且自定义厂商也出现余额查询能力。
- **执行过程**:
1. 排查模型管理页与后端余额接口的 DeepSeek 判定条件。
2. 将判定逻辑从“厂商或 endpoint 命中 DeepSeek”收紧为“仅当 provider 明确为 DeepSeek 时才视为 DeepSeek 模型”。
3. 执行后端编译检查与前端 `npm run build`,确认修正后功能正常。
- **执行结果**: 当前只有在厂商明确设置为 `DeepSeek` 时,页面才会显示 DeepSeek 预设状态与余额查询按钮;改为自定义厂商后不会再被 endpoint 误判为 DeepSeek。
## 会话 ID: local-20260703111410
- [2026-07-03 11:14:10]
- **执行原因**: 用户要求将当前阶段改动提交到 Git。
- **执行过程**:
1. 检查工作区变更,确认本轮后端、前端与任务记录文件可一并提交。
2. 排除未跟踪的原型目录,仅暂存本次功能实现相关文件。
3. 使用中文提交信息完成本次代码提交。
- **执行结果**: 当前模板编辑、历史文件复用、模型管理增强等改动已整理完成,准备提交到本地 Git 历史。
+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>
+29 -1
View File
@@ -242,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 {
@@ -299,26 +304,35 @@ onBeforeUnmount(() => {
.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 {
@@ -416,7 +430,7 @@ onBeforeUnmount(() => {
border-radius: 14px;
background: #fff;
border: 1px solid #e5e7eb;
min-height: 480px;
min-height: 100%;
}
.preview-section-head {
@@ -444,6 +458,20 @@ onBeforeUnmount(() => {
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;
+270 -167
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,7 +29,7 @@
@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>
@@ -35,30 +39,78 @@
<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-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" 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>
</section>
@@ -78,6 +130,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 +153,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 +218,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,7 +256,7 @@
</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 {
@@ -239,18 +267,11 @@ import {
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 ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
const route = useRoute()
const router = useRouter()
@@ -261,33 +282,112 @@ 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.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() {
const templateId = Number(route.params.id)
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 +395,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 +438,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 +682,7 @@ onMounted(async () => {
padding: 24px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.doc-edit-page {
@@ -652,6 +691,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 {
@@ -705,6 +745,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 +838,10 @@ onMounted(async () => {
margin-top: 16px;
}
.template-alert {
margin-bottom: 12px;
}
.para-list-item {
display: flex;
align-items: center;