chore: bootstrap storage migrations and frontend
This commit is contained in:
@@ -2,3 +2,10 @@
|
||||
.idea
|
||||
.idea/**
|
||||
node_modules
|
||||
dist
|
||||
frontend/dist
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.tsbuildinfo
|
||||
frontend/vite.config.js
|
||||
frontend/vite.config.d.ts
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
@@ -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** | |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,前端开发服务器已启动。
|
||||
@@ -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>
|
||||
Generated
+2012
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useAppStore = defineStore("app", {
|
||||
state: () => ({
|
||||
projectName: "AI 文档模板系统",
|
||||
saveStatus: "已保存",
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 3、Vite、Ant Design Vue、Router、Pinia、Axios 与全局样式变量已接入。</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>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user