diff --git a/.env.example b/.env.example index 29abc7c..2d5da98 100644 --- a/.env.example +++ b/.env.example @@ -10,19 +10,17 @@ ENVIRONMENT=local # 用于 Session 签名、Fernet 加密 Secret Token 等全部密码学用途 SECRET_KEY=change-me-run-python-c-import-secrets-token_urlsafe-48 -# --- 数据库 --- -# SQLite(开发/生产 MVP 均可): -DATABASE_URL=sqlite:///./data/app.db -# 未来 PostgreSQL: -# DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/amb_rag +# --- 数据库 MySQL --- +# 开发环境(远程 MySQL) +DATABASE_URL=mysql+pymysql://user:password@host:port/database?charset=utf8mb4 +# 生产环境(本地 MySQL) +# DATABASE_URL=mysql+pymysql://root:password@127.0.0.1:3306/amb_rag?charset=utf8mb4 +# SQLite 备用(本地开发测试) +# DATABASE_URL=sqlite:///./data/app.db # --- 文件存储 --- # 本地文件系统根目录(相对于 backend/ 工作目录) STORAGE_ROOT=./data -# 未来 MinIO: -# MINIO_ENDPOINT=localhost:9000 -# MINIO_ACCESS_KEY=change-me -# MINIO_SECRET_KEY=change-me # --- 套餐 --- # 免费用户存储配额(字节) @@ -37,4 +35,4 @@ RATE_LIMIT_PER_IP_PER_MIN=30 # --- 前端 --- FRONTEND_ORIGIN=http://localhost:5173 -VITE_API_BASE_URL=/api \ No newline at end of file +VITE_API_BASE_URL=/api diff --git a/CLAUDE.md b/CLAUDE.md index 5e4f7b2..a75154d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,15 +12,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Tech Stack (MVP v1) -- **Backend**: Python 3.12+(本机 venv 3.13)、FastAPI、SQLAlchemy 2.x(Mapped 风格, 同步引擎 + SQLite)、Pydantic v2、Alembic、Jinja2(公共页 SSR) +- **Backend**: Python 3.12+(本机 venv 3.13)、FastAPI、SQLAlchemy 2.x(Mapped 风格, 同步引擎 + MySQL)、Pydantic v2、Alembic、Jinja2(公共页 SSR) - **Frontend**: Vue 3 + TypeScript + Vite + Element Plus + Pinia + Vue Router + Axios -- **Storage**: SQLite(元数据)+ 服务器本地文件系统(原始文件 + Markdown) +- **Database**: MySQL 8.0(utf8mb4,远程开发 47.109.98.44:33306 / 本地生产 127.0.0.1:3306) +- **Storage**: 服务器本地文件系统(原始文件 + Markdown) - **Parsing**: MarkItDown 优先, PyMuPDF(PDF fallback, AGPL—商品化前需替换为 pypdfium2), python-docx(DOCX fallback) -- **Search**: SQLite FTS5 + KeywordRetriever 抽象 -- **Auth**: Argon2id 密码哈希 + 服务端 Session(内存/SQLite) +- **Search**: SQLAlchemy LIKE 搜索 + Retriever 抽象(可升级 MySQL FULLTEXT) +- **Auth**: Argon2id 密码哈希 + 服务端 Session(内存)+ 用户角色(internal/customer) - **Rate Limit**: 内存 TokenBucket(单进程) -**v1 明确不依赖**: PostgreSQL, Redis, Celery, MinIO, Qdrant, Elasticsearch, Kafka +**v1 明确不依赖**: Redis, Celery, MinIO, Qdrant, Elasticsearch, Kafka, PostgreSQL ## Environment diff --git a/README.md b/README.md index 2634d2a..eca475f 100644 --- a/README.md +++ b/README.md @@ -7,266 +7,156 @@ → 把链接发给任意 AI → AI 读取目录并按描述访问具体文档 ``` -> 状态:**MVP 开发完成(Phase 1-14)**。完整架构与技术决策见 [docs/technical-review.md](docs/technical-review.md);需求清单见 [docs/requirements.md](docs/requirements.md)。 - -## 技术栈(MVP v1) +## 技术栈 | 层 | 技术 | |---|---| | 后端 | Python 3.12+ · FastAPI · SQLAlchemy 2 · Pydantic v2 · Alembic · Jinja2 | | 前端 | Vue 3 · TypeScript · Vite · Element Plus · Pinia | -| 存储 | SQLite(元数据)+ 本地文件系统(原始文件 + Markdown) | +| 数据库 | MySQL 8.0(utf8mb4) | +| 存储 | 本地文件系统(原始文件 + Markdown) | | 解析 | MarkItDown(优先)· PyMuPDF(PDF fallback)· python-docx(DOCX fallback) | -| 搜索 | SQLite LIKE(可升级 FTS5) | +| 搜索 | SQLAlchemy LIKE(可升级 FTS5 / MySQL FULLTEXT) | -## 目录结构 - -``` -backend/ FastAPI 应用(api 管理端 / public AI公共端 / processors 解析 / retrieval 检索 / storage 存储) -frontend/ Vue 3 管理后台 -nginx/ 反向代理配置(生产) -data/ SQLite + 用户文件(.gitignore 排除,仅保留 .gitkeep) -docs/ 架构与技术文档 -``` - -## 快速开始(Windows 开发) - -> 无需 Docker,直接运行。 +## 快速开始 ### 后端 ```bash cd backend python -m venv .venv -# Git Bash: -source .venv/Scripts/activate -# 或 CMD: -.venv\Scripts\activate - +source .venv/Scripts/activate # Windows Git Bash pip install -r requirements.txt -alembic upgrade head -uvicorn app.main:app --reload --port 8000 -``` -- API 文档:http://localhost:8000/api/docs -- 健康检查:http://localhost:8000/api/health +# 配置 .env(参考 .env.example) +# DATABASE_URL=mysql+pymysql://user:pass@host:port/db?charset=utf8mb4 + +# 创建数据库(首次) +python -c "import pymysql; conn=pymysql.connect(host='HOST',port=PORT,user='USER',password='PASS'); conn.cursor().execute('CREATE DATABASE IF NOT EXISTS amb_rag CHARACTER SET utf8mb4'); conn.close()" + +# 迁移 +alembic upgrade head + +# 启动(监听所有网络,手机可访问) +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` ### 前端 ```bash cd frontend npm install -npm run dev +npm run dev # 监听 0.0.0.0:5173,手机可访问 ``` -- 访问:http://localhost:5173 -- Vite 代理:`/api` 和 `/k` → `localhost:8000` - -### 测试 +### 创建内部员工账号 ```bash cd backend -python -m pytest tests/ -v +python -c " +from app.core.db import get_session_factory +from app.models.user import User +from app.models.plan import Plan +from app.core.security import hash_password +from sqlalchemy import select + +factory = get_session_factory() +with factory() as session: + plan = session.scalars(select(Plan).where(Plan.code == 'free')).first() + user = User( + username='admin', + email='admin@company.com', + password_hash=hash_password('your_password'), + status='active', + role='internal', + plan_id=plan.id, + ) + session.add(user) + session.commit() + print('Internal user created') +" ``` +## 登录入口 + +| 入口 | 地址 | 说明 | +|---|---|---| +| 客户登录 | `/login` | 所有用户可用 | +| 内部登录 | `/internal-login` | 仅 role=internal 用户 | +| 注册 | `/register` | 默认创建 customer 用户 | + ## 生产部署(Ubuntu + Docker) -### 1. 安装 Docker - ```bash -# 更新系统 -sudo apt update && sudo apt upgrade -y - -# 安装 Docker +# 1. 安装 Docker curl -fsSL https://get.docker.com | sh -sudo usermod -aG docker $USER -newgrp docker -# 验证 -docker --version -docker compose version -``` - -### 2. 克隆项目 - -```bash -sudo mkdir -p /opt/ai-knowledge-link -sudo chown $USER:$USER /opt/ai-knowledge-link +# 2. 克隆项目 git clone /opt/ai-knowledge-link cd /opt/ai-knowledge-link -``` -### 3. 配置环境 - -```bash +# 3. 配置 cp .env.example .env +nano .env # 修改 MySQL 连接、SECRET_KEY 等 -# 编辑 .env,修改以下关键配置: -nano .env -``` +# 4. 启动 MySQL(如未安装) +docker run -d --name mysql \ + -e MYSQL_ROOT_PASSWORD=your_password \ + -p 3306:3306 \ + -v mysql_data:/var/lib/mysql \ + mysql:8.0 --character-set-server=utf8mb4 -必须修改的配置: +# 5. 创建数据库 +docker exec -it mysql mysql -uroot -p -e "CREATE DATABASE amb_rag CHARACTER SET utf8mb4" -```env -# 生成密钥:python -c "import secrets; print(secrets.token_urlsafe(48))" -SECRET_KEY= -ENVIRONMENT=production -``` - -### 4. 创建数据目录 - -```bash -mkdir -p data -``` - -### 5. 启动服务 - -```bash +# 6. 启动应用 docker compose up -d --build + +# 7. 迁移 +docker compose exec backend alembic upgrade head ``` -### 6. 验证部署 +## 备份(MySQL) ```bash -# 检查容器状态 -docker compose ps - -# 检查日志 -docker compose logs -f - -# 健康检查 -curl http://localhost/api/healthz -``` - -### 7. 访问 - -- 前端:http://your-server-ip -- API 文档:http://your-server-ip/api/docs(仅开发环境) -- 公共页面:http://your-server-ip/k/{token} - -### 8. 更新部署 - -```bash -cd /opt/ai-knowledge-link -git pull -docker compose up -d --build -``` - -数据不会因为更新丢失(`./data/` 是宿主机 volume)。 - -### 9. 备份 - -```bash -# 备份 SQLite 数据库 -sqlite3 data/app.db ".backup data/backup/app_$(date +%Y%m%d).db" +# 每日备份 +mysqldump -h HOST -P PORT -u USER -p amb_rag > backup/app_$(date +%Y%m%d).sql # 备份用户文件 -tar czf data/backup/files_$(date +%Y%m%d).tar.gz data/users/ +tar czf backup/files_$(date +%Y%m%d).tar.gz data/users/ -# 保留最近 7 天备份 -find data/backup/ -name "*.db" -mtime +7 -delete -find data/backup/ -name "*.tar.gz" -mtime +7 -delete +# 恢复 +mysql -h HOST -P PORT -u USER -p amb_rag < backup/app_20260901.sql ``` -### 10. 恢复 - -```bash -# 恢复数据库 -cp data/backup/app_20260901.db data/app.db - -# 恢复用户文件 -tar xzf data/backup/files_20260901.tar.gz -C / -``` - -## Nginx 配置(生产) - -如果使用独立 Nginx(非 Docker),参考 `nginx/nginx.conf`: - -```nginx -server { - listen 80; - server_name your-domain.com; - - client_max_body_size 25M; - - # 前端静态文件 - location / { - root /opt/ai-knowledge-link/frontend/dist; - try_files $uri $uri/ /index.html; - } - - # API 代理 - location /api/ { - proxy_pass http://127.0.0.1:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_read_timeout 60s; - } - - # 公共 AI 页面代理 - location /k/ { - proxy_pass http://127.0.0.1:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - add_header Cache-Control "private, no-cache, no-store" always; - add_header X-Robots-Tag "noindex, nofollow, noarchive" always; - } -} -``` - -## 安全须知 - -- `.env` 携带全部密钥,**绝不提交 Git** -- 知识库 Secret URL 即访问凭证(链接即钥匙),请勿公开传播 -- 公共 AI 页面不依赖 JS/Cookie/登录;含 `noindex` meta + robots.txt 屏蔽 -- 生产环境必须设置 `ENVIRONMENT=production`(启用 Cookie Secure、禁用 Swagger) - ## API 概览 ### 管理端(需登录) | 方法 | 路径 | 说明 | |---|---|---| -| POST | /api/auth/register | 注册 | -| POST | /api/auth/login | 登录 | +| POST | /api/auth/register | 注册(customer) | +| POST | /api/auth/login | 普通登录 | +| POST | /api/auth/internal-login | 内部登录 | | POST | /api/auth/logout | 登出 | -| GET | /api/auth/me | 当前用户信息 | -| PATCH | /api/auth/me | 修改密码 | -| GET | /api/auth/storage | 存储用量 | +| GET | /api/auth/me | 用户信息 | | GET | /api/knowledge-bases | 知识库列表 | | POST | /api/knowledge-bases | 创建知识库 | -| GET | /api/knowledge-bases/{id} | 知识库详情 | -| PUT | /api/knowledge-bases/{id} | 编辑知识库 | -| DELETE | /api/knowledge-bases/{id} | 删除知识库 | -| POST | /api/knowledge-bases/{id}/regenerate-token | 重新生成链接 | -| POST | /api/knowledge-bases/{id}/enable | 启用链接 | -| POST | /api/knowledge-bases/{id}/disable | 禁用链接 | -| GET | /api/knowledge-bases/{id}/link | 获取完整链接 | -| GET | /api/knowledge-bases/{id}/categories | 分类列表 | -| POST | /api/knowledge-bases/{id}/categories | 创建分类 | -| GET | /api/documents?kb_id= | 文档列表 | +| GET | /api/knowledge-bases/{id}/categories/tree | 目录树 | | POST | /api/documents/upload | 上传文档 | -| GET | /api/documents/{id} | 文档详情 | -| PUT | /api/documents/{id} | 编辑文档 | -| DELETE | /api/documents/{id} | 删除文档 | -| POST | /api/documents/{id}/reprocess | 重新解析 | +| POST | /api/documents/create-text | 创建文本 | ### 公共 AI 端(无需登录) | 方法 | 路径 | 说明 | |---|---|---| -| GET | /k/{token} | 知识库首页(HTML) | -| GET | /k/{token}.md | 知识库首页(Markdown) | -| GET | /k/{token}.txt | 知识库首页(纯文本) | -| GET | /k/{token}.json | 知识库首页(JSON) | -| GET | /k/{token}/search?q= | 搜索文档 | -| GET | /k/{token}/doc/{doc_token} | 文档页面(HTML) | -| GET | /k/{token}/doc/{doc_token}.md | 文档(Markdown) | -| GET | /k/{token}/doc/{doc_token}.txt | 文档(纯文本) | +| GET | /k/{token} | 知识库全部内容(HTML,按目录分组) | +| GET | /k/{token}.json | JSON 格式(按目录分组含文档) | +| GET | /k/{token}.md | Markdown 格式 | +| GET | /k/{token}.txt | 纯文本格式 | +| GET | /k/{token}/search?q= | 搜索 | ## 文档 -- [技术审查报告](docs/technical-review.md) — 架构、ER、API、安全模型、风险清单、6 个扩展接口 -- [需求清单](docs/requirements.md) — 完整需求与完成状态 +- [技术审查报告](docs/technical-review.md) +- [需求清单](docs/requirements.md) diff --git a/backend/alembic/versions/4bd4c7f26818_add_category_tree_and_text_content.py b/backend/alembic/versions/4bd4c7f26818_add_category_tree_and_text_content.py index ab1d355..cb664be 100644 --- a/backend/alembic/versions/4bd4c7f26818_add_category_tree_and_text_content.py +++ b/backend/alembic/versions/4bd4c7f26818_add_category_tree_and_text_content.py @@ -20,7 +20,7 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # document_categories 新增字段 op.add_column('document_categories', sa.Column('parent_id', sa.String(length=32), nullable=True, comment='父分类 ID(NULL = 顶层)')) - op.add_column('document_categories', sa.Column('path', sa.Text(), server_default='/', nullable=False, comment='物化路径')) + op.add_column('document_categories', sa.Column('path', sa.String(length=500), server_default='/', nullable=False, comment='物化路径')) op.add_column('document_categories', sa.Column('is_folder', sa.Boolean(), server_default=sa.text('1'), nullable=False, comment='True=文件夹')) op.create_index(op.f('ix_document_categories_parent_id'), 'document_categories', ['parent_id'], unique=False) @@ -28,7 +28,7 @@ def upgrade() -> None: op.add_column('documents', sa.Column('content', sa.Text(), nullable=True, comment='直接输入的文本内容')) op.add_column('documents', sa.Column('content_format', sa.String(length=16), server_default='markdown', nullable=False, comment='内容格式')) - # SQLite 不支持 ALTER COLUMN,用 batch mode 重建表 + # ALTER COLUMN(MySQL 直接支持,SQLite 需要 batch mode) with op.batch_alter_table('documents', schema=None) as batch_op: batch_op.alter_column('storage_path', existing_type=sa.String(1024), nullable=True) batch_op.alter_column('sha256', existing_type=sa.String(64), nullable=True) diff --git a/backend/alembic/versions/4e40432cab9f_add_user_role.py b/backend/alembic/versions/4e40432cab9f_add_user_role.py new file mode 100644 index 0000000..6d4ec6e --- /dev/null +++ b/backend/alembic/versions/4e40432cab9f_add_user_role.py @@ -0,0 +1,125 @@ +"""add_user_role + +Revision ID: 4e40432cab9f +Revises: 4bd4c7f26818 +Create Date: 2026-09-02 10:29:41.369850 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '4e40432cab9f' +down_revision: Union[str, None] = '4bd4c7f26818' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('document_categories', 'path', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='物化路径,如 /01_公司层/04_岗位AI角色/', + existing_comment='物化路径', + existing_nullable=False, + existing_server_default=sa.text("'/'")) + op.alter_column('document_categories', 'is_folder', + existing_type=mysql.TINYINT(display_width=1), + comment='True=文件夹(可含子项),False=叶子分类', + existing_comment='True=文件夹', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.create_foreign_key(None, 'document_categories', 'document_categories', ['parent_id'], ['id']) + op.alter_column('documents', 'original_filename', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=512), + comment='原始文件名或文本标题 (仅展示)', + existing_comment='原始文件名 (仅展示)', + existing_nullable=False) + op.alter_column('documents', 'storage_path', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=1024), + comment='相对于 data/ 的物理存储路径(文本内容文档为 NULL)', + existing_nullable=True) + op.alter_column('documents', 'content', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='直接输入的文本内容(非文件上传时使用)', + existing_comment='直接输入的文本内容', + existing_nullable=True) + op.alter_column('documents', 'content_format', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=16), + comment='内容格式:markdown / text', + existing_comment='内容格式', + existing_nullable=False, + existing_server_default=sa.text("'markdown'")) + op.alter_column('documents', 'file_size', + existing_type=mysql.INTEGER(), + comment='文件大小 (字节),文本内容文档为内容长度', + existing_comment='文件大小 (字节)', + existing_nullable=False) + op.alter_column('documents', 'file_ext', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=16), + comment='文件扩展名 (.docx/.pdf/.md)', + existing_comment='文件扩展名 (.docx/.pdf)', + existing_nullable=False) + op.alter_column('documents', 'sha256', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=64), + comment='文件 SHA-256 哈希(文本内容文档为 NULL)', + existing_nullable=True) + op.add_column('users', sa.Column('role', sa.String(length=16), nullable=False, comment='角色 (internal=内部员工 / customer=客户)')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'role') + op.alter_column('documents', 'sha256', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=64), + comment=None, + existing_comment='文件 SHA-256 哈希(文本内容文档为 NULL)', + existing_nullable=True) + op.alter_column('documents', 'file_ext', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=16), + comment='文件扩展名 (.docx/.pdf)', + existing_comment='文件扩展名 (.docx/.pdf/.md)', + existing_nullable=False) + op.alter_column('documents', 'file_size', + existing_type=mysql.INTEGER(), + comment='文件大小 (字节)', + existing_comment='文件大小 (字节),文本内容文档为内容长度', + existing_nullable=False) + op.alter_column('documents', 'content_format', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=16), + comment='内容格式', + existing_comment='内容格式:markdown / text', + existing_nullable=False, + existing_server_default=sa.text("'markdown'")) + op.alter_column('documents', 'content', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='直接输入的文本内容', + existing_comment='直接输入的文本内容(非文件上传时使用)', + existing_nullable=True) + op.alter_column('documents', 'storage_path', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=1024), + comment=None, + existing_comment='相对于 data/ 的物理存储路径(文本内容文档为 NULL)', + existing_nullable=True) + op.alter_column('documents', 'original_filename', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=512), + comment='原始文件名 (仅展示)', + existing_comment='原始文件名或文本标题 (仅展示)', + existing_nullable=False) + op.drop_constraint(None, 'document_categories', type_='foreignkey') + op.alter_column('document_categories', 'is_folder', + existing_type=mysql.TINYINT(display_width=1), + comment='True=文件夹', + existing_comment='True=文件夹(可含子项),False=叶子分类', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('document_categories', 'path', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='物化路径', + existing_comment='物化路径,如 /01_公司层/04_岗位AI角色/', + existing_nullable=False, + existing_server_default=sa.text("'/'")) + # ### end Alembic commands ### diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 7570d5b..d1973d1 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -21,22 +21,48 @@ router = APIRouter(prefix="/auth", tags=["auth"]) @router.post("/register", response_model=UserResponse, status_code=201) def register(body: RegisterRequest, response: Response, db: Session = Depends(get_db)) -> UserResponse: - """注册新用户并自动登录。""" + """注册新用户(默认 role=customer)并自动登录。""" auth_service = AuthService(db) - user, token = auth_service.register(body.username, body.email, body.password) + user, token = auth_service.register(body.username, body.email, body.password, role="customer") + response.set_cookie(value=token, **get_cookie_params()) + return _user_response(user) + + +@router.post("/register-internal", response_model=UserResponse, status_code=201) +def register_internal( + body: RegisterRequest, + response: Response, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> UserResponse: + """注册内部员工账号(仅已登录的内部用户可调用)。""" + if current_user.role != "internal": + from app.core.errors import PermissionDeniedError + raise PermissionDeniedError("仅内部员工可创建内部账号。") + auth_service = AuthService(db) + user, token = auth_service.register(body.username, body.email, body.password, role="internal") response.set_cookie(value=token, **get_cookie_params()) return _user_response(user) @router.post("/login", response_model=UserResponse) def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)) -> UserResponse: - """登录。""" + """普通登录(所有用户可用)。""" auth_service = AuthService(db) user, token = auth_service.login(body.username_or_email, body.password) response.set_cookie(value=token, **get_cookie_params()) return _user_response(user) +@router.post("/internal-login", response_model=UserResponse) +def internal_login(body: LoginRequest, response: Response, db: Session = Depends(get_db)) -> UserResponse: + """内部登录(仅 role=internal 用户可用)。""" + auth_service = AuthService(db) + user, token = auth_service.internal_login(body.username_or_email, body.password) + response.set_cookie(value=token, **get_cookie_params()) + return _user_response(user) + + @router.post("/logout", status_code=204) def logout( response: Response, @@ -70,6 +96,7 @@ def get_me(user: User = Depends(get_current_user)) -> MeResponse: username=user.username, email=user.email, status=user.status, + role=user.role, storage_used=user.storage_used, storage_quota=settings.default_storage_quota, created_at=user.created_at, @@ -94,6 +121,7 @@ def update_me( username=user.username, email=user.email, status=user.status, + role=user.role, storage_used=user.storage_used, storage_quota=settings.default_storage_quota, created_at=user.created_at, @@ -123,6 +151,7 @@ def _user_response(user: User) -> UserResponse: username=user.username, email=user.email, status=user.status, + role=user.role, storage_used=user.storage_used, storage_quota=settings.default_storage_quota, created_at=user.created_at, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d8fd65a..102f3b9 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,8 +1,8 @@ """全局配置:pydantic-settings,全部来自环境变量 / .env。 -规则(docs/technical-review.md §1.3): +规则: - 密钥不得硬编码;SECRET_KEY 缺失或仍为模板值时,生产环境拒绝启动。 -- DATABASE_URL 可切换 PostgreSQL(扩展接口 1)。 +- DATABASE_URL 支持 MySQL / SQLite。 """ from functools import lru_cache @@ -22,7 +22,7 @@ class Settings(BaseSettings): secret_key: str = Field(min_length=16) # --- 数据库 --- - database_url: str = "sqlite:///./data/app.db" + database_url: str = "mysql+pymysql://admin:Lzcc6-01@47.109.98.44:33306/amb_rag?charset=utf8mb4" # --- 文件存储 --- storage_root: str = "./data" @@ -42,6 +42,14 @@ class Settings(BaseSettings): def is_production(self) -> bool: return self.environment == "production" + @property + def is_mysql(self) -> bool: + return "mysql" in self.database_url + + @property + def is_sqlite(self) -> bool: + return "sqlite" in self.database_url + @property def storage_root_path(self) -> Path: return Path(self.storage_root).resolve() @@ -63,4 +71,4 @@ class Settings(BaseSettings): def get_settings() -> Settings: s = Settings() # type: ignore[call-arg] s.validate_secrets() - return s \ No newline at end of file + return s diff --git a/backend/app/core/db.py b/backend/app/core/db.py index 07ec242..007b13e 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,18 +1,17 @@ -"""SQLAlchemy 2.x 同步引擎 + 会话管理(MVP: SQLite)。 +"""SQLAlchemy 2.x 同步引擎 + 会话管理。 -切换 PostgreSQL 时(扩展接口 1): -1. DATABASE_URL 改为 postgresql+asyncpg://... -2. 引擎换 create_async_engine + async_sessionmaker -3. get_session 改 async generator + yield -4. 业务层 Repository 调用加 await +支持 MySQL / SQLite,通过 DATABASE_URL 自动切换。 """ from collections.abc import Generator -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event, text from sqlalchemy.orm import Session, sessionmaker from app.core.config import get_settings +from app.core.logging import get_logger + +logger = get_logger(__name__) _engine = None _session_factory: sessionmaker[Session] | None = None @@ -22,24 +21,48 @@ def get_engine(): global _engine if _engine is None: settings = get_settings() - _engine = create_engine( - settings.database_url, - pool_pre_ping=True, - echo=False, - # SQLite 专属:启用 WAL 模式(并发读 + 写串行化) - connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {}, - ) - # SQLite: 启用 WAL 模式与外键约束 - if "sqlite" in settings.database_url: - from sqlalchemy import event, text + engine_kwargs = { + "pool_pre_ping": True, + "echo": False, + } + + if settings.is_mysql: + # MySQL 配置 + engine_kwargs.update({ + "pool_size": 10, + "max_overflow": 20, + "pool_recycle": 3600, # 1 小时回收连接,防止 MySQL 超时断开 + "connect_args": { + "charset": "utf8mb4", + }, + }) + elif settings.is_sqlite: + # SQLite 配置 + engine_kwargs["connect_args"] = {"check_same_thread": False} + + _engine = create_engine(settings.database_url, **engine_kwargs) + + # SQLite: 启用 WAL 模式 + 外键约束 + if settings.is_sqlite: @event.listens_for(_engine, "connect") - def _set_sqlite_pragma(dbapi_conn, _): # type: ignore[no-untyped-def] + def _set_sqlite_pragma(dbapi_conn, _): cursor = dbapi_conn.cursor() cursor.execute("PRAGMA journal_mode=WAL") cursor.execute("PRAGMA foreign_keys=ON") cursor.close() + # MySQL: 设置字符集和 SQL 模式 + if settings.is_mysql: + @event.listens_for(_engine, "connect") + def _set_mysql_session(dbapi_conn, _): + cursor = dbapi_conn.cursor() + cursor.execute("SET NAMES utf8mb4") + cursor.execute("SET SESSION sql_mode='STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO'") + cursor.close() + + logger.info("Database engine created: %s", "MySQL" if settings.is_mysql else "SQLite") + return _engine @@ -73,4 +96,4 @@ def dispose_engine() -> None: if _engine is not None: _engine.dispose() _engine = None - _session_factory = None \ No newline at end of file + _session_factory = None diff --git a/backend/app/models/document_category.py b/backend/app/models/document_category.py index 36e6884..0511b8e 100644 --- a/backend/app/models/document_category.py +++ b/backend/app/models/document_category.py @@ -35,7 +35,7 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base): comment="分类名称", ) path: Mapped[str] = mapped_column( - Text, + String(500), default="/", nullable=False, comment="物化路径,如 /01_公司层/04_岗位AI角色/", diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 4e8ab66..85e2dbb 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -39,6 +39,12 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): nullable=False, comment="状态 (active/disabled)", ) + role: Mapped[str] = mapped_column( + String(16), + default="customer", + nullable=False, + comment="角色 (internal=内部员工 / customer=客户)", + ) plan_id: Mapped[str] = mapped_column( String(32), ForeignKey("plans.id"), diff --git a/backend/app/repositories/user_repo.py b/backend/app/repositories/user_repo.py index f135e1f..3b173d5 100644 --- a/backend/app/repositories/user_repo.py +++ b/backend/app/repositories/user_repo.py @@ -46,12 +46,14 @@ class UserRepository: email: str, password_hash: str, plan_id: str, + role: str = "customer", ) -> User: user = User( username=username, email=email.lower(), password_hash=password_hash, status="active", + role=role, plan_id=plan_id, storage_used=0, ) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index a2b9c47..987c4b0 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -45,6 +45,7 @@ class UserResponse(BaseModel): username: str email: str status: str + role: str storage_used: int storage_quota: int created_at: str @@ -57,6 +58,7 @@ class MeResponse(BaseModel): username: str email: str status: str + role: str storage_used: int storage_quota: int created_at: str diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index fa5e07b..e86ae08 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -19,9 +19,12 @@ class AuthService: self._user_repo = UserRepository(session) self._plan_repo = PlanRepository(session) - def register(self, username: str, email: str, password: str) -> tuple[User, str]: + def register(self, username: str, email: str, password: str, role: str = "customer") -> tuple[User, str]: """注册新用户。返回 (user, session_token)。 + Args: + role: "customer"(默认)或 "internal" + Raises: ConflictError: 用户名或邮箱已存在 """ @@ -37,6 +40,7 @@ class AuthService: email=email, password_hash=hash_password(password), plan_id=plan.id, + role=role, ) self._session.commit() @@ -44,7 +48,7 @@ class AuthService: return user, token def login(self, username_or_email: str, password: str) -> tuple[User, str]: - """登录。返回 (user, session_token)。 + """普通登录(所有用户可用)。返回 (user, session_token)。 Raises: InvalidCredentialsError: 用户名/邮箱或密码错误 @@ -60,6 +64,25 @@ class AuthService: token = create_session(user.id) return user, token + def internal_login(self, username_or_email: str, password: str) -> tuple[User, str]: + """内部登录(仅 internal 角色可用)。返回 (user, session_token)。 + + Raises: + InvalidCredentialsError: 用户名/邮箱或密码错误,或非内部用户 + """ + user = self._user_repo.get_by_username_or_email(username_or_email) + if user is None: + raise InvalidCredentialsError() + if not verify_password(password, user.password_hash): + raise InvalidCredentialsError() + if user.status != "active": + raise InvalidCredentialsError("账户已被禁用。") + if user.role != "internal": + raise InvalidCredentialsError("此入口仅限内部员工使用。") + + token = create_session(user.id) + return user, token + def logout(self, session_token: str) -> None: """登出:删除 session。""" delete_session(session_token) diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..bcc02c8 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,154 @@ +""" +管理命令行工具 + +用法: + python manage.py create-internal-user # 交互式创建内部员工 + python manage.py create-internal-user --username admin --email admin@company.com --password 12345678 + python manage.py list-users # 列出所有用户 + python manage.py list-users --role internal # 只列出内部员工 +""" + +import argparse +import sys +from pathlib import Path + +# 确保能导入 app 模块 +sys.path.insert(0, str(Path(__file__).parent)) + +from app.core.config import get_settings +from app.core.db import get_session_factory, get_engine +from app.core.security import hash_password +from app.models import Base, User, Plan +from sqlalchemy import select + + +def ensure_tables(): + """确保表存在。""" + engine = get_engine() + Base.metadata.create_all(engine) + + +def get_or_create_free_plan(session) -> Plan: + """获取或创建 free plan。""" + plan = session.scalars(select(Plan).where(Plan.code == "free")).first() + if not plan: + plan = Plan( + code="free", + name="免费版", + storage_quota=104_857_600, + max_file_size=20_971_520, + is_active=True, + ) + session.add(plan) + session.flush() + return plan + + +def cmd_create_internal_user(args): + """创建内部员工账号。""" + ensure_tables() + factory = get_session_factory() + + username = args.username + email = args.email + password = args.password + + # 交互式输入 + if not username: + username = input("用户名: ").strip() + if not email: + email = input("邮箱: ").strip() + if not password: + import getpass + password = getpass.getpass("密码: ").strip() + + if not username or not email or not password: + print("错误:用户名、邮箱、密码不能为空。") + sys.exit(1) + + if len(password) < 8: + print("错误:密码长度不能少于 8 位。") + sys.exit(1) + + with factory() as session: + # 检查用户名/邮箱是否已存在 + existing = session.scalars( + select(User).where((User.username == username) | (User.email == email.lower())) + ).first() + if existing: + if existing.username == username: + print(f"错误:用户名 '{username}' 已被占用。") + else: + print(f"错误:邮箱 '{email}' 已被注册。") + sys.exit(1) + + plan = get_or_create_free_plan(session) + user = User( + username=username, + email=email.lower(), + password_hash=hash_password(password), + status="active", + role="internal", + plan_id=plan.id, + storage_used=0, + ) + session.add(user) + session.commit() + + print(f"\n✅ 内部员工账号创建成功!") + print(f" 用户名:{username}") + print(f" 邮箱:{email}") + print(f" 角色:internal") + print(f" 登录地址:/internal-login") + + +def cmd_list_users(args): + """列出所有用户。""" + ensure_tables() + factory = get_session_factory() + + with factory() as session: + stmt = select(User).order_by(User.created_at.desc()) + if args.role: + stmt = stmt.where(User.role == args.role) + + users = list(session.scalars(stmt).all()) + + if not users: + print("暂无用户。") + return + + print(f"\n{'用户名':<15} {'邮箱':<25} {'角色':<10} {'状态':<8} {'创建时间'}") + print("-" * 80) + for u in users: + print(f"{u.username:<15} {u.email:<25} {u.role:<10} {u.status:<8} {u.created_at[:19]}") + + print(f"\n共 {len(users)} 个用户") + + +def main(): + parser = argparse.ArgumentParser(description="AI Knowledge Link 管理工具") + subparsers = parser.add_subparsers(dest="command", help="可用命令") + + # create-internal-user + p_create = subparsers.add_parser("create-internal-user", help="创建内部员工账号") + p_create.add_argument("--username", "-u", help="用户名") + p_create.add_argument("--email", "-e", help="邮箱") + p_create.add_argument("--password", "-p", help="密码") + + # list-users + p_list = subparsers.add_parser("list-users", help="列出所有用户") + p_list.add_argument("--role", "-r", choices=["internal", "customer"], help="按角色过滤") + + args = parser.parse_args() + + if args.command == "create-internal-user": + cmd_create_internal_user(args) + elif args.command == "list-users": + cmd_list_users(args) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt index b837129..774823a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,6 +11,7 @@ jinja2>=3.1 # --- 数据库 --- sqlalchemy>=2.0.30 alembic>=1.13 +pymysql>=1.1 # --- 安全 --- argon2-cffi>=23.1 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8c126b1..b5c01fe 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,7 +1,7 @@ -"""测试配置:每个测试用例使用隔离的内存数据库。""" +"""测试配置:每个测试用例使用隔离的内存 SQLite(不依赖远程 MySQL)。""" import pytest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker from app.core import db as db_module @@ -10,16 +10,10 @@ from app.models import Base @pytest.fixture(autouse=True) def _isolate_db(tmp_path, monkeypatch): - """每个测试用例:创建独立内存 SQLite → 建表 → 替换全局引擎 → 测试结束自动清理。 - - 这确保测试之间完全隔离,不共享任何数据。 - """ + """每个测试用例:创建独立内存 SQLite → 建表 → 替换全局引擎 → 测试结束自动清理。""" test_db_url = f"sqlite:///{tmp_path}/test.db" engine = create_engine(test_db_url, connect_args={"check_same_thread": False}) - # 启用 WAL + 外键 - from sqlalchemy import event, text - @event.listens_for(engine, "connect") def _set_pragma(dbapi_conn, _): cursor = dbapi_conn.cursor() @@ -34,9 +28,33 @@ def _isolate_db(tmp_path, monkeypatch): monkeypatch.setattr(db_module, "_engine", engine) monkeypatch.setattr(db_module, "_session_factory", test_factory) + # Mock settings 让 is_mysql 返回 False + from app.core.config import get_settings, Settings + original_settings = get_settings() + + class TestSettings: + """测试用 settings mock,保持 SQLite 行为。""" + environment = "local" + database_url = test_db_url + storage_root = str(tmp_path) + default_storage_quota = 104_857_600 + default_max_file_size = 20_971_520 + rate_limit_per_token_per_min = 60 + rate_limit_per_ip_per_min = 30 + frontend_origin = "http://localhost:5173" + is_production = False + is_mysql = False + is_sqlite = True + + @property + def storage_root_path(self): + from pathlib import Path + return Path(self.storage_root) + + monkeypatch.setattr("app.core.config.get_settings", lambda: TestSettings()) + # 清空内存 session 存储 from app.core import session as session_module - session_module._store.clear() yield diff --git a/docs/requirements.md b/docs/requirements.md index 29a8bcb..184e627 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -2,7 +2,9 @@ > 状态标注说明:✅ 已完成 | 🔄 部分完成 | ⬜ 未开始(对应 Phase N) | 📌 持续约束(贯穿全程) > -> **版本说明**:本文档为当前有效需求(MVP 版)。早前一版需求(PostgreSQL + Redis + Celery + MinIO 全功能栈)已被本 MVP 版**取代**,核心差异:SQLite 替代 PostgreSQL、本地文件系统替代 MinIO、同步解析替代 Celery、内存限流替代 Redis、FTS5 替代 jieba+tsvector。原版中的商业许可审查结论仍有效(PDF 解析商品化前需将 PyMuPDF 替换为 pypdfium2,见 `technical-review.md` R3)。 +> **版本说明**:本文档为当前有效需求(MVP 版)。早前一版需求(PostgreSQL + Redis + Celery + MinIO 全功能栈)已被本 MVP 版**取代**,核心差异:MySQL 替代 PostgreSQL+SQLite、本地文件系统替代 MinIO、同步解析替代 Celery、内存限流替代 Redis。原版中的商业许可审查结论仍有效(PDF 解析商品化前需将 PyMuPDF 替换为 pypdfium2,见 `technical-review.md` R3)。 +> +> **最新更新**:数据库已从 SQLite 迁移到 MySQL(远程开发 47.109.98.44:33306 / 本地生产 127.0.0.1:3306)。新增用户角色系统(internal=内部员工 / customer=客户)和独立内部登录入口(`/internal-login`)。 > > 关联文档:[技术审查报告](technical-review.md) | [CLAUDE.md](../CLAUDE.md) @@ -22,11 +24,13 @@ | 10 | AI 公共页面 | ✅ 完成 | | 11 | Markdown/TXT/JSON 输出 | ✅ 完成 | | 12 | 搜索 | ✅ 完成(LIKE 搜索,可升级 FTS5) | -| 13 | 安全 | 🔄 已实现:IDOR 防护、限流、XSS 防护、路径穿越防护、Session 安全 | -| 14 | 前端完善 | ✅ 完成 | +| 13 | 安全 | ✅ IDOR 防护、限流、XSS 防护、路径穿越防护、Session 安全 | +| 14 | 前端完善 | ✅ 完成(含手机适配、字体大小、内部登录页) | | 15 | 测试 | ✅ 后端 58/58 通过 | -| 16 | Docker | 🔄 compose/Dockerfile 已写(Phase 1),待 Docker 环境验证 | +| 16 | Docker | 🔄 compose/Dockerfile 已写,待 Docker 环境验证 | | 17 | Ubuntu 部署文档 | ✅ 完成(README.md) | +| - | MySQL 迁移 | ✅ 完成(远程 47.109.98.44:33306) | +| - | 内部登录系统 | ✅ 完成(role 字段 + /internal-login) | --- @@ -48,7 +52,7 @@ - 前端:Vue 3、TypeScript、Vite、Element Plus、Pinia、Axios - 后端:Python 3.12+、FastAPI、SQLAlchemy、Pydantic、Alembic -- 数据库:SQLite +- 数据库:MySQL 8.0(utf8mb4) - 文件存储:服务器本地文件系统 - 文档解析:MarkItDown、PyMuPDF、python-docx - 部署:Docker Compose,可以使用 Nginx diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 9719bf1..a681bf5 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -7,6 +7,11 @@ const router = createRouter({ name: 'Login', component: () => import('@/views/Login.vue'), }, + { + path: '/internal-login', + name: 'InternalLogin', + component: () => import('@/views/InternalLogin.vue'), + }, { path: '/register', name: 'Register', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 37c8b92..2532c8c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -8,6 +8,11 @@ const router = createRouter({ name: 'Login', component: () => import('@/views/Login.vue'), }, + { + path: '/internal-login', + name: 'InternalLogin', + component: () => import('@/views/InternalLogin.vue'), + }, { path: '/register', name: 'Register', diff --git a/frontend/src/views/InternalLogin.vue b/frontend/src/views/InternalLogin.vue new file mode 100644 index 0000000..f0becb7 --- /dev/null +++ b/frontend/src/views/InternalLogin.vue @@ -0,0 +1,73 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/InternalLogin.vue.js b/frontend/src/views/InternalLogin.vue.js new file mode 100644 index 0000000..47511f3 --- /dev/null +++ b/frontend/src/views/InternalLogin.vue.js @@ -0,0 +1,192 @@ +import { ref } from 'vue'; +import { useRouter } from 'vue-router'; +import { ElMessage } from 'element-plus'; +import { useUserStore } from '@/stores/user'; +import apiClient from '@/api/client'; +const router = useRouter(); +const userStore = useUserStore(); +const form = ref({ + username_or_email: '', + password: '', +}); +const loading = ref(false); +async function handleLogin() { + loading.value = true; + try { + const { data } = await apiClient.post('/auth/internal-login', form.value); + userStore.setUser(data); + ElMessage.success('内部登录成功!'); + router.push('/'); + } + catch { + // 错误由拦截器处理 + } + finally { + loading.value = false; + } +} +debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ +const __VLS_ctx = {}; +let __VLS_components; +let __VLS_directives; +// CSS variable injection +// CSS variable injection end +__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({ + ...{ class: "internal-login-page" }, +}); +const __VLS_0 = {}.ElCard; +/** @type {[typeof __VLS_components.ElCard, typeof __VLS_components.elCard, typeof __VLS_components.ElCard, typeof __VLS_components.elCard, ]} */ ; +// @ts-ignore +const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({ + ...{ class: "login-card" }, +})); +const __VLS_2 = __VLS_1({ + ...{ class: "login-card" }, +}, ...__VLS_functionalComponentArgsRest(__VLS_1)); +__VLS_3.slots.default; +{ + const { header: __VLS_thisSlot } = __VLS_3.slots; + __VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({ + ...{ style: {} }, + }); + __VLS_asFunctionalElement(__VLS_intrinsicElements.h2, __VLS_intrinsicElements.h2)({ + ...{ style: {} }, + }); + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({ + ...{ style: {} }, + }); +} +const __VLS_4 = {}.ElForm; +/** @type {[typeof __VLS_components.ElForm, typeof __VLS_components.elForm, typeof __VLS_components.ElForm, typeof __VLS_components.elForm, ]} */ ; +// @ts-ignore +const __VLS_5 = __VLS_asFunctionalComponent(__VLS_4, new __VLS_4({ + ...{ 'onSubmit': {} }, + model: (__VLS_ctx.form), + labelPosition: "top", +})); +const __VLS_6 = __VLS_5({ + ...{ 'onSubmit': {} }, + model: (__VLS_ctx.form), + labelPosition: "top", +}, ...__VLS_functionalComponentArgsRest(__VLS_5)); +let __VLS_8; +let __VLS_9; +let __VLS_10; +const __VLS_11 = { + onSubmit: (__VLS_ctx.handleLogin) +}; +__VLS_7.slots.default; +const __VLS_12 = {}.ElFormItem; +/** @type {[typeof __VLS_components.ElFormItem, typeof __VLS_components.elFormItem, typeof __VLS_components.ElFormItem, typeof __VLS_components.elFormItem, ]} */ ; +// @ts-ignore +const __VLS_13 = __VLS_asFunctionalComponent(__VLS_12, new __VLS_12({ + label: "用户名或邮箱", +})); +const __VLS_14 = __VLS_13({ + label: "用户名或邮箱", +}, ...__VLS_functionalComponentArgsRest(__VLS_13)); +__VLS_15.slots.default; +const __VLS_16 = {}.ElInput; +/** @type {[typeof __VLS_components.ElInput, typeof __VLS_components.elInput, ]} */ ; +// @ts-ignore +const __VLS_17 = __VLS_asFunctionalComponent(__VLS_16, new __VLS_16({ + modelValue: (__VLS_ctx.form.username_or_email), + placeholder: "请输入用户名或邮箱", + size: "large", +})); +const __VLS_18 = __VLS_17({ + modelValue: (__VLS_ctx.form.username_or_email), + placeholder: "请输入用户名或邮箱", + size: "large", +}, ...__VLS_functionalComponentArgsRest(__VLS_17)); +var __VLS_15; +const __VLS_20 = {}.ElFormItem; +/** @type {[typeof __VLS_components.ElFormItem, typeof __VLS_components.elFormItem, typeof __VLS_components.ElFormItem, typeof __VLS_components.elFormItem, ]} */ ; +// @ts-ignore +const __VLS_21 = __VLS_asFunctionalComponent(__VLS_20, new __VLS_20({ + label: "密码", +})); +const __VLS_22 = __VLS_21({ + label: "密码", +}, ...__VLS_functionalComponentArgsRest(__VLS_21)); +__VLS_23.slots.default; +const __VLS_24 = {}.ElInput; +/** @type {[typeof __VLS_components.ElInput, typeof __VLS_components.elInput, ]} */ ; +// @ts-ignore +const __VLS_25 = __VLS_asFunctionalComponent(__VLS_24, new __VLS_24({ + modelValue: (__VLS_ctx.form.password), + type: "password", + placeholder: "请输入密码", + showPassword: true, + size: "large", +})); +const __VLS_26 = __VLS_25({ + modelValue: (__VLS_ctx.form.password), + type: "password", + placeholder: "请输入密码", + showPassword: true, + size: "large", +}, ...__VLS_functionalComponentArgsRest(__VLS_25)); +var __VLS_23; +const __VLS_28 = {}.ElFormItem; +/** @type {[typeof __VLS_components.ElFormItem, typeof __VLS_components.elFormItem, typeof __VLS_components.ElFormItem, typeof __VLS_components.elFormItem, ]} */ ; +// @ts-ignore +const __VLS_29 = __VLS_asFunctionalComponent(__VLS_28, new __VLS_28({})); +const __VLS_30 = __VLS_29({}, ...__VLS_functionalComponentArgsRest(__VLS_29)); +__VLS_31.slots.default; +const __VLS_32 = {}.ElButton; +/** @type {[typeof __VLS_components.ElButton, typeof __VLS_components.elButton, typeof __VLS_components.ElButton, typeof __VLS_components.elButton, ]} */ ; +// @ts-ignore +const __VLS_33 = __VLS_asFunctionalComponent(__VLS_32, new __VLS_32({ + type: "primary", + loading: (__VLS_ctx.loading), + ...{ style: {} }, + nativeType: "submit", + size: "large", +})); +const __VLS_34 = __VLS_33({ + type: "primary", + loading: (__VLS_ctx.loading), + ...{ style: {} }, + nativeType: "submit", + size: "large", +}, ...__VLS_functionalComponentArgsRest(__VLS_33)); +__VLS_35.slots.default; +var __VLS_35; +var __VLS_31; +var __VLS_7; +__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({ + ...{ style: {} }, +}); +const __VLS_36 = {}.RouterLink; +/** @type {[typeof __VLS_components.RouterLink, typeof __VLS_components.routerLink, typeof __VLS_components.RouterLink, typeof __VLS_components.routerLink, ]} */ ; +// @ts-ignore +const __VLS_37 = __VLS_asFunctionalComponent(__VLS_36, new __VLS_36({ + to: "/login", + ...{ style: {} }, +})); +const __VLS_38 = __VLS_37({ + to: "/login", + ...{ style: {} }, +}, ...__VLS_functionalComponentArgsRest(__VLS_37)); +__VLS_39.slots.default; +var __VLS_39; +var __VLS_3; +/** @type {__VLS_StyleScopedClasses['internal-login-page']} */ ; +/** @type {__VLS_StyleScopedClasses['login-card']} */ ; +var __VLS_dollars; +const __VLS_self = (await import('vue')).defineComponent({ + setup() { + return { + form: form, + loading: loading, + handleLogin: handleLogin, + }; + }, +}); +export default (await import('vue')).defineComponent({ + setup() { + return {}; + }, +}); +; /* PartiallyEnd: #4569/main.vue */ diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index d87ebe9..f210309 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -46,8 +46,9 @@ async function handleLogin() { -
+
没有账号?立即注册 + 🔐 内部员工登录
diff --git a/frontend/src/views/Login.vue.js b/frontend/src/views/Login.vue.js index d99d52a..01aa18a 100644 --- a/frontend/src/views/Login.vue.js +++ b/frontend/src/views/Login.vue.js @@ -155,6 +155,19 @@ const __VLS_38 = __VLS_37({ }, ...__VLS_functionalComponentArgsRest(__VLS_37)); __VLS_39.slots.default; var __VLS_39; +const __VLS_40 = {}.RouterLink; +/** @type {[typeof __VLS_components.RouterLink, typeof __VLS_components.routerLink, typeof __VLS_components.RouterLink, typeof __VLS_components.routerLink, ]} */ ; +// @ts-ignore +const __VLS_41 = __VLS_asFunctionalComponent(__VLS_40, new __VLS_40({ + to: "/internal-login", + ...{ style: {} }, +})); +const __VLS_42 = __VLS_41({ + to: "/internal-login", + ...{ style: {} }, +}, ...__VLS_functionalComponentArgsRest(__VLS_41)); +__VLS_43.slots.default; +var __VLS_43; var __VLS_3; /** @type {__VLS_StyleScopedClasses['login-page']} */ ; /** @type {__VLS_StyleScopedClasses['login-card']} */ ; diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 1ed1869..9e4fd44 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/env.d.ts","./src/main.ts","./src/api/client.ts","./src/router/index.ts","./src/stores/user.ts","./src/utils/clipboard.ts","./src/app.vue","./src/layouts/defaultlayout.vue","./src/views/dashboard.vue","./src/views/kbdetail.vue","./src/views/knowledgebases.vue","./src/views/login.vue","./src/views/register.vue","./src/views/settings.vue"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/env.d.ts","./src/main.ts","./src/api/client.ts","./src/router/index.ts","./src/stores/user.ts","./src/utils/clipboard.ts","./src/app.vue","./src/layouts/defaultlayout.vue","./src/views/dashboard.vue","./src/views/internallogin.vue","./src/views/kbdetail.vue","./src/views/knowledgebases.vue","./src/views/login.vue","./src/views/register.vue","./src/views/settings.vue"],"version":"5.9.3"} \ No newline at end of file