完善模板编辑与模型管理体验
This commit is contained in:
@@ -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'"))
|
||||
|
||||
@@ -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="预设提示词")
|
||||
|
||||
@@ -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": []},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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", []),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)})
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 历史。
|
||||
|
||||
@@ -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 '预设提示词',
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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('请先选择模板')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user