chore: bootstrap storage migrations and frontend

This commit is contained in:
zwt13703
2026-07-01 20:25:37 +08:00
parent 393a170d3a
commit beec8cf5bb
25 changed files with 2817 additions and 101 deletions
+7
View File
@@ -2,3 +2,10 @@
.idea
.idea/**
node_modules
dist
frontend/dist
__pycache__
*.pyc
*.tsbuildinfo
frontend/vite.config.js
frontend/vite.config.d.ts
+40
View File
@@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = mysql+pymysql://root:root123@localhost:3306/ai_doc_template?charset=utf8mb4
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+62
View File
@@ -0,0 +1,62 @@
from logging.config import fileConfig
import os
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.database import Base
from app.models.block_config import BlockConfig
from app.models.template import Template
from app.models.template_block import TemplateBlock
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_database_url() -> str:
db_host = os.getenv("DB_HOST", "localhost")
db_port = os.getenv("DB_PORT", "3306")
db_user = os.getenv("DB_USER", "root")
db_pass = os.getenv("DB_PASS", "root123")
db_name = os.getenv("DB_NAME", "ai_doc_template")
return f"mysql+pymysql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}?charset=utf8mb4"
config.set_main_option("sqlalchemy.url", get_database_url())
def run_migrations_offline() -> None:
context.configure(
url=get_database_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,74 @@
"""create template tables
Revision ID: 20260701_0001
Revises:
Create Date: 2026-07-01 12:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260701_0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"template",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=200), nullable=False, comment="模板名称"),
sa.Column("type", sa.String(length=50), nullable=True, comment="报告类/公文类/总结类/合同类"),
sa.Column("version", sa.String(length=20), nullable=True, comment="版本号"),
sa.Column("original_file_path", sa.String(length=500), nullable=False, comment="原始 Word 文件路径"),
sa.Column("status", sa.Integer(), nullable=True, comment="1=启用 0=停用"),
sa.Column("created_by", sa.String(length=50), nullable=True, comment="创建人"),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"template_block",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("template_id", sa.Integer(), nullable=False, comment="所属模板"),
sa.Column("block_id", sa.String(length=50), nullable=False, comment="唯一标识 block_001"),
sa.Column("parent_block_id", sa.String(length=50), nullable=True, comment="父级 block_id"),
sa.Column("block_type", sa.String(length=20), nullable=False, comment="title/heading/paragraph/table"),
sa.Column("block_name", sa.String(length=200), nullable=True, comment="区域名称"),
sa.Column("text_preview", sa.String(length=500), nullable=True, comment="文本预览"),
sa.Column("level", sa.Integer(), nullable=True, comment="0=文档标题 1=一级 2=二级"),
sa.Column("sort_order", sa.Integer(), nullable=True, comment="排序号"),
sa.Column("table_rows", sa.Integer(), nullable=True, comment="表格行数"),
sa.Column("table_cols", sa.Integer(), nullable=True, comment="表格列数"),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["template_id"], ["template.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"block_config",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("template_id", sa.Integer(), nullable=False),
sa.Column("block_id", sa.String(length=50), nullable=False),
sa.Column("region_name", sa.String(length=200), nullable=True, comment="区域名称"),
sa.Column("region_type", sa.String(length=30), nullable=True, comment="ai_generate/manual/fixed/table"),
sa.Column("data_sources", sa.Text(), nullable=True, comment="数据来源 JSON 数组"),
sa.Column("prompt", sa.Text(), nullable=True, comment="提示词"),
sa.Column("output_format", sa.String(length=30), nullable=True, comment="输出格式"),
sa.Column("need_review", sa.Integer(), nullable=True, comment="1=需要审核"),
sa.Column("remark", sa.Text(), nullable=True, comment="备注"),
sa.Column("enabled", sa.Integer(), nullable=True, comment="1=启用"),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["template_id"], ["template.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("template_id", "block_id", name="uq_template_block"),
)
def downgrade() -> None:
op.drop_table("block_config")
op.drop_table("template_block")
op.drop_table("template")
+90 -10
View File
@@ -1,28 +1,108 @@
import os
from io import BytesIO
# 先实现本地文件存储,MinIO 作为选项
from minio import Minio
from minio.error import S3Error
STORAGE_BASE = os.getenv("LOCAL_STORAGE_PATH", "./data/files")
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
def save_file(content: bytes, file_path: str) -> str:
"""保存文件到本地存储,返回完整路径"""
full_path = os.path.join(STORAGE_BASE, file_path)
def _local_path(bucket: str, file_path: str) -> str:
return os.path.join(STORAGE_BASE, bucket, file_path)
def _save_local(bucket: str, file_path: str, content: bytes) -> str:
full_path = _local_path(bucket, file_path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "wb") as f:
f.write(content)
return full_path
def read_file(file_path: str) -> bytes:
"""读取文件内容"""
full_path = os.path.join(STORAGE_BASE, file_path)
with open(full_path, "rb") as f:
def _read_local(bucket: str, file_path: str) -> bytes:
with open(_local_path(bucket, file_path), "rb") as f:
return f.read()
def get_minio_client() -> Minio:
return Minio(
MINIO_ENDPOINT,
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=MINIO_SECURE,
)
def ensure_bucket(bucket_name: str) -> bool:
"""确保 bucket 存在;MinIO 不可用时返回 False 表示使用本地降级。"""
try:
client = get_minio_client()
if not client.bucket_exists(bucket_name):
client.make_bucket(bucket_name)
return True
except S3Error:
return False
except Exception:
return False
def upload_file(bucket: str, file_path: str, content: bytes) -> str:
"""上传文件到 MinIO;连接失败时降级保存到本地文件系统。"""
if ensure_bucket(bucket):
try:
client = get_minio_client()
client.put_object(
bucket,
file_path,
BytesIO(content),
length=len(content),
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
return f"minio://{bucket}/{file_path}"
except S3Error:
pass
except Exception:
pass
return _save_local(bucket, file_path, content)
def download_file(bucket: str, file_path: str) -> bytes:
"""从 MinIO 下载文件;读取失败时尝试本地降级路径。"""
if ensure_bucket(bucket):
try:
client = get_minio_client()
response = client.get_object(bucket, file_path)
try:
return response.read()
finally:
response.close()
response.release_conn()
except S3Error:
pass
except Exception:
pass
return _read_local(bucket, file_path)
def save_file(content: bytes, file_path: str) -> str:
"""兼容旧调用:保存到默认 templates bucket。"""
return upload_file("templates", file_path, content)
def read_file(file_path: str) -> bytes:
"""兼容旧调用:从默认 templates bucket 读取。"""
return download_file("templates", file_path)
def delete_file(file_path: str):
"""删除文件"""
full_path = os.path.join(STORAGE_BASE, file_path)
"""删除本地降级文件。MinIO 对象删除后续按接口需要再补充。"""
full_path = _local_path("templates", file_path)
if os.path.exists(full_path):
os.remove(full_path)
+1 -1
View File
@@ -20,7 +20,7 @@ services:
restart: unless-stopped
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioa...n
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000" # API
- "9001:9001" # Console
@@ -8,96 +8,96 @@
## 第一阶段:MVP 任务总表(73 个)
| # | 任务名 | 端 | 预估 |
|---|--------|:--:|:----:|
| | **环境搭建** | | |
| 001 | 初始化 FastAPI 项目 + main.py + health 接口 | 后端 | 0.25d |
| 002 | 配置 MySQL 连接 + SQLAlchemy session | 后端 | 0.25d |
| 003 | 配置 MinIO 客户端 | 后端 | 0.25d |
| 004 | Alembic 初始化 + 第一个迁移脚本 | 后端 | 0.25d |
| 005 | Docker Compose 编排(MySQL + MinIO | 后端 | 0.25d |
| 006 | 初始化 Vue3 + Vite 项目 | 前端 | 0.25d |
| 007 | 安装 Ant Design Vue + 全局注册 | 前端 | 0.25d |
| 008 | 安装 Vue Router + 配置路由结构 | 前端 | 0.25d |
| 009 | 安装 Pinia + 创建根 store | 前端 | 0.25d |
| 010 | 安装 Axios + 创建 API 客户端 base.ts | 前端 | 0.25d |
| 011 | 定义全局 CSS 变量 | 前端 | 0.25d |
| | **数据库建表(3 个)** | | |
| 012 | 创建 template 表 + SQLAlchemy Model | 后端 | 0.25d |
| 013 | 创建 template_block 表 + SQLAlchemy Model | 后端 | 0.25d |
| 014 | 创建 block_config 表 + SQLAlchemy Model(含 unique 约束) | 后端 | 0.25d |
| | **模板上传** | | |
| 015 | 实现文件接收 + .docx 格式校验 + 大小校验 | 后端 | 0.25d |
| 016 | 实现文件存储到 MinIO | 后端 | 0.25d |
| 017 | 实现 template 表 insert | 后端 | 0.25d |
| 018 | 组装上传接口 `POST /api/templates/upload` | 后端 | 0.25d |
| | **Word 解析** | | |
| 019 | 实现 docx 文件打开 + 逐段落遍历 | 后端 | 0.25d |
| 020 | 实现标题识别(Heading 1-6)→ type=heading | 后端 | 0.25d |
| 021 | 实现段落识别 → type=paragraph | 后端 | 0.25d |
| 022 | 实现表格识别 + 行数列数提取 → type=table | 后端 | 0.25d |
| 023 | 实现 block_id 生成器(顺序编号) | 后端 | 0.25d |
| 024 | 实现层级构建(parent_block_id+ 结构树 tree 输出 | 后端 | 0.25d |
| 025 | 将解析结果批量写入 template_block 表 | 后端 | 0.25d |
| | **HTML 预览生成** | | |
| 026 | 标题 → `<h2 data-block-id>` 转换 + 居中加粗样式 | 后端 | 0.25d |
| 027 | 段落 → `<p data-block-id>` 转换 + 首行缩进样式 | 后端 | 0.25d |
| 028 | 表格 → `<table data-block-id>` 转换 + 边框样式 | 后端 | 0.25d |
| 029 | 拼接完整 HTML 字符串(带内联 CSS | 后端 | 0.25d |
| | **查询 + 配置接口** | | |
| 030 | 实现 `GET /api/templates/{id}` 组装全部数据 | 后端 | 0.5d |
| 031 | 实现 `POST /api/templates/{id}/blocks/{blockId}/config` upsert | 后端 | 0.25d |
| 032 | 实现 `GET /api/data-sources` 返回预设列表 | 后端 | 0.25d |
| 033 | 实现 `POST /api/data-sources` 新增数据源 | 后端 | 0.25d |
| | **四栏布局** | | |
| 034 | 实现四栏 CSS Grid 布局 | 前端 | 0.5d |
| 035 | 实现左侧菜单 shell 组件 | 前端 | 0.25d |
| 036 | 实现顶部工具栏 shell 组件 | 前端 | 0.25d |
| 037 | 实现配置面板 shell 组件 | 前端 | 0.25d |
| 038 | 实现预览区 shell 组件(A4 纸效果) | 前端 | 0.25d |
| 039 | 实现结构树 shell 组件 | 前端 | 0.25d |
| | **左侧菜单** | | |
| 040 | 菜单数据模型(JSON+ 子菜单配置 | 前端 | 0.25d |
| 041 | 菜单展开/收起交互 + 箭头旋转动画 | 前端 | 0.25d |
| 042 | 菜单选中高亮(蓝色左侧竖条) | 前端 | 0.25d |
| | **顶部工具栏** | | |
| 043 | 面包屑导航渲染 | 前端 | 0.25d |
| 044 | 模板名称 + 版本号标签展示 | 前端 | 0.25d |
| 045 | 保存状态指示器(已保存/未保存 圆点切换) | 前端 | 0.25d |
| 046 | 操作按钮区(预览/保存/生成测试/导出模板/全屏) | 前端 | 0.25d |
| | **Word 预览区** | | |
| 047 | v-html 渲染后端 HTML + 为 [data-block-id] 添加 doc-block class | 前端 | 0.25d |
| 048 | 点击事件委托 + block_id 提取 | 前端 | 0.25d |
| 049 | 选中高亮(蓝色虚线 outline + 浅蓝背景) | 前端 | 0.25d |
| 050 | 区域类型颜色映射(蓝/黄/绿/紫/灰)+ 保存后更新 | 前端 | 0.5d |
| 051 | block_id 标签 absolute 定位 + hover 显示 | 前端 | 0.25d |
| 052 | 显示/隐藏标签 toggle 开关 | 前端 | 0.25d |
| 053 | 缩放控制(70%/100%/150% scale 切换) | 前端 | 0.25d |
| | **结构树** | | |
| 054 | 递归树组件 + 数据绑定 | 前端 | 0.5d |
| 055 | 展开/折叠交互 + 图标 | 前端 | 0.25d |
| 056 | 状态圆点(已配置绿/待审核黄/已禁用红/未配置灰) | 前端 | 0.25d |
| 057 | 点击树节点 → 预览区 scrollIntoView + 闪烁高亮 | 前端 | 0.5d |
| | **配置面板** | | |
| 058 | 区域名称 input(默认取 text_preview | 前端 | 0.25d |
| 059 | 区域类型 select | 前端 | 0.25d |
| 060 | 数据来源 tag chips 多选组件 | 前端 | 0.5d |
| 061 | 提示词 textarea + 字数统计 | 前端 | 0.5d |
| 062 | 输出格式 select | 前端 | 0.25d |
| 063 | 是否需要审核 radio | 前端 | 0.25d |
| 064 | 备注 textarea + 字数统计 | 前端 | 0.25d |
| 065 | 保存按钮 + loading 状态 + 成功/失败提示 | 前端 | 0.25d |
| 066 | 未选中区域时的空状态占位提示 | 前端 | 0.25d |
| | **Pinia Store** | | |
| 067 | templateStore | 前端 | 0.5d |
| 068 | selectionStore | 前端 | 0.25d |
| 069 | uiStore | 前端 | 0.25d |
| | **三区联动** | | |
| 070 | watch selectedBlockId → 配置面板加载 | 前端 | 0.5d |
| 071 | watch selectedBlockId → 结构树节点高亮 | 前端 | 0.25d |
| 072 | 页面初始化 loading(全页遮罩 + a-spin | 前端 | 0.25d |
| 073 | 加载失败错误页 + 重新加载按钮 | 前端 | 0.25d |
| | **总计** | | **~18.25d** |
| # | 任务名 | 端 | 预估 | 状态 |
|---|--------|:--:|:----:|:--:|
| | **环境搭建** | | | |
| 001 | 初始化 FastAPI 项目 + main.py + health 接口 | 后端 | 0.25d | ✅ 已完成 |
| 002 | 配置 MySQL 连接 + SQLAlchemy session | 后端 | 0.25d | ✅ 已完成 |
| 003 | 配置 MinIO 客户端 | 后端 | 0.25d | ✅ 已完成 |
| 004 | Alembic 初始化 + 第一个迁移脚本 | 后端 | 0.25d | ✅ 已完成 |
| 005 | Docker Compose 编排(MySQL + MinIO | 后端 | 0.25d | ✅ 已完成 |
| 006 | 初始化 Vue3 + Vite 项目 | 前端 | 0.25d | ✅ 已完成 |
| 007 | 安装 Ant Design Vue + 全局注册 | 前端 | 0.25d | ✅ 已完成 |
| 008 | 安装 Vue Router + 配置路由结构 | 前端 | 0.25d | ✅ 已完成 |
| 009 | 安装 Pinia + 创建根 store | 前端 | 0.25d | ✅ 已完成 |
| 010 | 安装 Axios + 创建 API 客户端 base.ts | 前端 | 0.25d | ✅ 已完成 |
| 011 | 定义全局 CSS 变量 | 前端 | 0.25d | ✅ 已完成 |
| | **数据库建表(3 个)** | | | |
| 012 | 创建 template 表 + SQLAlchemy Model | 后端 | 0.25d | ✅ 已完成 |
| 013 | 创建 template_block 表 + SQLAlchemy Model | 后端 | 0.25d | ✅ 已完成 |
| 014 | 创建 block_config 表 + SQLAlchemy Model(含 unique 约束) | 后端 | 0.25d | ✅ 已完成 |
| | **模板上传** | | | |
| 015 | 实现文件接收 + .docx 格式校验 + 大小校验 | 后端 | 0.25d | ⏳ 未完成 |
| 016 | 实现文件存储到 MinIO | 后端 | 0.25d | ⏳ 未完成 |
| 017 | 实现 template 表 insert | 后端 | 0.25d | ⏳ 未完成 |
| 018 | 组装上传接口 `POST /api/templates/upload` | 后端 | 0.25d | ⏳ 未完成 |
| | **Word 解析** | | | |
| 019 | 实现 docx 文件打开 + 逐段落遍历 | 后端 | 0.25d | ⏳ 未完成 |
| 020 | 实现标题识别(Heading 1-6)→ type=heading | 后端 | 0.25d | ⏳ 未完成 |
| 021 | 实现段落识别 → type=paragraph | 后端 | 0.25d | ⏳ 未完成 |
| 022 | 实现表格识别 + 行数列数提取 → type=table | 后端 | 0.25d | ⏳ 未完成 |
| 023 | 实现 block_id 生成器(顺序编号) | 后端 | 0.25d | ⏳ 未完成 |
| 024 | 实现层级构建(parent_block_id+ 结构树 tree 输出 | 后端 | 0.25d | ⏳ 未完成 |
| 025 | 将解析结果批量写入 template_block 表 | 后端 | 0.25d | ⏳ 未完成 |
| | **HTML 预览生成** | | | |
| 026 | 标题 → `<h2 data-block-id>` 转换 + 居中加粗样式 | 后端 | 0.25d | ⏳ 未完成 |
| 027 | 段落 → `<p data-block-id>` 转换 + 首行缩进样式 | 后端 | 0.25d | ⏳ 未完成 |
| 028 | 表格 → `<table data-block-id>` 转换 + 边框样式 | 后端 | 0.25d | ⏳ 未完成 |
| 029 | 拼接完整 HTML 字符串(带内联 CSS | 后端 | 0.25d | ⏳ 未完成 |
| | **查询 + 配置接口** | | | |
| 030 | 实现 `GET /api/templates/{id}` 组装全部数据 | 后端 | 0.5d | ⏳ 未完成 |
| 031 | 实现 `POST /api/templates/{id}/blocks/{blockId}/config` upsert | 后端 | 0.25d | ⏳ 未完成 |
| 032 | 实现 `GET /api/data-sources` 返回预设列表 | 后端 | 0.25d | ⏳ 未完成 |
| 033 | 实现 `POST /api/data-sources` 新增数据源 | 后端 | 0.25d | ⏳ 未完成 |
| | **四栏布局** | | | |
| 034 | 实现四栏 CSS Grid 布局 | 前端 | 0.5d | ⏳ 未完成 |
| 035 | 实现左侧菜单 shell 组件 | 前端 | 0.25d | ⏳ 未完成 |
| 036 | 实现顶部工具栏 shell 组件 | 前端 | 0.25d | ⏳ 未完成 |
| 037 | 实现配置面板 shell 组件 | 前端 | 0.25d | ⏳ 未完成 |
| 038 | 实现预览区 shell 组件(A4 纸效果) | 前端 | 0.25d | ⏳ 未完成 |
| 039 | 实现结构树 shell 组件 | 前端 | 0.25d | ⏳ 未完成 |
| | **左侧菜单** | | | |
| 040 | 菜单数据模型(JSON+ 子菜单配置 | 前端 | 0.25d | ⏳ 未完成 |
| 041 | 菜单展开/收起交互 + 箭头旋转动画 | 前端 | 0.25d | ⏳ 未完成 |
| 042 | 菜单选中高亮(蓝色左侧竖条) | 前端 | 0.25d | ⏳ 未完成 |
| | **顶部工具栏** | | | |
| 043 | 面包屑导航渲染 | 前端 | 0.25d | ⏳ 未完成 |
| 044 | 模板名称 + 版本号标签展示 | 前端 | 0.25d | ⏳ 未完成 |
| 045 | 保存状态指示器(已保存/未保存 圆点切换) | 前端 | 0.25d | ⏳ 未完成 |
| 046 | 操作按钮区(预览/保存/生成测试/导出模板/全屏) | 前端 | 0.25d | ⏳ 未完成 |
| | **Word 预览区** | | | |
| 047 | v-html 渲染后端 HTML + 为 [data-block-id] 添加 doc-block class | 前端 | 0.25d | ⏳ 未完成 |
| 048 | 点击事件委托 + block_id 提取 | 前端 | 0.25d | ⏳ 未完成 |
| 049 | 选中高亮(蓝色虚线 outline + 浅蓝背景) | 前端 | 0.25d | ⏳ 未完成 |
| 050 | 区域类型颜色映射(蓝/黄/绿/紫/灰)+ 保存后更新 | 前端 | 0.5d | ⏳ 未完成 |
| 051 | block_id 标签 absolute 定位 + hover 显示 | 前端 | 0.25d | ⏳ 未完成 |
| 052 | 显示/隐藏标签 toggle 开关 | 前端 | 0.25d | ⏳ 未完成 |
| 053 | 缩放控制(70%/100%/150% scale 切换) | 前端 | 0.25d | ⏳ 未完成 |
| | **结构树** | | | |
| 054 | 递归树组件 + 数据绑定 | 前端 | 0.5d | ⏳ 未完成 |
| 055 | 展开/折叠交互 + 图标 | 前端 | 0.25d | ⏳ 未完成 |
| 056 | 状态圆点(已配置绿/待审核黄/已禁用红/未配置灰) | 前端 | 0.25d | ⏳ 未完成 |
| 057 | 点击树节点 → 预览区 scrollIntoView + 闪烁高亮 | 前端 | 0.5d | ⏳ 未完成 |
| | **配置面板** | | | |
| 058 | 区域名称 input(默认取 text_preview | 前端 | 0.25d | ⏳ 未完成 |
| 059 | 区域类型 select | 前端 | 0.25d | ⏳ 未完成 |
| 060 | 数据来源 tag chips 多选组件 | 前端 | 0.5d | ⏳ 未完成 |
| 061 | 提示词 textarea + 字数统计 | 前端 | 0.5d | ⏳ 未完成 |
| 062 | 输出格式 select | 前端 | 0.25d | ⏳ 未完成 |
| 063 | 是否需要审核 radio | 前端 | 0.25d | ⏳ 未完成 |
| 064 | 备注 textarea + 字数统计 | 前端 | 0.25d | ⏳ 未完成 |
| 065 | 保存按钮 + loading 状态 + 成功/失败提示 | 前端 | 0.25d | ⏳ 未完成 |
| 066 | 未选中区域时的空状态占位提示 | 前端 | 0.25d | ⏳ 未完成 |
| | **Pinia Store** | | | |
| 067 | templateStore | 前端 | 0.5d | ⏳ 未完成 |
| 068 | selectionStore | 前端 | 0.25d | ⏳ 未完成 |
| 069 | uiStore | 前端 | 0.25d | ⏳ 未完成 |
| | **三区联动** | | | |
| 070 | watch selectedBlockId → 配置面板加载 | 前端 | 0.5d | ⏳ 未完成 |
| 071 | watch selectedBlockId → 结构树节点高亮 | 前端 | 0.25d | ⏳ 未完成 |
| 072 | 页面初始化 loading(全页遮罩 + a-spin | 前端 | 0.25d | ⏳ 未完成 |
| 073 | 加载失败错误页 + 重新加载按钮 | 前端 | 0.25d | ⏳ 未完成 |
| | **总计** | | **~18.25d** | |
---
+14
View File
@@ -0,0 +1,14 @@
# 任务执行摘要
## 会话 ID: 20260701-foundation-frontend
- [2026-07-01 20:19:33]
- **执行原因**: 根据原型图与像素级任务清单,按实施计划补齐当前项目缺失的基础设施任务。
- **执行过程**:
1. 对照任务清单核对现有代码,确认 001、002、005、012-014 已有基础实现,003、004、006-011 存在缺口。
2. 完善 `backend/app/services/storage.py`,新增 MinIO 客户端、`ensure_bucket``upload_file``download_file`,并保留本地文件存储降级与旧函数兼容。
3. 修正 `docker-compose.yml` 中 MinIO 默认密码,使其与后端默认客户端配置一致。
4. 新增 Alembic 配置、迁移环境与首个建表迁移,覆盖 `template``template_block``block_config` 三张表。
5. 新增 `frontend/` Vue 3 + TypeScript + Vite 工程,接入 Ant Design Vue、Vue Router、Pinia、Axios 与全局 CSS 变量。
6. 新增基础模板中心页面,用于验证 Ant Design Vue 按钮、路由渲染、Pinia 状态和全局样式变量接入。
7. 执行后端 Python 语法检查、前端依赖安装、前端生产构建与 Vite 服务可达性检查。
- **执行结果**: 完成任务 003、004、006、007、008、009、010、011 的基础实现;`npm run build` 通过,`http://localhost:5173/` 返回 200,前端开发服务器已启动。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 文档模板系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2012
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ai-doc-template-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@vitejs/plugin-vue": "^5.2.4",
"ant-design-vue": "^4.2.6",
"axios": "^1.7.9",
"pinia": "^2.3.1",
"vite": "^5.4.11",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2",
"vue-tsc": "^2.2.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+14
View File
@@ -0,0 +1,14 @@
import axios from "axios";
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000/api",
timeout: 15000,
headers: {
"Content-Type": "application/json",
},
});
apiClient.interceptors.response.use(
(response) => response,
(error) => Promise.reject(error),
);
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";
import App from "./App.vue";
import router from "./router";
import "./styles/variables.css";
import "./styles/global.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(Antd);
app.mount("#app");
+16
View File
@@ -0,0 +1,16 @@
import { createRouter, createWebHistory } from "vue-router";
import TemplateCenter from "@/views/TemplateCenter.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
name: "template-center",
component: TemplateCenter,
},
],
});
export default router;
+8
View File
@@ -0,0 +1,8 @@
import { defineStore } from "pinia";
export const useAppStore = defineStore("app", {
state: () => ({
projectName: "AI 文档模板系统",
saveStatus: "已保存",
}),
});
+23
View File
@@ -0,0 +1,23 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 1024px;
min-height: 100vh;
color: var(--c-text);
background: var(--c-bg);
font-family: var(--font-app);
font-size: 13px;
-webkit-font-smoothing: antialiased;
}
button,
input,
textarea,
select {
font: inherit;
}
+19
View File
@@ -0,0 +1,19 @@
:root {
--c-primary: #5b5bd6;
--c-primary-hover: #4a4ac0;
--c-primary-soft: #eeeefb;
--c-bg: #f5f6f8;
--c-surface: #ffffff;
--c-surface-soft: #f0f1f3;
--c-border: #e0e2e6;
--c-border-light: #eaecef;
--c-text: #1a1d24;
--c-text-secondary: #5b626e;
--c-text-muted: #9aa1ad;
--c-success: #1a8c4a;
--c-warn: #d48a00;
--radius-sm: 4px;
--radius-md: 6px;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 6%);
--font-app: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
}
+214
View File
@@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed } from "vue";
import { FileTextOutlined, PlusOutlined } from "@ant-design/icons-vue";
import { useAppStore } from "@/stores/appStore";
const appStore = useAppStore();
const statusClass = computed(() => (appStore.saveStatus === "已保存" ? "saved" : "dirty"));
</script>
<template>
<main class="template-center">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">AI</div>
<span>{{ appStore.projectName }}</span>
</div>
<nav class="nav-list">
<button class="nav-item active" type="button">
<FileTextOutlined />
模板中心
</button>
</nav>
</aside>
<section class="workspace">
<header class="toolbar">
<div>
<div class="breadcrumb">模板管理 / 模板中心</div>
<h1>模板中心</h1>
</div>
<div class="toolbar-actions">
<span class="save-status" :class="statusClass">{{ appStore.saveStatus }}</span>
<a-button type="primary">
<template #icon><PlusOutlined /></template>
上传模板
</a-button>
</div>
</header>
<section class="content">
<div class="empty-panel">
<FileTextOutlined class="empty-icon" />
<h2>前端基础工程已就绪</h2>
<p>Vue 3ViteAnt Design VueRouterPiniaAxios 与全局样式变量已接入</p>
</div>
</section>
</section>
</main>
</template>
<style scoped>
.template-center {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 220px;
flex: 0 0 220px;
border-right: 1px solid var(--c-border);
background: var(--c-surface);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
height: 52px;
padding: 0 16px;
border-bottom: 1px solid var(--c-border);
font-weight: 600;
}
.brand-mark {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border-radius: 5px;
color: #fff;
background: var(--c-primary);
font-size: 13px;
font-weight: 700;
}
.nav-list {
padding: 8px 0;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
width: 100%;
gap: 8px;
padding: 8px 16px;
border: 0;
color: var(--c-text-secondary);
background: transparent;
cursor: pointer;
text-align: left;
}
.nav-item.active {
color: var(--c-primary);
background: var(--c-primary-soft);
font-weight: 500;
}
.nav-item.active::before {
position: absolute;
top: 4px;
bottom: 4px;
left: 0;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--c-primary);
content: "";
}
.workspace {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 24px;
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
}
.breadcrumb {
color: var(--c-text-muted);
font-size: 12px;
}
h1 {
margin: 2px 0 0;
font-size: 18px;
line-height: 1.3;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.save-status {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--c-text-secondary);
font-size: 12px;
}
.save-status::before {
width: 7px;
height: 7px;
border-radius: 50%;
content: "";
}
.save-status.saved::before {
background: var(--c-success);
}
.save-status.dirty::before {
background: var(--c-warn);
}
.content {
flex: 1;
padding: 24px;
overflow: auto;
}
.empty-panel {
display: grid;
min-height: 320px;
place-items: center;
align-content: center;
gap: 8px;
border: 1px dashed var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface);
color: var(--c-text-secondary);
box-shadow: var(--shadow-sm);
}
.empty-icon {
color: var(--c-primary);
font-size: 30px;
}
.empty-panel h2 {
margin: 4px 0 0;
color: var(--c-text);
font-size: 16px;
}
.empty-panel p {
margin: 0;
color: var(--c-text-muted);
}
</style>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from "node:url";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
host: "0.0.0.0",
port: 5173,
},
});