实现模板解析与模板管理基础链路
This commit is contained in:
+14
-3
@@ -1,7 +1,8 @@
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from database import init_db, engine
|
from database import init_db, engine
|
||||||
from config import settings
|
from config import settings
|
||||||
from routers import templates, models, generate, export
|
from routers import templates, models, generate, export
|
||||||
@@ -32,14 +33,24 @@ app.include_router(generate.router, prefix="/api/v1/generate", tags=["生成管
|
|||||||
app.include_router(export.router, prefix="/api/v1/export", tags=["导出管理"])
|
app.include_router(export.router, prefix="/api/v1/export", tags=["导出管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_exception_handler(_: Request, exc: HTTPException):
|
||||||
|
return JSONResponse(status_code=exc.status_code, content={"code": -1, "message": exc.detail})
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_exception_handler(_: Request, exc: Exception):
|
||||||
|
return JSONResponse(status_code=500, content={"code": -1, "message": str(exc) or "服务器内部错误"})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "AI 文档模板生成系统 API", "version": settings.APP_VERSION}
|
return {"code": 0, "data": {"name": settings.APP_NAME, "version": settings.APP_VERSION}, "message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"code": 0, "data": {"status": "ok"}, "message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import get_db
|
||||||
|
from models.paragraph import Paragraph
|
||||||
|
from models.template import Template
|
||||||
|
from schemas.schemas import Response, TemplateSave
|
||||||
|
from services.minio_client import minio_client
|
||||||
|
from services.template_parser import parse_template
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_object_path(filename: str) -> tuple[str, str]:
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
date_prefix = datetime.now().strftime("%Y%m%d")
|
||||||
|
object_name = f"{date_prefix}/{uuid.uuid4().hex}{ext}"
|
||||||
|
return ext, object_name
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_paragraph(paragraph: Paragraph) -> dict:
|
||||||
|
return {
|
||||||
|
"id": paragraph.id,
|
||||||
|
"template_id": paragraph.template_id,
|
||||||
|
"sort_index": paragraph.sort_index,
|
||||||
|
"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,
|
||||||
|
"model_id": paragraph.model_id,
|
||||||
|
"need_prompt": paragraph.need_prompt,
|
||||||
|
"prompt_text": paragraph.prompt_text,
|
||||||
|
"need_file": paragraph.need_file,
|
||||||
|
"file_note": paragraph.file_note,
|
||||||
|
"output_format": paragraph.output_format,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_template(template: Template) -> dict:
|
||||||
|
return {
|
||||||
|
"id": template.id,
|
||||||
|
"name": template.name,
|
||||||
|
"description": template.description,
|
||||||
|
"file_path": template.file_path,
|
||||||
|
"paragraph_count": template.paragraph_count,
|
||||||
|
"status": template.status,
|
||||||
|
"created_at": template.created_at,
|
||||||
|
"updated_at": template.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_templates(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
keyword: str = Query("", alias="q"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
filters = []
|
||||||
|
if keyword:
|
||||||
|
filters.append(Template.name.like(f"%{keyword}%"))
|
||||||
|
|
||||||
|
total_stmt = select(func.count(Template.id))
|
||||||
|
list_stmt = select(Template).order_by(Template.id.desc())
|
||||||
|
if filters:
|
||||||
|
total_stmt = total_stmt.where(*filters)
|
||||||
|
list_stmt = list_stmt.where(*filters)
|
||||||
|
|
||||||
|
total = (await db.execute(total_stmt)).scalar_one()
|
||||||
|
result = await db.execute(list_stmt.offset((page - 1) * page_size).limit(page_size))
|
||||||
|
items = [_serialize_template(item) for item in result.scalars().all()]
|
||||||
|
return Response(
|
||||||
|
data={"items": items, "total": total, "page": page, "page_size": page_size}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{template_id}")
|
||||||
|
async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
template = await db.get(Template, template_id)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Paragraph)
|
||||||
|
.where(Paragraph.template_id == template_id)
|
||||||
|
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
|
||||||
|
)
|
||||||
|
paragraphs = [_serialize_paragraph(item) for item in result.scalars().all()]
|
||||||
|
payload = _serialize_template(template)
|
||||||
|
payload["paragraphs"] = paragraphs
|
||||||
|
return Response(data=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(status_code=400, detail="文件名不能为空")
|
||||||
|
|
||||||
|
ext, object_name = _build_object_path(file.filename)
|
||||||
|
if ext != ".docx":
|
||||||
|
raise HTTPException(status_code=400, detail="模板仅支持 .docx 格式")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="上传文件不能为空")
|
||||||
|
if len(content) > settings.MAX_UPLOAD_SIZE:
|
||||||
|
raise HTTPException(status_code=400, detail="文件大小超过限制")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as temp_file:
|
||||||
|
temp_file.write(content)
|
||||||
|
temp_path = temp_file.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_items = await asyncio.to_thread(parse_template, temp_path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
|
||||||
|
await asyncio.to_thread(
|
||||||
|
minio_client.put_object,
|
||||||
|
settings.MINIO_BUCKET_TEMPLATES,
|
||||||
|
object_name,
|
||||||
|
BytesIO(content),
|
||||||
|
len(content),
|
||||||
|
file.content_type or "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
)
|
||||||
|
|
||||||
|
template = Template(
|
||||||
|
name=os.path.splitext(file.filename)[0],
|
||||||
|
description="",
|
||||||
|
file_path=f"{settings.MINIO_BUCKET_TEMPLATES}/{object_name}",
|
||||||
|
paragraph_count=len(parsed_items),
|
||||||
|
status="draft",
|
||||||
|
)
|
||||||
|
db.add(template)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
paragraph_rows: list[Paragraph] = []
|
||||||
|
for item in parsed_items:
|
||||||
|
paragraph = Paragraph(
|
||||||
|
template_id=template.id,
|
||||||
|
sort_index=item.sort_index,
|
||||||
|
title=item.title,
|
||||||
|
content=item.content,
|
||||||
|
style_json=item.style_json,
|
||||||
|
is_table=item.is_table,
|
||||||
|
table_json=item.table_json,
|
||||||
|
)
|
||||||
|
db.add(paragraph)
|
||||||
|
paragraph_rows.append(paragraph)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(template)
|
||||||
|
for paragraph in paragraph_rows:
|
||||||
|
await db.refresh(paragraph)
|
||||||
|
|
||||||
|
payload = _serialize_template(template)
|
||||||
|
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
|
||||||
|
return Response(data=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{template_id}/paragraphs")
|
||||||
|
async def save_template_paragraphs(
|
||||||
|
template_id: int,
|
||||||
|
body: TemplateSave,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
template = await db.get(Template, template_id)
|
||||||
|
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()}
|
||||||
|
|
||||||
|
for config in body.paragraphs:
|
||||||
|
paragraph = paragraph_map.get(config.id)
|
||||||
|
if paragraph is None:
|
||||||
|
continue
|
||||||
|
paragraph.sort_index = config.sort_index
|
||||||
|
paragraph.title = config.title
|
||||||
|
paragraph.edit_mode = config.edit_mode
|
||||||
|
paragraph.model_id = config.model_id
|
||||||
|
paragraph.need_prompt = config.need_prompt
|
||||||
|
paragraph.prompt_text = config.prompt_text
|
||||||
|
paragraph.need_file = config.need_file
|
||||||
|
paragraph.file_note = config.file_note
|
||||||
|
paragraph.output_format = config.output_format
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{template_id}")
|
||||||
|
async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
template = await db.get(Template, template_id)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
|
||||||
|
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
|
||||||
|
for paragraph in result.scalars().all():
|
||||||
|
await db.delete(paragraph)
|
||||||
|
|
||||||
|
file_path = template.file_path or ""
|
||||||
|
if "/" in file_path:
|
||||||
|
bucket, object_name = file_path.split("/", 1)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(minio_client.remove_object, bucket, object_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await db.delete(template)
|
||||||
|
await db.commit()
|
||||||
|
return Response(data={"id": template_id})
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from minio import Minio
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
# MinIO 客户端
|
||||||
|
minio_client = Minio(
|
||||||
|
settings.MINIO_ENDPOINT,
|
||||||
|
access_key=settings.MINIO_ACCESS_KEY,
|
||||||
|
secret_key=settings.MINIO_SECRET_KEY,
|
||||||
|
secure=settings.MINIO_USE_SSL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def init_buckets():
|
||||||
|
"""初始化 MinIO 存储桶(应用启动时调用)"""
|
||||||
|
buckets = [
|
||||||
|
settings.MINIO_BUCKET_TEMPLATES, # 原始模板文件
|
||||||
|
settings.MINIO_BUCKET_UPLOADS, # 用户上传的参考文件
|
||||||
|
settings.MINIO_BUCKET_OUTPUTS, # 生成的文档
|
||||||
|
]
|
||||||
|
for bucket in buckets:
|
||||||
|
if not minio_client.bucket_exists(bucket):
|
||||||
|
minio_client.make_bucket(bucket)
|
||||||
|
print(f"[MinIO] 创建存储桶: {bucket}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_url(bucket: str, object_name: str) -> str:
|
||||||
|
"""获取文件的公开访问 URL"""
|
||||||
|
if settings.MINIO_USE_SSL:
|
||||||
|
protocol = "https"
|
||||||
|
else:
|
||||||
|
protocol = "http"
|
||||||
|
return f"{protocol}://{settings.MINIO_ENDPOINT}/{bucket}/{object_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_presigned_url(bucket: str, object_name: str, expires: int = 3600) -> str:
|
||||||
|
"""获取预签名下载 URL(带过期时间)"""
|
||||||
|
return minio_client.presigned_get_object(bucket, object_name, expires=expires)
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import json
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.document import Document as DocumentObject
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.oxml.table import CT_Tbl
|
||||||
|
from docx.oxml.text.paragraph import CT_P
|
||||||
|
from docx.table import Table
|
||||||
|
from docx.text.paragraph import Paragraph
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedParagraph:
|
||||||
|
sort_index: int
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
style_json: str
|
||||||
|
is_table: bool
|
||||||
|
table_json: str
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]:
|
||||||
|
body = document.element.body
|
||||||
|
for child in body.iterchildren():
|
||||||
|
if isinstance(child, CT_P):
|
||||||
|
yield Paragraph(child, document)
|
||||||
|
elif isinstance(child, CT_Tbl):
|
||||||
|
yield Table(child, document)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_pt(value: object) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(float(value.pt), 2)
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_indent(value: object) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return round(float(value.pt), 2)
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _alignment_name(value: WD_ALIGN_PARAGRAPH | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "LEFT"
|
||||||
|
return getattr(value, "name", "LEFT")
|
||||||
|
|
||||||
|
|
||||||
|
def _heading_level(style_name: str) -> int | None:
|
||||||
|
if not style_name:
|
||||||
|
return None
|
||||||
|
normalized = style_name.lower().replace(" ", "")
|
||||||
|
if normalized.startswith("heading"):
|
||||||
|
level = normalized.replace("heading", "")
|
||||||
|
if level.isdigit():
|
||||||
|
return int(level)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_run_font_info(paragraph: Paragraph) -> dict:
|
||||||
|
for run in paragraph.runs:
|
||||||
|
if not run.text.strip():
|
||||||
|
continue
|
||||||
|
r_fonts = getattr(run._element.rPr, "rFonts", None) if run._element.rPr is not None else None
|
||||||
|
east_asia = r_fonts.get(qn("w:eastAsia")) if r_fonts is not None else None
|
||||||
|
color = None
|
||||||
|
if run.font.color is not None and run.font.color.rgb is not None:
|
||||||
|
color = str(run.font.color.rgb)
|
||||||
|
return {
|
||||||
|
"name": run.font.name,
|
||||||
|
"eastAsia": east_asia,
|
||||||
|
"size": _safe_pt(run.font.size),
|
||||||
|
"bold": bool(run.bold) if run.bold is not None else False,
|
||||||
|
"italic": bool(run.italic) if run.italic is not None else False,
|
||||||
|
"color": color or "000000",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"name": None,
|
||||||
|
"eastAsia": None,
|
||||||
|
"size": None,
|
||||||
|
"bold": False,
|
||||||
|
"italic": False,
|
||||||
|
"color": "000000",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_paragraph_style(paragraph: Paragraph, level: int) -> dict:
|
||||||
|
fmt = paragraph.paragraph_format
|
||||||
|
return {
|
||||||
|
"font": _get_run_font_info(paragraph),
|
||||||
|
"paragraph": {
|
||||||
|
"alignment": _alignment_name(paragraph.alignment),
|
||||||
|
"spaceBefore": _safe_pt(fmt.space_before),
|
||||||
|
"spaceAfter": _safe_pt(fmt.space_after),
|
||||||
|
"lineSpacing": fmt.line_spacing,
|
||||||
|
"firstLineIndent": _safe_indent(fmt.first_line_indent),
|
||||||
|
},
|
||||||
|
"headingLevel": level,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cell_style(cell) -> dict:
|
||||||
|
paragraph = cell.paragraphs[0] if cell.paragraphs else None
|
||||||
|
font_info = _get_run_font_info(paragraph) if paragraph is not None else {
|
||||||
|
"name": None,
|
||||||
|
"eastAsia": None,
|
||||||
|
"size": None,
|
||||||
|
"bold": False,
|
||||||
|
"italic": False,
|
||||||
|
"color": "000000",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"font": font_info,
|
||||||
|
"shading": None,
|
||||||
|
"alignment": _alignment_name(paragraph.alignment) if paragraph is not None else "LEFT",
|
||||||
|
"borders": {"top": None, "bottom": None, "left": None, "right": None},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_table_data(table: Table) -> dict:
|
||||||
|
rows = len(table.rows)
|
||||||
|
cols = max((len(row.cells) for row in table.rows), default=0)
|
||||||
|
grid_span: dict[str, int] = {}
|
||||||
|
cell_styles: list[dict] = []
|
||||||
|
matrix: list[list[str]] = []
|
||||||
|
|
||||||
|
for row_index, row in enumerate(table.rows):
|
||||||
|
row_values: list[str] = []
|
||||||
|
for col_index, cell in enumerate(row.cells):
|
||||||
|
text = "\n".join(paragraph.text.strip() for paragraph in cell.paragraphs if paragraph.text.strip())
|
||||||
|
row_values.append(text)
|
||||||
|
tc_pr = cell._tc.tcPr
|
||||||
|
grid_span_value = None
|
||||||
|
if tc_pr is not None and tc_pr.gridSpan is not None:
|
||||||
|
grid_span_value = tc_pr.gridSpan.val
|
||||||
|
if grid_span_value:
|
||||||
|
grid_span[f"{row_index}-{col_index}"] = int(grid_span_value)
|
||||||
|
cell_styles.append(_get_cell_style(cell))
|
||||||
|
matrix.append(row_values)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rows": rows,
|
||||||
|
"cols": cols,
|
||||||
|
"gridSpan": grid_span,
|
||||||
|
"cellStyles": cell_styles,
|
||||||
|
"tableWidth": None,
|
||||||
|
"data": matrix,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_template(file_path: str) -> list[ParsedParagraph]:
|
||||||
|
document = Document(file_path)
|
||||||
|
parsed: list[ParsedParagraph] = []
|
||||||
|
current_item: ParsedParagraph | None = None
|
||||||
|
loose_table_count = 0
|
||||||
|
|
||||||
|
for block in _iter_block_items(document):
|
||||||
|
if isinstance(block, Paragraph):
|
||||||
|
text = block.text.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
level = _heading_level(block.style.name if block.style is not None else "")
|
||||||
|
if level is not None:
|
||||||
|
current_item = ParsedParagraph(
|
||||||
|
sort_index=len(parsed) + 1,
|
||||||
|
title=text,
|
||||||
|
content="",
|
||||||
|
style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False),
|
||||||
|
is_table=False,
|
||||||
|
table_json="{}",
|
||||||
|
)
|
||||||
|
parsed.append(current_item)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_item is None:
|
||||||
|
current_item = ParsedParagraph(
|
||||||
|
sort_index=len(parsed) + 1,
|
||||||
|
title="未命名段落",
|
||||||
|
content=text,
|
||||||
|
style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False),
|
||||||
|
is_table=False,
|
||||||
|
table_json="{}",
|
||||||
|
)
|
||||||
|
parsed.append(current_item)
|
||||||
|
else:
|
||||||
|
current_item.content = "\n".join(filter(None, [current_item.content, text]))
|
||||||
|
else:
|
||||||
|
table_data = _extract_table_data(block)
|
||||||
|
table_text = f"[表格] {table_data['rows']} 行 {table_data['cols']} 列"
|
||||||
|
if current_item is None:
|
||||||
|
loose_table_count += 1
|
||||||
|
current_item = ParsedParagraph(
|
||||||
|
sort_index=len(parsed) + 1,
|
||||||
|
title=f"表格_{loose_table_count}",
|
||||||
|
content=table_text,
|
||||||
|
style_json="{}",
|
||||||
|
is_table=True,
|
||||||
|
table_json=json.dumps(table_data, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
parsed.append(current_item)
|
||||||
|
else:
|
||||||
|
current_item.is_table = True
|
||||||
|
current_item.table_json = json.dumps(table_data, ensure_ascii=False)
|
||||||
|
current_item.content = "\n".join(filter(None, [current_item.content, table_text]))
|
||||||
|
|
||||||
|
return parsed
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 任务执行摘要
|
||||||
|
|
||||||
|
## 会话 ID: local-20260702145410
|
||||||
|
- [2026-07-02 14:54:10]
|
||||||
|
- **执行原因**: 按任务拆解清单逐项推进,优先打通模板管理与模板解析的第一条可用链路,并同步回填已完成状态。
|
||||||
|
- **执行过程**:
|
||||||
|
1. 核对任务拆解清单、开发规范和模板格式规范,确认项目约束与当前仓库状态。
|
||||||
|
2. 检查前后端现状,识别出前端脚手架、数据库模型和多页面壳子已存在,但后端核心路由仍为空。
|
||||||
|
3. 新增模板解析服务,基于 `python-docx` 实现标题识别、正文归并、样式提取、表格基础结构提取和结构化输出。
|
||||||
|
4. 实现模板 CRUD 路由,补齐模板列表、详情、上传解析、段落配置保存和删除能力,并接入 MinIO 模板存储。
|
||||||
|
5. 为后端补充统一错误响应格式与空路由占位,避免应用启动时报错。
|
||||||
|
6. 修复前端若干现存类型/图标问题,确保 `vue-tsc --noEmit` 可通过。
|
||||||
|
7. 更新任务拆解清单,标记本轮已确认完成的阶段项与子任务。
|
||||||
|
- **执行结果**: 已完成模板 CRUD 路由和模板解析器基础能力,项目当前可通过后端语法检查与前端类型检查,任务清单已同步标注已完成项。
|
||||||
+14
-14
@@ -4,24 +4,24 @@
|
|||||||
|
|
||||||
## 第一阶段:架构与规范(第 1 周)
|
## 第一阶段:架构与规范(第 1 周)
|
||||||
|
|
||||||
- [ ] 搭建前端脚手架(Vue 3 + Vite + TS + Ant Design Vue + Pinia + Router)
|
- [x] 搭建前端脚手架(Vue 3 + Vite + TS + Ant Design Vue + Pinia + Router)
|
||||||
- [ ] 搭建后端脚手架(FastAPI + SQLAlchemy async + SQLite)
|
- [x] 搭建后端脚手架(FastAPI + SQLAlchemy async + SQLite)
|
||||||
- [ ] 数据库表设计与建表
|
- [x] 数据库表设计与建表
|
||||||
- [ ] 模板格式规范定稿(段落边界规则、标题样式要求、表格归属)
|
- [x] 模板格式规范定稿(段落边界规则、标题样式要求、表格归属)
|
||||||
- [ ] AI 输出格式规范定稿(JSON 结构、表格标记、错误兜底)
|
- [x] AI 输出格式规范定稿(JSON 结构、表格标记、错误兜底)
|
||||||
- [ ] 导出策略定稿(基于原模板替换内容)
|
- [x] 导出策略定稿(基于原模板替换内容)
|
||||||
- [ ] 前后端 API 接口约定
|
- [x] 前后端 API 接口约定
|
||||||
|
|
||||||
## 第二阶段:后端核心开发(第 2-3 周)
|
## 第二阶段:后端核心开发(第 2-3 周)
|
||||||
|
|
||||||
### Word 解析器(5-7 天)
|
### Word 解析器(5-7 天)
|
||||||
- [ ] python-docx 打开模板,逐段落遍历
|
- [x] python-docx 打开模板,逐段落遍历
|
||||||
- [ ] 标题样式识别(Heading 1~6)→ 段落边界
|
- [x] 标题样式识别(Heading 1~6)→ 段落边界
|
||||||
- [ ] 正文内容捕获 → 合并到上一标题
|
- [x] 正文内容捕获 → 合并到上一标题
|
||||||
- [ ] 表格结构提取(行列数、合并单元格、边框、底纹)
|
- [ ] 表格结构提取(行列数、合并单元格、边框、底纹)
|
||||||
- [ ] 样式捕获(字体名、字号、加粗、颜色、对齐、缩进、间距、行距)
|
- [x] 样式捕获(字体名、字号、加粗、颜色、对齐、缩进、间距、行距)
|
||||||
- [ ] 段落索引记录(在文档中的位置,用于导出定位)
|
- [x] 段落索引记录(在文档中的位置,用于导出定位)
|
||||||
- [ ] 输出结构化 JSON
|
- [x] 输出结构化 JSON
|
||||||
|
|
||||||
### AI 服务层(5-7 天)
|
### AI 服务层(5-7 天)
|
||||||
- [ ] OpenAI 格式适配(GPT-4o、DeepSeek-V3、通义千问)
|
- [ ] OpenAI 格式适配(GPT-4o、DeepSeek-V3、通义千问)
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
- [ ] PDF 导出(调用 LibreOffice 命令)
|
- [ ] PDF 导出(调用 LibreOffice 命令)
|
||||||
|
|
||||||
### 路由与 API(3 天)
|
### 路由与 API(3 天)
|
||||||
- [ ] 模板 CRUD 路由
|
- [x] 模板 CRUD 路由
|
||||||
- [ ] 模型 CRUD 路由
|
- [ ] 模型 CRUD 路由
|
||||||
- [ ] 生成相关路由(测试/全量/进度SSE/取消)
|
- [ ] 生成相关路由(测试/全量/进度SSE/取消)
|
||||||
- [ ] 导出路由(Word/PDF)
|
- [ ] 导出路由(Word/PDF)
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
<div class="logo">{{ collapsed ? "A" : "AI 文档模板" }}</div>
|
<div class="logo">{{ collapsed ? "A" : "AI 文档模板" }}</div>
|
||||||
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" @click="onMenuClick">
|
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" @click="onMenuClick">
|
||||||
<a-menu-item key="/templates"><folder-outlined /> 模板管理</a-menu-item>
|
<a-menu-item key="/templates"><folder-outlined /> 模板管理</a-menu-item>
|
||||||
<a-menu-item key="/models"><cpu-outlined /> 模型管理</a-menu-item>
|
<a-menu-item key="/models"><api-outlined /> 模型管理</a-menu-item>
|
||||||
<a-menu-item key="/generate"><thunderbolt-outlined /> 执行生成</a-menu-item>
|
<a-menu-item key="/generate"><thunderbolt-outlined /> 执行生成</a-menu-item>
|
||||||
<a-menu-item key="/history"><clock-circle-outlined /> 生成记录</a-menu-item>
|
<a-menu-item key="/history"><clock-circle-outlined /> 生成记录</a-menu-item>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
FolderOutlined, CpuOutlined, ThunderboltOutlined, ClockCircleOutlined,
|
FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined,
|
||||||
MenuUnfoldOutlined, MenuFoldOutlined, BellOutlined
|
MenuUnfoldOutlined, MenuFoldOutlined, BellOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template><a-upload-dragger :multiple="multiple" :beforeUpload="(f)=>{$emit('upload',f);return false}" :showUploadList="showList"><p class="ant-upload-drag-icon"><upload-outlined /></p><p class="ant-upload-text">{{text}}</p><p class="ant-upload-hint">{{hint}}</p></a-upload-dragger></template>
|
<template><a-upload-dragger :multiple="multiple" :beforeUpload="(f: File)=>{$emit('upload',f);return false}" :showUploadList="showList"><p class="ant-upload-drag-icon"><upload-outlined /></p><p class="ant-upload-text">{{text}}</p><p class="ant-upload-hint">{{hint}}</p></a-upload-dragger></template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { UploadOutlined } from "@ant-design/icons-vue";
|
import { UploadOutlined } from "@ant-design/icons-vue";
|
||||||
defineProps<{text?:string;hint?:string;multiple?:boolean;showList?:boolean}>()
|
defineProps<{text?:string;hint?:string;multiple?:boolean;showList?:boolean}>()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template><a-modal v-model:open="visible" title="段落测试" :footer="null" width="680px"><a-steps :current="step" style="margin-bottom:24px"><a-step title="上传附件" /><a-step title="处理中" /><a-step title="查看结果" /></a-steps><div v-if="step===0"><a-upload-dragger :beforeUpload="(f)=>{uploadFile=f;step=1;return false}"><p class="ant-upload-drag-icon"><file-add-outlined /></p><p class="ant-upload-text">点击上传参考文件</p></a-upload-dragger></div><div v-if="step===1" style="text-align:center;padding:40px"><a-spin size="large" /><p style="margin-top:16px;color:#666">正在解析文件 → 请求 AI 模型...</p></div><div v-if="step===2"><div style="background:#f0f0ff;padding:16px;border-left:3px solid #5b5bd6;border-radius:4px"><p>AI 生成结果预览:</p><p>{{result}}</p></div></div></a-modal></template>
|
<template><a-modal v-model:open="visible" title="段落测试" :footer="null" width="680px"><a-steps :current="step" style="margin-bottom:24px"><a-step title="上传附件" /><a-step title="处理中" /><a-step title="查看结果" /></a-steps><div v-if="step===0"><a-upload-dragger :beforeUpload="(f: File)=>{uploadFile=f;step=1;return false}"><p class="ant-upload-drag-icon"><file-add-outlined /></p><p class="ant-upload-text">点击上传参考文件</p></a-upload-dragger></div><div v-if="step===1" style="text-align:center;padding:40px"><a-spin size="large" /><p style="margin-top:16px;color:#666">正在解析文件 → 请求 AI 模型...</p></div><div v-if="step===2"><div style="background:#f0f0ff;padding:16px;border-left:3px solid #5b5bd6;border-radius:4px"><p>AI 生成结果预览:</p><p>{{result}}</p></div></div></a-modal></template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { FileAddOutlined } from "@ant-design/icons-vue";
|
import { FileAddOutlined } from "@ant-design/icons-vue";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template><div style="padding:24px;display:flex;gap:24px;height:calc(100vh - 112px)"><div style="width:340px;flex-shrink:0"><a-card title="选择模板"><a-select style="width:100%" v-model:value="selectedTplId" placeholder="请选择已编辑好的模板" @change="onTplChange"><a-select-option v-for="t in templates" :key="t.id" :value="t.id">{{t.name}}</a-select-option></a-select><a-divider /><a-statistic title="总段落" :value="tplInfo.paragraph_count||0" /><a-statistic title="需上传文件" :value="tplInfo.fileCount||0" suffix="/"+String(tplInfo.paragraph_count||0) /></a-card><div v-if="generating" style="margin-top:16px"><a-card title="生成进度"><a-progress :percent="progress" /><p>{{progressText}}</p></a-card></div></div><div style="flex:1;display:flex;flex-direction:column"><a-card title="段落文件配置" style="flex:1"><div v-for="p in paragraphs" :key="p.id" :class="['para-row',{needFile:p.need_file,noFile:!p.need_file}]"><div class="para-info"><span class="idx">{{p.sort_index}}</span><span>{{p.title}}</span><a-tag color="blue">{{p.modelName||"默认"}}</a-tag></div><div v-if="p.need_file" class="file-info"><a-upload :beforeUpload="(f)=>{return handleFileUpload(p.id,f)}" :showUploadList="false"><a-button size="small">{{uploadedFiles[p.id]?"已上传":"上传文件"}}</a-button></a-upload><span v-if="uploadedFiles[p.id]" style="color:green;margin-left:8px">{{uploadedFiles[p.id]}}</span></div><span v-else class="no-file-tag">无需上传</span></div></a-card><div style="margin-top:16px;display:flex;justify-content:space-between;align-items:center"><span>{{fileCount}}/{{needFileCount}} 个文件已上传</span><a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button></div></div></div></template>
|
<template><div style="padding:24px;display:flex;gap:24px;height:calc(100vh - 112px)"><div style="width:340px;flex-shrink:0"><a-card title="选择模板"><a-select style="width:100%" v-model:value="selectedTplId" placeholder="请选择已编辑好的模板" @change="onTplChange"><a-select-option v-for="t in templates" :key="t.id" :value="t.id">{{t.name}}</a-select-option></a-select><a-divider /><a-statistic title="总段落" :value="tplInfo.paragraph_count||0" /><a-statistic title="需上传文件" :value="tplInfo.fileCount||0" suffix="/"+String(tplInfo.paragraph_count||0) /></a-card><div v-if="generating" style="margin-top:16px"><a-card title="生成进度"><a-progress :percent="progress" /><p>{{progressText}}</p></a-card></div></div><div style="flex:1;display:flex;flex-direction:column"><a-card title="段落文件配置" style="flex:1"><div v-for="p in paragraphs" :key="p.id" :class="['para-row',{needFile:p.need_file,noFile:!p.need_file}]"><div class="para-info"><span class="idx">{{p.sort_index}}</span><span>{{p.title}}</span><a-tag color="blue">{{p.modelName||"默认"}}</a-tag></div><div v-if="p.need_file" class="file-info"><a-upload :beforeUpload="(f: File)=>{return handleFileUpload(p.id,f)}" :showUploadList="false"><a-button size="small">{{uploadedFiles[p.id]?"已上传":"上传文件"}}</a-button></a-upload><span v-if="uploadedFiles[p.id]" style="color:green;margin-left:8px">{{uploadedFiles[p.id]}}</span></div><span v-else class="no-file-tag">无需上传</span></div></a-card><div style="margin-top:16px;display:flex;justify-content:space-between;align-items:center"><span>{{fileCount}}/{{needFileCount}} 个文件已上传</span><a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button></div></div></div></template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template><div style="padding:24px"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"><h2>模型管理</h2><a-button type="primary" @click="openAdd">添加模型</a-button></div><a-row :gutter="[16,16]"><a-col :span="8" v-for="m in models" :key="m.id"><a-card :title="m.name" :extra="<a-button type='link' size='small' @click='openEdit(m)'>编辑</a-button>"><p>厂商:{{m.provider}}</p><p>格式:{{m.api_format}}</p><p>地址:{{m.api_endpoint}}</p><p>状态:<a-switch :checked="m.status==='enabled'" @change="toggleStatus(m)" /></p></a-card></a-col></a-row><a-modal v-model:open="modalOpen" :title="isEdit?'编辑模型':'添加模型'" @ok="saveModel"><a-form layout="vertical"><a-form-item label="模型名称"><a-input v-model:value="form.name" /></a-form-item><a-form-item label="供应厂商"><a-input v-model:value="form.provider" /></a-form-item><a-form-item label="API 格式"><a-select v-model:value="form.api_format"><a-select-option value="openai">OpenAI 格式</a-select-option><a-select-option value="anthropic">Anthropic 格式</a-select-option></a-select></a-form-item><a-form-item label="API 地址"><a-input v-model:value="form.api_endpoint" placeholder="https://api.xxx.com" /></a-form-item><a-form-item label="API Key"><a-input-password v-model:value="form.api_key" /></a-form-item></a-form></a-modal></div></template>
|
<template><div style="padding:24px"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"><h2>模型管理</h2><a-button type="primary" @click="openAdd">添加模型</a-button></div><a-row :gutter="[16,16]"><a-col :span="8" v-for="m in models" :key="m.id"><a-card :title="m.name"><template #extra><a-button type="link" size="small" @click="openEdit(m)">编辑</a-button></template><p>厂商:{{m.provider}}</p><p>格式:{{m.api_format}}</p><p>地址:{{m.api_endpoint}}</p><p>状态:<a-switch :checked="m.status==='enabled'" @change="toggleStatus(m)" /></p></a-card></a-col></a-row><a-modal v-model:open="modalOpen" :title="isEdit?'编辑模型':'添加模型'" @ok="saveModel"><a-form layout="vertical"><a-form-item label="模型名称"><a-input v-model:value="form.name" /></a-form-item><a-form-item label="供应厂商"><a-input v-model:value="form.provider" /></a-form-item><a-form-item label="API 格式"><a-select v-model:value="form.api_format"><a-select-option value="openai">OpenAI 格式</a-select-option><a-select-option value="anthropic">Anthropic 格式</a-select-option></a-select></a-form-item><a-form-item label="API 地址"><a-input v-model:value="form.api_endpoint" placeholder="https://api.xxx.com" /></a-form-item><a-form-item label="API Key"><a-input-password v-model:value="form.api_key" /></a-form-item></a-form></a-modal></div></template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { useModelStore } from "@/stores/model";
|
import { useModelStore } from "@/stores/model";
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template><div style="padding:24px"><a-card><template #title><span style="display:flex;align-items:center;gap:8px"><file-text-outlined /> 文档编辑</span></template><template #extra><a-button-group><a-button @click="undo"><undo-outlined />撤销</a-button><a-button @click="redo"><redo-outlined />重做</a-button></a-button-group><a-button type="primary" style="margin-left:12px" @click="exportDocx">导出 Word</a-button><a-button style="margin-left:8px" @click="exportPdf">导出 PDF</a-button></template><div style="display:flex;gap:16px"><div style="flex:1;min-width:0"><div style="display:flex;gap:4px;padding:8px;border:1px solid #d9d9d9;border-bottom:none;border-radius:6px 6px 0 0;flex-wrap:wrap"><a-button size="small"><b>B</b></a-button><a-button size="small"><i>I</i></a-button><a-button size="small"><u>U</u></a-button><a-divider type="vertical" /><a-button size="small"><heading-outlined /></a-button><a-button size="small"><ordered-list-outlined /></a-button><a-button size="small"><table-outlined /></a-button></div><div ref="editorRef" contenteditable="true" style="border:1px solid #d9d9d9;border-radius:0 0 6px 6px;padding:24px;min-height:500px;outline:none;font-size:14px;line-height:1.8" v-html="editorHtml" @input="onEdit"></div></div><div style="width:200px;flex-shrink:0"><a-card title="文档结构" size="small"><div v-for="s in structure" :key="s.id" :class="['struct-item',{active:s.active}]" @click="scrollTo(s.id)"><file-text-outlined /> {{s.title}}</div></a-card></div></div></a-card></div></template>
|
<template><div style="padding:24px"><a-card><template #title><span style="display:flex;align-items:center;gap:8px"><file-text-outlined /> 文档编辑</span></template><template #extra><a-button-group><a-button @click="undo"><undo-outlined />撤销</a-button><a-button @click="redo"><redo-outlined />重做</a-button></a-button-group><a-button type="primary" style="margin-left:12px" @click="exportDocx">导出 Word</a-button><a-button style="margin-left:8px" @click="exportPdf">导出 PDF</a-button></template><div style="display:flex;gap:16px"><div style="flex:1;min-width:0"><div style="display:flex;gap:4px;padding:8px;border:1px solid #d9d9d9;border-bottom:none;border-radius:6px 6px 0 0;flex-wrap:wrap"><a-button size="small"><b>B</b></a-button><a-button size="small"><i>I</i></a-button><a-button size="small"><u>U</u></a-button><a-divider type="vertical" /><a-button size="small"><font-size-outlined /></a-button><a-button size="small"><ordered-list-outlined /></a-button><a-button size="small"><table-outlined /></a-button></div><div ref="editorRef" contenteditable="true" style="border:1px solid #d9d9d9;border-radius:0 0 6px 6px;padding:24px;min-height:500px;outline:none;font-size:14px;line-height:1.8" v-html="editorHtml" @input="onEdit"></div></div><div style="width:200px;flex-shrink:0"><a-card title="文档结构" size="small"><div v-for="s in structure" :key="s.id" :class="['struct-item',{active:s.active}]" @click="scrollTo(s.id)"><file-text-outlined /> {{s.title}}</div></a-card></div></div></a-card></div></template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { generateApi } from "@/api/generate";
|
import { generateApi } from "@/api/generate";
|
||||||
import { message } from "ant-design-vue";
|
import { message } from "ant-design-vue";
|
||||||
import { FileTextOutlined, UndoOutlined, RedoOutlined, HeadingOutlined, OrderedListOutlined, TableOutlined } from "@ant-design/icons-vue";
|
import { FileTextOutlined, UndoOutlined, RedoOutlined, FontSizeOutlined, OrderedListOutlined, TableOutlined } from "@ant-design/icons-vue";
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const editorRef = ref<HTMLElement|null>(null);
|
const editorRef = ref<HTMLElement|null>(null);
|
||||||
const editorHtml = ref(`<h2 style="text-align:center">2025年第一季度经济活动分析报告</h2><p style="text-align:center;color:#666">某某集团有限公司</p><h3>一、主要经营指标完成情况</h3><div style="background:#f0f0ff;padding:12px;border-left:3px solid #5b5bd6;border-radius:4px;margin:8px 0"><p>本季度营业收入完成<strong>12.35亿元</strong>,同比上升<strong>8.7%</strong>。</p></div><h3>主要经营指标完成情况表</h3><table border="1" style="width:100%;border-collapse:collapse"><tr><th>指标</th><th>完成值</th><th>同比</th></tr><tr><td>营业收入</td><td>12.35亿</td><td>+8.7%</td></tr></table><h3>二、成本费用分析</h3><p>本季度总成本9.87亿元,同比上升6.2%。</p>`);
|
const editorHtml = ref(`<h2 style="text-align:center">2025年第一季度经济活动分析报告</h2><p style="text-align:center;color:#666">某某集团有限公司</p><h3>一、主要经营指标完成情况</h3><div style="background:#f0f0ff;padding:12px;border-left:3px solid #5b5bd6;border-radius:4px;margin:8px 0"><p>本季度营业收入完成<strong>12.35亿元</strong>,同比上升<strong>8.7%</strong>。</p></div><h3>主要经营指标完成情况表</h3><table border="1" style="width:100%;border-collapse:collapse"><tr><th>指标</th><th>完成值</th><th>同比</th></tr><tr><td>营业收入</td><td>12.35亿</td><td>+8.7%</td></tr></table><h3>二、成本费用分析</h3><p>本季度总成本9.87亿元,同比上升6.2%。</p>`);
|
||||||
|
|||||||
Reference in New Issue
Block a user