fix:优化项目内容
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -20,6 +20,8 @@ data/
|
|||||||
# Node / frontend
|
# Node / frontend
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/.next/
|
frontend/.next/
|
||||||
|
frontend_spa/node_modules/
|
||||||
|
frontend_spa/dist/
|
||||||
|
|
||||||
# Environment & secrets
|
# Environment & secrets
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
# 分层构建:依赖与代码分离,仅代码变更时只重建最后一层,加快迭代
|
# 分层构建:避免依赖第三方基础镜像(例如 tiangolo)拉取失败
|
||||||
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
ENV MODULE_NAME=backend.app.main
|
|
||||||
ENV VARIABLE_NAME=app
|
|
||||||
ENV PORT=8000
|
|
||||||
ENV HOST=0.0.0.0
|
|
||||||
# SQLite 对并发写不友好,单 worker 避免多进程竞争
|
|
||||||
ENV WEB_CONCURRENCY=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# 依赖层:只有 requirements 变更时才重建
|
# 依赖层:只有 requirements 变更时才重建
|
||||||
COPY requirements.txt /app/requirements.txt
|
COPY requirements.txt /app/requirements.txt
|
||||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
@@ -17,3 +13,8 @@ RUN pip install --no-cache-dir -r /app/requirements.txt
|
|||||||
# 代码层:仅此层会随业务代码变更而重建
|
# 代码层:仅此层会随业务代码变更而重建
|
||||||
COPY backend /app/backend
|
COPY backend /app/backend
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# SQLite 对并发写不友好,单进程即可;如需生产多 worker 可在 compose 里调整
|
||||||
|
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
|
|||||||
27
README.md
Normal file
27
README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
## Ops-Core (Vite 静态 SPA + FastAPI)
|
||||||
|
|
||||||
|
当前前端已迁移为 **Vite + React SPA**(目录:`frontend_spa/`),生产构建产物为 `dist/`,由 Nginx 静态托管,并通过同域 `/api` 反代到 FastAPI。
|
||||||
|
|
||||||
|
### 启动方式(推荐)
|
||||||
|
- **生产/默认**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./docker_dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- **开发(容器内 Vite 热更新,前端端口 3001)**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./docker_dev.sh dev
|
||||||
|
```
|
||||||
|
|
||||||
|
开发模式访问:`http://localhost:3001`
|
||||||
|
生产模式访问:`http://localhost:3000`
|
||||||
|
|
||||||
|
### 目录说明
|
||||||
|
- `frontend_spa/`: 新前端(Vite SPA)
|
||||||
|
- `backend/`: 后端(FastAPI)
|
||||||
|
- `nginx_spa.conf`: 单机部署 Nginx 示例配置(history 路由 + `/api`/`/data` + SSE 禁用 buffering)
|
||||||
|
|
||||||
|
> 旧的 Next.js 前端目录 `frontend/` 已不再作为可运行入口,相关构建配置已移除,避免误用。
|
||||||
|
|
||||||
56
backend/app/deps.py
Normal file
56
backend/app/deps.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import Depends, Request, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from backend.app.db import get_db
|
||||||
|
from backend.app import models
|
||||||
|
|
||||||
|
DEVICE_COOKIE_NAME = "opc_device_token"
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_new_device_user(db: Session, response: Response) -> models.User:
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
user = models.User(
|
||||||
|
device_token=token,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
last_seen_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
response.set_cookie(
|
||||||
|
key=DEVICE_COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
secure=False,
|
||||||
|
samesite="Lax",
|
||||||
|
max_age=60 * 60 * 24 * 365,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> models.User:
|
||||||
|
token = request.cookies.get(DEVICE_COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
return _issue_new_device_user(db, response)
|
||||||
|
|
||||||
|
user = (
|
||||||
|
db.query(models.User)
|
||||||
|
.filter(models.User.device_token == token)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
return _issue_new_device_user(db, response)
|
||||||
|
|
||||||
|
user.last_seen_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
@@ -32,27 +32,28 @@ def create_app() -> FastAPI:
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup() -> None:
|
def on_startup() -> None:
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
# Add new columns to finance_records if they don't exist (Module 6)
|
# Lightweight schema migrations for SQLite (add columns if missing)
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
|
# finance_records: amount, billing_date
|
||||||
r = conn.execute(text("PRAGMA table_info(finance_records)"))
|
r = conn.execute(text("PRAGMA table_info(finance_records)"))
|
||||||
cols = [row[1] for row in r]
|
cols = [row[1] for row in r]
|
||||||
if "amount" not in cols:
|
if "amount" not in cols:
|
||||||
conn.execute(text("ALTER TABLE finance_records ADD COLUMN amount NUMERIC(12,2)"))
|
conn.execute(text("ALTER TABLE finance_records ADD COLUMN amount NUMERIC(12,2)"))
|
||||||
if "billing_date" not in cols:
|
if "billing_date" not in cols:
|
||||||
conn.execute(text("ALTER TABLE finance_records ADD COLUMN billing_date DATE"))
|
conn.execute(text("ALTER TABLE finance_records ADD COLUMN billing_date DATE"))
|
||||||
conn.commit()
|
if "tags" not in cols:
|
||||||
except Exception:
|
conn.execute(text("ALTER TABLE finance_records ADD COLUMN tags VARCHAR(512)"))
|
||||||
pass
|
if "meta_json" not in cols:
|
||||||
# Add customers.tags if missing (customer tags for project 收纳)
|
conn.execute(text("ALTER TABLE finance_records ADD COLUMN meta_json TEXT"))
|
||||||
try:
|
|
||||||
from sqlalchemy import text
|
# customers: tags
|
||||||
with engine.connect() as conn:
|
|
||||||
r = conn.execute(text("PRAGMA table_info(customers)"))
|
r = conn.execute(text("PRAGMA table_info(customers)"))
|
||||||
cols = [row[1] for row in r]
|
cols = [row[1] for row in r]
|
||||||
if "tags" not in cols:
|
if "tags" not in cols:
|
||||||
conn.execute(text("ALTER TABLE customers ADD COLUMN tags VARCHAR(512)"))
|
conn.execute(text("ALTER TABLE customers ADD COLUMN tags VARCHAR(512)"))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from datetime import date, datetime
|
|||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Date,
|
Date,
|
||||||
Column,
|
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
@@ -48,7 +47,6 @@ class Project(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
customer: Mapped[Customer] = relationship("Customer", back_populates="projects")
|
customer: Mapped[Customer] = relationship("Customer", back_populates="projects")
|
||||||
quotes: Mapped[list["Quote"]] = relationship(
|
quotes: Mapped[list["Quote"]] = relationship(
|
||||||
"Quote", back_populates="project", cascade="all, delete-orphan"
|
"Quote", back_populates="project", cascade="all, delete-orphan"
|
||||||
@@ -103,9 +101,10 @@ class FinanceRecord(Base):
|
|||||||
type: Mapped[str] = mapped_column(String(50), nullable=False) # invoice / bank_receipt / manual / ...
|
type: Mapped[str] = mapped_column(String(50), nullable=False) # invoice / bank_receipt / manual / ...
|
||||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
tags: Mapped[str | None] = mapped_column(String(512), nullable=True) # 逗号分隔标签
|
||||||
|
meta_json: Mapped[str | None] = mapped_column(Text, nullable=True) # 结构化识别结果 JSON
|
||||||
amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||||
billing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
billing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
from datetime import date
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -9,6 +12,8 @@ from backend.app import models
|
|||||||
from backend.app.schemas import (
|
from backend.app.schemas import (
|
||||||
FinanceRecordRead,
|
FinanceRecordRead,
|
||||||
FinanceRecordUpdate,
|
FinanceRecordUpdate,
|
||||||
|
FinanceBatchDeleteRequest,
|
||||||
|
FinanceSyncRequest,
|
||||||
FinanceSyncResponse,
|
FinanceSyncResponse,
|
||||||
FinanceSyncResult,
|
FinanceSyncResult,
|
||||||
FinanceUploadResponse,
|
FinanceUploadResponse,
|
||||||
@@ -21,9 +26,14 @@ router = APIRouter(prefix="/finance", tags=["finance"])
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/sync", response_model=FinanceSyncResponse)
|
@router.post("/sync", response_model=FinanceSyncResponse)
|
||||||
async def sync_finance():
|
async def sync_finance(payload: FinanceSyncRequest = Body(default=FinanceSyncRequest())):
|
||||||
try:
|
try:
|
||||||
items_raw = await sync_finance_emails()
|
items_raw = await sync_finance_emails(
|
||||||
|
mode=payload.mode,
|
||||||
|
start_date=payload.start_date,
|
||||||
|
end_date=payload.end_date,
|
||||||
|
doc_types=payload.doc_types,
|
||||||
|
)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
# 邮箱配置/连接等问题属于可预期的业务错误,用 400 让前端直接展示原因,而不是泛化为 500。
|
# 邮箱配置/连接等问题属于可预期的业务错误,用 400 让前端直接展示原因,而不是泛化为 500。
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
@@ -108,6 +118,60 @@ async def update_finance_record(
|
|||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/records/{record_id}")
|
||||||
|
async def delete_finance_record(
|
||||||
|
record_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""删除单条财务记录及对应文件(若存在)。"""
|
||||||
|
record = db.query(models.FinanceRecord).get(record_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(404, "记录不存在")
|
||||||
|
|
||||||
|
file_path = Path(record.file_path)
|
||||||
|
if not file_path.is_absolute():
|
||||||
|
file_path = Path(".") / file_path
|
||||||
|
if file_path.exists():
|
||||||
|
try:
|
||||||
|
file_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.delete(record)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted", "id": record_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/records/batch-delete")
|
||||||
|
async def batch_delete_finance_records(
|
||||||
|
payload: FinanceBatchDeleteRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""批量删除财务记录及对应文件。"""
|
||||||
|
if not payload.ids:
|
||||||
|
return {"status": "ok", "deleted": 0}
|
||||||
|
|
||||||
|
records = (
|
||||||
|
db.query(models.FinanceRecord)
|
||||||
|
.filter(models.FinanceRecord.id.in_(payload.ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
file_path = Path(record.file_path)
|
||||||
|
if not file_path.is_absolute():
|
||||||
|
file_path = Path(".") / file_path
|
||||||
|
if file_path.exists():
|
||||||
|
try:
|
||||||
|
file_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
db.delete(record)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted", "deleted": len(records)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/download/{month}")
|
@router.get("/download/{month}")
|
||||||
async def download_finance_month(month: str):
|
async def download_finance_month(month: str):
|
||||||
"""
|
"""
|
||||||
@@ -124,3 +188,53 @@ async def download_finance_month(month: str):
|
|||||||
filename=f"finance_{month}.zip",
|
filename=f"finance_{month}.zip",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/download-range")
|
||||||
|
async def download_finance_range(
|
||||||
|
start_date: date = Query(..., description="起始日期 YYYY-MM-DD"),
|
||||||
|
end_date: date = Query(..., description="结束日期 YYYY-MM-DD(含当日)"),
|
||||||
|
only_invoices: bool = Query(True, description="是否仅包含发票类型"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
按时间范围打包下载发票(默认仅发票,可扩展)。
|
||||||
|
"""
|
||||||
|
if end_date < start_date:
|
||||||
|
raise HTTPException(status_code=400, detail="结束日期不能早于开始日期")
|
||||||
|
|
||||||
|
q = db.query(models.FinanceRecord).filter(
|
||||||
|
models.FinanceRecord.billing_date.isnot(None),
|
||||||
|
models.FinanceRecord.billing_date >= start_date,
|
||||||
|
models.FinanceRecord.billing_date <= end_date,
|
||||||
|
)
|
||||||
|
if only_invoices:
|
||||||
|
q = q.filter(models.FinanceRecord.type == "invoices")
|
||||||
|
|
||||||
|
records = q.all()
|
||||||
|
if not records:
|
||||||
|
raise HTTPException(status_code=404, detail="该时间段内没有可导出的记录")
|
||||||
|
|
||||||
|
base_dir = Path("data/finance")
|
||||||
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
zip_name = f"invoices_{start_date.isoformat()}_{end_date.isoformat()}.zip"
|
||||||
|
zip_path = base_dir / zip_name
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for r in records:
|
||||||
|
file_path = Path(r.file_path)
|
||||||
|
if not file_path.is_absolute():
|
||||||
|
file_path = Path(".") / file_path
|
||||||
|
if not file_path.exists():
|
||||||
|
continue
|
||||||
|
# 保持月份/类型的相对结构
|
||||||
|
rel = file_path.relative_to(Path("data")) if "data" in file_path.parts else file_path.name
|
||||||
|
zf.write(file_path, arcname=rel)
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(zip_path),
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=zip_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Union
|
from typing import Any, Dict, List, Union
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
import json
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from backend.app import models
|
from backend.app import models
|
||||||
@@ -105,6 +109,7 @@ def _build_markdown_from_analysis(data: Union[Dict[str, Any], List[Any]]) -> str
|
|||||||
@router.get("/", response_model=list[ProjectRead])
|
@router.get("/", response_model=list[ProjectRead])
|
||||||
async def list_projects(
|
async def list_projects(
|
||||||
customer_tag: str | None = None,
|
customer_tag: str | None = None,
|
||||||
|
limit: int = Query(30, ge=1, le=200, description="默认只返回最近 N 条"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""列表项目;customer_tag 不为空时只返回该客户标签下的项目(按客户 tags 筛选)。"""
|
"""列表项目;customer_tag 不为空时只返回该客户标签下的项目(按客户 tags 筛选)。"""
|
||||||
@@ -127,7 +132,7 @@ async def list_projects(
|
|||||||
t.ilike(f"%,{tag}"),
|
t.ilike(f"%,{tag}"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return query.all()
|
return query.limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}", response_model=ProjectRead)
|
@router.get("/{project_id}", response_model=ProjectRead)
|
||||||
@@ -200,6 +205,88 @@ async def analyze_project_requirement(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyze_stream")
|
||||||
|
async def analyze_project_requirement_stream(
|
||||||
|
payload: RequirementAnalyzeRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
SSE 流式输出 Markdown 到前端(用于编辑器实时显示)。
|
||||||
|
data: {"type":"delta","content":"..."} / {"type":"done","project_id":1}
|
||||||
|
"""
|
||||||
|
customer = db.query(models.Customer).get(payload.customer_id)
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
|
||||||
|
|
||||||
|
# Create a project first so we can return project_id at end
|
||||||
|
project = models.Project(
|
||||||
|
customer_id=payload.customer_id,
|
||||||
|
raw_requirement=payload.raw_text,
|
||||||
|
ai_solution_md="",
|
||||||
|
status="draft",
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(project)
|
||||||
|
|
||||||
|
async def gen() -> AsyncGenerator[str, None]:
|
||||||
|
from backend.app.services.ai_service import get_active_ai_config
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
config = get_active_ai_config()
|
||||||
|
api_key = (config.get("api_key") or "").strip()
|
||||||
|
if not api_key:
|
||||||
|
yield f"data: {json.dumps({'type':'error','message':'AI API Key 未配置'})}\n\n"
|
||||||
|
return
|
||||||
|
base_url = (config.get("base_url") or "").strip() or None
|
||||||
|
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||||
|
model = config.get("model_name") or "gpt-4o-mini"
|
||||||
|
temperature = float(config.get("temperature", 0.2))
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
(config.get("system_prompt_override") or "").strip()
|
||||||
|
or "你是一名资深系统架构师,请输出可直接编辑的 Markdown 方案,不要输出 JSON。"
|
||||||
|
)
|
||||||
|
user_prompt = (
|
||||||
|
"请基于以下客户原始需求,输出一份可交付的项目方案草稿(Markdown)。\n"
|
||||||
|
"要求包含:概要、功能模块拆分、技术实现思路、工时与报价估算、备注。\n\n"
|
||||||
|
f"【客户原始需求】\n{payload.raw_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
full = ""
|
||||||
|
try:
|
||||||
|
stream = await client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
temperature=temperature,
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
async for event in stream:
|
||||||
|
delta = (event.choices[0].delta.content or "") if event.choices else ""
|
||||||
|
if not delta:
|
||||||
|
continue
|
||||||
|
full += delta
|
||||||
|
yield f"data: {json.dumps({'type':'delta','content':delta}, ensure_ascii=False)}\n\n"
|
||||||
|
except Exception as exc:
|
||||||
|
yield f"data: {json.dumps({'type':'error','message':str(exc)}, ensure_ascii=False)}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save final markdown
|
||||||
|
try:
|
||||||
|
project.ai_solution_md = full
|
||||||
|
db.add(project)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
yield f"data: {json.dumps({'type':'done','project_id':project.id}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/generate_quote", response_model=QuoteGenerateResponse)
|
@router.post("/{project_id}/generate_quote", response_model=QuoteGenerateResponse)
|
||||||
async def generate_project_quote(
|
async def generate_project_quote(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|||||||
@@ -104,12 +104,31 @@ class FinanceSyncResponse(BaseModel):
|
|||||||
details: List[FinanceSyncResult] = Field(default_factory=list)
|
details: List[FinanceSyncResult] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceSyncRequest(BaseModel):
|
||||||
|
"""
|
||||||
|
邮箱附件同步策略:
|
||||||
|
- mode=incremental:默认策略。首次(无历史)全量,否则仅同步 UNSEEN。
|
||||||
|
- mode=all:同步全部附件(可配合时间范围)。
|
||||||
|
- mode=latest:只同步「最新一封」邮件中的附件(可配合时间范围)。
|
||||||
|
时间范围为任意起止日期(含起止日),内部会转为 IMAP 的 SINCE/BEFORE。
|
||||||
|
"""
|
||||||
|
mode: str = Field("incremental", description="incremental | all | latest")
|
||||||
|
start_date: Optional[date] = Field(None, description="YYYY-MM-DD")
|
||||||
|
end_date: Optional[date] = Field(None, description="YYYY-MM-DD")
|
||||||
|
doc_types: Optional[List[str]] = Field(
|
||||||
|
None,
|
||||||
|
description="要同步的附件类型:invoices/receipts/statements。为空表示默认全部类型。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FinanceRecordRead(BaseModel):
|
class FinanceRecordRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
month: str
|
month: str
|
||||||
type: str
|
type: str
|
||||||
file_name: str
|
file_name: str
|
||||||
file_path: str
|
file_path: str
|
||||||
|
tags: Optional[str] = None
|
||||||
|
meta_json: Optional[str] = None
|
||||||
amount: Optional[float] = None
|
amount: Optional[float] = None
|
||||||
billing_date: Optional[date] = None
|
billing_date: Optional[date] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -123,6 +142,10 @@ class FinanceRecordUpdate(BaseModel):
|
|||||||
billing_date: Optional[date] = None
|
billing_date: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceBatchDeleteRequest(BaseModel):
|
||||||
|
ids: List[int] = Field(..., description="要删除的财务记录 ID 列表")
|
||||||
|
|
||||||
|
|
||||||
class FinanceUploadResponse(BaseModel):
|
class FinanceUploadResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
month: str
|
month: str
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Tuple
|
from typing import Any, Dict, Tuple, List
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
from openai import NotFoundError as OpenAINotFoundError
|
from openai import NotFoundError as OpenAINotFoundError
|
||||||
@@ -197,6 +197,65 @@ async def extract_invoice_metadata(image_bytes: bytes, mime: str = "image/jpeg")
|
|||||||
api_key = (config.get("api_key") or "").strip()
|
api_key = (config.get("api_key") or "").strip()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_finance_tags(
|
||||||
|
content_text: str,
|
||||||
|
doc_type: str,
|
||||||
|
filename: str = "",
|
||||||
|
) -> Tuple[List[str], Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
从附件文本内容中抽取标签与结构化信息(JSON)。
|
||||||
|
返回 (tags, meta)。
|
||||||
|
"""
|
||||||
|
config = _load_ai_config()
|
||||||
|
client = _client_from_config(config)
|
||||||
|
model = config.get("model_name") or "gpt-4o-mini"
|
||||||
|
temperature = float(config.get("temperature", 0.2))
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"你是一名财务助理。请根据附件的文本内容,为它生成可检索的标签,并抽取关键字段。\n"
|
||||||
|
"只返回 JSON,不要任何解释文字。\n"
|
||||||
|
"输入信息:\n"
|
||||||
|
f"- 类型 doc_type: {doc_type}\n"
|
||||||
|
f"- 文件名 filename: {filename}\n"
|
||||||
|
"- 附件文本 content_text: (见下)\n\n"
|
||||||
|
"返回 JSON 格式:\n"
|
||||||
|
"{\n"
|
||||||
|
' "tags": ["标签1","标签2"],\n'
|
||||||
|
' "meta": {\n'
|
||||||
|
' "counterparty": "对方单位/收款方/付款方(如能识别)或 null",\n'
|
||||||
|
' "account": "账户/卡号后四位(如能识别)或 null",\n'
|
||||||
|
' "amount": "金额数字字符串或 null",\n'
|
||||||
|
' "date": "YYYY-MM-DD 或 null",\n'
|
||||||
|
' "summary": "一句话摘要"\n'
|
||||||
|
" }\n"
|
||||||
|
"}\n\n"
|
||||||
|
"content_text:\n"
|
||||||
|
f"{content_text[:12000]}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
completion = await client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
response_format={"type": "json_object"},
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=500,
|
||||||
|
)
|
||||||
|
content = completion.choices[0].message.content or "{}"
|
||||||
|
try:
|
||||||
|
data: Any = json.loads(content)
|
||||||
|
except Exception:
|
||||||
|
return ([], {"summary": "", "raw": content})
|
||||||
|
|
||||||
|
tags = data.get("tags") if isinstance(data, dict) else None
|
||||||
|
meta = data.get("meta") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(tags, list):
|
||||||
|
tags = []
|
||||||
|
tags = [str(t).strip() for t in tags if str(t).strip()][:12]
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
meta = {}
|
||||||
|
return (tags, meta)
|
||||||
try:
|
try:
|
||||||
client = _client_from_config(config)
|
client = _client_from_config(config)
|
||||||
model = config.get("model_name") or "gpt-4o-mini"
|
model = config.get("model_name") or "gpt-4o-mini"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import ssl
|
import ssl
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timedelta
|
||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Tuple
|
||||||
@@ -109,6 +109,29 @@ def _run_invoice_ocr_sync(file_path: str, mime: str, raw_bytes: bytes) -> Tuple[
|
|||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text_for_tagging(file_path: str, mime: str, raw_bytes: bytes) -> str:
|
||||||
|
"""
|
||||||
|
Extract best-effort text from PDF/image/xlsx for tagging.
|
||||||
|
- PDF: extract text via fitz; fallback to first page OCR image (handled elsewhere if needed)
|
||||||
|
- Image: no local OCR here; return empty and let AI decide (optional)
|
||||||
|
- XLSX: not parsed currently
|
||||||
|
"""
|
||||||
|
p = Path(file_path)
|
||||||
|
suf = p.suffix.lower()
|
||||||
|
if suf == ".pdf" or "pdf" in (mime or "").lower():
|
||||||
|
try:
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
doc = fitz.open(stream=raw_bytes, filetype="pdf")
|
||||||
|
texts: list[str] = []
|
||||||
|
for i in range(min(5, doc.page_count)):
|
||||||
|
texts.append(doc.load_page(i).get_text("text") or "")
|
||||||
|
doc.close()
|
||||||
|
return "\n".join(texts).strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _rename_invoice_file(
|
def _rename_invoice_file(
|
||||||
file_path: str,
|
file_path: str,
|
||||||
amount: float | None,
|
amount: float | None,
|
||||||
@@ -173,6 +196,7 @@ def _has_sync_history() -> bool:
|
|||||||
def _save_attachment(
|
def _save_attachment(
|
||||||
msg: email.message.Message,
|
msg: email.message.Message,
|
||||||
month_str: str,
|
month_str: str,
|
||||||
|
allowed_doc_types: set[str] | None = None,
|
||||||
) -> List[Tuple[str, str, str, bytes, str]]:
|
) -> List[Tuple[str, str, str, bytes, str]]:
|
||||||
"""
|
"""
|
||||||
Save PDF/image attachments.
|
Save PDF/image attachments.
|
||||||
@@ -193,17 +217,20 @@ def _save_attachment(
|
|||||||
_ensure_sync_history_table(conn)
|
_ensure_sync_history_table(conn)
|
||||||
|
|
||||||
for part in msg.walk():
|
for part in msg.walk():
|
||||||
content_disposition = part.get("Content-Disposition", "")
|
# 许多邮件附件会以 inline 或缺失 Content-Disposition 的形式出现,
|
||||||
if "attachment" not in content_disposition:
|
# 只要存在 filename 且扩展名符合,就视为可下载附件。
|
||||||
continue
|
content_disposition = (part.get("Content-Disposition", "") or "").lower()
|
||||||
|
|
||||||
filename = part.get_filename()
|
filename = part.get_filename()
|
||||||
filename = _decode_header_value(filename)
|
filename = _decode_header_value(filename)
|
||||||
if not filename:
|
if not filename:
|
||||||
continue
|
continue
|
||||||
|
if content_disposition and ("attachment" not in content_disposition and "inline" not in content_disposition):
|
||||||
|
# 明确的非附件 disposition,跳过
|
||||||
|
continue
|
||||||
|
|
||||||
ext = Path(filename).suffix.lower()
|
ext = Path(filename).suffix.lower()
|
||||||
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".xlsx"):
|
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp", ".xlsx", ".xls"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
maintype = part.get_content_maintype()
|
maintype = part.get_content_maintype()
|
||||||
@@ -216,6 +243,8 @@ def _save_attachment(
|
|||||||
|
|
||||||
# 分类:基于主题 + 文件名
|
# 分类:基于主题 + 文件名
|
||||||
doc_type = _classify_type(subject, filename)
|
doc_type = _classify_type(subject, filename)
|
||||||
|
if allowed_doc_types is not None and doc_type not in allowed_doc_types:
|
||||||
|
continue
|
||||||
base_dir = _ensure_month_dir(month_str, doc_type)
|
base_dir = _ensure_month_dir(month_str, doc_type)
|
||||||
|
|
||||||
# 增量去重:根据 (message_id, md5) 判断是否已同步过
|
# 增量去重:根据 (message_id, md5) 判断是否已同步过
|
||||||
@@ -421,7 +450,56 @@ def _select_mailbox(imap: imaplib.IMAP4_SSL, mailbox: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _sync_one_account(config: Dict[str, Any], db: Session, results: List[Dict[str, Any]]) -> None:
|
def _imap_date(d: date) -> str:
|
||||||
|
# IMAP date format: 16-Mar-2026 (English month)
|
||||||
|
import calendar
|
||||||
|
return f"{d.day:02d}-{calendar.month_abbr[d.month]}-{d.year}"
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_latest_msg_id(imap: imaplib.IMAP4_SSL, msg_ids: List[bytes]) -> bytes | None:
|
||||||
|
"""从一批 msg_id 中按 INTERNALDATE 选择最新的一封。"""
|
||||||
|
latest_id: bytes | None = None
|
||||||
|
latest_ts: float = -1.0
|
||||||
|
for mid in msg_ids:
|
||||||
|
try:
|
||||||
|
typ, data = imap.fetch(mid, "(INTERNALDATE)")
|
||||||
|
if typ != "OK" or not data or not data[0]:
|
||||||
|
continue
|
||||||
|
# imaplib.Internaldate2tuple expects a bytes response line
|
||||||
|
raw = data[0]
|
||||||
|
if isinstance(raw, tuple):
|
||||||
|
raw = raw[0]
|
||||||
|
if not isinstance(raw, (bytes, bytearray)):
|
||||||
|
raw = str(raw).encode("utf-8", errors="ignore")
|
||||||
|
t = imaplib.Internaldate2tuple(raw)
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
import time
|
||||||
|
ts = time.mktime(t)
|
||||||
|
if ts > latest_ts:
|
||||||
|
latest_ts = ts
|
||||||
|
latest_id = mid
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return latest_id
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_one_account(
|
||||||
|
config: Dict[str, Any],
|
||||||
|
db: Session,
|
||||||
|
results: List[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
mode: str = "incremental",
|
||||||
|
start_date: date | None = None,
|
||||||
|
end_date: date | None = None,
|
||||||
|
doc_types: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
allowed: set[str] | None = None
|
||||||
|
if doc_types:
|
||||||
|
allowed = {d.strip().lower() for d in doc_types if d and d.strip()}
|
||||||
|
allowed = {d for d in allowed if d in ("invoices", "receipts", "statements")}
|
||||||
|
if not allowed:
|
||||||
|
allowed = None
|
||||||
host = config.get("host")
|
host = config.get("host")
|
||||||
user = config.get("user")
|
user = config.get("user")
|
||||||
password = config.get("password")
|
password = config.get("password")
|
||||||
@@ -461,20 +539,53 @@ def _sync_one_account(config: Dict[str, Any], db: Session, results: List[Dict[st
|
|||||||
f"无法选择邮箱「{mailbox}」,请检查该账户的 Mailbox 配置(如 163 使用 INBOX)"
|
f"无法选择邮箱「{mailbox}」,请检查该账户的 Mailbox 配置(如 163 使用 INBOX)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 首次同步(历史库无记录):拉取全部邮件中的附件,由 attachment_history 去重
|
# 支持:
|
||||||
# 已有历史:只拉取未读邮件,避免重复拉取
|
# - mode=incremental: 首次全量,否则 UNSEEN
|
||||||
|
# - mode=all: 全量(可加时间范围)
|
||||||
|
# - mode=latest: 仅最新一封(可加时间范围)
|
||||||
|
mode = (mode or "incremental").strip().lower()
|
||||||
|
if mode not in ("incremental", "all", "latest"):
|
||||||
|
mode = "incremental"
|
||||||
|
|
||||||
is_first_sync = not _has_sync_history()
|
is_first_sync = not _has_sync_history()
|
||||||
search_criterion = "ALL" if is_first_sync else "UNSEEN"
|
base_criterion = "ALL"
|
||||||
|
if mode == "incremental":
|
||||||
|
base_criterion = "ALL" if is_first_sync else "UNSEEN"
|
||||||
|
elif mode == "all":
|
||||||
|
base_criterion = "ALL"
|
||||||
|
elif mode == "latest":
|
||||||
|
base_criterion = "ALL"
|
||||||
|
|
||||||
|
criteria: List[str] = [base_criterion]
|
||||||
|
if start_date:
|
||||||
|
criteria += ["SINCE", _imap_date(start_date)]
|
||||||
|
if end_date:
|
||||||
|
# BEFORE is exclusive; add one day to make end_date inclusive
|
||||||
|
criteria += ["BEFORE", _imap_date(end_date + timedelta(days=1))]
|
||||||
|
|
||||||
logging.getLogger(__name__).info(
|
logging.getLogger(__name__).info(
|
||||||
"Finance sync: %s (criterion=%s)",
|
"Finance sync: mode=%s criterion=%s range=%s~%s",
|
||||||
"全量" if is_first_sync else "增量",
|
mode,
|
||||||
search_criterion,
|
base_criterion,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
)
|
)
|
||||||
status, data = imap.search(None, search_criterion)
|
|
||||||
|
status, data = imap.search(None, *criteria)
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
return
|
return
|
||||||
|
|
||||||
id_list = data[0].split()
|
id_list: List[bytes] = data[0].split() if data and data[0] else []
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
"Finance sync: matched messages=%d (mode=%s)", len(id_list), mode
|
||||||
|
)
|
||||||
|
if not id_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode == "latest":
|
||||||
|
latest = _pick_latest_msg_id(imap, id_list)
|
||||||
|
id_list = [latest] if latest else []
|
||||||
|
|
||||||
for msg_id in id_list:
|
for msg_id in id_list:
|
||||||
status, msg_data = imap.fetch(msg_id, "(RFC822)")
|
status, msg_data = imap.fetch(msg_id, "(RFC822)")
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
@@ -485,7 +596,7 @@ def _sync_one_account(config: Dict[str, Any], db: Session, results: List[Dict[st
|
|||||||
dt = _parse_email_date(msg)
|
dt = _parse_email_date(msg)
|
||||||
month_str = dt.strftime("%Y-%m")
|
month_str = dt.strftime("%Y-%m")
|
||||||
|
|
||||||
saved = _save_attachment(msg, month_str)
|
saved = _save_attachment(msg, month_str, allowed_doc_types=allowed)
|
||||||
for file_name, file_path, mime, raw_bytes, doc_type in saved:
|
for file_name, file_path, mime, raw_bytes, doc_type in saved:
|
||||||
final_name = file_name
|
final_name = file_name
|
||||||
final_path = file_path
|
final_path = file_path
|
||||||
@@ -510,11 +621,28 @@ def _sync_one_account(config: Dict[str, Any], db: Session, results: List[Dict[st
|
|||||||
type=doc_type,
|
type=doc_type,
|
||||||
file_name=final_name,
|
file_name=final_name,
|
||||||
file_path=final_path,
|
file_path=final_path,
|
||||||
|
tags=None,
|
||||||
|
meta_json=None,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
billing_date=billing_date,
|
billing_date=billing_date,
|
||||||
)
|
)
|
||||||
db.add(record)
|
db.add(record)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
# 自动识别打标签(同步后自动跑)
|
||||||
|
try:
|
||||||
|
from backend.app.services.ai_service import extract_finance_tags
|
||||||
|
content_text = _extract_text_for_tagging(final_path, mime, raw_bytes)
|
||||||
|
tags, meta = asyncio.run(extract_finance_tags(content_text, doc_type, final_name)) # type: ignore[arg-type]
|
||||||
|
if tags:
|
||||||
|
record.tags = ",".join(tags)
|
||||||
|
if meta:
|
||||||
|
import json as _json
|
||||||
|
record.meta_json = _json.dumps(meta, ensure_ascii=False)
|
||||||
|
db.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"id": record.id,
|
"id": record.id,
|
||||||
"month": record.month,
|
"month": record.month,
|
||||||
@@ -526,7 +654,13 @@ def _sync_one_account(config: Dict[str, Any], db: Session, results: List[Dict[st
|
|||||||
imap.store(msg_id, "+FLAGS", "\\Seen \\Flagged")
|
imap.store(msg_id, "+FLAGS", "\\Seen \\Flagged")
|
||||||
|
|
||||||
|
|
||||||
async def sync_finance_emails() -> List[Dict[str, Any]]:
|
async def sync_finance_emails(
|
||||||
|
*,
|
||||||
|
mode: str = "incremental",
|
||||||
|
start_date: date | None = None,
|
||||||
|
end_date: date | None = None,
|
||||||
|
doc_types: list[str] | None = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Sync from all active email configs (data/email_configs.json).
|
Sync from all active email configs (data/email_configs.json).
|
||||||
Falls back to env vars if no configs. Classifies into invoices/, receipts/, statements/.
|
Falls back to env vars if no configs. Classifies into invoices/, receipts/, statements/.
|
||||||
@@ -546,7 +680,15 @@ async def sync_finance_emails() -> List[Dict[str, Any]]:
|
|||||||
try:
|
try:
|
||||||
for config in configs:
|
for config in configs:
|
||||||
try:
|
try:
|
||||||
_sync_one_account(config, db, results)
|
_sync_one_account(
|
||||||
|
config,
|
||||||
|
db,
|
||||||
|
results,
|
||||||
|
mode=mode,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
doc_types=doc_types,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 不让单个账户的异常中断全部同步,记录错误并继续其他账户。
|
# 不让单个账户的异常中断全部同步,记录错误并继续其他账户。
|
||||||
user = config.get("user", "") or config.get("id", "")
|
user = config.get("user", "") or config.get("id", "")
|
||||||
|
|||||||
@@ -21,22 +21,23 @@ services:
|
|||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend_spa
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.dev
|
||||||
|
image: aitool-frontend-dev
|
||||||
|
working_dir: /app
|
||||||
container_name: ops-core-frontend
|
container_name: ops-core-frontend
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend_spa:/app
|
||||||
# 保留容器内 node_modules,避免被宿主机目录覆盖
|
- frontend_spa_node_modules:/app/node_modules
|
||||||
- frontend_node_modules:/app/node_modules
|
# 宿主机 3001 避免与占用 3000 的进程冲突;访问 http://localhost:3001
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
NEXT_TELEMETRY_DISABLED: 1
|
VITE_DEV_PROXY_TARGET: http://backend:8000
|
||||||
# 开发模式:先装依赖(volume 首次为空),再 dev,代码变更热更新
|
# 开发模式:命令在 Dockerfile.dev 的 CMD 中
|
||||||
command: sh -c "npm install && npm run dev"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
frontend_node_modules:
|
frontend_spa_node_modules:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# 生产/默认:分层构建,仅代码变更时只重建最后一层。
|
# 生产/默认:前端为静态 SPA(Vite build + Nginx),后端 FastAPI。
|
||||||
# 开发(代码挂载+热重载): docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
# 开发:使用 docker-compose.dev.yml(容器内 Vite 热更新),执行: ./docker_dev.sh dev
|
||||||
# 或执行: ./docker_dev.sh dev
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
@@ -16,11 +15,11 @@ services:
|
|||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend_spa
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: ops-core-frontend
|
container_name: ops-core-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
cd "${SCRIPT_DIR}"
|
cd "${SCRIPT_DIR}"
|
||||||
|
|
||||||
COMPOSE_BASE="docker compose -f docker-compose.yml"
|
COMPOSE_BASE="docker compose -f docker-compose.yml"
|
||||||
COMPOSE_DEV="docker compose -f docker-compose.yml -f docker-compose.dev.yml"
|
# dev 直接使用独立 compose 文件,避免与生产 compose 合并导致 ports/镜像覆盖问题
|
||||||
|
COMPOSE_DEV="docker compose -f docker-compose.dev.yml"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "用法: $0 [mode]"
|
echo "用法: $0 [mode]"
|
||||||
echo ""
|
echo ""
|
||||||
echo " (无参数) 默认构建并启动(生产模式,分层构建仅重建变更层)"
|
echo " (无参数) 默认构建并启动(生产模式:前端为静态 SPA + Nginx,后端 FastAPI)"
|
||||||
echo " dev 开发模式:挂载代码 + 热重载(前台,Ctrl+C 会停容器)"
|
echo " dev 开发模式:容器内 Vite 热重载,前端 http://localhost:3001(Ctrl+C 停容器)"
|
||||||
echo " dev-bg 开发模式后台运行,Ctrl+C 不会停容器,停止用: $0 down"
|
echo " dev-bg 开发模式后台运行,前端 http://localhost:3001,停止用: $0 down"
|
||||||
echo " restart 仅重启容器内服务,不重建镜像"
|
echo " restart 仅重启容器内服务,不重建镜像"
|
||||||
echo " build 仅重新构建镜像(依赖未变时只重建代码层,较快)"
|
echo " build 仅重新构建镜像(依赖未变时只重建代码层,较快)"
|
||||||
echo " down 停止并移除容器"
|
echo " down 停止并移除容器"
|
||||||
@@ -22,11 +23,11 @@ usage() {
|
|||||||
|
|
||||||
case "${1:-up}" in
|
case "${1:-up}" in
|
||||||
up|"")
|
up|"")
|
||||||
echo "[Ops-Core] 构建并启动(生产模式)..."
|
echo "[Ops-Core] 构建并启动(生产模式:静态 SPA + API)..."
|
||||||
${COMPOSE_BASE} up --build
|
${COMPOSE_BASE} up --build
|
||||||
;;
|
;;
|
||||||
dev)
|
dev)
|
||||||
echo "[Ops-Core] 开发模式:代码挂载 + 热重载,无需重建(Ctrl+C 会停止容器)..."
|
echo "[Ops-Core] 开发模式:容器内 Vite 热重载,前端 http://localhost:3001(Ctrl+C 停止)..."
|
||||||
${COMPOSE_DEV} up --build
|
${COMPOSE_DEV} up --build
|
||||||
;;
|
;;
|
||||||
dev-bg)
|
dev-bg)
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# 国内镜像,加快安装、避免卡死(Docker 内已单独配置,此文件供本地/CI 使用)
|
|
||||||
registry=https://registry.npmmirror.com
|
|
||||||
fetch-retries=5
|
|
||||||
fetch-timeout=60000
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 分层构建:依赖与代码分离,仅代码变更时只重建 COPY 及以后层
|
|
||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 使用国内镜像源,加快安装并减少网络卡死
|
|
||||||
RUN npm config set registry https://registry.npmmirror.com \
|
|
||||||
&& npm config set fetch-retries 5 \
|
|
||||||
&& npm config set fetch-timeout 60000 \
|
|
||||||
&& npm config set fetch-retry-mintimeout 10000
|
|
||||||
|
|
||||||
# 依赖层:只有 package.json / package-lock 变更时才重建
|
|
||||||
COPY package.json ./
|
|
||||||
RUN npm install --prefer-offline --no-audit --progress=false
|
|
||||||
|
|
||||||
# 代码层:业务代码变更只重建此层及后续 build
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-full flex-col items-center justify-center p-8">
|
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Ops-Core</h1>
|
|
||||||
<p className="mt-2 text-muted-foreground">自动化办公与业务中台</p>
|
|
||||||
<div className="mt-6 flex gap-3">
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/workspace">需求与方案工作台</Link>
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href="/finance">财务归档</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Ops-Core | 自动化办公与业务中台",
|
|
||||||
description: "Monolithic automation & business ops platform",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<body className="antialiased min-h-screen">{children}</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "default",
|
|
||||||
"rsc": true,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.ts",
|
|
||||||
"css": "app/globals.css",
|
|
||||||
"baseColor": "slate",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide"
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const Separator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
|
||||||
ref
|
|
||||||
) => (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 bg-border",
|
|
||||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
|
||||||
|
|
||||||
export { Separator };
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const Table = React.forwardRef<
|
|
||||||
HTMLTableElement,
|
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div className="relative w-full overflow-auto">
|
|
||||||
<table
|
|
||||||
ref={ref}
|
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
Table.displayName = "Table";
|
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
|
||||||
));
|
|
||||||
TableHeader.displayName = "TableHeader";
|
|
||||||
|
|
||||||
const TableBody = React.forwardRef<
|
|
||||||
HTMLTableSectionElement,
|
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tbody
|
|
||||||
ref={ref}
|
|
||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
TableBody.displayName = "TableBody";
|
|
||||||
|
|
||||||
const TableRow = React.forwardRef<
|
|
||||||
HTMLTableRowElement,
|
|
||||||
React.HTMLAttributes<HTMLTableRowElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<tr
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
TableRow.displayName = "TableRow";
|
|
||||||
|
|
||||||
const TableHead = React.forwardRef<
|
|
||||||
HTMLTableCellElement,
|
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<th
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
TableHead.displayName = "TableHead";
|
|
||||||
|
|
||||||
const TableCell = React.forwardRef<
|
|
||||||
HTMLTableCellElement,
|
|
||||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<td
|
|
||||||
ref={ref}
|
|
||||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
TableCell.displayName = "TableCell";
|
|
||||||
|
|
||||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* 快捷门户地址配置
|
|
||||||
* 通过环境变量 NEXT_PUBLIC_* 覆盖,未设置时使用默认值
|
|
||||||
*/
|
|
||||||
const DEFAULTS = {
|
|
||||||
/** 国家税务总局门户 */
|
|
||||||
TAX_GATEWAY_URL: "https://www.chinatax.gov.cn",
|
|
||||||
/** 电子税务局(如上海电子税务局) */
|
|
||||||
TAX_PORTAL_URL: "https://etax.shanghai.chinatax.gov.cn:8443/",
|
|
||||||
/** 公积金管理中心 */
|
|
||||||
HOUSING_FUND_PORTAL_URL: "https://www.shzfgjj.cn/static/unit/web/",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const portalConfig = {
|
|
||||||
taxGatewayUrl:
|
|
||||||
process.env.NEXT_PUBLIC_TAX_GATEWAY_URL ?? DEFAULTS.TAX_GATEWAY_URL,
|
|
||||||
taxPortalUrl:
|
|
||||||
process.env.NEXT_PUBLIC_TAX_PORTAL_URL ?? DEFAULTS.TAX_PORTAL_URL,
|
|
||||||
housingFundPortalUrl:
|
|
||||||
process.env.NEXT_PUBLIC_HOUSING_FUND_PORTAL_URL ??
|
|
||||||
DEFAULTS.HOUSING_FUND_PORTAL_URL,
|
|
||||||
} as const;
|
|
||||||
5
frontend/next-env.d.ts
vendored
5
frontend/next-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
reactStrictMode: true,
|
|
||||||
// Proxy API to FastAPI in dev if needed (optional; frontend can call API_BASE directly)
|
|
||||||
async rewrites() {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [{ "name": "next" }],
|
|
||||||
"paths": { "@/*": ["./*"] }
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
17
frontend_spa/Dockerfile
Normal file
17
frontend_spa/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --progress=false
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
13
frontend_spa/Dockerfile.dev
Normal file
13
frontend_spa/Dockerfile.dev
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 仅用于开发:容器内跑 Vite dev server
|
||||||
|
# 依赖用 volume 缓存(frontend_spa_node_modules),首次启动会 npm install
|
||||||
|
|
||||||
|
RUN apk add --no-cache nodejs npm
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0 --port 3000"]
|
||||||
|
|
||||||
13
frontend_spa/index.html
Normal file
13
frontend_spa/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ops-Core</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
47
frontend_spa/nginx.conf
Normal file
47
frontend_spa/nginx.conf
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass http://backend:8000/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /data/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass http://backend:8000/data/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/projects/analyze_stream {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_send_timeout 1h;
|
||||||
|
chunked_transfer_encoding off;
|
||||||
|
add_header X-Accel-Buffering no;
|
||||||
|
|
||||||
|
proxy_pass http://backend:8000/projects/analyze_stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
5610
frontend/package-lock.json → frontend_spa/package-lock.json
generated
5610
frontend/package-lock.json → frontend_spa/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "ops-core-frontend",
|
"name": "ops-core-frontend-spa",
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc -p tsconfig.json && vite build",
|
||||||
"start": "next start",
|
"preview": "vite preview"
|
||||||
"lint": "next lint"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
@@ -23,24 +24,24 @@
|
|||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "14.2.18",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
"tailwind-merge": "^2.5.4"
|
"tailwind-merge": "^2.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/node": "^22.9.0",
|
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8.57.1",
|
|
||||||
"eslint-config-next": "14.2.18",
|
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.14",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,3 +7,4 @@ const config = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { usePathname } from "next/navigation";
|
import { useEffect, useState, useCallback, memo, useRef } from "react";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
FolderArchive,
|
FolderArchive,
|
||||||
@@ -16,45 +15,82 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HistoricalReferences } from "@/components/historical-references";
|
import { HistoricalReferences } from "@/components/historical-references";
|
||||||
import { cloudDocsApi, portalLinksApi, type CloudDocLinkRead, type PortalLinkRead } from "@/lib/api/client";
|
import {
|
||||||
|
cloudDocsApi,
|
||||||
|
portalLinksApi,
|
||||||
|
type CloudDocLinkRead,
|
||||||
|
type PortalLinkRead,
|
||||||
|
} from "@/lib/api/client";
|
||||||
|
|
||||||
|
const MemoHistoricalReferences = memo(HistoricalReferences);
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 60_000;
|
||||||
|
function readCache<T>(key: string): T | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
if (typeof parsed.t !== "number") return null;
|
||||||
|
if (Date.now() - parsed.t > CACHE_TTL_MS) return null;
|
||||||
|
return parsed.v as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function writeCache<T>(key: string, value: T) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(key, JSON.stringify({ t: Date.now(), v: value }));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const pathname = usePathname();
|
const { pathname } = useLocation();
|
||||||
const [cloudDocs, setCloudDocs] = useState<CloudDocLinkRead[]>([]);
|
const [cloudDocs, setCloudDocs] = useState<CloudDocLinkRead[]>([]);
|
||||||
const [portalLinks, setPortalLinks] = useState<PortalLinkRead[]>([]);
|
const [portalLinks, setPortalLinks] = useState<PortalLinkRead[]>([]);
|
||||||
const [projectArchiveOpen, setProjectArchiveOpen] = useState(false);
|
const [projectArchiveOpen, setProjectArchiveOpen] = useState(false);
|
||||||
|
const didInitRef = useRef(false);
|
||||||
|
|
||||||
const loadCloudDocs = useCallback(async () => {
|
const loadCloudDocs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await cloudDocsApi.list();
|
const list = await cloudDocsApi.list();
|
||||||
setCloudDocs(list);
|
setCloudDocs(list);
|
||||||
|
writeCache("opc_cloud_docs", list);
|
||||||
} catch {
|
} catch {
|
||||||
setCloudDocs([]);
|
setCloudDocs([]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadCloudDocs();
|
|
||||||
}, [loadCloudDocs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onCloudDocsChanged = () => loadCloudDocs();
|
|
||||||
window.addEventListener("cloud-docs-changed", onCloudDocsChanged);
|
|
||||||
return () => window.removeEventListener("cloud-docs-changed", onCloudDocsChanged);
|
|
||||||
}, [loadCloudDocs]);
|
|
||||||
|
|
||||||
const loadPortalLinks = useCallback(async () => {
|
const loadPortalLinks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await portalLinksApi.list();
|
const list = await portalLinksApi.list();
|
||||||
setPortalLinks(list);
|
setPortalLinks(list);
|
||||||
|
writeCache("opc_portal_links", list);
|
||||||
} catch {
|
} catch {
|
||||||
setPortalLinks([]);
|
setPortalLinks([]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPortalLinks();
|
if (didInitRef.current) return;
|
||||||
}, [loadPortalLinks]);
|
didInitRef.current = true;
|
||||||
|
|
||||||
|
const cachedDocs = readCache<CloudDocLinkRead[]>("opc_cloud_docs");
|
||||||
|
if (cachedDocs) setCloudDocs(cachedDocs);
|
||||||
|
const cachedPortals = readCache<PortalLinkRead[]>("opc_portal_links");
|
||||||
|
if (cachedPortals) setPortalLinks(cachedPortals);
|
||||||
|
|
||||||
|
void loadCloudDocs();
|
||||||
|
void loadPortalLinks();
|
||||||
|
}, [loadCloudDocs, loadPortalLinks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCloudDocsChanged = () => loadCloudDocs();
|
||||||
|
window.addEventListener("cloud-docs-changed", onCloudDocsChanged);
|
||||||
|
return () => window.removeEventListener("cloud-docs-changed", onCloudDocsChanged);
|
||||||
|
}, [loadCloudDocs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPortalLinksChanged = () => loadPortalLinks();
|
const onPortalLinksChanged = () => loadPortalLinks();
|
||||||
@@ -71,7 +107,7 @@ export function AppSidebar() {
|
|||||||
return (
|
return (
|
||||||
<aside className="flex w-56 flex-col border-r bg-card text-card-foreground">
|
<aside className="flex w-56 flex-col border-r bg-card text-card-foreground">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<Link href="/" className="font-semibold text-foreground">
|
<Link to="/" className="font-semibold text-foreground">
|
||||||
Ops-Core
|
Ops-Core
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">自动化办公中台</p>
|
<p className="text-xs text-muted-foreground mt-0.5">自动化办公中台</p>
|
||||||
@@ -79,7 +115,7 @@ export function AppSidebar() {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<nav className="flex flex-1 flex-col gap-1 p-2">
|
<nav className="flex flex-1 flex-col gap-1 p-2">
|
||||||
{nav.map((item) => (
|
{nav.map((item) => (
|
||||||
<Link key={item.href} href={item.href}>
|
<Link key={item.href} to={item.href}>
|
||||||
<Button
|
<Button
|
||||||
variant={pathname === item.href ? "secondary" : "ghost"}
|
variant={pathname === item.href ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -92,7 +128,6 @@ export function AppSidebar() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<Separator />
|
<Separator />
|
||||||
{/* 项目档案放在云文档之前,支持收纳折叠 */}
|
|
||||||
<div className="px-2 pt-2">
|
<div className="px-2 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -103,19 +138,19 @@ export function AppSidebar() {
|
|||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-3 w-3 transition-transform",
|
"h-3 w-3 transition-transform",
|
||||||
projectArchiveOpen ? "rotate-180" : "rotate-0"
|
projectArchiveOpen ? "rotate-180" : "rotate-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{projectArchiveOpen && <HistoricalReferences />}
|
{projectArchiveOpen && <MemoHistoricalReferences />}
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||||
<div className="p-2 shrink-0">
|
<div className="p-2 shrink-0">
|
||||||
<div className="flex items-center justify-between px-2 mb-2">
|
<div className="flex items-center justify-between px-2 mb-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground">云文档</p>
|
<p className="text-xs font-medium text-muted-foreground">云文档</p>
|
||||||
<Link
|
<Link
|
||||||
href="/settings/cloud-docs"
|
to="/settings/cloud-docs"
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
title="管理云文档入口"
|
title="管理云文档入口"
|
||||||
>
|
>
|
||||||
@@ -133,7 +168,7 @@ export function AppSidebar() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FileStack className="h-4 w-4 shrink-0" />
|
<FileStack className="h-4 w-4 shrink-0" />
|
||||||
@@ -147,7 +182,7 @@ export function AppSidebar() {
|
|||||||
<div className="flex items-center justify-between px-2 mb-2">
|
<div className="flex items-center justify-between px-2 mb-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground">快捷门户</p>
|
<p className="text-xs font-medium text-muted-foreground">快捷门户</p>
|
||||||
<Link
|
<Link
|
||||||
href="/settings/portal-links"
|
to="/settings/portal-links"
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
title="管理快捷门户"
|
title="管理快捷门户"
|
||||||
>
|
>
|
||||||
@@ -165,7 +200,7 @@ export function AppSidebar() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Globe className="h-4 w-4 shrink-0" />
|
<Globe className="h-4 w-4 shrink-0" />
|
||||||
@@ -179,3 +214,4 @@ export function AppSidebar() {
|
|||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -27,9 +28,8 @@ import {
|
|||||||
type ProjectRead,
|
type ProjectRead,
|
||||||
type CustomerRead,
|
type CustomerRead,
|
||||||
} from "@/lib/api/client";
|
} from "@/lib/api/client";
|
||||||
import { Copy, Search, FileText, Eye, Pencil, Loader2 } from "lucide-react";
|
import { Copy, Search, Eye, Pencil, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
|
|
||||||
function parseTags(tagsStr: string | null | undefined): string[] {
|
function parseTags(tagsStr: string | null | undefined): string[] {
|
||||||
if (!tagsStr?.trim()) return [];
|
if (!tagsStr?.trim()) return [];
|
||||||
@@ -58,11 +58,12 @@ export function HistoricalReferences() {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await listProjects(
|
const data = await listProjects({
|
||||||
selectedTag ? { customer_tag: selectedTag } : undefined
|
customer_tag: selectedTag || undefined,
|
||||||
);
|
limit: 30,
|
||||||
|
});
|
||||||
setProjects(data);
|
setProjects(data);
|
||||||
} catch (e) {
|
} catch {
|
||||||
toast.error("加载历史项目失败");
|
toast.error("加载历史项目失败");
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -71,14 +72,12 @@ export function HistoricalReferences() {
|
|||||||
}, [selectedTag]);
|
}, [selectedTag]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const allTags = useMemo(() => {
|
const allTags = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
projects.forEach((p) =>
|
projects.forEach((p) => parseTags(p.customer?.tags ?? null).forEach((t) => set.add(t)));
|
||||||
parseTags(p.customer?.tags ?? null).forEach((t) => set.add(t))
|
|
||||||
);
|
|
||||||
return Array.from(set).sort();
|
return Array.from(set).sort();
|
||||||
}, [projects]);
|
}, [projects]);
|
||||||
|
|
||||||
@@ -89,7 +88,7 @@ export function HistoricalReferences() {
|
|||||||
list = list.filter(
|
list = list.filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.raw_requirement.toLowerCase().includes(q) ||
|
p.raw_requirement.toLowerCase().includes(q) ||
|
||||||
(p.ai_solution_md || "").toLowerCase().includes(q)
|
(p.ai_solution_md || "").toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return list.slice(0, 50);
|
return list.slice(0, 50);
|
||||||
@@ -99,9 +98,8 @@ export function HistoricalReferences() {
|
|||||||
const map = new Map<string, ProjectRead[]>();
|
const map = new Map<string, ProjectRead[]>();
|
||||||
filtered.forEach((p) => {
|
filtered.forEach((p) => {
|
||||||
const name = p.customer?.name || "未关联客户";
|
const name = p.customer?.name || "未关联客户";
|
||||||
const key = name;
|
if (!map.has(name)) map.set(name, []);
|
||||||
if (!map.has(key)) map.set(key, []);
|
map.get(name)!.push(p);
|
||||||
map.get(key)!.push(p);
|
|
||||||
});
|
});
|
||||||
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b, "zh-CN"));
|
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b, "zh-CN"));
|
||||||
}, [filtered]);
|
}, [filtered]);
|
||||||
@@ -129,8 +127,8 @@ export function HistoricalReferences() {
|
|||||||
});
|
});
|
||||||
toast.success("已保存");
|
toast.success("已保存");
|
||||||
setEditProject(null);
|
setEditProject(null);
|
||||||
load();
|
await load();
|
||||||
} catch (e) {
|
} catch {
|
||||||
toast.error("保存失败");
|
toast.error("保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -155,14 +153,11 @@ export function HistoricalReferences() {
|
|||||||
tags: customerTags.trim() || null,
|
tags: customerTags.trim() || null,
|
||||||
});
|
});
|
||||||
toast.success("客户信息已保存");
|
toast.success("客户信息已保存");
|
||||||
// 更新本地 projects 中的 customer 信息
|
|
||||||
setProjects((prev) =>
|
setProjects((prev) =>
|
||||||
prev.map((p) =>
|
prev.map((p) => (p.customer_id === updated.id ? { ...p, customer: updated } : p)),
|
||||||
p.customer_id === updated.id ? { ...p, customer: updated } : p
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
setEditCustomer(null);
|
setEditCustomer(null);
|
||||||
} catch (e) {
|
} catch {
|
||||||
toast.error("保存客户失败");
|
toast.error("保存客户失败");
|
||||||
} finally {
|
} finally {
|
||||||
setSavingCustomer(false);
|
setSavingCustomer(false);
|
||||||
@@ -173,10 +168,7 @@ export function HistoricalReferences() {
|
|||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="space-y-2 mb-2">
|
<div className="space-y-2 mb-2">
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
<Select
|
<Select value={selectedTag || "__all__"} onValueChange={(v) => setSelectedTag(v === "__all__" ? "" : v)}>
|
||||||
value={selectedTag || "__all__"}
|
|
||||||
onValueChange={(v) => setSelectedTag(v === "__all__" ? "" : v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs flex-1">
|
<SelectTrigger className="h-8 text-xs flex-1">
|
||||||
<SelectValue placeholder="按标签收纳" />
|
<SelectValue placeholder="按标签收纳" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -208,18 +200,11 @@ export function HistoricalReferences() {
|
|||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground px-2">暂无项目</p>
|
<p className="text-xs text-muted-foreground px-2">暂无项目</p>
|
||||||
) : (
|
) : (
|
||||||
groupedByCustomer.map(([customerName, items]) => (
|
groupedByCustomer.map(([customerName2, items]) => (
|
||||||
<div key={customerName} className="space-y-1">
|
<div key={customerName2} className="space-y-1">
|
||||||
<p className="px-2 text-[11px] font-medium text-muted-foreground">
|
<p className="px-2 text-[11px] font-medium text-muted-foreground">{customerName2}</p>
|
||||||
{customerName}
|
|
||||||
</p>
|
|
||||||
{items.map((p) => (
|
{items.map((p) => (
|
||||||
<div
|
<div key={p.id} className={cn("rounded border bg-background/50 p-2 text-xs space-y-1 ml-1")}>
|
||||||
key={p.id}
|
|
||||||
className={cn(
|
|
||||||
"rounded border bg-background/50 p-2 text-xs space-y-1 ml-1"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="font-medium text-foreground line-clamp-1 flex items-center justify-between gap-1">
|
<p className="font-medium text-foreground line-clamp-1 flex items-center justify-between gap-1">
|
||||||
<span>项目 #{p.id}</span>
|
<span>项目 #{p.id}</span>
|
||||||
{p.customer?.tags && (
|
{p.customer?.tags && (
|
||||||
@@ -228,9 +213,7 @@ export function HistoricalReferences() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground line-clamp-2">
|
<p className="text-muted-foreground line-clamp-2">{p.raw_requirement.slice(0, 80)}…</p>
|
||||||
{p.raw_requirement.slice(0, 80)}…
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1 pt-1">
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -278,7 +261,6 @@ export function HistoricalReferences() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 预览弹窗 */}
|
|
||||||
<Dialog open={!!previewProject} onOpenChange={() => setPreviewProject(null)}>
|
<Dialog open={!!previewProject} onOpenChange={() => setPreviewProject(null)}>
|
||||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -322,7 +304,6 @@ export function HistoricalReferences() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 二次编辑弹窗 */}
|
|
||||||
<Dialog open={!!editProject} onOpenChange={() => !saving && setEditProject(null)}>
|
<Dialog open={!!editProject} onOpenChange={() => !saving && setEditProject(null)}>
|
||||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -351,16 +332,13 @@ export function HistoricalReferences() {
|
|||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSaveEdit} disabled={saving}>
|
<Button onClick={handleSaveEdit} disabled={saving}>
|
||||||
{saving ? (
|
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
) : null}
|
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 客户二次编辑弹窗 */}
|
|
||||||
<Dialog open={!!editCustomer} onOpenChange={() => !savingCustomer && setEditCustomer(null)}>
|
<Dialog open={!!editCustomer} onOpenChange={() => !savingCustomer && setEditCustomer(null)}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -369,11 +347,7 @@ export function HistoricalReferences() {
|
|||||||
<div className="grid gap-4 py-2">
|
<div className="grid gap-4 py-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="hist-customer-name">客户名称</Label>
|
<Label htmlFor="hist-customer-name">客户名称</Label>
|
||||||
<Input
|
<Input id="hist-customer-name" value={customerName} onChange={(e) => setCustomerName(e.target.value)} />
|
||||||
id="hist-customer-name"
|
|
||||||
value={customerName}
|
|
||||||
onChange={(e) => setCustomerName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="hist-customer-contact">联系方式</Label>
|
<Label htmlFor="hist-customer-contact">联系方式</Label>
|
||||||
@@ -394,11 +368,7 @@ export function HistoricalReferences() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => setEditCustomer(null)} disabled={savingCustomer}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setEditCustomer(null)}
|
|
||||||
disabled={savingCustomer}
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSaveCustomer} disabled={savingCustomer}>
|
<Button onClick={handleSaveCustomer} disabled={savingCustomer}>
|
||||||
@@ -411,3 +381,4 @@ export function HistoricalReferences() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,9 +9,12 @@ const buttonVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
destructive:
|
||||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
@@ -26,7 +29,7 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
@@ -45,8 +48,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button, buttonVariants };
|
||||||
|
|
||||||
@@ -1,30 +1,22 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
HTMLDivElement,
|
({ className, ...props }, ref) => (
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
ref={ref}
|
||||||
<div
|
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn(
|
/>
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
),
|
||||||
className
|
);
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
Card.displayName = "Card";
|
Card.displayName = "Card";
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
const CardHeader = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
CardHeader.displayName = "CardHeader";
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
@@ -34,10 +26,7 @@ const CardTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<h3
|
<h3
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -47,11 +36,7 @@ const CardDescription = React.forwardRef<
|
|||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<p
|
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
CardDescription.displayName = "CardDescription";
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
@@ -67,12 +52,9 @@ const CardFooter = React.forwardRef<
|
|||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
CardFooter.displayName = "CardFooter";
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
|
|
||||||
46
frontend_spa/src/components/ui/checkbox.tsx
Normal file
46
frontend_spa/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface CheckboxProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> {
|
||||||
|
indeterminate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
CheckboxProps
|
||||||
|
>(({ className, indeterminate, checked, ...props }, ref) => {
|
||||||
|
const resolvedChecked =
|
||||||
|
indeterminate && !checked
|
||||||
|
? "indeterminate"
|
||||||
|
: (checked as CheckboxPrimitive.CheckedState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-input bg-background",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
"data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={resolvedChecked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -58,10 +58,7 @@ const DialogHeader = ({
|
|||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -74,7 +71,7 @@ const DialogFooter = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -117,3 +114,4 @@ export {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
@@ -11,14 +10,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
Input.displayName = "Input";
|
Input.displayName = "Input";
|
||||||
|
|
||||||
export { Input };
|
export { Input };
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const labelVariants = cva(
|
const labelVariants = cva(
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
);
|
);
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<
|
||||||
@@ -23,3 +23,4 @@ const Label = React.forwardRef<
|
|||||||
Label.displayName = LabelPrimitive.Root.displayName;
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Label };
|
export { Label };
|
||||||
|
|
||||||
24
frontend_spa/src/components/ui/progress.tsx
Normal file
24
frontend_spa/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value?: number; // 0-100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Progress({ value = 0, className, ...props }: ProgressProps) {
|
||||||
|
const v = Math.max(0, Math.min(100, value));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-transform duration-300 ease-out"
|
||||||
|
style={{ transform: `translateX(-${100 - v}%)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,9 +6,7 @@ import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root;
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
const SelectGroup = SelectPrimitive.Group;
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value;
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
const SelectTrigger = React.forwardRef<
|
||||||
@@ -19,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -37,10 +35,7 @@ const SelectScrollUpButton = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
@@ -54,17 +49,13 @@ const SelectScrollDownButton = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
));
|
));
|
||||||
SelectScrollDownButton.displayName =
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
SelectPrimitive.ScrollDownButton.displayName;
|
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
@@ -77,7 +68,7 @@ const SelectContent = React.forwardRef<
|
|||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -87,7 +78,7 @@ const SelectContent = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -118,7 +109,7 @@ const SelectItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -155,3 +146,4 @@ export {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
};
|
};
|
||||||
|
|
||||||
25
frontend_spa/src/components/ui/separator.tsx
Normal file
25
frontend_spa/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
65
frontend_spa/src/components/ui/table.tsx
Normal file
65
frontend_spa/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -44,7 +44,7 @@ const TabsContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -52,3 +52,4 @@ const TabsContent = React.forwardRef<
|
|||||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* API client for the FastAPI backend (Ops-Core).
|
* API client for the FastAPI backend (Ops-Core).
|
||||||
* Base URL is read from NEXT_PUBLIC_API_BASE (default http://localhost:8000).
|
*
|
||||||
|
* In SPA mode we default to same-origin `/api` (nginx reverse proxy).
|
||||||
|
* For local dev you can set VITE_API_BASE, e.g. `http://localhost:8000`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const getBase = (): string => {
|
const getBase = (): string => {
|
||||||
if (typeof window !== "undefined") {
|
const base = (import.meta.env.VITE_API_BASE as string | undefined) ?? "/api";
|
||||||
return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
return base.replace(/\/$/, "");
|
||||||
}
|
|
||||||
return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiBase = getBase;
|
export const apiBase = getBase;
|
||||||
@@ -86,6 +86,8 @@ export interface FinanceRecordRead {
|
|||||||
amount: number | null;
|
amount: number | null;
|
||||||
billing_date: string | null;
|
billing_date: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
tags?: string | null;
|
||||||
|
meta_json?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateInfo {
|
export interface TemplateInfo {
|
||||||
@@ -157,7 +159,7 @@ export interface ProjectUpdate {
|
|||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit & { params?: Record<string, string> } = {}
|
options: RequestInit & { params?: Record<string, string> } = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { params, ...init } = options;
|
const { params, ...init } = options;
|
||||||
const base = apiBase();
|
const base = apiBase();
|
||||||
@@ -169,6 +171,7 @@ async function request<T>(
|
|||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...init.headers,
|
...init.headers,
|
||||||
@@ -202,16 +205,13 @@ async function request<T>(
|
|||||||
/** Download a file from the backend and return a Blob (for Excel/PDF/Zip). */
|
/** Download a file from the backend and return a Blob (for Excel/PDF/Zip). */
|
||||||
export async function downloadBlob(path: string): Promise<Blob> {
|
export async function downloadBlob(path: string): Promise<Blob> {
|
||||||
const base = apiBase();
|
const base = apiBase();
|
||||||
const url = path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`;
|
const url =
|
||||||
|
path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||||
const res = await fetch(url, { credentials: "include" });
|
const res = await fetch(url, { credentials: "include" });
|
||||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||||
return res.blob();
|
return res.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger download in the browser (Excel, PDF, or Zip).
|
|
||||||
* path: backend-returned path e.g. "data/quotes/quote_project_1.xlsx" -> we request /data/quotes/quote_project_1.xlsx
|
|
||||||
*/
|
|
||||||
export function downloadFile(path: string, filename: string): void {
|
export function downloadFile(path: string, filename: string): void {
|
||||||
const base = apiBase();
|
const base = apiBase();
|
||||||
const normalized = path.startsWith("/") ? path : `/${path}`;
|
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||||
@@ -226,11 +226,10 @@ export function downloadFile(path: string, filename: string): void {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same as downloadFile but using fetch + Blob for consistent CORS and filename control. */
|
|
||||||
export async function downloadFileAsBlob(
|
export async function downloadFileAsBlob(
|
||||||
path: string,
|
path: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
mime?: string
|
_mime?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const blob = await downloadBlob(path);
|
const blob = await downloadBlob(path);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -248,7 +247,7 @@ export async function downloadFileAsBlob(
|
|||||||
export const customersApi = {
|
export const customersApi = {
|
||||||
list: (params?: { q?: string }) =>
|
list: (params?: { q?: string }) =>
|
||||||
request<CustomerRead[]>(
|
request<CustomerRead[]>(
|
||||||
params?.q ? `/customers/?q=${encodeURIComponent(params.q)}` : "/customers/"
|
params?.q ? `/customers/?q=${encodeURIComponent(params.q)}` : "/customers/",
|
||||||
),
|
),
|
||||||
get: (id: number) => request<CustomerRead>(`/customers/${id}`),
|
get: (id: number) => request<CustomerRead>(`/customers/${id}`),
|
||||||
create: (body: CustomerCreate) =>
|
create: (body: CustomerCreate) =>
|
||||||
@@ -261,8 +260,7 @@ export const customersApi = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
delete: (id: number) =>
|
delete: (id: number) => request<void>(`/customers/${id}`, { method: "DELETE" }),
|
||||||
request<void>(`/customers/${id}`, { method: "DELETE" }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --------------- Projects ---------------
|
// --------------- Projects ---------------
|
||||||
@@ -282,32 +280,27 @@ export const projectsApi = {
|
|||||||
|
|
||||||
generateQuote: (projectId: number, template?: string | null) =>
|
generateQuote: (projectId: number, template?: string | null) =>
|
||||||
request<QuoteGenerateResponse>(
|
request<QuoteGenerateResponse>(
|
||||||
`/projects/${projectId}/generate_quote${template ? `?template=${encodeURIComponent(template)}` : ""}`,
|
`/projects/${projectId}/generate_quote${
|
||||||
{ method: "POST" }
|
template ? `?template=${encodeURIComponent(template)}` : ""
|
||||||
|
}`,
|
||||||
|
{ method: "POST" },
|
||||||
),
|
),
|
||||||
|
|
||||||
generateContract: (projectId: number, body: ContractGenerateRequest) =>
|
generateContract: (projectId: number, body: ContractGenerateRequest) =>
|
||||||
request<ContractGenerateResponse>(
|
request<ContractGenerateResponse>(`/projects/${projectId}/generate_contract`, {
|
||||||
`/projects/${projectId}/generate_contract`,
|
method: "POST",
|
||||||
{
|
body: JSON.stringify(body),
|
||||||
method: "POST",
|
}),
|
||||||
body: JSON.stringify(body),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listProjects(params?: {
|
export async function listProjects(params?: {
|
||||||
customer_tag?: string;
|
customer_tag?: string;
|
||||||
|
limit?: number;
|
||||||
}): Promise<ProjectRead[]> {
|
}): Promise<ProjectRead[]> {
|
||||||
const searchParams: Record<string, string> = {};
|
const searchParams: Record<string, string> = {};
|
||||||
if (params?.customer_tag?.trim()) searchParams.customer_tag = params.customer_tag.trim();
|
if (params?.customer_tag?.trim()) searchParams.customer_tag = params.customer_tag.trim();
|
||||||
return request<ProjectRead[]>("/projects/", {
|
if (params?.limit != null) searchParams.limit = String(params.limit);
|
||||||
params: searchParams,
|
return request<ProjectRead[]>("/projects/", { params: searchParams });
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProject(projectId: number): Promise<ProjectRead> {
|
|
||||||
return request<ProjectRead>(`/projects/${projectId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------- Settings / Templates ---------------
|
// --------------- Settings / Templates ---------------
|
||||||
@@ -322,6 +315,7 @@ export const templatesApi = {
|
|||||||
const res = await fetch(`${base}/settings/templates/upload`, {
|
const res = await fetch(`${base}/settings/templates/upload`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@@ -345,27 +339,21 @@ export const aiSettingsApi = {
|
|||||||
list: () => request<AIConfigListItem[]>("/settings/ai/list"),
|
list: () => request<AIConfigListItem[]>("/settings/ai/list"),
|
||||||
getById: (id: string) => request<AIConfig>(`/settings/ai/${id}`),
|
getById: (id: string) => request<AIConfig>(`/settings/ai/${id}`),
|
||||||
create: (body: AIConfigCreate) =>
|
create: (body: AIConfigCreate) =>
|
||||||
request<AIConfig>("/settings/ai", {
|
request<AIConfig>("/settings/ai", { method: "POST", body: JSON.stringify(body) }),
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
update: (id: string, body: AIConfigUpdate) =>
|
update: (id: string, body: AIConfigUpdate) =>
|
||||||
request<AIConfig>(`/settings/ai/${id}`, {
|
request<AIConfig>(`/settings/ai/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
method: "PUT",
|
delete: (id: string) => request<void>(`/settings/ai/${id}`, { method: "DELETE" }),
|
||||||
body: JSON.stringify(body),
|
activate: (id: string) => request<AIConfig>(`/settings/ai/${id}/activate`, { method: "POST" }),
|
||||||
}),
|
|
||||||
delete: (id: string) =>
|
|
||||||
request<void>(`/settings/ai/${id}`, { method: "DELETE" }),
|
|
||||||
activate: (id: string) =>
|
|
||||||
request<AIConfig>(`/settings/ai/${id}/activate`, { method: "POST" }),
|
|
||||||
test: (configId?: string) =>
|
test: (configId?: string) =>
|
||||||
request<{ status: string; message: string }>(
|
request<{ status: string; message: string }>(
|
||||||
configId ? `/settings/ai/test?config_id=${encodeURIComponent(configId)}` : "/settings/ai/test",
|
configId
|
||||||
{ method: "POST" }
|
? `/settings/ai/test?config_id=${encodeURIComponent(configId)}`
|
||||||
|
: "/settings/ai/test",
|
||||||
|
{ method: "POST" },
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --------------- Email Configs (multi-account sync) ---------------
|
// --------------- Email Configs ---------------
|
||||||
|
|
||||||
export interface EmailConfigRead {
|
export interface EmailConfigRead {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -402,18 +390,10 @@ export interface EmailFolder {
|
|||||||
export const emailConfigsApi = {
|
export const emailConfigsApi = {
|
||||||
list: () => request<EmailConfigRead[]>("/settings/email"),
|
list: () => request<EmailConfigRead[]>("/settings/email"),
|
||||||
create: (body: EmailConfigCreate) =>
|
create: (body: EmailConfigCreate) =>
|
||||||
request<EmailConfigRead>("/settings/email", {
|
request<EmailConfigRead>("/settings/email", { method: "POST", body: JSON.stringify(body) }),
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
update: (id: string, body: EmailConfigUpdate) =>
|
update: (id: string, body: EmailConfigUpdate) =>
|
||||||
request<EmailConfigRead>(`/settings/email/${id}`, {
|
request<EmailConfigRead>(`/settings/email/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
method: "PUT",
|
delete: (id: string) => request<void>(`/settings/email/${id}`, { method: "DELETE" }),
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
delete: (id: string) =>
|
|
||||||
request<void>(`/settings/email/${id}`, { method: "DELETE" }),
|
|
||||||
/** List mailbox folders for an account (to pick custom label). Use decoded as mailbox value. */
|
|
||||||
listFolders: (configId: string) =>
|
listFolders: (configId: string) =>
|
||||||
request<{ folders: EmailFolder[] }>(`/settings/email/${configId}/folders`),
|
request<{ folders: EmailFolder[] }>(`/settings/email/${configId}/folders`),
|
||||||
};
|
};
|
||||||
@@ -439,20 +419,13 @@ export interface CloudDocLinkUpdate {
|
|||||||
export const cloudDocsApi = {
|
export const cloudDocsApi = {
|
||||||
list: () => request<CloudDocLinkRead[]>("/settings/cloud-docs"),
|
list: () => request<CloudDocLinkRead[]>("/settings/cloud-docs"),
|
||||||
create: (body: CloudDocLinkCreate) =>
|
create: (body: CloudDocLinkCreate) =>
|
||||||
request<CloudDocLinkRead>("/settings/cloud-docs", {
|
request<CloudDocLinkRead>("/settings/cloud-docs", { method: "POST", body: JSON.stringify(body) }),
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
update: (id: string, body: CloudDocLinkUpdate) =>
|
update: (id: string, body: CloudDocLinkUpdate) =>
|
||||||
request<CloudDocLinkRead>(`/settings/cloud-docs/${id}`, {
|
request<CloudDocLinkRead>(`/settings/cloud-docs/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
method: "PUT",
|
delete: (id: string) => request<void>(`/settings/cloud-docs/${id}`, { method: "DELETE" }),
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
delete: (id: string) =>
|
|
||||||
request<void>(`/settings/cloud-docs/${id}`, { method: "DELETE" }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --------------- Cloud Doc Config (API 凭证) ---------------
|
// --------------- Cloud Doc Config ---------------
|
||||||
|
|
||||||
export interface CloudDocConfigRead {
|
export interface CloudDocConfigRead {
|
||||||
feishu: { app_id: string; app_secret_configured: boolean };
|
feishu: { app_id: string; app_secret_configured: boolean };
|
||||||
@@ -488,10 +461,7 @@ export interface PushToCloudResponse {
|
|||||||
cloud_doc_id: string;
|
cloud_doc_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pushProjectToCloud(
|
export function pushProjectToCloud(projectId: number, body: PushToCloudRequest): Promise<PushToCloudResponse> {
|
||||||
projectId: number,
|
|
||||||
body: PushToCloudRequest
|
|
||||||
): Promise<PushToCloudResponse> {
|
|
||||||
return request<PushToCloudResponse>(`/projects/${projectId}/push-to-cloud`, {
|
return request<PushToCloudResponse>(`/projects/${projectId}/push-to-cloud`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -519,26 +489,26 @@ export interface PortalLinkUpdate {
|
|||||||
export const portalLinksApi = {
|
export const portalLinksApi = {
|
||||||
list: () => request<PortalLinkRead[]>("/settings/portal-links"),
|
list: () => request<PortalLinkRead[]>("/settings/portal-links"),
|
||||||
create: (body: PortalLinkCreate) =>
|
create: (body: PortalLinkCreate) =>
|
||||||
request<PortalLinkRead>("/settings/portal-links", {
|
request<PortalLinkRead>("/settings/portal-links", { method: "POST", body: JSON.stringify(body) }),
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
update: (id: string, body: PortalLinkUpdate) =>
|
update: (id: string, body: PortalLinkUpdate) =>
|
||||||
request<PortalLinkRead>(`/settings/portal-links/${id}`, {
|
request<PortalLinkRead>(`/settings/portal-links/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
method: "PUT",
|
delete: (id: string) => request<void>(`/settings/portal-links/${id}`, { method: "DELETE" }),
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
delete: (id: string) =>
|
|
||||||
request<void>(`/settings/portal-links/${id}`, { method: "DELETE" }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --------------- Finance ---------------
|
// --------------- Finance ---------------
|
||||||
|
|
||||||
export const financeApi = {
|
export const financeApi = {
|
||||||
sync: () =>
|
sync: (body?: {
|
||||||
request<FinanceSyncResponse>("/finance/sync", { method: "POST" }),
|
mode?: "incremental" | "all" | "latest";
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
doc_types?: ("invoices" | "receipts" | "statements")[];
|
||||||
|
}) =>
|
||||||
|
request<FinanceSyncResponse>("/finance/sync", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
}),
|
||||||
|
|
||||||
/** Upload invoice (PDF/image); returns created record with AI-extracted amount/date. */
|
|
||||||
uploadInvoice: async (file: File): Promise<FinanceRecordRead> => {
|
uploadInvoice: async (file: File): Promise<FinanceRecordRead> => {
|
||||||
const base = apiBase();
|
const base = apiBase();
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -546,6 +516,7 @@ export const financeApi = {
|
|||||||
const res = await fetch(`${base}/finance/upload`, {
|
const res = await fetch(`${base}/finance/upload`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@@ -561,26 +532,36 @@ export const financeApi = {
|
|||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Update amount/billing_date of a record (e.g. after manual review). */
|
|
||||||
updateRecord: (id: number, body: { amount?: number | null; billing_date?: string | null }) =>
|
updateRecord: (id: number, body: { amount?: number | null; billing_date?: string | null }) =>
|
||||||
request<FinanceRecordRead>(`/finance/records/${id}`, {
|
request<FinanceRecordRead>(`/finance/records/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/** List distinct months with records (YYYY-MM). */
|
|
||||||
listMonths: () => request<string[]>("/finance/months"),
|
listMonths: () => request<string[]>("/finance/months"),
|
||||||
|
|
||||||
/** List records for a month (YYYY-MM). */
|
|
||||||
listRecords: (month: string) =>
|
listRecords: (month: string) =>
|
||||||
request<FinanceRecordRead[]>(`/finance/records?month=${encodeURIComponent(month)}`),
|
request<FinanceRecordRead[]>(`/finance/records?month=${encodeURIComponent(month)}`),
|
||||||
|
|
||||||
/** Returns the URL to download the zip (or use downloadFile with the path). */
|
|
||||||
getDownloadUrl: (month: string) => `${apiBase()}/finance/download/${month}`,
|
|
||||||
|
|
||||||
/** Download monthly zip as blob and trigger save. */
|
|
||||||
downloadMonth: async (month: string): Promise<void> => {
|
downloadMonth: async (month: string): Promise<void> => {
|
||||||
const path = `/finance/download/${month}`;
|
const path = `/finance/download/${month}`;
|
||||||
await downloadFileAsBlob(path, `finance_${month}.zip`, "application/zip");
|
await downloadFileAsBlob(path, `finance_${month}.zip`, "application/zip");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteRecord: (id: number) =>
|
||||||
|
request<{ status: string; id: number }>(`/finance/records/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
batchDeleteRecords: (ids: number[]) =>
|
||||||
|
request<{ status: string; deleted: number }>(`/finance/records/batch-delete`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
downloadRangeInvoices: async (start_date: string, end_date: string): Promise<void> => {
|
||||||
|
const path = `/finance/download-range?start_date=${encodeURIComponent(
|
||||||
|
start_date,
|
||||||
|
)}&end_date=${encodeURIComponent(end_date)}&only_invoices=true`;
|
||||||
|
await downloadFileAsBlob(path, `invoices_${start_date}_${end_date}.zip`, "application/zip");
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4,3 +4,4 @@ import { twMerge } from "tailwind-merge";
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
14
frontend_spa/src/main.tsx
Normal file
14
frontend_spa/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./routes/App";
|
||||||
|
import "./styles/globals.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|
||||||
@@ -27,16 +27,18 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import {
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
apiBase,
|
import { financeApi, type FinanceRecordRead, type FinanceSyncResponse, type FinanceSyncResult } from "@/lib/api/client";
|
||||||
financeApi,
|
|
||||||
type FinanceRecordRead,
|
|
||||||
type FinanceSyncResponse,
|
|
||||||
type FinanceSyncResult,
|
|
||||||
} from "@/lib/api/client";
|
|
||||||
import { Download, Inbox, Loader2, Mail, FileText, Upload } from "lucide-react";
|
import { Download, Inbox, Loader2, Mail, FileText, Upload } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function fileHref(filePath: string): string {
|
||||||
|
if (!filePath) return "#";
|
||||||
|
if (filePath.startsWith("http")) return filePath;
|
||||||
|
if (filePath.startsWith("/")) return filePath;
|
||||||
|
return `/${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function FinancePage() {
|
export default function FinancePage() {
|
||||||
const [months, setMonths] = useState<string[]>([]);
|
const [months, setMonths] = useState<string[]>([]);
|
||||||
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
||||||
@@ -44,6 +46,14 @@ export default function FinancePage() {
|
|||||||
const [loadingMonths, setLoadingMonths] = useState(true);
|
const [loadingMonths, setLoadingMonths] = useState(true);
|
||||||
const [loadingRecords, setLoadingRecords] = useState(false);
|
const [loadingRecords, setLoadingRecords] = useState(false);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncMode, setSyncMode] = useState<"incremental" | "all" | "latest">("incremental");
|
||||||
|
const [syncStart, setSyncStart] = useState<string>("");
|
||||||
|
const [syncEnd, setSyncEnd] = useState<string>("");
|
||||||
|
const [syncTypes, setSyncTypes] = useState<{ invoices: boolean; receipts: boolean; statements: boolean }>({
|
||||||
|
invoices: true,
|
||||||
|
receipts: true,
|
||||||
|
statements: true,
|
||||||
|
});
|
||||||
const [lastSync, setLastSync] = useState<FinanceSyncResponse | null>(null);
|
const [lastSync, setLastSync] = useState<FinanceSyncResponse | null>(null);
|
||||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
@@ -53,6 +63,7 @@ export default function FinancePage() {
|
|||||||
const [reviewAmount, setReviewAmount] = useState("");
|
const [reviewAmount, setReviewAmount] = useState("");
|
||||||
const [reviewDate, setReviewDate] = useState("");
|
const [reviewDate, setReviewDate] = useState("");
|
||||||
const [savingReview, setSavingReview] = useState(false);
|
const [savingReview, setSavingReview] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const previewUrlRef = useRef<string | null>(null);
|
const previewUrlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -85,18 +96,24 @@ export default function FinancePage() {
|
|||||||
}, [selectedMonth]);
|
}, [selectedMonth]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMonths();
|
void loadMonths();
|
||||||
}, [loadMonths]);
|
}, [loadMonths]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRecords();
|
void loadRecords();
|
||||||
|
setSelectedIds([]);
|
||||||
}, [loadRecords]);
|
}, [loadRecords]);
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
toast.loading("正在同步邮箱…", { id: "finance-sync" });
|
toast.loading("正在同步邮箱…", { id: "finance-sync" });
|
||||||
try {
|
try {
|
||||||
const res: FinanceSyncResponse = await financeApi.sync();
|
const res: FinanceSyncResponse = await financeApi.sync({
|
||||||
|
mode: syncMode,
|
||||||
|
start_date: syncStart || undefined,
|
||||||
|
end_date: syncEnd || undefined,
|
||||||
|
doc_types: (Object.entries(syncTypes).filter(([, v]) => v).map(([k]) => k) as any) || undefined,
|
||||||
|
});
|
||||||
setLastSync(res);
|
setLastSync(res);
|
||||||
toast.dismiss("finance-sync");
|
toast.dismiss("finance-sync");
|
||||||
if (res.new_files > 0) {
|
if (res.new_files > 0) {
|
||||||
@@ -201,6 +218,39 @@ export default function FinancePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteSelected = async () => {
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
toast.error("请先勾选要删除的文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`确定删除选中的 ${selectedIds.length} 个文件?该操作不可恢复。`)) return;
|
||||||
|
try {
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
await financeApi.deleteRecord(selectedIds[0]);
|
||||||
|
} else {
|
||||||
|
await financeApi.batchDeleteRecords(selectedIds);
|
||||||
|
}
|
||||||
|
toast.success("已删除选中文件");
|
||||||
|
setSelectedIds([]);
|
||||||
|
if (selectedMonth) await loadRecords();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadRangeInvoices = async () => {
|
||||||
|
if (!syncStart || !syncEnd) {
|
||||||
|
toast.error("请先选择开始和结束日期");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await financeApi.downloadRangeInvoices(syncStart, syncEnd);
|
||||||
|
toast.success(`已下载 ${syncStart} 至 ${syncEnd} 的发票 zip`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "下载失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (s: string) =>
|
const formatDate = (s: string) =>
|
||||||
new Date(s).toLocaleString("zh-CN", {
|
new Date(s).toLocaleString("zh-CN", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -220,9 +270,9 @@ export default function FinancePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const monthlyTotal = records.reduce((sum, r) => sum + (r.amount ?? 0), 0);
|
const monthlyTotal = records.reduce((sum, r) => sum + (r.amount ?? 0), 0);
|
||||||
const totalInvoicesThisMonth = records.filter(
|
const totalInvoicesThisMonth = records
|
||||||
(r) => r.amount != null && (r.type === "manual" || r.type === "invoices")
|
.filter((r) => r.amount != null && (r.type === "manual" || r.type === "invoices"))
|
||||||
).reduce((s, r) => s + (r.amount ?? 0), 0);
|
.reduce((s, r) => s + (r.amount ?? 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-5xl">
|
<div className="p-6 max-w-5xl">
|
||||||
@@ -253,22 +303,86 @@ export default function FinancePage() {
|
|||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
<span className="ml-2">上传发票</span>
|
<span className="ml-2">上传发票</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">同步模式</Label>
|
||||||
|
<Select value={syncMode} onValueChange={(v) => setSyncMode(v as any)}>
|
||||||
|
<SelectTrigger className="w-[140px] h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="incremental">最新附件(增量)</SelectItem>
|
||||||
|
<SelectItem value="all">全部附件</SelectItem>
|
||||||
|
<SelectItem value="latest">仅最新一封</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">开始日期</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={syncStart}
|
||||||
|
onChange={(e) => setSyncStart(e.target.value)}
|
||||||
|
className="h-9 w-[150px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">结束日期</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={syncEnd}
|
||||||
|
onChange={(e) => setSyncEnd(e.target.value)}
|
||||||
|
className="h-9 w-[150px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">同步内容</Label>
|
||||||
|
<div className="flex items-center gap-3 h-9 px-2 rounded-md border bg-background">
|
||||||
|
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={syncTypes.invoices}
|
||||||
|
onChange={(e) => setSyncTypes((s) => ({ ...s, invoices: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
发票
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={syncTypes.receipts}
|
||||||
|
onChange={(e) => setSyncTypes((s) => ({ ...s, receipts: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
回执
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={syncTypes.statements}
|
||||||
|
onChange={(e) => setSyncTypes((s) => ({ ...s, statements: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
流水
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownloadRangeInvoices} disabled={!syncStart || !syncEnd}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span className="ml-1.5">下载发票 Zip</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Button onClick={handleSync} disabled={syncing} size="default">
|
<Button onClick={handleSync} disabled={syncing} size="default">
|
||||||
{syncing ? (
|
{syncing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Mail className="h-4 w-4" />}
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Mail className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="ml-2">同步邮箱</span>
|
<span className="ml-2">同步邮箱</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload Invoice Dialog */}
|
<Dialog
|
||||||
<Dialog open={uploadDialogOpen} onOpenChange={(open) => {
|
open={uploadDialogOpen}
|
||||||
setUploadDialogOpen(open);
|
onOpenChange={(open) => {
|
||||||
if (!open) resetUploadDialog();
|
setUploadDialogOpen(open);
|
||||||
}}>
|
if (!open) resetUploadDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{reviewRecord ? "核对金额与日期" : "上传发票"}</DialogTitle>
|
<DialogTitle>{reviewRecord ? "核对金额与日期" : "上传发票"}</DialogTitle>
|
||||||
@@ -306,9 +420,7 @@ export default function FinancePage() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{previewUrl && (
|
{previewUrl && <img src={previewUrl} alt="预览" className="max-h-32 rounded border object-contain" />}
|
||||||
<img src={previewUrl} alt="预览" className="max-h-32 rounded border object-contain" />
|
|
||||||
)}
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>金额</Label>
|
<Label>金额</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -321,14 +433,17 @@ export default function FinancePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>开票日期</Label>
|
<Label>开票日期</Label>
|
||||||
<Input
|
<Input type="date" value={reviewDate} onChange={(e) => setReviewDate(e.target.value)} />
|
||||||
type="date"
|
|
||||||
value={reviewDate}
|
|
||||||
onChange={(e) => setReviewDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => { setReviewRecord(null); setReviewAmount(""); setReviewDate(""); }}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setReviewRecord(null);
|
||||||
|
setReviewAmount("");
|
||||||
|
setReviewDate("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
继续上传
|
继续上传
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleReviewSave} disabled={savingReview}>
|
<Button onClick={handleReviewSave} disabled={savingReview}>
|
||||||
@@ -341,7 +456,6 @@ export default function FinancePage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Sync History / Last sync */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="py-3">
|
<CardHeader className="py-3">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
@@ -358,15 +472,12 @@ export default function FinancePage() {
|
|||||||
{lastSync.details && lastSync.details.length > 0 && (
|
{lastSync.details && lastSync.details.length > 0 && (
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
{Object.entries(
|
{Object.entries(
|
||||||
lastSync.details.reduce<Record<string, FinanceSyncResult[]>>(
|
lastSync.details.reduce<Record<string, FinanceSyncResult[]>>((acc, item) => {
|
||||||
(acc, item) => {
|
const t = item.type || "others";
|
||||||
const t = item.type || "others";
|
if (!acc[t]) acc[t] = [];
|
||||||
if (!acc[t]) acc[t] = [];
|
acc[t].push(item);
|
||||||
acc[t].push(item);
|
return acc;
|
||||||
return acc;
|
}, {}),
|
||||||
},
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
).map(([t, items]) => (
|
).map(([t, items]) => (
|
||||||
<div key={t}>
|
<div key={t}>
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
@@ -376,9 +487,7 @@ export default function FinancePage() {
|
|||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<li key={it.id}>
|
<li key={it.id}>
|
||||||
{it.file_name}
|
{it.file_name}
|
||||||
<span className="ml-1 text-[11px] text-muted-foreground/80">
|
<span className="ml-1 text-[11px] text-muted-foreground/80">[{it.month}]</span>
|
||||||
[{it.month}]
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -388,24 +497,17 @@ export default function FinancePage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">点击「同步邮箱」后,将显示本次同步结果</p>
|
||||||
点击「同步邮箱」后,将显示本次同步结果
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Month + Download */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="py-3">
|
<CardHeader className="py-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<CardTitle className="text-base">按月份查看</CardTitle>
|
<CardTitle className="text-base">按月份查看</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Select value={selectedMonth} onValueChange={setSelectedMonth} disabled={loadingMonths}>
|
||||||
value={selectedMonth}
|
|
||||||
onValueChange={setSelectedMonth}
|
|
||||||
disabled={loadingMonths}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[160px]">
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectValue placeholder="选择月份" />
|
<SelectValue placeholder="选择月份" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -417,15 +519,15 @@ export default function FinancePage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="outline"
|
<Button variant="outline" size="sm" onClick={handleDownloadZip} disabled={!selectedMonth || records.length === 0}>
|
||||||
size="sm"
|
<Download className="h-4 w-4" />
|
||||||
onClick={handleDownloadZip}
|
<span className="ml-1.5">下载本月全部 (.zip)</span>
|
||||||
disabled={!selectedMonth || records.length === 0}
|
</Button>
|
||||||
>
|
<Button variant="outline" size="sm" onClick={handleDeleteSelected} disabled={selectedIds.length === 0}>
|
||||||
<Download className="h-4 w-4" />
|
删除选中
|
||||||
<span className="ml-1.5">下载本月全部 (.zip)</span>
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -436,63 +538,75 @@ export default function FinancePage() {
|
|||||||
加载中…
|
加载中…
|
||||||
</p>
|
</p>
|
||||||
) : records.length === 0 ? (
|
) : records.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground py-4">
|
<p className="text-sm text-muted-foreground py-4">{selectedMonth ? "该月份暂无归档文件" : "请选择月份或先同步邮箱"}</p>
|
||||||
{selectedMonth ? "该月份暂无归档文件" : "请选择月份或先同步邮箱"}
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
类型:发票 / 回执 / 流水。同步的发票已按「日期_金额_原文件名」重命名。
|
类型:发票 / 回执 / 流水。同步的发票已按「日期_金额_原文件名」重命名。
|
||||||
</p>
|
</p>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>类型</TableHead>
|
<TableHead className="w-[36px]">
|
||||||
<TableHead>文件名</TableHead>
|
<Checkbox
|
||||||
<TableHead>金额</TableHead>
|
checked={records.length > 0 && selectedIds.length === records.length}
|
||||||
<TableHead>开票/归档时间</TableHead>
|
indeterminate={selectedIds.length > 0 && selectedIds.length < records.length}
|
||||||
<TableHead className="w-[100px]">操作</TableHead>
|
onCheckedChange={(checked) => {
|
||||||
</TableRow>
|
if (checked) setSelectedIds(records.map((r) => r.id));
|
||||||
</TableHeader>
|
else setSelectedIds([]);
|
||||||
<TableBody>
|
}}
|
||||||
{records.map((r) => (
|
aria-label="选择本月全部"
|
||||||
<TableRow key={r.id}>
|
/>
|
||||||
<TableCell>
|
</TableHead>
|
||||||
<span className="text-muted-foreground">
|
<TableHead>类型</TableHead>
|
||||||
{typeLabel[r.type] ?? r.type}
|
<TableHead>文件名</TableHead>
|
||||||
</span>
|
<TableHead>金额</TableHead>
|
||||||
</TableCell>
|
<TableHead>开票/归档时间</TableHead>
|
||||||
<TableCell className="font-medium">{r.file_name}</TableCell>
|
<TableHead className="w-[100px]">操作</TableHead>
|
||||||
<TableCell>
|
|
||||||
{r.amount != null
|
|
||||||
? `¥${Number(r.amount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}`
|
|
||||||
: "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-sm">
|
|
||||||
{r.billing_date || formatDate(r.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<a
|
|
||||||
href={`${apiBase()}${r.file_path.startsWith("/") ? "" : "/"}${r.file_path}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary text-sm hover:underline inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<FileText className="h-3.5 w-3.5" />
|
|
||||||
下载
|
|
||||||
</a>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
<TableRow className="bg-muted/30 font-medium">
|
<TableBody>
|
||||||
<TableCell colSpan={2}>本月合计</TableCell>
|
{records.map((r) => (
|
||||||
<TableCell>
|
<TableRow key={r.id}>
|
||||||
¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
|
<TableCell>
|
||||||
</TableCell>
|
<Checkbox
|
||||||
<TableCell colSpan={2} />
|
checked={selectedIds.includes(r.id)}
|
||||||
</TableRow>
|
onCheckedChange={(checked) => {
|
||||||
</TableBody>
|
setSelectedIds((prev) => (checked ? [...prev, r.id] : prev.filter((id) => id !== r.id)));
|
||||||
</Table>
|
}}
|
||||||
|
aria-label="选择记录"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-muted-foreground">{typeLabel[r.type] ?? r.type}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{r.file_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{r.amount != null
|
||||||
|
? `¥${Number(r.amount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}`
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">{r.billing_date || formatDate(r.created_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<a
|
||||||
|
href={fileHref(r.file_path)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary text-sm hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
下载
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
<TableRow className="bg-muted/30 font-medium">
|
||||||
|
<TableCell colSpan={3}>本月合计</TableCell>
|
||||||
|
<TableCell>¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}</TableCell>
|
||||||
|
<TableCell colSpan={2} />
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -501,3 +615,4 @@ export default function FinancePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,36 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import { Link } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
SelectContent,
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
aiSettingsApi,
|
aiSettingsApi,
|
||||||
type AIConfig,
|
|
||||||
type AIConfigListItem,
|
type AIConfigListItem,
|
||||||
type AIConfigCreate,
|
type AIConfigCreate,
|
||||||
type AIConfigUpdate,
|
type AIConfigUpdate,
|
||||||
@@ -69,7 +49,7 @@ export default function SettingsAIPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadList();
|
void loadList();
|
||||||
}, [loadList]);
|
}, [loadList]);
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
@@ -134,9 +114,7 @@ export default function SettingsAIPage() {
|
|||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
await loadList();
|
await loadList();
|
||||||
if (typeof window !== "undefined") {
|
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
|
||||||
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -191,7 +169,7 @@ export default function SettingsAIPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl">
|
<div className="p-6 max-w-4xl">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
<Link to="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
← 设置
|
← 设置
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +211,7 @@ export default function SettingsAIPage() {
|
|||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{item.name || "未命名"}
|
{item.name || "未命名"}
|
||||||
{item.is_active && (
|
{item.is_active && (
|
||||||
<span className="ml-2 text-xs text-primary flex items-center gap-0.5">
|
<span className="ml-2 text-xs text-primary inline-flex items-center gap-0.5">
|
||||||
<CheckCircle className="h-3.5 w-3.5" /> 当前选用
|
<CheckCircle className="h-3.5 w-3.5" /> 当前选用
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -244,39 +222,26 @@ export default function SettingsAIPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{!item.is_active && (
|
{!item.is_active && (
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => void handleActivate(item.id)}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleActivate(item.id)}
|
|
||||||
>
|
|
||||||
选用
|
选用
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => void openEdit(item.id)} title="编辑">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => openEdit(item.id)}
|
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleTest(item.id)}
|
onClick={() => void handleTest(item.id)}
|
||||||
disabled={testing === item.id}
|
disabled={testing === item.id}
|
||||||
title="测试连接"
|
title="测试连接"
|
||||||
>
|
>
|
||||||
{testing === item.id ? (
|
{testing === item.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Zap className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(item.id)}
|
onClick={() => void handleDelete(item.id)}
|
||||||
title="删除"
|
title="删除"
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
>
|
>
|
||||||
@@ -389,3 +354,4 @@ export default function SettingsAIPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import { Link } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import { cloudDocConfigApi, type CloudDocConfigRead, type CloudDocConfigUpdate } from "@/lib/api/client";
|
||||||
cloudDocConfigApi,
|
|
||||||
type CloudDocConfigRead,
|
|
||||||
type CloudDocConfigUpdate,
|
|
||||||
} from "@/lib/api/client";
|
|
||||||
import { Loader2, Save, FileStack } from "lucide-react";
|
import { Loader2, Save, FileStack } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -42,7 +38,7 @@ export default function SettingsCloudDocConfigPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -52,8 +48,7 @@ export default function SettingsCloudDocConfigPage() {
|
|||||||
if (form.feishu?.app_id !== undefined) payload.feishu = { app_id: form.feishu.app_id };
|
if (form.feishu?.app_id !== undefined) payload.feishu = { app_id: form.feishu.app_id };
|
||||||
if (form.feishu?.app_secret !== undefined && form.feishu.app_secret !== "")
|
if (form.feishu?.app_secret !== undefined && form.feishu.app_secret !== "")
|
||||||
payload.feishu = { ...payload.feishu, app_secret: form.feishu.app_secret };
|
payload.feishu = { ...payload.feishu, app_secret: form.feishu.app_secret };
|
||||||
if (form.yuque?.token !== undefined && form.yuque.token !== "")
|
if (form.yuque?.token !== undefined && form.yuque.token !== "") payload.yuque = { token: form.yuque.token };
|
||||||
payload.yuque = { token: form.yuque.token };
|
|
||||||
if (form.yuque?.default_repo !== undefined)
|
if (form.yuque?.default_repo !== undefined)
|
||||||
payload.yuque = { ...payload.yuque, default_repo: form.yuque.default_repo };
|
payload.yuque = { ...payload.yuque, default_repo: form.yuque.default_repo };
|
||||||
if (form.tencent?.client_id !== undefined) payload.tencent = { client_id: form.tencent.client_id };
|
if (form.tencent?.client_id !== undefined) payload.tencent = { client_id: form.tencent.client_id };
|
||||||
@@ -81,10 +76,7 @@ export default function SettingsCloudDocConfigPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl">
|
<div className="p-6 max-w-2xl">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link
|
<Link to="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
href="/settings"
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
← 设置
|
← 设置
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,19 +91,13 @@ export default function SettingsCloudDocConfigPage() {
|
|||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* 飞书 */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium">飞书 (Feishu)</h3>
|
<h3 className="text-sm font-medium">飞书 (Feishu)</h3>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>App ID</Label>
|
<Label>App ID</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.feishu?.app_id ?? config?.feishu?.app_id ?? ""}
|
value={form.feishu?.app_id ?? config?.feishu?.app_id ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm((f) => ({ ...f, feishu: { ...f.feishu, app_id: e.target.value } }))}
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
feishu: { ...f.feishu, app_id: e.target.value },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="在飞书开放平台创建应用后获取"
|
placeholder="在飞书开放平台创建应用后获取"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,18 +106,12 @@ export default function SettingsCloudDocConfigPage() {
|
|||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={form.feishu?.app_secret ?? ""}
|
value={form.feishu?.app_secret ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm((f) => ({ ...f, feishu: { ...f.feishu, app_secret: e.target.value } }))}
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
feishu: { ...f.feishu, app_secret: e.target.value },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={config?.feishu?.app_secret_configured ? "已配置,留空不修改" : "必填"}
|
placeholder={config?.feishu?.app_secret_configured ? "已配置,留空不修改" : "必填"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 语雀 */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium">语雀 (Yuque)</h3>
|
<h3 className="text-sm font-medium">语雀 (Yuque)</h3>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -139,12 +119,7 @@ export default function SettingsCloudDocConfigPage() {
|
|||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={form.yuque?.token ?? ""}
|
value={form.yuque?.token ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm((f) => ({ ...f, yuque: { ...f.yuque, token: e.target.value } }))}
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
yuque: { ...f.yuque, token: e.target.value },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={config?.yuque?.token_configured ? "已配置,留空不修改" : "在语雀 设置 → Token 中创建"}
|
placeholder={config?.yuque?.token_configured ? "已配置,留空不修改" : "在语雀 设置 → Token 中创建"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,17 +128,13 @@ export default function SettingsCloudDocConfigPage() {
|
|||||||
<Input
|
<Input
|
||||||
value={form.yuque?.default_repo ?? config?.yuque?.default_repo ?? ""}
|
value={form.yuque?.default_repo ?? config?.yuque?.default_repo ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((f) => ({
|
setForm((f) => ({ ...f, yuque: { ...f.yuque, default_repo: e.target.value } }))
|
||||||
...f,
|
|
||||||
yuque: { ...f.yuque, default_repo: e.target.value },
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
placeholder="如:your_username/repo"
|
placeholder="如:your_username/repo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 腾讯文档 */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium">腾讯文档 (Tencent)</h3>
|
<h3 className="text-sm font-medium">腾讯文档 (Tencent)</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -173,12 +144,7 @@ export default function SettingsCloudDocConfigPage() {
|
|||||||
<Label>Client ID</Label>
|
<Label>Client ID</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.tencent?.client_id ?? config?.tencent?.client_id ?? ""}
|
value={form.tencent?.client_id ?? config?.tencent?.client_id ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => setForm((f) => ({ ...f, tencent: { ...f.tencent, client_id: e.target.value } }))}
|
||||||
setForm((f) => ({
|
|
||||||
...f,
|
|
||||||
tencent: { ...f.tencent, client_id: e.target.value },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="开放平台应用 Client ID"
|
placeholder="开放平台应用 Client ID"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,10 +154,7 @@ export default function SettingsCloudDocConfigPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={form.tencent?.client_secret ?? ""}
|
value={form.tencent?.client_secret ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((f) => ({
|
setForm((f) => ({ ...f, tencent: { ...f.tencent, client_secret: e.target.value } }))
|
||||||
...f,
|
|
||||||
tencent: { ...f.tencent, client_secret: e.target.value },
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
placeholder={config?.tencent?.client_secret_configured ? "已配置,留空不修改" : "选填"}
|
placeholder={config?.tencent?.client_secret_configured ? "已配置,留空不修改" : "选填"}
|
||||||
/>
|
/>
|
||||||
@@ -207,3 +170,4 @@ export default function SettingsCloudDocConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,31 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import { Link } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
import { cloudDocsApi, type CloudDocLinkRead, type CloudDocLinkCreate } from "@/lib/api/client";
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
cloudDocsApi,
|
|
||||||
type CloudDocLinkRead,
|
|
||||||
type CloudDocLinkCreate,
|
|
||||||
} from "@/lib/api/client";
|
|
||||||
import { FileStack, Plus, Pencil, Trash2, Loader2, ExternalLink } from "lucide-react";
|
import { FileStack, Plus, Pencil, Trash2, Loader2, ExternalLink } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -49,7 +32,7 @@ export default function SettingsCloudDocsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLinks();
|
void loadLinks();
|
||||||
}, [loadLinks]);
|
}, [loadLinks]);
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
@@ -73,24 +56,16 @@ export default function SettingsCloudDocsPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await cloudDocsApi.update(editingId, {
|
await cloudDocsApi.update(editingId, { name: form.name.trim(), url: form.url.trim() });
|
||||||
name: form.name.trim(),
|
|
||||||
url: form.url.trim(),
|
|
||||||
});
|
|
||||||
toast.success("已更新");
|
toast.success("已更新");
|
||||||
} else {
|
} else {
|
||||||
const payload: CloudDocLinkCreate = {
|
const payload: CloudDocLinkCreate = { name: form.name.trim(), url: form.url.trim() };
|
||||||
name: form.name.trim(),
|
|
||||||
url: form.url.trim(),
|
|
||||||
};
|
|
||||||
await cloudDocsApi.create(payload);
|
await cloudDocsApi.create(payload);
|
||||||
toast.success("已添加");
|
toast.success("已添加");
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
await loadLinks();
|
await loadLinks();
|
||||||
if (typeof window !== "undefined") {
|
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
|
||||||
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -104,9 +79,7 @@ export default function SettingsCloudDocsPage() {
|
|||||||
await cloudDocsApi.delete(id);
|
await cloudDocsApi.delete(id);
|
||||||
toast.success("已删除");
|
toast.success("已删除");
|
||||||
await loadLinks();
|
await loadLinks();
|
||||||
if (typeof window !== "undefined") {
|
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
|
||||||
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "删除失败");
|
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||||
}
|
}
|
||||||
@@ -115,10 +88,7 @@ export default function SettingsCloudDocsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl">
|
<div className="p-6 max-w-4xl">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link
|
<Link to="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
href="/settings"
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
← 设置
|
← 设置
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,28 +145,13 @@ export default function SettingsCloudDocsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => openEdit(item)} title="编辑">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => openEdit(item)}
|
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => void handleDelete(item.id)} title="删除">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(item.id)}
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="sm" asChild title="打开">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
asChild
|
|
||||||
title="打开"
|
|
||||||
>
|
|
||||||
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
@@ -214,9 +169,7 @@ export default function SettingsCloudDocsPage() {
|
|||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{editingId ? "编辑云文档入口" : "添加云文档入口"}</DialogTitle>
|
||||||
{editingId ? "编辑云文档入口" : "添加云文档入口"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
@@ -241,11 +194,7 @@ export default function SettingsCloudDocsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDialogOpen(false)}
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={saving}>
|
<Button type="submit" disabled={saving}>
|
||||||
@@ -259,3 +208,4 @@ export default function SettingsCloudDocsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,39 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
emailConfigsApi,
|
emailConfigsApi,
|
||||||
type EmailConfigRead,
|
type EmailConfigRead,
|
||||||
type EmailConfigCreate,
|
|
||||||
type EmailConfigUpdate,
|
type EmailConfigUpdate,
|
||||||
type EmailFolder,
|
type EmailFolder,
|
||||||
} from "@/lib/api/client";
|
} from "@/lib/api/client";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Loader2, Mail, Plus, Pencil, Trash2, FolderOpen } from "lucide-react";
|
import { Loader2, Mail, Plus, Pencil, Trash2, FolderOpen } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -66,11 +47,12 @@ export default function SettingsEmailPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfigs();
|
void loadConfigs();
|
||||||
}, [loadConfigs]);
|
}, [loadConfigs]);
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
|
setFolders(null);
|
||||||
setForm({
|
setForm({
|
||||||
host: "",
|
host: "",
|
||||||
port: "993",
|
port: "993",
|
||||||
@@ -163,9 +145,9 @@ export default function SettingsEmailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl">
|
<div className="p-6 max-w-4xl">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<a href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
<Link to="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
← 设置
|
← 设置
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -228,7 +210,7 @@ export default function SettingsEmailPage() {
|
|||||||
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
|
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(c.id)}>
|
<Button variant="ghost" size="sm" onClick={() => void handleDelete(c.id)}>
|
||||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,9 +228,15 @@ export default function SettingsEmailPage() {
|
|||||||
<CardTitle className="text-sm">网易 163 邮箱配置说明</CardTitle>
|
<CardTitle className="text-sm">网易 163 邮箱配置说明</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm text-muted-foreground space-y-2 py-2">
|
<CardContent className="text-sm text-muted-foreground space-y-2 py-2">
|
||||||
<p><strong>IMAP 服务器:</strong> imap.163.com,端口 993(SSL)。需在网易邮箱网页端开启「IMAP/SMTP 服务」。</p>
|
<p>
|
||||||
<p><strong>密码:</strong> 使用「授权码」而非登录密码。在 设置 → POP3/SMTP/IMAP → 授权码管理 中生成。</p>
|
<strong>IMAP 服务器:</strong> imap.163.com,端口 993(SSL)。需在网易邮箱网页端开启「IMAP/SMTP 服务」。
|
||||||
<p><strong>邮箱夹:</strong> 填 INBOX 或 收件箱;若同步失败,请编辑该账户并点击「获取邮箱列表」选择「收件箱」或目标标签。</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>密码:</strong> 使用「授权码」而非登录密码。在 设置 → POP3/SMTP/IMAP → 授权码管理 中生成。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>邮箱夹:</strong> 填 INBOX 或 收件箱;若同步失败,请编辑该账户并点击「获取邮箱列表」选择「收件箱」或目标标签。
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -301,23 +289,14 @@ export default function SettingsEmailPage() {
|
|||||||
<Label>邮箱夹 / 自定义标签 (Mailbox)</Label>
|
<Label>邮箱夹 / 自定义标签 (Mailbox)</Label>
|
||||||
{editingId && (
|
{editingId && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button type="button" variant="outline" size="sm" onClick={() => void loadFolders()} disabled={foldersLoading}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={loadFolders}
|
|
||||||
disabled={foldersLoading}
|
|
||||||
>
|
|
||||||
{foldersLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <FolderOpen className="h-4 w-4" />}
|
{foldersLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <FolderOpen className="h-4 w-4" />}
|
||||||
<span className="ml-1">获取邮箱列表</span>
|
<span className="ml-1">获取邮箱列表</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{folders && folders.length > 0 ? (
|
{folders && folders.length > 0 ? (
|
||||||
<Select
|
<Select value={form.mailbox} onValueChange={(v) => setForm((f) => ({ ...f, mailbox: v }))}>
|
||||||
value={form.mailbox}
|
|
||||||
onValueChange={(v) => setForm((f) => ({ ...f, mailbox: v }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="选择邮箱夹" />
|
<SelectValue placeholder="选择邮箱夹" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -333,7 +312,7 @@ export default function SettingsEmailPage() {
|
|||||||
<Input
|
<Input
|
||||||
value={form.mailbox}
|
value={form.mailbox}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, mailbox: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, mailbox: e.target.value }))}
|
||||||
placeholder="INBOX、收件箱或自定义标签(163 等若 INBOX 失败会自动尝试收件箱)"
|
placeholder="INBOX、收件箱或自定义标签"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -362,3 +341,4 @@ export default function SettingsEmailPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,45 +1,47 @@
|
|||||||
import Link from "next/link";
|
"use client";
|
||||||
|
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FileSpreadsheet, FileText, Mail, FileStack, Globe } from "lucide-react";
|
import { FileSpreadsheet, FileText, Mail, FileStack, Globe } from "lucide-react";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsIndexPage() {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl">
|
<div className="p-6 max-w-2xl">
|
||||||
<h1 className="text-xl font-semibold">设置</h1>
|
<h1 className="text-xl font-semibold">设置</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">系统与业务配置</p>
|
<p className="text-sm text-muted-foreground mt-1">系统与业务配置</p>
|
||||||
<div className="mt-6 flex flex-col gap-2">
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
<Button variant="outline" className="justify-start" asChild>
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
<Link href="/settings/templates" className="flex items-center gap-2">
|
<Link to="/settings/templates" className="flex items-center gap-2">
|
||||||
<FileSpreadsheet className="h-4 w-4" />
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
模板管理(报价 Excel / 合同 Word)
|
模板管理(报价 Excel / 合同 Word)
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="justify-start" asChild>
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
<Link href="/settings/ai" className="flex items-center gap-2">
|
<Link to="/settings/ai" className="flex items-center gap-2">
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
AI 模型配置
|
AI 模型配置
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="justify-start" asChild>
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
<Link href="/settings/email" className="flex items-center gap-2">
|
<Link to="/settings/email" className="flex items-center gap-2">
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
邮箱账户(多账户同步)
|
邮箱账户(多账户同步)
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="justify-start" asChild>
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
<Link href="/settings/cloud-docs" className="flex items-center gap-2">
|
<Link to="/settings/cloud-docs" className="flex items-center gap-2">
|
||||||
<FileStack className="h-4 w-4" />
|
<FileStack className="h-4 w-4" />
|
||||||
云文档快捷入口
|
云文档快捷入口
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="justify-start" asChild>
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
<Link href="/settings/cloud-doc-config" className="flex items-center gap-2">
|
<Link to="/settings/cloud-doc-config" className="flex items-center gap-2">
|
||||||
<FileStack className="h-4 w-4" />
|
<FileStack className="h-4 w-4" />
|
||||||
云文档配置(飞书 / 语雀 / 腾讯文档 API 凭证)
|
云文档配置(飞书 / 语雀 / 腾讯文档 API 凭证)
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="justify-start" asChild>
|
<Button variant="outline" className="justify-start" asChild>
|
||||||
<Link href="/settings/portal-links" className="flex items-center gap-2">
|
<Link to="/settings/portal-links" className="flex items-center gap-2">
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
快捷门户
|
快捷门户
|
||||||
</Link>
|
</Link>
|
||||||
@@ -48,3 +50,4 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,31 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import { Link } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
import { portalLinksApi, type PortalLinkRead, type PortalLinkCreate } from "@/lib/api/client";
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
portalLinksApi,
|
|
||||||
type PortalLinkRead,
|
|
||||||
type PortalLinkCreate,
|
|
||||||
} from "@/lib/api/client";
|
|
||||||
import { Globe, Plus, Pencil, Trash2, Loader2, ExternalLink } from "lucide-react";
|
import { Globe, Plus, Pencil, Trash2, Loader2, ExternalLink } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -49,7 +32,7 @@ export default function SettingsPortalLinksPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLinks();
|
void loadLinks();
|
||||||
}, [loadLinks]);
|
}, [loadLinks]);
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
@@ -73,24 +56,16 @@ export default function SettingsPortalLinksPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await portalLinksApi.update(editingId, {
|
await portalLinksApi.update(editingId, { name: form.name.trim(), url: form.url.trim() });
|
||||||
name: form.name.trim(),
|
|
||||||
url: form.url.trim(),
|
|
||||||
});
|
|
||||||
toast.success("已更新");
|
toast.success("已更新");
|
||||||
} else {
|
} else {
|
||||||
const payload: PortalLinkCreate = {
|
const payload: PortalLinkCreate = { name: form.name.trim(), url: form.url.trim() };
|
||||||
name: form.name.trim(),
|
|
||||||
url: form.url.trim(),
|
|
||||||
};
|
|
||||||
await portalLinksApi.create(payload);
|
await portalLinksApi.create(payload);
|
||||||
toast.success("已添加");
|
toast.success("已添加");
|
||||||
}
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
await loadLinks();
|
await loadLinks();
|
||||||
if (typeof window !== "undefined") {
|
window.dispatchEvent(new CustomEvent("portal-links-changed"));
|
||||||
window.dispatchEvent(new CustomEvent("portal-links-changed"));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -104,9 +79,7 @@ export default function SettingsPortalLinksPage() {
|
|||||||
await portalLinksApi.delete(id);
|
await portalLinksApi.delete(id);
|
||||||
toast.success("已删除");
|
toast.success("已删除");
|
||||||
await loadLinks();
|
await loadLinks();
|
||||||
if (typeof window !== "undefined") {
|
window.dispatchEvent(new CustomEvent("portal-links-changed"));
|
||||||
window.dispatchEvent(new CustomEvent("portal-links-changed"));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "删除失败");
|
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||||
}
|
}
|
||||||
@@ -115,10 +88,7 @@ export default function SettingsPortalLinksPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl">
|
<div className="p-6 max-w-4xl">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link
|
<Link to="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
href="/settings"
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
← 设置
|
← 设置
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,9 +100,7 @@ export default function SettingsPortalLinksPage() {
|
|||||||
<Globe className="h-5 w-5" />
|
<Globe className="h-5 w-5" />
|
||||||
快捷门户
|
快捷门户
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">添加税务、公积金等门户链接,侧栏可快速打开。</p>
|
||||||
添加税务、公积金等门户链接,侧栏可快速打开。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openAdd}>
|
<Button onClick={openAdd}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -175,20 +143,10 @@ export default function SettingsPortalLinksPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => openEdit(item)} title="编辑">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => openEdit(item)}
|
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => void handleDelete(item.id)} title="删除">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(item.id)}
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" asChild title="打开">
|
<Button variant="ghost" size="sm" asChild title="打开">
|
||||||
@@ -209,9 +167,7 @@ export default function SettingsPortalLinksPage() {
|
|||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{editingId ? "编辑快捷门户入口" : "添加快捷门户入口"}</DialogTitle>
|
||||||
{editingId ? "编辑快捷门户入口" : "添加快捷门户入口"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
@@ -236,11 +192,7 @@ export default function SettingsPortalLinksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDialogOpen(false)}
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={saving}>
|
<Button type="submit" disabled={saving}>
|
||||||
@@ -254,3 +206,4 @@ export default function SettingsPortalLinksPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { templatesApi, type TemplateInfo } from "@/lib/api/client";
|
import { templatesApi, type TemplateInfo } from "@/lib/api/client";
|
||||||
import { Upload, Loader2, FileSpreadsheet, FileText } from "lucide-react";
|
import { Upload, Loader2, FileSpreadsheet, FileText } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -33,7 +27,7 @@ export default function SettingsTemplatesPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTemplates();
|
void loadTemplates();
|
||||||
}, [loadTemplates]);
|
}, [loadTemplates]);
|
||||||
|
|
||||||
const handleFile = async (file: File) => {
|
const handleFile = async (file: File) => {
|
||||||
@@ -58,7 +52,7 @@ export default function SettingsTemplatesPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const file = e.dataTransfer.files[0];
|
const file = e.dataTransfer.files[0];
|
||||||
if (file) handleFile(file);
|
if (file) void handleFile(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragOver = (e: React.DragEvent) => {
|
const onDragOver = (e: React.DragEvent) => {
|
||||||
@@ -70,7 +64,7 @@ export default function SettingsTemplatesPage() {
|
|||||||
|
|
||||||
const onSelectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onSelectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) handleFile(file);
|
if (file) void handleFile(file);
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,9 +80,9 @@ export default function SettingsTemplatesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl">
|
<div className="p-6 max-w-4xl">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<a href="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
<Link to="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
← 设置
|
← 设置
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -156,12 +150,8 @@ export default function SettingsTemplatesPage() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">{t.name}</TableCell>
|
<TableCell className="font-medium">{t.name}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">{(t.size / 1024).toFixed(1)} KB</TableCell>
|
||||||
{(t.size / 1024).toFixed(1)} KB
|
<TableCell className="text-muted-foreground">{formatDate(t.uploaded_at)}</TableCell>
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{formatDate(t.uploaded_at)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -173,3 +163,4 @@ export default function SettingsTemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -21,10 +23,19 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import ReactMarkdown from "react-markdown";
|
import {
|
||||||
import { Wand2, Save, FileSpreadsheet, FileDown, Loader2, Plus, Search, CloudUpload } from "lucide-react";
|
Wand2,
|
||||||
|
Save,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileDown,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
CloudUpload,
|
||||||
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
apiBase,
|
||||||
customersApi,
|
customersApi,
|
||||||
projectsApi,
|
projectsApi,
|
||||||
templatesApi,
|
templatesApi,
|
||||||
@@ -44,6 +55,7 @@ export default function WorkspacePage() {
|
|||||||
const [projectId, setProjectId] = useState<number | null>(null);
|
const [projectId, setProjectId] = useState<number | null>(null);
|
||||||
const [lastQuote, setLastQuote] = useState<QuoteGenerateResponse | null>(null);
|
const [lastQuote, setLastQuote] = useState<QuoteGenerateResponse | null>(null);
|
||||||
const [analyzing, setAnalyzing] = useState(false);
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
|
const [analyzeProgress, setAnalyzeProgress] = useState(0);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [generatingQuote, setGeneratingQuote] = useState(false);
|
const [generatingQuote, setGeneratingQuote] = useState(false);
|
||||||
const [addCustomerOpen, setAddCustomerOpen] = useState(false);
|
const [addCustomerOpen, setAddCustomerOpen] = useState(false);
|
||||||
@@ -55,32 +67,67 @@ export default function WorkspacePage() {
|
|||||||
const [selectedQuoteTemplate, setSelectedQuoteTemplate] = useState<string>("");
|
const [selectedQuoteTemplate, setSelectedQuoteTemplate] = useState<string>("");
|
||||||
const [customerSearch, setCustomerSearch] = useState("");
|
const [customerSearch, setCustomerSearch] = useState("");
|
||||||
const [pushToCloudLoading, setPushToCloudLoading] = useState(false);
|
const [pushToCloudLoading, setPushToCloudLoading] = useState(false);
|
||||||
|
const didInitRef = useRef(false);
|
||||||
|
|
||||||
const loadCustomers = useCallback(async (search?: string) => {
|
const loadCustomers = useCallback(
|
||||||
try {
|
async (search?: string) => {
|
||||||
const list = await customersApi.list(search?.trim() ? { q: search.trim() } : undefined);
|
try {
|
||||||
setCustomers(list);
|
const list = await customersApi.list(search?.trim() ? { q: search.trim() } : undefined);
|
||||||
if (list.length > 0 && !customerId) setCustomerId(String(list[0].id));
|
setCustomers(list);
|
||||||
} catch (e) {
|
if (list.length > 0 && !customerId) setCustomerId(String(list[0].id));
|
||||||
toast.error("加载客户列表失败");
|
if (!search?.trim()) {
|
||||||
}
|
try {
|
||||||
}, [customerId]);
|
sessionStorage.setItem("opc_customers", JSON.stringify({ t: Date.now(), v: list }));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("加载客户列表失败");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[customerId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCustomers(customerSearch);
|
if (!customerSearch.trim()) {
|
||||||
|
if (!didInitRef.current) {
|
||||||
|
didInitRef.current = true;
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem("opc_customers");
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed?.v && Date.now() - (parsed.t || 0) < 60_000) setCustomers(parsed.v);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
void loadCustomers("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadCustomers(customerSearch);
|
||||||
}, [loadCustomers, customerSearch]);
|
}, [loadCustomers, customerSearch]);
|
||||||
|
|
||||||
const loadQuoteTemplates = useCallback(async () => {
|
const loadQuoteTemplates = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await templatesApi.list();
|
const list = await templatesApi.list();
|
||||||
setQuoteTemplates(list.filter((t) => t.type === "excel"));
|
setQuoteTemplates(list.filter((t) => t.type === "excel"));
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("opc_templates", JSON.stringify({ t: Date.now(), v: list }));
|
||||||
|
} catch {}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadQuoteTemplates();
|
try {
|
||||||
|
const raw = sessionStorage.getItem("opc_templates");
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed?.v && Date.now() - (parsed.t || 0) < 60_000) {
|
||||||
|
setQuoteTemplates((parsed.v as TemplateInfo[]).filter((t) => t.type === "excel"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
void loadQuoteTemplates();
|
||||||
}, [loadQuoteTemplates]);
|
}, [loadQuoteTemplates]);
|
||||||
|
|
||||||
const handleAddCustomer = async (e: React.FormEvent) => {
|
const handleAddCustomer = async (e: React.FormEvent) => {
|
||||||
@@ -118,15 +165,65 @@ export default function WorkspacePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAnalyzing(true);
|
setAnalyzing(true);
|
||||||
|
setAnalyzeProgress(8);
|
||||||
|
setSolutionMd("");
|
||||||
try {
|
try {
|
||||||
const res = await projectsApi.analyze({
|
const base = apiBase();
|
||||||
customer_id: Number(customerId),
|
const res = await fetch(`${base}/projects/analyze_stream`, {
|
||||||
raw_text: rawText.trim(),
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer_id: Number(customerId),
|
||||||
|
raw_text: rawText.trim(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
setSolutionMd(res.ai_solution_md);
|
if (!res.ok || !res.body) {
|
||||||
setProjectId(res.project_id);
|
const text = await res.text();
|
||||||
setLastQuote(null);
|
throw new Error(text || "AI 解析失败");
|
||||||
toast.success("方案已生成,可在右侧编辑");
|
}
|
||||||
|
|
||||||
|
let buf = "";
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
const progressTimer = window.setInterval(() => {
|
||||||
|
setAnalyzeProgress((p) => (p < 90 ? p + Math.max(1, Math.round((90 - p) / 10)) : p));
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
const parts = buf.split("\n\n");
|
||||||
|
buf = parts.pop() || "";
|
||||||
|
for (const chunk of parts) {
|
||||||
|
const line = chunk.split("\n").find((l) => l.startsWith("data: "));
|
||||||
|
if (!line) continue;
|
||||||
|
const payload = line.slice(6);
|
||||||
|
let evt: any;
|
||||||
|
try {
|
||||||
|
evt = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (evt.type === "delta" && typeof evt.content === "string") {
|
||||||
|
setSolutionMd((prev) => prev + evt.content);
|
||||||
|
} else if (evt.type === "done" && typeof evt.project_id === "number") {
|
||||||
|
setProjectId(evt.project_id);
|
||||||
|
setLastQuote(null);
|
||||||
|
setAnalyzeProgress(100);
|
||||||
|
} else if (evt.type === "error") {
|
||||||
|
throw new Error(evt.message || "AI 解析失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
window.clearInterval(progressTimer);
|
||||||
|
}
|
||||||
|
toast.success("方案已生成(流式),可在右侧编辑");
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : "AI 解析失败";
|
const msg = e instanceof Error ? e.message : "AI 解析失败";
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
@@ -144,10 +241,7 @@ export default function WorkspacePage() {
|
|||||||
}
|
}
|
||||||
setPushToCloudLoading(true);
|
setPushToCloudLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await pushProjectToCloud(projectId, {
|
const res = await pushProjectToCloud(projectId, { platform, body_md: md });
|
||||||
platform,
|
|
||||||
body_md: md,
|
|
||||||
});
|
|
||||||
toast.success("已推送到云文档", {
|
toast.success("已推送到云文档", {
|
||||||
action: res.url
|
action: res.url
|
||||||
? {
|
? {
|
||||||
@@ -187,10 +281,7 @@ export default function WorkspacePage() {
|
|||||||
}
|
}
|
||||||
setGeneratingQuote(true);
|
setGeneratingQuote(true);
|
||||||
try {
|
try {
|
||||||
const res = await projectsApi.generateQuote(
|
const res = await projectsApi.generateQuote(projectId, selectedQuoteTemplate || undefined);
|
||||||
projectId,
|
|
||||||
selectedQuoteTemplate || undefined
|
|
||||||
);
|
|
||||||
setLastQuote(res);
|
setLastQuote(res);
|
||||||
toast.success("报价单已生成");
|
toast.success("报价单已生成");
|
||||||
downloadFile(res.excel_path, `quote_project_${projectId}.xlsx`);
|
downloadFile(res.excel_path, `quote_project_${projectId}.xlsx`);
|
||||||
@@ -214,10 +305,7 @@ export default function WorkspacePage() {
|
|||||||
}
|
}
|
||||||
setGeneratingQuote(true);
|
setGeneratingQuote(true);
|
||||||
try {
|
try {
|
||||||
const res = await projectsApi.generateQuote(
|
const res = await projectsApi.generateQuote(projectId, selectedQuoteTemplate || undefined);
|
||||||
projectId,
|
|
||||||
selectedQuoteTemplate || undefined
|
|
||||||
);
|
|
||||||
setLastQuote(res);
|
setLastQuote(res);
|
||||||
await downloadFileAsBlob(res.pdf_path, `quote_project_${projectId}.pdf`);
|
await downloadFileAsBlob(res.pdf_path, `quote_project_${projectId}.pdf`);
|
||||||
toast.success("PDF 已下载");
|
toast.success("PDF 已下载");
|
||||||
@@ -232,23 +320,22 @@ export default function WorkspacePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
{/* Left Panel — 40% */}
|
|
||||||
<div className="flex w-[40%] flex-col border-r">
|
<div className="flex w-[40%] flex-col border-r">
|
||||||
<Card className="rounded-none border-0 border-b h-full flex flex-col">
|
<Card className="rounded-none border-0 border-b h-full flex flex-col">
|
||||||
<CardHeader className="py-3">
|
<CardHeader className="py-3">
|
||||||
<CardTitle className="text-base flex items-center justify-between">
|
<CardTitle className="text-base flex items-center justify-between">
|
||||||
原始需求
|
原始需求
|
||||||
<Button
|
<Button size="sm" onClick={handleAnalyze} disabled={analyzing || !rawText.trim() || !customerId}>
|
||||||
size="sm"
|
|
||||||
onClick={handleAnalyze}
|
|
||||||
disabled={analyzing || !rawText.trim() || !customerId}
|
|
||||||
>
|
|
||||||
{analyzing ? (
|
{analyzing ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<span className="w-[160px]">
|
||||||
|
<Progress value={analyzeProgress} />
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Wand2 className="h-4 w-4" />
|
<>
|
||||||
|
<Wand2 className="h-4 w-4" />
|
||||||
|
<span className="ml-1.5">AI 解析</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="ml-1.5">AI 解析</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -276,11 +363,7 @@ export default function WorkspacePage() {
|
|||||||
<SelectItem key={c.id} value={String(c.id)}>
|
<SelectItem key={c.id} value={String(c.id)}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{c.name}
|
{c.name}
|
||||||
{c.tags && (
|
{c.tags && <span className="text-muted-foreground text-xs">({c.tags})</span>}
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
({c.tags})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -327,19 +410,11 @@ export default function WorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => setAddCustomerOpen(false)}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setAddCustomerOpen(false)}
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={addingCustomer}>
|
<Button type="submit" disabled={addingCustomer}>
|
||||||
{addingCustomer ? (
|
{addingCustomer ? <Loader2 className="h-4 w-4 animate-spin" /> : "添加"}
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"添加"
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -360,7 +435,6 @@ export default function WorkspacePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Panel — 60% */}
|
|
||||||
<div className="flex flex-1 flex-col min-w-0">
|
<div className="flex flex-1 flex-col min-w-0">
|
||||||
<Card className="rounded-none border-0 h-full flex flex-col">
|
<Card className="rounded-none border-0 h-full flex flex-col">
|
||||||
<CardHeader className="py-3">
|
<CardHeader className="py-3">
|
||||||
@@ -382,11 +456,7 @@ export default function WorkspacePage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="preview" className="flex-1 min-h-0 mt-2 overflow-auto">
|
<TabsContent value="preview" className="flex-1 min-h-0 mt-2 overflow-auto">
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none p-2">
|
<div className="prose prose-sm dark:prose-invert max-w-none p-2">
|
||||||
{solutionMd ? (
|
{solutionMd ? <ReactMarkdown>{solutionMd}</ReactMarkdown> : <p className="text-muted-foreground">暂无内容</p>}
|
||||||
<ReactMarkdown>{solutionMd}</ReactMarkdown>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground">暂无内容</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -395,14 +465,10 @@ export default function WorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Action Bar */}
|
|
||||||
<div className="flex items-center gap-3 border-t bg-card px-4 py-3 shadow-[0_-2px 10px rgba(0,0,0,0.05)] flex-wrap">
|
<div className="flex items-center gap-3 border-t bg-card px-4 py-3 shadow-[0_-2px 10px rgba(0,0,0,0.05)] flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">报价模板</span>
|
<span className="text-xs text-muted-foreground whitespace-nowrap">报价模板</span>
|
||||||
<Select
|
<Select value={selectedQuoteTemplate || "__latest__"} onValueChange={(v) => setSelectedQuoteTemplate(v === "__latest__" ? "" : v)}>
|
||||||
value={selectedQuoteTemplate || "__latest__"}
|
|
||||||
onValueChange={(v) => setSelectedQuoteTemplate(v === "__latest__" ? "" : v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[180px] h-8">
|
<SelectTrigger className="w-[180px] h-8">
|
||||||
<SelectValue placeholder="使用最新上传" />
|
<SelectValue placeholder="使用最新上传" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -416,58 +482,27 @@ export default function WorkspacePage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={handleSaveToArchive} disabled={saving || projectId == null}>
|
||||||
variant="outline"
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
size="sm"
|
|
||||||
onClick={handleSaveToArchive}
|
|
||||||
disabled={saving || projectId == null}
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="ml-1.5">保存到档案</span>
|
<span className="ml-1.5">保存到档案</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={handleDraftQuote} disabled={generatingQuote || projectId == null}>
|
||||||
variant="outline"
|
{generatingQuote ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
|
||||||
size="sm"
|
|
||||||
onClick={handleDraftQuote}
|
|
||||||
disabled={generatingQuote || projectId == null}
|
|
||||||
>
|
|
||||||
{generatingQuote ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<FileSpreadsheet className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="ml-1.5">生成报价单 (Excel)</span>
|
<span className="ml-1.5">生成报价单 (Excel)</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={handleExportPdf} disabled={generatingQuote || projectId == null}>
|
||||||
variant="outline"
|
{generatingQuote ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileDown className="h-4 w-4" />}
|
||||||
size="sm"
|
|
||||||
onClick={handleExportPdf}
|
|
||||||
disabled={generatingQuote || projectId == null}
|
|
||||||
>
|
|
||||||
{generatingQuote ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<FileDown className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="ml-1.5">导出 PDF</span>
|
<span className="ml-1.5">导出 PDF</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Select
|
<Select
|
||||||
value="__none__"
|
value="__none__"
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v === "feishu" || v === "yuque" || v === "tencent") handlePushToCloud(v);
|
if (v === "feishu" || v === "yuque" || v === "tencent") void handlePushToCloud(v);
|
||||||
}}
|
}}
|
||||||
disabled={pushToCloudLoading || projectId == null}
|
disabled={pushToCloudLoading || projectId == null}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[140px] h-8">
|
<SelectTrigger className="w-[140px] h-8">
|
||||||
{pushToCloudLoading ? (
|
{pushToCloudLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudUpload className="h-4 w-4 mr-1" />}
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<CloudUpload className="h-4 w-4 mr-1" />
|
|
||||||
)}
|
|
||||||
<SelectValue placeholder="推送到云文档" />
|
<SelectValue placeholder="推送到云文档" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -479,12 +514,9 @@ export default function WorkspacePage() {
|
|||||||
<SelectItem value="tencent">腾讯文档</SelectItem>
|
<SelectItem value="tencent">腾讯文档</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{projectId != null && (
|
{projectId != null && <span className="text-xs text-muted-foreground ml-2">当前项目 #{projectId}</span>}
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
|
||||||
当前项目 #{projectId}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
32
frontend_spa/src/routes/App.tsx
Normal file
32
frontend_spa/src/routes/App.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import MainLayout from "./MainLayout";
|
||||||
|
import WorkspacePage from "@/pages/workspace";
|
||||||
|
import FinancePage from "@/pages/finance";
|
||||||
|
import SettingsIndexPage from "@/pages/settings";
|
||||||
|
import SettingsTemplatesPage from "@/pages/settings/templates";
|
||||||
|
import SettingsAIPage from "@/pages/settings/ai";
|
||||||
|
import SettingsEmailPage from "@/pages/settings/email";
|
||||||
|
import SettingsCloudDocsPage from "@/pages/settings/cloud-docs";
|
||||||
|
import SettingsCloudDocConfigPage from "@/pages/settings/cloud-doc-config";
|
||||||
|
import SettingsPortalLinksPage from "@/pages/settings/portal-links";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/workspace" replace />} />
|
||||||
|
<Route element={<MainLayout />}>
|
||||||
|
<Route path="/workspace" element={<WorkspacePage />} />
|
||||||
|
<Route path="/finance" element={<FinancePage />} />
|
||||||
|
<Route path="/settings" element={<SettingsIndexPage />} />
|
||||||
|
<Route path="/settings/templates" element={<SettingsTemplatesPage />} />
|
||||||
|
<Route path="/settings/ai" element={<SettingsAIPage />} />
|
||||||
|
<Route path="/settings/email" element={<SettingsEmailPage />} />
|
||||||
|
<Route path="/settings/cloud-docs" element={<SettingsCloudDocsPage />} />
|
||||||
|
<Route path="/settings/cloud-doc-config" element={<SettingsCloudDocConfigPage />} />
|
||||||
|
<Route path="/settings/portal-links" element={<SettingsPortalLinksPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<div className="p-6">Not Found</div>} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
|
|
||||||
export default function MainLayout({
|
export default function MainLayout() {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main className="flex-1 overflow-auto bg-background">{children}</main>
|
<main className="flex-1 overflow-auto bg-background">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
<Toaster position="top-right" richColors closeButton />
|
<Toaster position="top-right" richColors closeButton />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,3 +57,4 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,11 +2,7 @@ import type { Config } from "tailwindcss";
|
|||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: [
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
@@ -55,3 +51,4 @@ const config: Config = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
||||||
22
frontend_spa/tsconfig.json
Normal file
22
frontend_spa/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
||||||
35
frontend_spa/vite.config.ts
Normal file
35
frontend_spa/vite.config.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: process.env.VITE_DEV_PROXY_TARGET ?? "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
// dev 对齐生产 Nginx:/api/* -> backend /*
|
||||||
|
rewrite: (p) => p.replace(/^\/api/, ""),
|
||||||
|
},
|
||||||
|
"/data": {
|
||||||
|
target: process.env.VITE_DEV_PROXY_TARGET ?? "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
54
nginx_spa.conf
Normal file
54
nginx_spa.conf
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# SPA static files
|
||||||
|
root /var/www/ops-core/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# React Router history mode
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse proxy to FastAPI (same-origin /api)
|
||||||
|
location /api/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# keep cookie + auth headers intact
|
||||||
|
proxy_pass http://127.0.0.1:8000/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend mounted static (/data) for downloads/preview
|
||||||
|
location /data/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass http://127.0.0.1:8000/data/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSE: disable buffering to stream tokens immediately
|
||||||
|
location = /api/projects/analyze_stream {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_send_timeout 1h;
|
||||||
|
chunked_transfer_encoding off;
|
||||||
|
add_header X-Accel-Buffering no;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:8000/projects/analyze_stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user