fix:优化项目内容

This commit is contained in:
Daniel
2026-03-18 17:01:10 +08:00
parent da63282a10
commit 27dc89e251
64 changed files with 3421 additions and 4982 deletions

2
.gitignore vendored
View File

@@ -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

View File

@@ -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
View 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
View 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

View File

@@ -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

View File

@@ -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
) )

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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"

View File

@@ -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", "")

View File

@@ -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:

View File

@@ -1,6 +1,5 @@
# 生产/默认:分层构建,仅代码变更时只重建最后一层 # 生产/默认:前端为静态 SPAVite 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

View File

@@ -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:3001Ctrl+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:3001Ctrl+C 停止)..."
${COMPOSE_DEV} up --build ${COMPOSE_DEV} up --build
;; ;;
dev-bg) dev-bg)

View File

@@ -1,4 +0,0 @@
# 国内镜像加快安装、避免卡死Docker 内已单独配置,此文件供本地/CI 使用)
registry=https://registry.npmmirror.com
fetch-retries=5
fetch-timeout=60000

View File

@@ -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"]

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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.

View File

@@ -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;

View File

@@ -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
View 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

View 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
View 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
View 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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"
} }
} }

View File

@@ -7,3 +7,4 @@ const config = {
}; };
export default config; export default config;

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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 };

View File

@@ -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 };

View 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 };

View File

@@ -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,
}; };

View File

@@ -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 };

View File

@@ -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 };

View 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>
);
}

View File

@@ -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,
}; };

View 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 };

View 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 };

View File

@@ -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 };

View File

@@ -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");
},
}; };

View File

@@ -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
View 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>,
);

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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 993SSLIMAP/SMTP </p> <p>
<p><strong></strong> 使 POP3/SMTP/IMAP </p> <strong>IMAP </strong> imap.163.com 993SSLIMAP/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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -57,3 +57,4 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }

View File

@@ -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;

View 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"]
}

View 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
View 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;
}
}