完善附件管理与生成任务跟踪

This commit is contained in:
zwt13703
2026-07-02 18:26:50 +08:00
parent d3530ab0b7
commit 401e8cf57b
16 changed files with 873 additions and 203 deletions
+5
View File
@@ -40,3 +40,8 @@ async def init_db():
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning BOOLEAN DEFAULT 0"))
else:
await conn.execute(text("ALTER TABLE ai_models ADD COLUMN enable_reasoning TINYINT(1) DEFAULT 0"))
document_columns = await conn.run_sync(
lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("documents")]
)
if "request_payload_json" not in document_columns:
await conn.execute(text("ALTER TABLE documents ADD COLUMN request_payload_json TEXT"))
+1
View File
@@ -11,5 +11,6 @@ class Document(Base):
status = Column(String(20), default="pending", comment="pending/generating/completed/failed/cancelled")
file_path = Column(String(500), default="", comment="生成的文件路径")
error = Column(Text, default="", comment="错误信息")
request_payload_json = Column(Text, nullable=True, comment="提交任务时的文件与段落配置快照")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+104 -5
View File
@@ -17,7 +17,7 @@ from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.reference_file import ReferenceFile
from models.template import Template
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, Response
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, ReferenceFileUpdate, Response
from services.ai_service import call_ai, stream_ai_preview
from services.file_summary import summarize_minio_files
from services.generation_runtime import (
@@ -27,12 +27,18 @@ from services.generation_runtime import (
run_generation,
update_progress,
)
from services.minio_client import upload_bytes
from services.minio_client import delete_object, split_bucket_path, upload_bytes, get_presigned_url
router = APIRouter()
def _serialize_document(document: Document) -> dict:
request_payload = {}
if document.request_payload_json:
try:
request_payload = json.loads(document.request_payload_json)
except Exception:
request_payload = {}
return {
"id": document.id,
"template_id": document.template_id,
@@ -42,6 +48,7 @@ def _serialize_document(document: Document) -> dict:
"status": document.status,
"file_path": document.file_path,
"error": document.error,
"request_payload": request_payload,
"created_at": document.created_at,
"updated_at": document.updated_at,
}
@@ -54,9 +61,25 @@ def _serialize_reference_file(file: ReferenceFile) -> dict:
"file_path": file.file_path,
"file_size": file.file_size,
"content_type": file.content_type,
"created_at": file.created_at,
"created_at": file.created_at.isoformat() if file.created_at else None,
}
async def _build_reference_name_mapping(db: AsyncSession, file_paths: list[str]) -> dict[str, str]:
if not file_paths:
return {}
result = await db.execute(
select(ReferenceFile.file_path, ReferenceFile.file_name).where(ReferenceFile.file_path.in_(file_paths))
)
return {file_path: file_name for file_path, file_name in result.all()}
async def _build_reference_records_mapping(db: AsyncSession, file_paths: list[str]) -> dict[str, ReferenceFile]:
if not file_paths:
return {}
result = await db.execute(select(ReferenceFile).where(ReferenceFile.file_path.in_(file_paths)))
return {item.file_path: item for item in result.scalars().all()}
@router.post("/test")
async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(get_db)):
paragraph = await db.get(Paragraph, body.paragraph_id)
@@ -71,7 +94,12 @@ async def generate_test(body: GenerateTestRequest, db: AsyncSession = Depends(ge
elif paragraph.model_id:
model = await db.get(AiModel, paragraph.model_id)
file_summaries = await asyncio.to_thread(summarize_minio_files, body.file_paths or []) if body.file_paths else []
file_name_mapping = await _build_reference_name_mapping(db, body.file_paths or [])
file_summaries = (
await asyncio.to_thread(summarize_minio_files, body.file_paths or [], file_name_mapping)
if body.file_paths
else []
)
if model is None or model.status != "enabled":
content = build_mock_content(paragraph)
message = "当前未找到可用模型,返回本地模拟生成结果。"
@@ -109,7 +137,12 @@ async def generate_test_stream(body: GenerateTestRequest, db: AsyncSession = Dep
if not model.supports_streaming:
raise HTTPException(status_code=400, detail="当前模型未开启流式传输")
file_summaries = await asyncio.to_thread(summarize_minio_files, body.file_paths or []) if body.file_paths else []
file_name_mapping = await _build_reference_name_mapping(db, body.file_paths or [])
file_summaries = (
await asyncio.to_thread(summarize_minio_files, body.file_paths or [], file_name_mapping)
if body.file_paths
else []
)
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
async def event_stream():
@@ -236,6 +269,41 @@ async def list_reference_files(
return Response(data={"items": items, "total": total, "page": page, "page_size": page_size})
@router.get("/reference-files/{file_id}/download")
async def download_reference_file(file_id: int, db: AsyncSession = Depends(get_db)):
record = await db.get(ReferenceFile, file_id)
if record is None:
raise HTTPException(status_code=404, detail="附件记录不存在")
bucket, object_name = split_bucket_path(record.file_path)
return Response(data={"url": get_presigned_url(bucket, object_name), "file_name": record.file_name})
@router.put("/reference-files/{file_id}")
async def update_reference_file(file_id: int, body: ReferenceFileUpdate, db: AsyncSession = Depends(get_db)):
record = await db.get(ReferenceFile, file_id)
if record is None:
raise HTTPException(status_code=404, detail="附件记录不存在")
record.file_name = body.file_name.strip()
await db.commit()
await db.refresh(record)
return Response(data=_serialize_reference_file(record))
@router.delete("/reference-files/{file_id}")
async def delete_reference_file(file_id: int, db: AsyncSession = Depends(get_db)):
record = await db.get(ReferenceFile, file_id)
if record is None:
raise HTTPException(status_code=404, detail="附件记录不存在")
try:
bucket, object_name = split_bucket_path(record.file_path)
await asyncio.to_thread(delete_object, bucket, object_name)
except Exception:
pass
await db.delete(record)
await db.commit()
return Response(data={"id": file_id})
@router.post("/full")
async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(get_db)):
template = await db.get(Template, body.template_id)
@@ -251,6 +319,36 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge
if not paragraphs:
raise HTTPException(status_code=400, detail="模板下暂无可生成段落")
normalized_file_map: dict[str, list[str]] = {}
for key, values in (body.file_map or {}).items():
if not isinstance(values, list):
continue
normalized_file_map[str(key)] = [item for item in values if item]
all_file_paths = [item for values in normalized_file_map.values() for item in values]
file_records = await _build_reference_records_mapping(db, all_file_paths)
paragraph_snapshot = []
for paragraph in paragraphs:
selected_paths = normalized_file_map.get(str(paragraph.id), [])
paragraph_snapshot.append(
{
"paragraph_id": paragraph.id,
"title": paragraph.title,
"sort_index": paragraph.sort_index,
"need_file": bool(paragraph.need_file),
"file_note": paragraph.file_note or "",
"selected_files": [
_serialize_reference_file(file_records[file_path])
for file_path in selected_paths
if file_path in file_records
],
}
)
request_payload = {
"template_name": template.name,
"file_map": normalized_file_map,
"paragraphs": paragraph_snapshot,
}
document = Document(
template_id=template.id,
name=f"{template.name}-{datetime.now().strftime('%Y%m%d%H%M%S')}",
@@ -259,6 +357,7 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge
status="generating",
file_path="",
error="",
request_payload_json=json.dumps(request_payload, ensure_ascii=False),
)
db.add(document)
await db.flush()
+5 -1
View File
@@ -87,7 +87,11 @@ class GenerateTestRequest(BaseModel):
class GenerateFullRequest(BaseModel):
template_id: int
file_map: dict[str, str] = {} # paragraph_id -> file_path
file_map: dict[str, list[str]] = {} # paragraph_id -> file_paths
class ReferenceFileUpdate(BaseModel):
file_name: str = Field(min_length=1, max_length=255)
class DocumentOut(BaseModel):
id: int
+24 -4
View File
@@ -1,6 +1,8 @@
import csv
import io
import json
import subprocess
import tempfile
from pathlib import Path
import pandas as pd
@@ -38,7 +40,10 @@ def _summarize_csv(content: bytes) -> str:
def _summarize_excel(content: bytes, suffix: str) -> str:
excel_buffer = io.BytesIO(content)
sheet_map = pd.read_excel(excel_buffer, sheet_name=None) if suffix == ".xlsx" else pd.read_excel(excel_buffer, sheet_name=None, engine="xlrd")
if suffix in {".xlsx", ".xlsm"}:
sheet_map = pd.read_excel(excel_buffer, sheet_name=None)
else:
sheet_map = pd.read_excel(excel_buffer, sheet_name=None, engine="xlrd")
parts: list[str] = []
for sheet_name, dataframe in list(sheet_map.items())[:5]:
preview = dataframe.head(10).fillna("").astype(str)
@@ -47,6 +52,21 @@ def _summarize_excel(content: bytes, suffix: str) -> str:
return "\n".join(parts)[:5000]
def _summarize_doc(content: bytes) -> str:
with tempfile.NamedTemporaryFile(suffix=".doc") as temp_file:
temp_file.write(content)
temp_file.flush()
result = subprocess.run(
["textutil", "-convert", "txt", "-stdout", temp_file.name],
capture_output=True,
check=False,
)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="ignore").strip()
return f"旧版 Word 文件解析失败:{stderr or 'textutil 无法提取正文'}"
return _decode_text(result.stdout)[:4000]
def _summarize_pdf(content: bytes) -> str:
if PdfReader is None:
return "当前环境未安装 PDF 文本解析依赖,无法提取 PDF 正文。"
@@ -68,18 +88,18 @@ def summarize_file_bytes(file_name: str, content: bytes) -> str:
if suffix == ".docx":
return _summarize_docx(content)
if suffix == ".doc":
return "当前暂不支持直接解析 .doc 旧版 Word 文件正文,建议先另存为 .docx 后再上传。"
return _summarize_doc(content)
if suffix == ".pdf":
return _summarize_pdf(content)
return f"暂不支持解析该文件内容:{file_name}"
def summarize_minio_files(file_paths: list[str]) -> list[dict]:
def summarize_minio_files(file_paths: list[str], file_name_mapping: dict[str, str] | None = None) -> list[dict]:
summaries: list[dict] = []
for file_path in file_paths:
bucket, object_name = split_bucket_path(file_path)
content = download_object_bytes(bucket, object_name)
file_name = Path(object_name).name
file_name = (file_name_mapping or {}).get(file_path) or Path(object_name).name
summaries.append(
{
"file_name": file_name,
+23 -1
View File
@@ -12,6 +12,7 @@ from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from services.ai_service import call_ai
from services.file_summary import summarize_minio_files
generation_progress: dict[int, dict] = {}
generation_cancel_flags: dict[int, bool] = {}
@@ -90,6 +91,13 @@ async def run_generation(document_id: int, template_id: int):
)
paragraphs = result.scalars().all()
total = len(paragraphs)
request_payload = {}
if document.request_payload_json:
try:
request_payload = json.loads(document.request_payload_json)
except Exception:
request_payload = {}
file_map = request_payload.get("file_map", {}) if isinstance(request_payload, dict) else {}
update_progress(document_id, status="generating", total=total, done=0, percent=0, message="开始生成...")
done_count = 0
@@ -114,11 +122,25 @@ async def run_generation(document_id: int, template_id: int):
model = await get_effective_model(paragraph)
model_id = model.id if model is not None else paragraph.model_id
try:
selected_file_paths = file_map.get(str(paragraph.id), [])
file_summaries = []
if selected_file_paths:
file_name_mapping = {}
for paragraph_item in request_payload.get("paragraphs", []):
for selected_file in paragraph_item.get("selected_files", []):
file_path = selected_file.get("file_path")
if file_path in selected_file_paths:
file_name_mapping[file_path] = selected_file.get("file_name")
file_summaries = await asyncio.to_thread(
summarize_minio_files,
selected_file_paths,
file_name_mapping,
)
if model is None:
content = build_mock_content(paragraph)
else:
setattr(paragraph, "enable_reasoning", bool(model.enable_reasoning))
result_data = await call_ai(paragraph, model)
result_data = await call_ai(paragraph, model, file_summaries)
content = result_data.content
status = "success"
error_message = ""
+4
View File
@@ -68,3 +68,7 @@ def upload_bytes(
len(content),
content_type=content_type,
)
def delete_object(bucket: str, object_name: str):
minio_client.remove_object(bucket, object_name)
+52
View File
@@ -156,3 +156,55 @@
3. 优化上传接口错误提示,返回实际不支持的扩展名和当前支持列表。
4. 同步更新模板测试弹窗中的上传提示文案,并执行后端语法检查与前端类型检查。
- **执行结果**: 当前参考文件上传支持范围更完整,遇到不支持的文件类型时也会直接显示具体后缀和支持列表,便于快速判断问题。
## 会话 ID: local-20260702173843
- [2026-07-02 17:38:43]
- **执行原因**: 用户要求先提交当前代码,并继续修复参考文件原名称丢失问题,同时核实 `.doc`、Excel、PDF 的内容提取能力。
- **执行过程**:
1. 提交当前阶段代码,提交信息为“完善模型测试与参考文件历史能力”,保持工作区清晰。
2. 修正参考文件摘要逻辑,优先从 `reference_files` 历史记录中读取原始文件名,不再把 MinIO 中的 UUID 文件名传给 AI。
3.`.doc` 文件接入 macOS `textutil` 文本提取能力,补齐旧版 Word 文档正文解析。
4. 修正 Excel 提取分支,确保 `.xlsm``.xlsx` 走正确读取方式;保留现有 PDF 文本提取逻辑。
5. 执行后端语法检查与前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前参考文件在提交给 AI 时会保留用户原始文件名;`.doc``.docx`、Excel、PDF 等常见文件均可进入提取链路,其中 Excel 和 PDF 原本已支持,本轮补强了 `.doc``.xlsm`
## 会话 ID: local-20260702175543
- [2026-07-02 17:55:43]
- **执行原因**: 用户提出系统缺少单独的附件历史菜单,希望可以直接查看已经上传过的文件。
- **执行过程**:
1. 新增前端附件历史页面,展示参考文件总数、当前页数量、文件名、对象路径、类型、大小和上传时间。
2. 将页面接入现有 `reference-files` 接口,支持按文件名搜索和分页查看。
3. 在顶部导航中新增“附件历史”菜单入口,并补充路由配置。
4. 执行前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前系统已新增独立的“附件历史”菜单,用户可直接查看和检索已上传的参考文件记录。
## 会话 ID: local-20260702181456
- [2026-07-02 18:14:56]
- **执行原因**: 用户希望附件历史支持下载、改名、查看与删除,同时要求生成页面支持多文件和文件说明,并让生成任务脱离当前页面、在记录页可持续查看状态与附件映射。
- **执行过程**:
1. 扩展参考附件接口,新增附件下载、改名、删除能力,并在附件历史页补齐对应操作按钮。
2. 为生成任务数据模型新增请求快照字段,在提交任务时持久化保存模板名、段落文件说明以及每个段落选中的附件列表。
3. 调整正式生成运行时,让每个段落在后台生成时真正读取该段落绑定的多文件内容,并把原始文件名传给 AI。
4. 重写执行生成页,展示每个段落的文件说明,支持多文件上传与移除,并将页面改为“提交任务后立即转到任务详情”模式,不再绑死在提交页。
5. 重写任务详情页,支持查看任务状态、实时进度、段落与附件映射、已完成段落内容,以及在运行中取消任务。
6. 执行后端语法检查与前端类型检查,确认本轮改动稳定。
- **执行结果**: 当前附件历史已支持基础管理;生成任务提交后会在后台继续执行,用户可离开提交页并在任务详情中持续查看状态、段落附件映射与生成结果。
## 会话 ID: local-20260702182202
- [2026-07-02 18:22:02]
- **执行原因**: 用户反馈 MySQL 启动时报 `TEXT` 列不能设置默认值,需要修复 `request_payload_json` 字段的数据库兼容性。
- **执行过程**:
1. 调整 `documents.request_payload_json` 的模型定义,取消 `TEXT` 列默认值,改为允许空值并由代码层负责兜底。
2. 修正数据库初始化与自动补列逻辑,避免执行 `ALTER TABLE ... TEXT DEFAULT '{}'` 这一类 MySQL 非法 SQL。
3. 同步更新 `init.sql``documents` 表定义,去掉 `request_payload_json` 的默认值。
4. 执行后端语法检查,确认修复稳定。
- **执行结果**: 当前 `request_payload_json` 字段已兼容 MySQL,应用启动时不会再因为 `TEXT DEFAULT` 语句失败。
## 会话 ID: local-20260702182417
- [2026-07-02 18:24:17]
- **执行原因**: 用户反馈提交生成任务时报 `Object of type datetime is not JSON serializable`,需要修复任务快照里的附件时间字段序列化。
- **执行过程**:
1. 检查生成任务快照构建逻辑,确认报错来源于附件记录中的 `created_at``datetime` 对象。
2. 调整参考附件序列化方法,将 `created_at` 统一转为 ISO 字符串后再写入任务快照 JSON。
3. 执行后端语法检查,确认修复稳定。
- **执行结果**: 当前生成任务提交时不会再因为附件记录中的 `datetime` 字段导致 JSON 序列化失败。
+1
View File
@@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS documents (
status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/generating/completed/failed/cancelled',
file_path VARCHAR(500) DEFAULT '' COMMENT 'MinIO 对象路径',
error TEXT DEFAULT '' COMMENT '错误信息',
request_payload_json TEXT COMMENT '提交任务时的文件与段落配置快照',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
+5 -1
View File
@@ -20,6 +20,10 @@
<thunderbolt-outlined />
执行生成
</button>
<button :class="['topbar-tab', { active: isActive('/attachments') }]" @click="router.push('/attachments')">
<paper-clip-outlined />
附件历史
</button>
<button :class="['topbar-tab', { active: isActive('/history') }]" @click="router.push('/history')">
<clock-circle-outlined />
生成记录
@@ -44,7 +48,7 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
import { FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined, PaperClipOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
+3
View File
@@ -5,6 +5,9 @@ export const generateApi = {
testStream: () => '/api/v1/generate/test-stream',
upload: (formData: FormData) => http.post('/generate/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
referenceFiles: (params?: any) => http.get('/generate/reference-files', { params }),
updateReferenceFile: (id: number, data: any) => http.put(`/generate/reference-files/${id}`, data),
deleteReferenceFile: (id: number) => http.delete(`/generate/reference-files/${id}`),
downloadReferenceFile: (id: number) => http.get(`/generate/reference-files/${id}/download`),
full: (data: any) => http.post('/generate/full', data),
progress: (id: number) => `/api/v1/generate/progress/${id}`,
cancel: (id: number) => http.post(`/generate/cancel/${id}`),
+1
View File
@@ -6,6 +6,7 @@ const routes = [
{ path: '/templates/:id/edit', name: 'TemplateEditor', component: () => import('@/views/TemplateEditor.vue') },
{ path: '/models', name: 'ModelManage', component: () => import('@/views/ModelManage.vue') },
{ path: '/generate', name: 'GeneratePage', component: () => import('@/views/GeneratePage.vue') },
{ path: '/attachments', name: 'AttachmentHistoryPage', component: () => import('@/views/AttachmentHistoryPage.vue') },
{ path: '/history', name: 'HistoryPage', component: () => import('@/views/HistoryPage.vue') },
{ path: '/preview/:id', name: 'PreviewEdit', component: () => import('@/views/PreviewEdit.vue') },
]
+12
View File
@@ -21,6 +21,18 @@ export interface Document {
para_count_done: number; para_count_total: number
status: 'pending' | 'generating' | 'completed' | 'failed' | 'cancelled'
file_path: string; error: string
request_payload?: {
template_name?: string
file_map?: Record<string, string[]>
paragraphs?: Array<{
paragraph_id: number
title: string
sort_index: number
need_file: boolean
file_note: string
selected_files: ReferenceFile[]
}>
}
}
export interface ReferenceFile {
+256
View File
@@ -0,0 +1,256 @@
<template>
<div class="attachment-page">
<div class="page-header">
<div>
<div class="page-title">附件历史</div>
<div class="page-desc">查看系统内已上传的参考文件便于后续模板测试时直接复用</div>
</div>
<a-button @click="fetchList">刷新列表</a-button>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">历史附件总数</div>
<div class="stat-value">{{ total }}</div>
</div>
<div class="stat-card">
<div class="stat-label">当前页文件数</div>
<div class="stat-value">{{ files.length }}</div>
</div>
</div>
<div class="toolbar">
<a-input-search
v-model:value="keyword"
placeholder="按文件名搜索附件"
allow-clear
@search="handleSearch"
/>
</div>
<a-card :bordered="false" class="table-card">
<a-table
:columns="columns"
:data-source="files"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'file_name'">
<div class="file-name">{{ record.file_name }}</div>
<div class="file-path">{{ record.file_path }}</div>
</template>
<template v-else-if="column.key === 'file_size'">
{{ formatFileSize(record.file_size) }}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="downloadFile(record)">下载</a-button>
<a-button size="small" @click="renameFile(record)">改名</a-button>
<a-button size="small" danger @click="removeFile(record)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { generateApi } from '@/api/generate'
import type { ReferenceFile } from '@/types'
const loading = ref(false)
const keyword = ref('')
const files = ref<ReferenceFile[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const columns = [
{ title: '文件名', key: 'file_name', dataIndex: 'file_name' },
{ title: '文件类型', key: 'content_type', dataIndex: 'content_type', width: 180 },
{ title: '文件大小', key: 'file_size', dataIndex: 'file_size', width: 120 },
{ title: '上传时间', key: 'created_at', dataIndex: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 220 },
]
const pagination = ref({
current: page.value,
pageSize: pageSize.value,
total: total.value,
showSizeChanger: true,
showTotal: (count: number) => `${count} 个附件`,
})
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 fetchList() {
loading.value = true
try {
const response: any = await generateApi.referenceFiles({
page: page.value,
page_size: pageSize.value,
keyword: keyword.value.trim(),
})
files.value = response.data?.items || []
total.value = response.data?.total || 0
pagination.value = {
...pagination.value,
current: response.data?.page || page.value,
pageSize: response.data?.page_size || pageSize.value,
total: response.data?.total || 0,
}
} catch (error: any) {
message.error(error.message || '加载附件历史失败')
} finally {
loading.value = false
}
}
async function downloadFile(record: ReferenceFile) {
try {
const response: any = await generateApi.downloadReferenceFile(record.id)
window.open(response.data?.url, '_blank')
} catch (error: any) {
message.error(error.message || '下载附件失败')
}
}
function renameFile(record: ReferenceFile) {
const nextName = window.prompt('请输入新的附件名称', record.file_name)
if (nextName === null) return
if (!nextName.trim()) {
message.warning('文件名不能为空')
return
}
generateApi.updateReferenceFile(record.id, { file_name: nextName.trim() }).then(async () => {
message.success('附件名称已更新')
await fetchList()
}).catch((error: any) => {
message.error(error.message || '附件改名失败')
})
}
function removeFile(record: ReferenceFile) {
Modal.confirm({
title: '删除附件',
content: `确认删除附件“${record.file_name}”吗?`,
okText: '删除',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
await generateApi.deleteReferenceFile(record.id)
message.success('附件已删除')
await fetchList()
},
})
}
function handleSearch() {
page.value = 1
fetchList()
}
function handleTableChange(nextPagination: any) {
page.value = nextPagination.current || 1
pageSize.value = nextPagination.pageSize || 10
fetchList()
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.attachment-page {
padding: 24px;
background: #f5f6f8;
min-height: 100%;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.page-title {
font-size: 24px;
font-weight: 700;
color: #111827;
}
.page-desc {
margin-top: 6px;
font-size: 13px;
color: #6b7280;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
padding: 18px 20px;
border-radius: 16px;
background: linear-gradient(135deg, #ffffff 0%, #f5f7fb 100%);
border: 1px solid #e5e7eb;
}
.stat-label {
font-size: 13px;
color: #6b7280;
}
.stat-value {
margin-top: 8px;
font-size: 28px;
font-weight: 700;
color: #111827;
}
.toolbar {
margin-bottom: 16px;
max-width: 360px;
}
.table-card {
border-radius: 18px;
}
.file-name {
font-size: 14px;
font-weight: 600;
color: #111827;
word-break: break-all;
}
.file-path {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
word-break: break-all;
}
</style>
+75 -70
View File
@@ -39,15 +39,11 @@
</div>
</div>
<div v-if="generating" class="status-block">
<div class="status-block">
<div class="gen-progress">
<div class="spinner" />
<div class="gen-text">{{ progressText }}</div>
</div>
<div class="progress-card">
<div class="progress-title">生成进度</div>
<a-progress :percent="progress" />
<a-button danger block @click="cancelGen">取消生成</a-button>
<div class="gen-text">
这里仅负责提交生成任务任务创建后会在后台继续执行你可以离开当前页面稍后在生成记录或任务详情中查看进度
</div>
</div>
</div>
</div>
@@ -61,14 +57,22 @@
<div v-for="paragraph in paragraphs" :key="paragraph.id" :class="['para-row', { needFile: paragraph.need_file, noFile: !paragraph.need_file }]">
<div class="para-info">
<span class="idx">{{ paragraph.sort_index }}</span>
<span>{{ paragraph.title }}</span>
<div class="para-main">
<span>{{ paragraph.title }}</span>
<span v-if="paragraph.need_file && paragraph.file_note" class="file-note">{{ paragraph.file_note }}</span>
</div>
<a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag>
</div>
<div v-if="paragraph.need_file" class="file-info">
<a-upload :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false">
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id] ? '上传' : '上传文件' }}</a-button>
<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>
<span v-if="uploadedFiles[paragraph.id]" class="uploaded-name">{{ uploadedFiles[paragraph.id] }}</span>
<div v-if="uploadedFiles[paragraph.id]?.length" class="uploaded-list">
<span v-for="item in uploadedFiles[paragraph.id]" :key="item.file_path" class="uploaded-name">
{{ item.file_name }}
<button class="remove-file-btn" @click="removeUploadedFile(paragraph.id, item.file_path)">x</button>
</span>
</div>
</div>
<span v-else class="no-file-tag">无需上传</span>
</div>
@@ -85,7 +89,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useTemplateStore } from '@/stores/template'
@@ -100,19 +104,15 @@ const docStore = useDocumentStore()
const templates = ref<any[]>([])
const paragraphs = ref<any[]>([])
const selectedTplId = ref<number | undefined>(undefined)
const uploadedFiles = ref<Record<number, string>>({})
const uploadedFilePaths = ref<Record<number, string>>({})
const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: string }>>>({})
const uploadedFilePaths = ref<Record<number, string[]>>({})
const uploadingMap = ref<Record<number, boolean>>({})
const generating = ref(false)
const progress = ref(0)
const progressText = ref('')
const tplInfo = ref<any>({})
const currentDocumentId = ref<number | null>(null)
let progressSource: EventSource | null = null
const needFileCount = computed(() => paragraphs.value.filter((item) => item.need_file).length)
const autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mode === 'ai').length)
const fileCount = computed(() => Object.keys(uploadedFiles.value).length)
const fileCount = computed(() => Object.values(uploadedFiles.value).filter((item) => item?.length).length)
const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '')
async function refreshTemplates() {
@@ -120,37 +120,6 @@ async function refreshTemplates() {
templates.value = tplStore.templates as any
}
function closeProgressSource() {
if (progressSource) {
progressSource.close()
progressSource = null
}
}
function bindProgress(documentId: number) {
closeProgressSource()
progressSource = new EventSource(generateApi.progress(documentId))
progressSource.addEventListener('progress', (event: MessageEvent) => {
const payload = JSON.parse(event.data)
progress.value = payload.percent || 0
progressText.value = payload.message || '正在生成...'
if (payload.status === 'completed' || payload.status === 'failed') {
generating.value = false
closeProgressSource()
message.success(payload.status === 'completed' ? '生成完成' : '生成结束,部分段落已回退为模拟结果')
router.push(`/preview/${documentId}`)
} else if (payload.status === 'cancelled') {
generating.value = false
closeProgressSource()
message.info('已取消生成')
}
})
progressSource.onerror = () => {
closeProgressSource()
}
}
onMounted(async () => {
await refreshTemplates()
const queryId = Number(route.query.templateId)
@@ -160,10 +129,6 @@ onMounted(async () => {
}
})
onBeforeUnmount(() => {
closeProgressSource()
})
async function onTplChange(id: number) {
const template = await tplStore.fetchOne(id)
paragraphs.value = tplStore.paragraphs as any
@@ -181,8 +146,9 @@ async function handleFileUpload(paragraphId: number, file: File) {
const fd = new FormData()
fd.append('file', file)
const res: any = await generateApi.upload(fd)
uploadedFiles.value[paragraphId] = res.data.file_name
uploadedFilePaths.value[paragraphId] = res.data.file_path
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 || '文件上传失败')
@@ -192,6 +158,11 @@ async function handleFileUpload(paragraphId: number, file: File) {
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('请先选择模板')
@@ -204,24 +175,18 @@ async function startGen() {
}
generating.value = true
progress.value = 0
progressText.value = '任务创建中...'
const fileMap = Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([key, value]) => [String(key), value]))
const fileMap = Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([key, value]) => [String(key), value || []]))
try {
const document: any = await docStore.generateFull({ template_id: selectedTplId.value, file_map: fileMap })
currentDocumentId.value = document.id
bindProgress(document.id)
message.success('生成任务已提交,已转到任务详情页继续查看进度')
router.push(`/preview/${document.id}`)
} catch (error: any) {
generating.value = false
message.error(error.message || '生成失败')
return
}
}
async function cancelGen() {
if (!currentDocumentId.value) return
await docStore.cancel(currentDocumentId.value)
progressText.value = '正在取消...'
generating.value = false
}
</script>
@@ -400,7 +365,32 @@ async function cancelGen() {
.para-info {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
}
.para-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.file-note {
font-size: 12px;
color: #6b7280;
}
.file-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.uploaded-list {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
@@ -419,7 +409,22 @@ async function cancelGen() {
.uploaded-name {
color: #1a8c4a;
margin-left: 8px;
border: 1px solid #cdebd8;
background: #eefbf2;
padding: 4px 8px;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.remove-file-btn {
border: none;
background: transparent;
color: #999;
cursor: pointer;
padding: 0;
}
.no-file-tag {
+302 -121
View File
@@ -1,70 +1,78 @@
<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;background:#fff"
v-html="editorHtml"
@input="onEdit"
/>
</div>
<div style="width:220px;flex-shrink:0">
<a-card title="文档结构" size="small">
<div
v-for="s in structure"
:key="s.id"
class="struct-item"
@click="scrollTo(s.anchor)"
>
<file-text-outlined />
{{ s.title }}
<div class="detail-page">
<div class="detail-head">
<div>
<div class="detail-title">任务详情</div>
<div class="detail-desc">查看当前生成状态已选附件以及已完成段落的输出结果</div>
</div>
<div class="detail-actions">
<a-button v-if="isRunning" danger @click="cancelTask">取消任务</a-button>
<a-button :disabled="!isCompleted" @click="exportDocx">导出 Word</a-button>
<a-button :disabled="!isCompleted" @click="exportPdf">导出 PDF</a-button>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">任务状态</div>
<div class="stat-value">{{ statusText(documentInfo.status) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">完成进度</div>
<div class="stat-value">{{ documentInfo.para_count_done || 0 }}/{{ documentInfo.para_count_total || 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">关联模板</div>
<div class="stat-value">{{ documentInfo.request_payload?.template_name || documentInfo.name || '-' }}</div>
</div>
</div>
<a-card class="status-card">
<a-badge :status="statusBadge(documentInfo.status)" :text="statusText(documentInfo.status)" />
<a-progress style="margin-top: 14px" :percent="progressPercent" />
<div class="status-message">{{ progressMessage || documentInfo.error || '任务已创建,等待执行。' }}</div>
</a-card>
<a-card class="mapping-card" title="段落与附件映射">
<div v-if="paragraphMappings.length" class="mapping-list">
<div v-for="item in paragraphMappings" :key="item.paragraph_id" class="mapping-item">
<div class="mapping-top">
<span class="mapping-index">{{ item.sort_index }}</span>
<div class="mapping-main">
<div class="mapping-title">{{ item.title }}</div>
<div v-if="item.file_note" class="mapping-note">{{ item.file_note }}</div>
</div>
</a-card>
</div>
<div v-if="item.selected_files?.length" class="mapping-files">
<span v-for="file in item.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
</div>
<div v-else class="mapping-empty">未选择附件</div>
</div>
</div>
<a-empty v-else description="当前任务未记录附件映射" />
</a-card>
<a-card class="preview-card" title="生成结果预览">
<div v-if="logs.length" class="preview-wrap">
<section v-for="item in logs" :key="item.paragraph_id" :id="`section-${item.paragraph_id}`" class="preview-section">
<div class="preview-section-head">
<h3>{{ item.title }}</h3>
<a-badge :status="statusBadge(item.status)" :text="statusText(item.status)" />
</div>
<div class="preview-block" v-html="renderBlocks(item.content?.content || [])" />
</section>
</div>
<a-empty v-else :description="isRunning ? '任务进行中,已完成段落会逐步出现在这里' : '暂无生成内容'" />
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { generateApi } from '@/api/generate'
import { message } from 'ant-design-vue'
import {
FileTextOutlined,
UndoOutlined,
RedoOutlined,
FontSizeOutlined,
OrderedListOutlined,
TableOutlined,
} from '@ant-design/icons-vue'
import { useDocumentStore } from '@/stores/document'
import { generateApi } from '@/api/generate'
interface ContentBlock {
type: string
@@ -78,23 +86,40 @@ interface LogItem {
paragraph_id: number
title: string
sort_index: number
content: {
content: ContentBlock[]
}
status: string
content: { content: ContentBlock[] }
}
const route = useRoute()
const editorRef = ref<HTMLElement | null>(null)
const docStore = useDocumentStore()
const documentInfo = ref<any>({})
const logs = ref<LogItem[]>([])
const editorHtml = ref('<p>加载中...</p>')
const progressPercent = ref(0)
const progressMessage = ref('')
let progressSource: EventSource | null = null
const structure = computed(() =>
logs.value.map((item) => ({
id: item.paragraph_id,
title: item.title || `段落 ${item.sort_index}`,
anchor: `section-${item.paragraph_id}`,
})),
)
const isRunning = computed(() => ['pending', 'generating'].includes(documentInfo.value.status))
const isCompleted = computed(() => documentInfo.value.status === 'completed')
const paragraphMappings = computed(() => documentInfo.value.request_payload?.paragraphs || [])
function statusText(status: string) {
const map: Record<string, string> = {
completed: '已完成',
failed: '失败',
cancelled: '已取消',
generating: '生成中',
pending: '等待中',
success: '成功',
}
return map[status] || status || '未知'
}
function statusBadge(status: string) {
if (status === 'completed' || status === 'success') return 'success'
if (status === 'failed') return 'error'
if (status === 'cancelled') return 'warning'
return 'processing'
}
function renderTable(block: ContentBlock) {
const headers = block.headers || []
@@ -102,19 +127,15 @@ function renderTable(block: ContentBlock) {
const thead = headers.length
? `<thead><tr>${headers.map((header) => `<th>${header}</th>`).join('')}</tr></thead>`
: ''
const tbody = `<tbody>${rows
.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`)
.join('')}</tbody>`
return `<table border="1" style="width:100%;border-collapse:collapse;margin:12px 0">${thead}${tbody}</table>`
const tbody = `<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody>`
return `<table class="result-table">${thead}${tbody}</table>`
}
function renderBlocks(blocks: ContentBlock[]) {
return blocks
.map((block) => {
if (block.type === 'table') {
return renderTable(block)
}
return `<p style="margin:10px 0">${block.text || ''}</p>`
if (block.type === 'table') return renderTable(block)
return `<p class="result-text">${block.text || ''}</p>`
})
.join('')
}
@@ -122,75 +143,235 @@ function renderBlocks(blocks: ContentBlock[]) {
async function loadDocument() {
const id = Number(route.params.id)
const response: any = await generateApi.getDocument(id)
documentInfo.value = response.data || {}
logs.value = response.data?.logs || []
if (!logs.value.length) {
editorHtml.value = '<p>暂无生成内容。</p>'
return
}
editorHtml.value = logs.value
.map(
(item) => `
<section id="section-${item.paragraph_id}" style="margin-bottom:24px">
<h3 style="margin-bottom:12px">${item.title}</h3>
<div style="background:#f8faff;padding:16px;border-left:3px solid #5b5bd6;border-radius:4px">
${renderBlocks(item.content?.content || [])}
</div>
</section>
`,
)
.join('')
}
function scrollTo(anchor: string) {
const element = document.getElementById(anchor)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
function bindProgress() {
const id = Number(route.params.id)
if (progressSource) progressSource.close()
progressSource = new EventSource(generateApi.progress(id))
progressSource.addEventListener('progress', async (event: MessageEvent) => {
const payload = JSON.parse(event.data)
progressPercent.value = payload.percent || 0
progressMessage.value = payload.message || ''
await loadDocument()
if (['completed', 'failed', 'cancelled'].includes(payload.status)) {
progressSource?.close()
progressSource = null
}
})
progressSource.onerror = () => {
progressSource?.close()
progressSource = null
}
}
function onEdit() {}
function undo() {
document.execCommand('undo')
}
function redo() {
document.execCommand('redo')
async function cancelTask() {
const id = Number(route.params.id)
await docStore.cancel(id)
message.info('已发起取消请求')
}
function exportDocx() {
const id = Number(route.params.id)
window.open(generateApi.exportDocx(id))
window.open(generateApi.exportDocx(Number(route.params.id)))
}
function exportPdf() {
const id = Number(route.params.id)
window.open(generateApi.exportPdf(id))
window.open(generateApi.exportPdf(Number(route.params.id)))
}
onMounted(async () => {
try {
await loadDocument()
progressPercent.value = Math.floor(((documentInfo.value.para_count_done || 0) / Math.max(documentInfo.value.para_count_total || 1, 1)) * 100)
if (isRunning.value) bindProgress()
} catch (error: any) {
message.error(error.message || '加载文档失败')
editorHtml.value = '<p>文档加载失败。</p>'
message.error(error.message || '加载任务详情失败')
}
})
onBeforeUnmount(() => {
progressSource?.close()
progressSource = null
})
</script>
<style scoped>
.struct-item {
padding: 8px;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
display: flex;
gap: 6px;
align-items: center;
.detail-page {
padding: 24px;
}
.struct-item:hover {
background: #f5f5f5;
.detail-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 20px;
}
.detail-title {
font-size: 24px;
font-weight: 700;
color: #111827;
}
.detail-desc {
margin-top: 6px;
font-size: 13px;
color: #6b7280;
}
.detail-actions {
display: flex;
gap: 8px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.stat-card {
padding: 18px;
border-radius: 16px;
border: 1px solid #e5e7eb;
background: linear-gradient(135deg, #ffffff 0%, #f7f8fc 100%);
}
.stat-label {
font-size: 13px;
color: #6b7280;
}
.stat-value {
margin-top: 8px;
font-size: 22px;
font-weight: 700;
color: #111827;
}
.status-card,
.mapping-card,
.preview-card {
margin-bottom: 16px;
border-radius: 18px;
}
.status-message {
margin-top: 12px;
font-size: 13px;
color: #6b7280;
}
.mapping-list {
display: grid;
gap: 12px;
}
.mapping-item {
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 14px;
background: #fafbfc;
}
.mapping-top {
display: flex;
gap: 10px;
}
.mapping-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: #eef2ff;
color: #4f46e5;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
.mapping-main {
flex: 1;
}
.mapping-title {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.mapping-note {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.mapping-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.mapping-file {
padding: 4px 10px;
border-radius: 999px;
background: #eefbf2;
border: 1px solid #cdebd8;
color: #1a8c4a;
font-size: 12px;
}
.mapping-empty {
margin-top: 10px;
font-size: 12px;
color: #9ca3af;
}
.preview-wrap {
display: grid;
gap: 16px;
}
.preview-section {
padding: 16px;
border-radius: 14px;
background: #fff;
border: 1px solid #e5e7eb;
}
.preview-section-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.preview-section-head h3 {
margin: 0;
}
:deep(.result-text) {
margin: 0 0 12px;
line-height: 1.8;
}
:deep(.result-table) {
width: 100%;
border-collapse: collapse;
}
:deep(.result-table th),
:deep(.result-table td) {
border: 1px solid #d1d5db;
padding: 6px 8px;
font-size: 12px;
}
</style>