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
frontend/node_modules/
frontend/.next/
frontend_spa/node_modules/
frontend_spa/dist/
# Environment & secrets
.env

View File

@@ -1,15 +1,11 @@
# 分层构建:依赖与代码分离,仅代码变更时只重建最后一层,加快迭代
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.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
# 分层构建:避免依赖第三方基础镜像(例如 tiangolo拉取失败
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# 依赖层:只有 requirements 变更时才重建
COPY requirements.txt /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
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")
def on_startup() -> None:
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:
from sqlalchemy import text
with engine.connect() as conn:
# finance_records: amount, billing_date
r = conn.execute(text("PRAGMA table_info(finance_records)"))
cols = [row[1] for row in r]
if "amount" not in cols:
conn.execute(text("ALTER TABLE finance_records ADD COLUMN amount NUMERIC(12,2)"))
if "billing_date" not in cols:
conn.execute(text("ALTER TABLE finance_records ADD COLUMN billing_date DATE"))
conn.commit()
except Exception:
pass
# Add customers.tags if missing (customer tags for project 收纳)
try:
from sqlalchemy import text
with engine.connect() as conn:
if "tags" not in cols:
conn.execute(text("ALTER TABLE finance_records ADD COLUMN tags VARCHAR(512)"))
if "meta_json" not in cols:
conn.execute(text("ALTER TABLE finance_records ADD COLUMN meta_json TEXT"))
# customers: tags
r = conn.execute(text("PRAGMA table_info(customers)"))
cols = [row[1] for row in r]
if "tags" not in cols:
conn.execute(text("ALTER TABLE customers ADD COLUMN tags VARCHAR(512)"))
conn.commit()
except Exception:
pass

View File

@@ -2,7 +2,6 @@ from datetime import date, datetime
from sqlalchemy import (
Date,
Column,
DateTime,
ForeignKey,
Integer,
@@ -48,7 +47,6 @@ class Project(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
customer: Mapped[Customer] = relationship("Customer", back_populates="projects")
quotes: Mapped[list["Quote"]] = relationship(
"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 / ...
file_name: Mapped[str] = mapped_column(String(255), 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)
billing_date: Mapped[date | None] = mapped_column(Date, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)

View File

@@ -1,6 +1,9 @@
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 sqlalchemy.orm import Session
@@ -9,6 +12,8 @@ from backend.app import models
from backend.app.schemas import (
FinanceRecordRead,
FinanceRecordUpdate,
FinanceBatchDeleteRequest,
FinanceSyncRequest,
FinanceSyncResponse,
FinanceSyncResult,
FinanceUploadResponse,
@@ -21,9 +26,14 @@ router = APIRouter(prefix="/finance", tags=["finance"])
@router.post("/sync", response_model=FinanceSyncResponse)
async def sync_finance():
async def sync_finance(payload: FinanceSyncRequest = Body(default=FinanceSyncRequest())):
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:
# 邮箱配置/连接等问题属于可预期的业务错误,用 400 让前端直接展示原因,而不是泛化为 500。
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -108,6 +118,60 @@ async def update_finance_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}")
async def download_finance_month(month: str):
"""
@@ -124,3 +188,53 @@ async def download_finance_month(month: str):
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 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 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])
async def list_projects(
customer_tag: str | None = None,
limit: int = Query(30, ge=1, le=200, description="默认只返回最近 N 条"),
db: Session = Depends(get_db),
):
"""列表项目customer_tag 不为空时只返回该客户标签下的项目(按客户 tags 筛选)。"""
@@ -127,7 +132,7 @@ async def list_projects(
t.ilike(f"%,{tag}"),
)
)
return query.all()
return query.limit(limit).all()
@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)
async def generate_project_quote(
project_id: int,

View File

@@ -104,12 +104,31 @@ class FinanceSyncResponse(BaseModel):
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):
id: int
month: str
type: str
file_name: str
file_path: str
tags: Optional[str] = None
meta_json: Optional[str] = None
amount: Optional[float] = None
billing_date: Optional[date] = None
created_at: datetime
@@ -123,6 +142,10 @@ class FinanceRecordUpdate(BaseModel):
billing_date: Optional[date] = None
class FinanceBatchDeleteRequest(BaseModel):
ids: List[int] = Field(..., description="要删除的财务记录 ID 列表")
class FinanceUploadResponse(BaseModel):
id: int
month: str

View File

@@ -3,7 +3,7 @@ import json
import os
import re
from pathlib import Path
from typing import Any, Dict, Tuple
from typing import Any, Dict, Tuple, List
from openai import AsyncOpenAI
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()
if not api_key:
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:
client = _client_from_config(config)
model = config.get("model_name") or "gpt-4o-mini"

View File

@@ -7,7 +7,7 @@ import os
import re
import sqlite3
import ssl
from datetime import date, datetime
from datetime import date, datetime, timedelta
from email.header import decode_header
from pathlib import Path
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()
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(
file_path: str,
amount: float | None,
@@ -173,6 +196,7 @@ def _has_sync_history() -> bool:
def _save_attachment(
msg: email.message.Message,
month_str: str,
allowed_doc_types: set[str] | None = None,
) -> List[Tuple[str, str, str, bytes, str]]:
"""
Save PDF/image attachments.
@@ -193,17 +217,20 @@ def _save_attachment(
_ensure_sync_history_table(conn)
for part in msg.walk():
content_disposition = part.get("Content-Disposition", "")
if "attachment" not in content_disposition:
continue
# 许多邮件附件会以 inline 或缺失 Content-Disposition 的形式出现,
# 只要存在 filename 且扩展名符合,就视为可下载附件。
content_disposition = (part.get("Content-Disposition", "") or "").lower()
filename = part.get_filename()
filename = _decode_header_value(filename)
if not filename:
continue
if content_disposition and ("attachment" not in content_disposition and "inline" not in content_disposition):
# 明确的非附件 disposition跳过
continue
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
maintype = part.get_content_maintype()
@@ -216,6 +243,8 @@ def _save_attachment(
# 分类:基于主题 + 文件名
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)
# 增量去重:根据 (message_id, md5) 判断是否已同步过
@@ -421,7 +450,56 @@ def _select_mailbox(imap: imaplib.IMAP4_SSL, mailbox: str) -> bool:
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")
user = config.get("user")
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"
)
# 首次同步(历史库无记录):拉取全部邮件中的附件,由 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()
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(
"Finance sync: %s (criterion=%s)",
"全量" if is_first_sync else "增量",
search_criterion,
"Finance sync: mode=%s criterion=%s range=%s~%s",
mode,
base_criterion,
start_date,
end_date,
)
status, data = imap.search(None, search_criterion)
status, data = imap.search(None, *criteria)
if status != "OK":
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:
status, msg_data = imap.fetch(msg_id, "(RFC822)")
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)
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:
final_name = file_name
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,
file_name=final_name,
file_path=final_path,
tags=None,
meta_json=None,
amount=amount,
billing_date=billing_date,
)
db.add(record)
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({
"id": record.id,
"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")
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).
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:
for config in configs:
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:
# 不让单个账户的异常中断全部同步,记录错误并继续其他账户。
user = config.get("user", "") or config.get("id", "")

View File

@@ -21,22 +21,23 @@ services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
context: ./frontend_spa
dockerfile: Dockerfile.dev
image: aitool-frontend-dev
working_dir: /app
container_name: ops-core-frontend
volumes:
- ./frontend:/app
# 保留容器内 node_modules避免被宿主机目录覆盖
- frontend_node_modules:/app/node_modules
- ./frontend_spa:/app
- frontend_spa_node_modules:/app/node_modules
# 宿主机 3001 避免与占用 3000 的进程冲突;访问 http://localhost:3001
ports:
- "3000:3000"
environment:
NODE_ENV: development
NEXT_TELEMETRY_DISABLED: 1
# 开发模式:先装依赖volume 首次为空),再 dev代码变更热更新
command: sh -c "npm install && npm run dev"
VITE_DEV_PROXY_TARGET: http://backend:8000
# 开发模式:命令在 Dockerfile.dev 的 CMD 中
depends_on:
- backend
volumes:
frontend_node_modules:
frontend_spa_node_modules:

View File

@@ -1,6 +1,5 @@
# 生产/默认:分层构建,仅代码变更时只重建最后一层
# 开发(代码挂载+热重载): docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# 或执行: ./docker_dev.sh dev
# 生产/默认:前端为静态 SPAVite build + Nginx后端 FastAPI
# 开发:使用 docker-compose.dev.yml容器内 Vite 热更新),执行: ./docker_dev.sh dev
services:
backend:
build:
@@ -16,11 +15,11 @@ services:
frontend:
build:
context: ./frontend
context: ./frontend_spa
dockerfile: Dockerfile
container_name: ops-core-frontend
ports:
- "3000:3000"
- "3000:80"
depends_on:
- backend

View File

@@ -6,14 +6,15 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}"
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() {
echo "用法: $0 [mode]"
echo ""
echo " (无参数) 默认构建并启动(生产模式,分层构建仅重建变更层"
echo " dev 开发模式:挂载代码 + 热重载(前台,Ctrl+C 停容器)"
echo " dev-bg 开发模式后台运行,Ctrl+C 不会停容器,停止用: $0 down"
echo " (无参数) 默认构建并启动(生产模式:前端为静态 SPA + Nginx后端 FastAPI"
echo " dev 开发模式:容器内 Vite 热重载,前端 http://localhost:3001Ctrl+C 停容器)"
echo " dev-bg 开发模式后台运行,前端 http://localhost:3001,停止用: $0 down"
echo " restart 仅重启容器内服务,不重建镜像"
echo " build 仅重新构建镜像(依赖未变时只重建代码层,较快)"
echo " down 停止并移除容器"
@@ -22,11 +23,11 @@ usage() {
case "${1:-up}" in
up|"")
echo "[Ops-Core] 构建并启动(生产模式)..."
echo "[Ops-Core] 构建并启动(生产模式:静态 SPA + API..."
${COMPOSE_BASE} up --build
;;
dev)
echo "[Ops-Core] 开发模式:代码挂载 + 热重载,无需重建Ctrl+C 停止容器..."
echo "[Ops-Core] 开发模式:容器内 Vite 热重载,前端 http://localhost:3001Ctrl+C 停止)..."
${COMPOSE_DEV} up --build
;;
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",
"version": "0.1.0",
"name": "ops-core-frontend-spa",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"dev": "vite",
"build": "tsc -p tsconfig.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
@@ -23,24 +24,24 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.454.0",
"next": "14.2.18",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.26.2",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.18",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"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;

View File

@@ -1,8 +1,7 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState, useCallback } from "react";
import { Link, useLocation } from "react-router-dom";
import { useEffect, useState, useCallback, memo, useRef } from "react";
import {
FileText,
FolderArchive,
@@ -16,45 +15,82 @@ import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
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() {
const pathname = usePathname();
const { pathname } = useLocation();
const [cloudDocs, setCloudDocs] = useState<CloudDocLinkRead[]>([]);
const [portalLinks, setPortalLinks] = useState<PortalLinkRead[]>([]);
const [projectArchiveOpen, setProjectArchiveOpen] = useState(false);
const didInitRef = useRef(false);
const loadCloudDocs = useCallback(async () => {
try {
const list = await cloudDocsApi.list();
setCloudDocs(list);
writeCache("opc_cloud_docs", list);
} catch {
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 () => {
try {
const list = await portalLinksApi.list();
setPortalLinks(list);
writeCache("opc_portal_links", list);
} catch {
setPortalLinks([]);
}
}, []);
useEffect(() => {
loadPortalLinks();
}, [loadPortalLinks]);
if (didInitRef.current) return;
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(() => {
const onPortalLinksChanged = () => loadPortalLinks();
@@ -71,7 +107,7 @@ export function AppSidebar() {
return (
<aside className="flex w-56 flex-col border-r bg-card text-card-foreground">
<div className="p-4">
<Link href="/" className="font-semibold text-foreground">
<Link to="/" className="font-semibold text-foreground">
Ops-Core
</Link>
<p className="text-xs text-muted-foreground mt-0.5"></p>
@@ -79,7 +115,7 @@ export function AppSidebar() {
<Separator />
<nav className="flex flex-1 flex-col gap-1 p-2">
{nav.map((item) => (
<Link key={item.href} href={item.href}>
<Link key={item.href} to={item.href}>
<Button
variant={pathname === item.href ? "secondary" : "ghost"}
size="sm"
@@ -92,7 +128,6 @@ export function AppSidebar() {
))}
</nav>
<Separator />
{/* 项目档案放在云文档之前,支持收纳折叠 */}
<div className="px-2 pt-2">
<button
type="button"
@@ -103,19 +138,19 @@ export function AppSidebar() {
<ChevronDown
className={cn(
"h-3 w-3 transition-transform",
projectArchiveOpen ? "rotate-180" : "rotate-0"
projectArchiveOpen ? "rotate-180" : "rotate-0",
)}
/>
</button>
</div>
{projectArchiveOpen && <HistoricalReferences />}
{projectArchiveOpen && <MemoHistoricalReferences />}
<Separator />
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
<div className="p-2 shrink-0">
<div className="flex items-center justify-between px-2 mb-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<Link
href="/settings/cloud-docs"
to="/settings/cloud-docs"
className="text-xs text-muted-foreground hover:text-foreground"
title="管理云文档入口"
>
@@ -133,7 +168,7 @@ export function AppSidebar() {
target="_blank"
rel="noopener noreferrer"
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" />
@@ -147,7 +182,7 @@ export function AppSidebar() {
<div className="flex items-center justify-between px-2 mb-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<Link
href="/settings/portal-links"
to="/settings/portal-links"
className="text-xs text-muted-foreground hover:text-foreground"
title="管理快捷门户"
>
@@ -165,7 +200,7 @@ export function AppSidebar() {
target="_blank"
rel="noopener noreferrer"
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" />
@@ -179,3 +214,4 @@ export function AppSidebar() {
</aside>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -27,9 +28,8 @@ import {
type ProjectRead,
type CustomerRead,
} 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 ReactMarkdown from "react-markdown";
function parseTags(tagsStr: string | null | undefined): string[] {
if (!tagsStr?.trim()) return [];
@@ -58,11 +58,12 @@ export function HistoricalReferences() {
const load = useCallback(async () => {
setLoading(true);
try {
const data = await listProjects(
selectedTag ? { customer_tag: selectedTag } : undefined
);
const data = await listProjects({
customer_tag: selectedTag || undefined,
limit: 30,
});
setProjects(data);
} catch (e) {
} catch {
toast.error("加载历史项目失败");
setProjects([]);
} finally {
@@ -71,14 +72,12 @@ export function HistoricalReferences() {
}, [selectedTag]);
useEffect(() => {
load();
void load();
}, [load]);
const allTags = useMemo(() => {
const set = new Set<string>();
projects.forEach((p) =>
parseTags(p.customer?.tags ?? null).forEach((t) => set.add(t))
);
projects.forEach((p) => parseTags(p.customer?.tags ?? null).forEach((t) => set.add(t)));
return Array.from(set).sort();
}, [projects]);
@@ -89,7 +88,7 @@ export function HistoricalReferences() {
list = list.filter(
(p) =>
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);
@@ -99,9 +98,8 @@ export function HistoricalReferences() {
const map = new Map<string, ProjectRead[]>();
filtered.forEach((p) => {
const name = p.customer?.name || "未关联客户";
const key = name;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(p);
if (!map.has(name)) map.set(name, []);
map.get(name)!.push(p);
});
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b, "zh-CN"));
}, [filtered]);
@@ -129,8 +127,8 @@ export function HistoricalReferences() {
});
toast.success("已保存");
setEditProject(null);
load();
} catch (e) {
await load();
} catch {
toast.error("保存失败");
} finally {
setSaving(false);
@@ -155,14 +153,11 @@ export function HistoricalReferences() {
tags: customerTags.trim() || null,
});
toast.success("客户信息已保存");
// 更新本地 projects 中的 customer 信息
setProjects((prev) =>
prev.map((p) =>
p.customer_id === updated.id ? { ...p, customer: updated } : p
)
prev.map((p) => (p.customer_id === updated.id ? { ...p, customer: updated } : p)),
);
setEditCustomer(null);
} catch (e) {
} catch {
toast.error("保存客户失败");
} finally {
setSavingCustomer(false);
@@ -173,10 +168,7 @@ export function HistoricalReferences() {
<div className="p-2">
<div className="space-y-2 mb-2">
<div className="flex gap-1 items-center">
<Select
value={selectedTag || "__all__"}
onValueChange={(v) => setSelectedTag(v === "__all__" ? "" : v)}
>
<Select value={selectedTag || "__all__"} onValueChange={(v) => setSelectedTag(v === "__all__" ? "" : v)}>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="按标签收纳" />
</SelectTrigger>
@@ -208,18 +200,11 @@ export function HistoricalReferences() {
) : filtered.length === 0 ? (
<p className="text-xs text-muted-foreground px-2"></p>
) : (
groupedByCustomer.map(([customerName, items]) => (
<div key={customerName} className="space-y-1">
<p className="px-2 text-[11px] font-medium text-muted-foreground">
{customerName}
</p>
groupedByCustomer.map(([customerName2, items]) => (
<div key={customerName2} className="space-y-1">
<p className="px-2 text-[11px] font-medium text-muted-foreground">{customerName2}</p>
{items.map((p) => (
<div
key={p.id}
className={cn(
"rounded border bg-background/50 p-2 text-xs space-y-1 ml-1"
)}
>
<div 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">
<span> #{p.id}</span>
{p.customer?.tags && (
@@ -228,9 +213,7 @@ export function HistoricalReferences() {
</span>
)}
</p>
<p className="text-muted-foreground line-clamp-2">
{p.raw_requirement.slice(0, 80)}
</p>
<p className="text-muted-foreground line-clamp-2">{p.raw_requirement.slice(0, 80)}</p>
<div className="flex flex-wrap gap-1 pt-1">
<Button
variant="ghost"
@@ -278,7 +261,6 @@ export function HistoricalReferences() {
)}
</div>
{/* 预览弹窗 */}
<Dialog open={!!previewProject} onOpenChange={() => setPreviewProject(null)}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
@@ -322,7 +304,6 @@ export function HistoricalReferences() {
</DialogContent>
</Dialog>
{/* 二次编辑弹窗 */}
<Dialog open={!!editProject} onOpenChange={() => !saving && setEditProject(null)}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
@@ -351,16 +332,13 @@ export function HistoricalReferences() {
</Button>
<Button onClick={handleSaveEdit} disabled={saving}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 客户二次编辑弹窗 */}
<Dialog open={!!editCustomer} onOpenChange={() => !savingCustomer && setEditCustomer(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
@@ -369,11 +347,7 @@ export function HistoricalReferences() {
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="hist-customer-name"></Label>
<Input
id="hist-customer-name"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
/>
<Input id="hist-customer-name" value={customerName} onChange={(e) => setCustomerName(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="hist-customer-contact"></Label>
@@ -394,11 +368,7 @@ export function HistoricalReferences() {
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditCustomer(null)}
disabled={savingCustomer}
>
<Button variant="outline" onClick={() => setEditCustomer(null)} disabled={savingCustomer}>
</Button>
<Button onClick={handleSaveCustomer} disabled={savingCustomer}>
@@ -411,3 +381,4 @@ export function HistoricalReferences() {
</div>
);
}

View File

@@ -9,9 +9,12 @@ const buttonVariants = cva(
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
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",
link: "text-primary underline-offset-4 hover:underline",
},
@@ -26,7 +29,7 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
},
);
export interface ButtonProps
@@ -45,8 +48,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
/>
);
}
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -1,30 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
));
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
@@ -34,10 +26,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
@@ -47,11 +36,7 @@ const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
@@ -67,12 +52,9 @@ const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
));
CardFooter.displayName = "CardFooter";
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}
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",
className
className,
)}
{...props}
/>
@@ -37,7 +37,7 @@ const DialogContent = React.forwardRef<
ref={ref}
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",
className
className,
)}
{...props}
>
@@ -58,10 +58,7 @@ const DialogHeader = ({
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
);
@@ -74,7 +71,7 @@ const DialogFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
className,
)}
{...props}
/>
@@ -117,3 +114,4 @@ export {
DialogTitle,
DialogDescription,
};

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
@@ -11,14 +10,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={type}
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",
className
className,
)}
ref={ref}
{...props}
/>
);
}
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
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<
@@ -23,3 +23,4 @@ const Label = React.forwardRef<
Label.displayName = LabelPrimitive.Root.displayName;
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";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
@@ -19,7 +17,7 @@ const SelectTrigger = React.forwardRef<
ref={ref}
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",
className
className,
)}
{...props}
>
@@ -37,10 +35,7 @@ const SelectScrollUpButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
@@ -54,17 +49,13 @@ const SelectScrollDownButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
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",
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",
className
className,
)}
position={position}
{...props}
@@ -87,7 +78,7 @@ const SelectContent = React.forwardRef<
className={cn(
"p-1",
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}
@@ -118,7 +109,7 @@ const SelectItem = React.forwardRef<
ref={ref}
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",
className
className,
)}
{...props}
>
@@ -155,3 +146,4 @@ export {
SelectItem,
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}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
className,
)}
{...props}
/>
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
ref={ref}
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",
className
className,
)}
{...props}
/>
@@ -44,7 +44,7 @@ const TabsContent = React.forwardRef<
ref={ref}
className={cn(
"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}
/>
@@ -52,3 +52,4 @@ const TabsContent = React.forwardRef<
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,13 +1,13 @@
/**
* 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 => {
if (typeof window !== "undefined") {
return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
}
return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
const base = (import.meta.env.VITE_API_BASE as string | undefined) ?? "/api";
return base.replace(/\/$/, "");
};
export const apiBase = getBase;
@@ -86,6 +86,8 @@ export interface FinanceRecordRead {
amount: number | null;
billing_date: string | null;
created_at: string;
tags?: string | null;
meta_json?: string | null;
}
export interface TemplateInfo {
@@ -157,7 +159,7 @@ export interface ProjectUpdate {
async function request<T>(
path: string,
options: RequestInit & { params?: Record<string, string> } = {}
options: RequestInit & { params?: Record<string, string> } = {},
): Promise<T> {
const { params, ...init } = options;
const base = apiBase();
@@ -169,6 +171,7 @@ async function request<T>(
const res = await fetch(url, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...init.headers,
@@ -202,16 +205,13 @@ async function request<T>(
/** Download a file from the backend and return a Blob (for Excel/PDF/Zip). */
export async function downloadBlob(path: string): Promise<Blob> {
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" });
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
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 {
const base = apiBase();
const normalized = path.startsWith("/") ? path : `/${path}`;
@@ -226,11 +226,10 @@ export function downloadFile(path: string, filename: string): void {
document.body.removeChild(a);
}
/** Same as downloadFile but using fetch + Blob for consistent CORS and filename control. */
export async function downloadFileAsBlob(
path: string,
filename: string,
mime?: string
_mime?: string,
): Promise<void> {
const blob = await downloadBlob(path);
const url = URL.createObjectURL(blob);
@@ -248,7 +247,7 @@ export async function downloadFileAsBlob(
export const customersApi = {
list: (params?: { q?: string }) =>
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}`),
create: (body: CustomerCreate) =>
@@ -261,8 +260,7 @@ export const customersApi = {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: number) =>
request<void>(`/customers/${id}`, { method: "DELETE" }),
delete: (id: number) => request<void>(`/customers/${id}`, { method: "DELETE" }),
};
// --------------- Projects ---------------
@@ -282,32 +280,27 @@ export const projectsApi = {
generateQuote: (projectId: number, template?: string | null) =>
request<QuoteGenerateResponse>(
`/projects/${projectId}/generate_quote${template ? `?template=${encodeURIComponent(template)}` : ""}`,
{ method: "POST" }
`/projects/${projectId}/generate_quote${
template ? `?template=${encodeURIComponent(template)}` : ""
}`,
{ method: "POST" },
),
generateContract: (projectId: number, body: ContractGenerateRequest) =>
request<ContractGenerateResponse>(
`/projects/${projectId}/generate_contract`,
{
request<ContractGenerateResponse>(`/projects/${projectId}/generate_contract`, {
method: "POST",
body: JSON.stringify(body),
}
),
}),
};
export async function listProjects(params?: {
customer_tag?: string;
limit?: number;
}): Promise<ProjectRead[]> {
const searchParams: Record<string, string> = {};
if (params?.customer_tag?.trim()) searchParams.customer_tag = params.customer_tag.trim();
return request<ProjectRead[]>("/projects/", {
params: searchParams,
});
}
export async function getProject(projectId: number): Promise<ProjectRead> {
return request<ProjectRead>(`/projects/${projectId}`);
if (params?.limit != null) searchParams.limit = String(params.limit);
return request<ProjectRead[]>("/projects/", { params: searchParams });
}
// --------------- Settings / Templates ---------------
@@ -322,6 +315,7 @@ export const templatesApi = {
const res = await fetch(`${base}/settings/templates/upload`, {
method: "POST",
body: formData,
credentials: "include",
});
if (!res.ok) {
const text = await res.text();
@@ -345,27 +339,21 @@ export const aiSettingsApi = {
list: () => request<AIConfigListItem[]>("/settings/ai/list"),
getById: (id: string) => request<AIConfig>(`/settings/ai/${id}`),
create: (body: AIConfigCreate) =>
request<AIConfig>("/settings/ai", {
method: "POST",
body: JSON.stringify(body),
}),
request<AIConfig>("/settings/ai", { method: "POST", body: JSON.stringify(body) }),
update: (id: string, body: AIConfigUpdate) =>
request<AIConfig>(`/settings/ai/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: string) =>
request<void>(`/settings/ai/${id}`, { method: "DELETE" }),
activate: (id: string) =>
request<AIConfig>(`/settings/ai/${id}/activate`, { method: "POST" }),
request<AIConfig>(`/settings/ai/${id}`, { method: "PUT", body: JSON.stringify(body) }),
delete: (id: string) => request<void>(`/settings/ai/${id}`, { method: "DELETE" }),
activate: (id: string) => request<AIConfig>(`/settings/ai/${id}/activate`, { method: "POST" }),
test: (configId?: string) =>
request<{ status: string; message: string }>(
configId ? `/settings/ai/test?config_id=${encodeURIComponent(configId)}` : "/settings/ai/test",
{ method: "POST" }
configId
? `/settings/ai/test?config_id=${encodeURIComponent(configId)}`
: "/settings/ai/test",
{ method: "POST" },
),
};
// --------------- Email Configs (multi-account sync) ---------------
// --------------- Email Configs ---------------
export interface EmailConfigRead {
id: string;
@@ -402,18 +390,10 @@ export interface EmailFolder {
export const emailConfigsApi = {
list: () => request<EmailConfigRead[]>("/settings/email"),
create: (body: EmailConfigCreate) =>
request<EmailConfigRead>("/settings/email", {
method: "POST",
body: JSON.stringify(body),
}),
request<EmailConfigRead>("/settings/email", { method: "POST", body: JSON.stringify(body) }),
update: (id: string, body: EmailConfigUpdate) =>
request<EmailConfigRead>(`/settings/email/${id}`, {
method: "PUT",
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. */
request<EmailConfigRead>(`/settings/email/${id}`, { method: "PUT", body: JSON.stringify(body) }),
delete: (id: string) => request<void>(`/settings/email/${id}`, { method: "DELETE" }),
listFolders: (configId: string) =>
request<{ folders: EmailFolder[] }>(`/settings/email/${configId}/folders`),
};
@@ -439,20 +419,13 @@ export interface CloudDocLinkUpdate {
export const cloudDocsApi = {
list: () => request<CloudDocLinkRead[]>("/settings/cloud-docs"),
create: (body: CloudDocLinkCreate) =>
request<CloudDocLinkRead>("/settings/cloud-docs", {
method: "POST",
body: JSON.stringify(body),
}),
request<CloudDocLinkRead>("/settings/cloud-docs", { method: "POST", body: JSON.stringify(body) }),
update: (id: string, body: CloudDocLinkUpdate) =>
request<CloudDocLinkRead>(`/settings/cloud-docs/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: string) =>
request<void>(`/settings/cloud-docs/${id}`, { method: "DELETE" }),
request<CloudDocLinkRead>(`/settings/cloud-docs/${id}`, { method: "PUT", 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 {
feishu: { app_id: string; app_secret_configured: boolean };
@@ -488,10 +461,7 @@ export interface PushToCloudResponse {
cloud_doc_id: string;
}
export function pushProjectToCloud(
projectId: number,
body: PushToCloudRequest
): Promise<PushToCloudResponse> {
export function pushProjectToCloud(projectId: number, body: PushToCloudRequest): Promise<PushToCloudResponse> {
return request<PushToCloudResponse>(`/projects/${projectId}/push-to-cloud`, {
method: "POST",
body: JSON.stringify(body),
@@ -519,26 +489,26 @@ export interface PortalLinkUpdate {
export const portalLinksApi = {
list: () => request<PortalLinkRead[]>("/settings/portal-links"),
create: (body: PortalLinkCreate) =>
request<PortalLinkRead>("/settings/portal-links", {
method: "POST",
body: JSON.stringify(body),
}),
request<PortalLinkRead>("/settings/portal-links", { method: "POST", body: JSON.stringify(body) }),
update: (id: string, body: PortalLinkUpdate) =>
request<PortalLinkRead>(`/settings/portal-links/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: (id: string) =>
request<void>(`/settings/portal-links/${id}`, { method: "DELETE" }),
request<PortalLinkRead>(`/settings/portal-links/${id}`, { method: "PUT", body: JSON.stringify(body) }),
delete: (id: string) => request<void>(`/settings/portal-links/${id}`, { method: "DELETE" }),
};
// --------------- Finance ---------------
export const financeApi = {
sync: () =>
request<FinanceSyncResponse>("/finance/sync", { method: "POST" }),
sync: (body?: {
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> => {
const base = apiBase();
const formData = new FormData();
@@ -546,6 +516,7 @@ export const financeApi = {
const res = await fetch(`${base}/finance/upload`, {
method: "POST",
body: formData,
credentials: "include",
});
if (!res.ok) {
const text = await res.text();
@@ -561,26 +532,36 @@ export const financeApi = {
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 }) =>
request<FinanceRecordRead>(`/finance/records/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
/** List distinct months with records (YYYY-MM). */
listMonths: () => request<string[]>("/finance/months"),
/** List records for a month (YYYY-MM). */
listRecords: (month: string) =>
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> => {
const path = `/finance/download/${month}`;
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[]) {
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,
TableRow,
} from "@/components/ui/table";
import {
apiBase,
financeApi,
type FinanceRecordRead,
type FinanceSyncResponse,
type FinanceSyncResult,
} from "@/lib/api/client";
import { Checkbox } from "@/components/ui/checkbox";
import { financeApi, type FinanceRecordRead, type FinanceSyncResponse, type FinanceSyncResult } from "@/lib/api/client";
import { Download, Inbox, Loader2, Mail, FileText, Upload } from "lucide-react";
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() {
const [months, setMonths] = useState<string[]>([]);
const [selectedMonth, setSelectedMonth] = useState<string>("");
@@ -44,6 +46,14 @@ export default function FinancePage() {
const [loadingMonths, setLoadingMonths] = useState(true);
const [loadingRecords, setLoadingRecords] = 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 [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
@@ -53,6 +63,7 @@ export default function FinancePage() {
const [reviewAmount, setReviewAmount] = useState("");
const [reviewDate, setReviewDate] = useState("");
const [savingReview, setSavingReview] = useState(false);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const previewUrlRef = useRef<string | null>(null);
@@ -85,18 +96,24 @@ export default function FinancePage() {
}, [selectedMonth]);
useEffect(() => {
loadMonths();
void loadMonths();
}, [loadMonths]);
useEffect(() => {
loadRecords();
void loadRecords();
setSelectedIds([]);
}, [loadRecords]);
const handleSync = async () => {
setSyncing(true);
toast.loading("正在同步邮箱…", { id: "finance-sync" });
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);
toast.dismiss("finance-sync");
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) =>
new Date(s).toLocaleString("zh-CN", {
year: "numeric",
@@ -220,9 +270,9 @@ export default function FinancePage() {
};
const monthlyTotal = records.reduce((sum, r) => sum + (r.amount ?? 0), 0);
const totalInvoicesThisMonth = records.filter(
(r) => r.amount != null && (r.type === "manual" || r.type === "invoices")
).reduce((s, r) => s + (r.amount ?? 0), 0);
const totalInvoicesThisMonth = records
.filter((r) => r.amount != null && (r.type === "manual" || r.type === "invoices"))
.reduce((s, r) => s + (r.amount ?? 0), 0);
return (
<div className="p-6 max-w-5xl">
@@ -253,22 +303,86 @@ export default function FinancePage() {
<Upload className="h-4 w-4" />
<span className="ml-2"></span>
</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">
{syncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Mail className="h-4 w-4" />
)}
{syncing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Mail className="h-4 w-4" />}
<span className="ml-2"></span>
</Button>
</div>
</div>
{/* Upload Invoice Dialog */}
<Dialog open={uploadDialogOpen} onOpenChange={(open) => {
<Dialog
open={uploadDialogOpen}
onOpenChange={(open) => {
setUploadDialogOpen(open);
if (!open) resetUploadDialog();
}}>
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{reviewRecord ? "核对金额与日期" : "上传发票"}</DialogTitle>
@@ -306,9 +420,7 @@ export default function FinancePage() {
</>
) : (
<>
{previewUrl && (
<img src={previewUrl} alt="预览" className="max-h-32 rounded border object-contain" />
)}
{previewUrl && <img src={previewUrl} alt="预览" className="max-h-32 rounded border object-contain" />}
<div className="grid gap-2">
<Label></Label>
<Input
@@ -321,14 +433,17 @@ export default function FinancePage() {
</div>
<div className="grid gap-2">
<Label></Label>
<Input
type="date"
value={reviewDate}
onChange={(e) => setReviewDate(e.target.value)}
/>
<Input type="date" value={reviewDate} onChange={(e) => setReviewDate(e.target.value)} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setReviewRecord(null); setReviewAmount(""); setReviewDate(""); }}>
<Button
variant="outline"
onClick={() => {
setReviewRecord(null);
setReviewAmount("");
setReviewDate("");
}}
>
</Button>
<Button onClick={handleReviewSave} disabled={savingReview}>
@@ -341,7 +456,6 @@ export default function FinancePage() {
</DialogContent>
</Dialog>
{/* Sync History / Last sync */}
<Card>
<CardHeader className="py-3">
<CardTitle className="text-base flex items-center gap-2">
@@ -358,15 +472,12 @@ export default function FinancePage() {
{lastSync.details && lastSync.details.length > 0 && (
<div className="mt-2 space-y-2">
{Object.entries(
lastSync.details.reduce<Record<string, FinanceSyncResult[]>>(
(acc, item) => {
lastSync.details.reduce<Record<string, FinanceSyncResult[]>>((acc, item) => {
const t = item.type || "others";
if (!acc[t]) acc[t] = [];
acc[t].push(item);
return acc;
},
{},
),
}, {}),
).map(([t, items]) => (
<div key={t}>
<p className="text-xs font-medium text-muted-foreground">
@@ -376,9 +487,7 @@ export default function FinancePage() {
{items.map((it) => (
<li key={it.id}>
{it.file_name}
<span className="ml-1 text-[11px] text-muted-foreground/80">
[{it.month}]
</span>
<span className="ml-1 text-[11px] text-muted-foreground/80">[{it.month}]</span>
</li>
))}
</ul>
@@ -388,24 +497,17 @@ export default function FinancePage() {
)}
</>
) : (
<p className="text-sm text-muted-foreground">
</p>
<p className="text-sm text-muted-foreground"></p>
)}
</CardContent>
</Card>
{/* Month + Download */}
<Card>
<CardHeader className="py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base"></CardTitle>
<div className="flex items-center gap-2">
<Select
value={selectedMonth}
onValueChange={setSelectedMonth}
disabled={loadingMonths}
>
<Select value={selectedMonth} onValueChange={setSelectedMonth} disabled={loadingMonths}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="选择月份" />
</SelectTrigger>
@@ -417,15 +519,15 @@ export default function FinancePage() {
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={handleDownloadZip}
disabled={!selectedMonth || records.length === 0}
>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleDownloadZip} disabled={!selectedMonth || records.length === 0}>
<Download className="h-4 w-4" />
<span className="ml-1.5"> (.zip)</span>
</Button>
<Button variant="outline" size="sm" onClick={handleDeleteSelected} disabled={selectedIds.length === 0}>
</Button>
</div>
</div>
</div>
</CardHeader>
@@ -436,9 +538,7 @@ export default function FinancePage() {
</p>
) : records.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
{selectedMonth ? "该月份暂无归档文件" : "请选择月份或先同步邮箱"}
</p>
<p className="text-sm text-muted-foreground py-4">{selectedMonth ? "该月份暂无归档文件" : "请选择月份或先同步邮箱"}</p>
) : (
<>
<p className="text-xs text-muted-foreground mb-2">
@@ -447,6 +547,17 @@ export default function FinancePage() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[36px]">
<Checkbox
checked={records.length > 0 && selectedIds.length === records.length}
indeterminate={selectedIds.length > 0 && selectedIds.length < records.length}
onCheckedChange={(checked) => {
if (checked) setSelectedIds(records.map((r) => r.id));
else setSelectedIds([]);
}}
aria-label="选择本月全部"
/>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
@@ -458,9 +569,16 @@ export default function FinancePage() {
{records.map((r) => (
<TableRow key={r.id}>
<TableCell>
<span className="text-muted-foreground">
{typeLabel[r.type] ?? r.type}
</span>
<Checkbox
checked={selectedIds.includes(r.id)}
onCheckedChange={(checked) => {
setSelectedIds((prev) => (checked ? [...prev, r.id] : prev.filter((id) => id !== r.id)));
}}
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>
@@ -468,12 +586,10 @@ export default function FinancePage() {
? `¥${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 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}`}
href={fileHref(r.file_path)}
target="_blank"
rel="noopener noreferrer"
className="text-primary text-sm hover:underline inline-flex items-center gap-1"
@@ -485,10 +601,8 @@ export default function FinancePage() {
</TableRow>
))}
<TableRow className="bg-muted/30 font-medium">
<TableCell colSpan={2}></TableCell>
<TableCell>
¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
</TableCell>
<TableCell colSpan={3}></TableCell>
<TableCell>¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}</TableCell>
<TableCell colSpan={2} />
</TableRow>
</TableBody>
@@ -501,3 +615,4 @@ export default function FinancePage() {
</div>
);
}

View File

@@ -1,36 +1,16 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
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 { Select, SelectContent, 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 {
aiSettingsApi,
type AIConfig,
type AIConfigListItem,
type AIConfigCreate,
type AIConfigUpdate,
@@ -69,7 +49,7 @@ export default function SettingsAIPage() {
}, []);
useEffect(() => {
loadList();
void loadList();
}, [loadList]);
const openAdd = () => {
@@ -134,9 +114,7 @@ export default function SettingsAIPage() {
}
setDialogOpen(false);
await loadList();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
@@ -191,7 +169,7 @@ export default function SettingsAIPage() {
return (
<div className="p-6 max-w-4xl">
<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>
</div>
@@ -233,7 +211,7 @@ export default function SettingsAIPage() {
<TableCell className="font-medium">
{item.name || "未命名"}
{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" />
</span>
)}
@@ -244,39 +222,26 @@ export default function SettingsAIPage() {
<TableCell>
<div className="flex items-center gap-1 flex-wrap">
{!item.is_active && (
<Button
variant="outline"
size="sm"
onClick={() => handleActivate(item.id)}
>
<Button variant="outline" size="sm" onClick={() => void handleActivate(item.id)}>
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(item.id)}
title="编辑"
>
<Button variant="ghost" size="sm" onClick={() => void openEdit(item.id)} title="编辑">
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleTest(item.id)}
onClick={() => void handleTest(item.id)}
disabled={testing === item.id}
title="测试连接"
>
{testing === item.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Zap className="h-4 w-4" />
)}
{testing === item.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item.id)}
onClick={() => void handleDelete(item.id)}
title="删除"
className="text-destructive"
>
@@ -389,3 +354,4 @@ export default function SettingsAIPage() {
</div>
);
}

View File

@@ -1,16 +1,12 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
cloudDocConfigApi,
type CloudDocConfigRead,
type CloudDocConfigUpdate,
} from "@/lib/api/client";
import { cloudDocConfigApi, type CloudDocConfigRead, type CloudDocConfigUpdate } from "@/lib/api/client";
import { Loader2, Save, FileStack } from "lucide-react";
import { toast } from "sonner";
@@ -42,7 +38,7 @@ export default function SettingsCloudDocConfigPage() {
}, []);
useEffect(() => {
load();
void load();
}, [load]);
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_secret !== undefined && form.feishu.app_secret !== "")
payload.feishu = { ...payload.feishu, app_secret: form.feishu.app_secret };
if (form.yuque?.token !== undefined && form.yuque.token !== "")
payload.yuque = { token: form.yuque.token };
if (form.yuque?.token !== undefined && form.yuque.token !== "") payload.yuque = { token: form.yuque.token };
if (form.yuque?.default_repo !== undefined)
payload.yuque = { ...payload.yuque, default_repo: form.yuque.default_repo };
if (form.tencent?.client_id !== undefined) payload.tencent = { client_id: form.tencent.client_id };
@@ -81,10 +76,7 @@ export default function SettingsCloudDocConfigPage() {
return (
<div className="p-6 max-w-2xl">
<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>
</div>
@@ -99,19 +91,13 @@ export default function SettingsCloudDocConfigPage() {
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* 飞书 */}
<div className="space-y-3">
<h3 className="text-sm font-medium"> (Feishu)</h3>
<div className="grid gap-2">
<Label>App ID</Label>
<Input
value={form.feishu?.app_id ?? config?.feishu?.app_id ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
feishu: { ...f.feishu, app_id: e.target.value },
}))
}
onChange={(e) => setForm((f) => ({ ...f, feishu: { ...f.feishu, app_id: e.target.value } }))}
placeholder="在飞书开放平台创建应用后获取"
/>
</div>
@@ -120,18 +106,12 @@ export default function SettingsCloudDocConfigPage() {
<Input
type="password"
value={form.feishu?.app_secret ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
feishu: { ...f.feishu, app_secret: e.target.value },
}))
}
onChange={(e) => setForm((f) => ({ ...f, feishu: { ...f.feishu, app_secret: e.target.value } }))}
placeholder={config?.feishu?.app_secret_configured ? "已配置,留空不修改" : "必填"}
/>
</div>
</div>
{/* 语雀 */}
<div className="space-y-3">
<h3 className="text-sm font-medium"> (Yuque)</h3>
<div className="grid gap-2">
@@ -139,12 +119,7 @@ export default function SettingsCloudDocConfigPage() {
<Input
type="password"
value={form.yuque?.token ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
yuque: { ...f.yuque, token: e.target.value },
}))
}
onChange={(e) => setForm((f) => ({ ...f, yuque: { ...f.yuque, token: e.target.value } }))}
placeholder={config?.yuque?.token_configured ? "已配置,留空不修改" : "在语雀 设置 → Token 中创建"}
/>
</div>
@@ -153,17 +128,13 @@ export default function SettingsCloudDocConfigPage() {
<Input
value={form.yuque?.default_repo ?? config?.yuque?.default_repo ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
yuque: { ...f.yuque, default_repo: e.target.value },
}))
setForm((f) => ({ ...f, yuque: { ...f.yuque, default_repo: e.target.value } }))
}
placeholder="如your_username/repo"
/>
</div>
</div>
{/* 腾讯文档 */}
<div className="space-y-3">
<h3 className="text-sm font-medium"> (Tencent)</h3>
<p className="text-xs text-muted-foreground">
@@ -173,12 +144,7 @@ export default function SettingsCloudDocConfigPage() {
<Label>Client ID</Label>
<Input
value={form.tencent?.client_id ?? config?.tencent?.client_id ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
tencent: { ...f.tencent, client_id: e.target.value },
}))
}
onChange={(e) => setForm((f) => ({ ...f, tencent: { ...f.tencent, client_id: e.target.value } }))}
placeholder="开放平台应用 Client ID"
/>
</div>
@@ -188,10 +154,7 @@ export default function SettingsCloudDocConfigPage() {
type="password"
value={form.tencent?.client_secret ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
tencent: { ...f.tencent, client_secret: e.target.value },
}))
setForm((f) => ({ ...f, tencent: { ...f.tencent, client_secret: e.target.value } }))
}
placeholder={config?.tencent?.client_secret_configured ? "已配置,留空不修改" : "选填"}
/>
@@ -207,3 +170,4 @@ export default function SettingsCloudDocConfigPage() {
</div>
);
}

View File

@@ -1,31 +1,14 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
cloudDocsApi,
type CloudDocLinkRead,
type CloudDocLinkCreate,
} from "@/lib/api/client";
import { Table, 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 { toast } from "sonner";
@@ -49,7 +32,7 @@ export default function SettingsCloudDocsPage() {
}, []);
useEffect(() => {
loadLinks();
void loadLinks();
}, [loadLinks]);
const openAdd = () => {
@@ -73,24 +56,16 @@ export default function SettingsCloudDocsPage() {
setSaving(true);
try {
if (editingId) {
await cloudDocsApi.update(editingId, {
name: form.name.trim(),
url: form.url.trim(),
});
await cloudDocsApi.update(editingId, { name: form.name.trim(), url: form.url.trim() });
toast.success("已更新");
} else {
const payload: CloudDocLinkCreate = {
name: form.name.trim(),
url: form.url.trim(),
};
const payload: CloudDocLinkCreate = { name: form.name.trim(), url: form.url.trim() };
await cloudDocsApi.create(payload);
toast.success("已添加");
}
setDialogOpen(false);
await loadLinks();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
@@ -104,9 +79,7 @@ export default function SettingsCloudDocsPage() {
await cloudDocsApi.delete(id);
toast.success("已删除");
await loadLinks();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("cloud-docs-changed"));
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
}
@@ -115,10 +88,7 @@ export default function SettingsCloudDocsPage() {
return (
<div className="p-6 max-w-4xl">
<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>
</div>
@@ -175,28 +145,13 @@ export default function SettingsCloudDocsPage() {
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(item)}
title="编辑"
>
<Button variant="ghost" size="sm" onClick={() => openEdit(item)} title="编辑">
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item.id)}
title="删除"
>
<Button variant="ghost" size="sm" onClick={() => void handleDelete(item.id)} title="删除">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<Button
variant="ghost"
size="sm"
asChild
title="打开"
>
<Button variant="ghost" size="sm" asChild title="打开">
<a href={item.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
@@ -214,9 +169,7 @@ export default function SettingsCloudDocsPage() {
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingId ? "编辑云文档入口" : "添加云文档入口"}
</DialogTitle>
<DialogTitle>{editingId ? "编辑云文档入口" : "添加云文档入口"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
@@ -241,11 +194,7 @@ export default function SettingsCloudDocsPage() {
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button type="submit" disabled={saving}>
@@ -259,3 +208,4 @@ export default function SettingsCloudDocsPage() {
</div>
);
}

View File

@@ -1,39 +1,20 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
emailConfigsApi,
type EmailConfigRead,
type EmailConfigCreate,
type EmailConfigUpdate,
type EmailFolder,
} from "@/lib/api/client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2, Mail, Plus, Pencil, Trash2, FolderOpen } from "lucide-react";
import { toast } from "sonner";
@@ -66,11 +47,12 @@ export default function SettingsEmailPage() {
}, []);
useEffect(() => {
loadConfigs();
void loadConfigs();
}, [loadConfigs]);
const openAdd = () => {
setEditingId(null);
setFolders(null);
setForm({
host: "",
port: "993",
@@ -163,9 +145,9 @@ export default function SettingsEmailPage() {
return (
<div className="p-6 max-w-4xl">
<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>
<Card>
<CardHeader>
@@ -228,7 +210,7 @@ export default function SettingsEmailPage() {
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
<Pencil className="h-3.5 w-3.5" />
</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" />
</Button>
</div>
@@ -246,9 +228,15 @@ export default function SettingsEmailPage() {
<CardTitle className="text-sm"> 163 </CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2 py-2">
<p><strong>IMAP </strong> imap.163.com 993SSLIMAP/SMTP </p>
<p><strong></strong> 使 POP3/SMTP/IMAP </p>
<p><strong></strong> INBOX </p>
<p>
<strong>IMAP </strong> imap.163.com 993SSLIMAP/SMTP
</p>
<p>
<strong></strong> 使 POP3/SMTP/IMAP
</p>
<p>
<strong></strong> INBOX
</p>
</CardContent>
</Card>
@@ -301,23 +289,14 @@ export default function SettingsEmailPage() {
<Label> / (Mailbox)</Label>
{editingId && (
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={loadFolders}
disabled={foldersLoading}
>
<Button type="button" variant="outline" size="sm" onClick={() => void loadFolders()} disabled={foldersLoading}>
{foldersLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <FolderOpen className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</div>
)}
{folders && folders.length > 0 ? (
<Select
value={form.mailbox}
onValueChange={(v) => setForm((f) => ({ ...f, mailbox: v }))}
>
<Select value={form.mailbox} onValueChange={(v) => setForm((f) => ({ ...f, mailbox: v }))}>
<SelectTrigger>
<SelectValue placeholder="选择邮箱夹" />
</SelectTrigger>
@@ -333,7 +312,7 @@ export default function SettingsEmailPage() {
<Input
value={form.mailbox}
onChange={(e) => setForm((f) => ({ ...f, mailbox: e.target.value }))}
placeholder="INBOX、收件箱或自定义标签163 等若 INBOX 失败会自动尝试收件箱)"
placeholder="INBOX、收件箱或自定义标签"
/>
)}
</div>
@@ -362,3 +341,4 @@ export default function SettingsEmailPage() {
</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 { FileSpreadsheet, FileText, Mail, FileStack, Globe } from "lucide-react";
export default function SettingsPage() {
export default function SettingsIndexPage() {
return (
<div className="p-6 max-w-2xl">
<h1 className="text-xl font-semibold"></h1>
<p className="text-sm text-muted-foreground mt-1"></p>
<div className="mt-6 flex flex-col gap-2">
<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" />
Excel / Word
</Link>
</Button>
<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" />
AI
</Link>
</Button>
<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" />
</Link>
</Button>
<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" />
</Link>
</Button>
<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" />
/ / API
</Link>
</Button>
<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" />
</Link>
@@ -48,3 +50,4 @@ export default function SettingsPage() {
</div>
);
}

View File

@@ -1,31 +1,14 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
portalLinksApi,
type PortalLinkRead,
type PortalLinkCreate,
} from "@/lib/api/client";
import { Table, 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 { toast } from "sonner";
@@ -49,7 +32,7 @@ export default function SettingsPortalLinksPage() {
}, []);
useEffect(() => {
loadLinks();
void loadLinks();
}, [loadLinks]);
const openAdd = () => {
@@ -73,24 +56,16 @@ export default function SettingsPortalLinksPage() {
setSaving(true);
try {
if (editingId) {
await portalLinksApi.update(editingId, {
name: form.name.trim(),
url: form.url.trim(),
});
await portalLinksApi.update(editingId, { name: form.name.trim(), url: form.url.trim() });
toast.success("已更新");
} else {
const payload: PortalLinkCreate = {
name: form.name.trim(),
url: form.url.trim(),
};
const payload: PortalLinkCreate = { name: form.name.trim(), url: form.url.trim() };
await portalLinksApi.create(payload);
toast.success("已添加");
}
setDialogOpen(false);
await loadLinks();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("portal-links-changed"));
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
@@ -104,9 +79,7 @@ export default function SettingsPortalLinksPage() {
await portalLinksApi.delete(id);
toast.success("已删除");
await loadLinks();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("portal-links-changed"));
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
}
@@ -115,10 +88,7 @@ export default function SettingsPortalLinksPage() {
return (
<div className="p-6 max-w-4xl">
<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>
</div>
@@ -130,9 +100,7 @@ export default function SettingsPortalLinksPage() {
<Globe className="h-5 w-5" />
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
</p>
<p className="text-sm text-muted-foreground mt-1"></p>
</div>
<Button onClick={openAdd}>
<Plus className="h-4 w-4" />
@@ -175,20 +143,10 @@ export default function SettingsPortalLinksPage() {
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(item)}
title="编辑"
>
<Button variant="ghost" size="sm" onClick={() => openEdit(item)} title="编辑">
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item.id)}
title="删除"
>
<Button variant="ghost" size="sm" onClick={() => void handleDelete(item.id)} title="删除">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<Button variant="ghost" size="sm" asChild title="打开">
@@ -209,9 +167,7 @@ export default function SettingsPortalLinksPage() {
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingId ? "编辑快捷门户入口" : "添加快捷门户入口"}
</DialogTitle>
<DialogTitle>{editingId ? "编辑快捷门户入口" : "添加快捷门户入口"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
@@ -236,11 +192,7 @@ export default function SettingsPortalLinksPage() {
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button type="submit" disabled={saving}>
@@ -254,3 +206,4 @@ export default function SettingsPortalLinksPage() {
</div>
);
}

View File

@@ -1,16 +1,10 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { templatesApi, type TemplateInfo } from "@/lib/api/client";
import { Upload, Loader2, FileSpreadsheet, FileText } from "lucide-react";
import { toast } from "sonner";
@@ -33,7 +27,7 @@ export default function SettingsTemplatesPage() {
}, []);
useEffect(() => {
loadTemplates();
void loadTemplates();
}, [loadTemplates]);
const handleFile = async (file: File) => {
@@ -58,7 +52,7 @@ export default function SettingsTemplatesPage() {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
if (file) void handleFile(file);
};
const onDragOver = (e: React.DragEvent) => {
@@ -70,7 +64,7 @@ export default function SettingsTemplatesPage() {
const onSelectFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
if (file) void handleFile(file);
e.target.value = "";
};
@@ -86,9 +80,9 @@ export default function SettingsTemplatesPage() {
return (
<div className="p-6 max-w-4xl">
<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>
<Card>
<CardHeader>
@@ -156,12 +150,8 @@ export default function SettingsTemplatesPage() {
)}
</TableCell>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="text-muted-foreground">
{(t.size / 1024).toFixed(1)} KB
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(t.uploaded_at)}
</TableCell>
<TableCell className="text-muted-foreground">{(t.size / 1024).toFixed(1)} KB</TableCell>
<TableCell className="text-muted-foreground">{formatDate(t.uploaded_at)}</TableCell>
</TableRow>
))}
</TableBody>
@@ -173,3 +163,4 @@ export default function SettingsTemplatesPage() {
</div>
);
}

View File

@@ -1,11 +1,13 @@
"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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
@@ -21,10 +23,19 @@ import {
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
import ReactMarkdown from "react-markdown";
import { Wand2, Save, FileSpreadsheet, FileDown, Loader2, Plus, Search, CloudUpload } from "lucide-react";
import {
Wand2,
Save,
FileSpreadsheet,
FileDown,
Loader2,
Plus,
Search,
CloudUpload,
} from "lucide-react";
import { toast } from "sonner";
import {
apiBase,
customersApi,
projectsApi,
templatesApi,
@@ -44,6 +55,7 @@ export default function WorkspacePage() {
const [projectId, setProjectId] = useState<number | null>(null);
const [lastQuote, setLastQuote] = useState<QuoteGenerateResponse | null>(null);
const [analyzing, setAnalyzing] = useState(false);
const [analyzeProgress, setAnalyzeProgress] = useState(0);
const [saving, setSaving] = useState(false);
const [generatingQuote, setGeneratingQuote] = useState(false);
const [addCustomerOpen, setAddCustomerOpen] = useState(false);
@@ -55,32 +67,67 @@ export default function WorkspacePage() {
const [selectedQuoteTemplate, setSelectedQuoteTemplate] = useState<string>("");
const [customerSearch, setCustomerSearch] = useState("");
const [pushToCloudLoading, setPushToCloudLoading] = useState(false);
const didInitRef = useRef(false);
const loadCustomers = useCallback(async (search?: string) => {
const loadCustomers = useCallback(
async (search?: string) => {
try {
const list = await customersApi.list(search?.trim() ? { q: search.trim() } : undefined);
setCustomers(list);
if (list.length > 0 && !customerId) setCustomerId(String(list[0].id));
} catch (e) {
if (!search?.trim()) {
try {
sessionStorage.setItem("opc_customers", JSON.stringify({ t: Date.now(), v: list }));
} catch {}
}
} catch {
toast.error("加载客户列表失败");
}
}, [customerId]);
},
[customerId],
);
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]);
const loadQuoteTemplates = useCallback(async () => {
try {
const list = await templatesApi.list();
setQuoteTemplates(list.filter((t) => t.type === "excel"));
try {
sessionStorage.setItem("opc_templates", JSON.stringify({ t: Date.now(), v: list }));
} catch {}
} catch {
// ignore
}
}, []);
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]);
const handleAddCustomer = async (e: React.FormEvent) => {
@@ -118,15 +165,65 @@ export default function WorkspacePage() {
return;
}
setAnalyzing(true);
setAnalyzeProgress(8);
setSolutionMd("");
try {
const res = await projectsApi.analyze({
const base = apiBase();
const res = await fetch(`${base}/projects/analyze_stream`, {
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);
setProjectId(res.project_id);
if (!res.ok || !res.body) {
const text = await res.text();
throw new Error(text || "AI 解析失败");
}
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);
toast.success("方案已生成,可在右侧编辑");
setAnalyzeProgress(100);
} else if (evt.type === "error") {
throw new Error(evt.message || "AI 解析失败");
}
}
}
} finally {
window.clearInterval(progressTimer);
}
toast.success("方案已生成(流式),可在右侧编辑");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "AI 解析失败";
toast.error(msg);
@@ -144,10 +241,7 @@ export default function WorkspacePage() {
}
setPushToCloudLoading(true);
try {
const res = await pushProjectToCloud(projectId, {
platform,
body_md: md,
});
const res = await pushProjectToCloud(projectId, { platform, body_md: md });
toast.success("已推送到云文档", {
action: res.url
? {
@@ -187,10 +281,7 @@ export default function WorkspacePage() {
}
setGeneratingQuote(true);
try {
const res = await projectsApi.generateQuote(
projectId,
selectedQuoteTemplate || undefined
);
const res = await projectsApi.generateQuote(projectId, selectedQuoteTemplate || undefined);
setLastQuote(res);
toast.success("报价单已生成");
downloadFile(res.excel_path, `quote_project_${projectId}.xlsx`);
@@ -214,10 +305,7 @@ export default function WorkspacePage() {
}
setGeneratingQuote(true);
try {
const res = await projectsApi.generateQuote(
projectId,
selectedQuoteTemplate || undefined
);
const res = await projectsApi.generateQuote(projectId, selectedQuoteTemplate || undefined);
setLastQuote(res);
await downloadFileAsBlob(res.pdf_path, `quote_project_${projectId}.pdf`);
toast.success("PDF 已下载");
@@ -232,23 +320,22 @@ export default function WorkspacePage() {
return (
<div className="flex h-full flex-col">
<div className="flex flex-1 min-h-0">
{/* Left Panel — 40% */}
<div className="flex w-[40%] flex-col border-r">
<Card className="rounded-none border-0 border-b h-full flex flex-col">
<CardHeader className="py-3">
<CardTitle className="text-base flex items-center justify-between">
<Button
size="sm"
onClick={handleAnalyze}
disabled={analyzing || !rawText.trim() || !customerId}
>
<Button size="sm" onClick={handleAnalyze} disabled={analyzing || !rawText.trim() || !customerId}>
{analyzing ? (
<Loader2 className="h-4 w-4 animate-spin" />
<span className="w-[160px]">
<Progress value={analyzeProgress} />
</span>
) : (
<>
<Wand2 className="h-4 w-4" />
)}
<span className="ml-1.5">AI </span>
</>
)}
</Button>
</CardTitle>
</CardHeader>
@@ -276,11 +363,7 @@ export default function WorkspacePage() {
<SelectItem key={c.id} value={String(c.id)}>
<span className="flex items-center gap-2">
{c.name}
{c.tags && (
<span className="text-muted-foreground text-xs">
({c.tags})
</span>
)}
{c.tags && <span className="text-muted-foreground text-xs">({c.tags})</span>}
</span>
</SelectItem>
))}
@@ -327,19 +410,11 @@ export default function WorkspacePage() {
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setAddCustomerOpen(false)}
>
<Button type="button" variant="outline" onClick={() => setAddCustomerOpen(false)}>
</Button>
<Button type="submit" disabled={addingCustomer}>
{addingCustomer ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"添加"
)}
{addingCustomer ? <Loader2 className="h-4 w-4 animate-spin" /> : "添加"}
</Button>
</DialogFooter>
</form>
@@ -360,7 +435,6 @@ export default function WorkspacePage() {
</Card>
</div>
{/* Right Panel — 60% */}
<div className="flex flex-1 flex-col min-w-0">
<Card className="rounded-none border-0 h-full flex flex-col">
<CardHeader className="py-3">
@@ -382,11 +456,7 @@ export default function WorkspacePage() {
</TabsContent>
<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">
{solutionMd ? (
<ReactMarkdown>{solutionMd}</ReactMarkdown>
) : (
<p className="text-muted-foreground"></p>
)}
{solutionMd ? <ReactMarkdown>{solutionMd}</ReactMarkdown> : <p className="text-muted-foreground"></p>}
</div>
</TabsContent>
</Tabs>
@@ -395,14 +465,10 @@ export default function WorkspacePage() {
</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-2">
<span className="text-xs text-muted-foreground whitespace-nowrap"></span>
<Select
value={selectedQuoteTemplate || "__latest__"}
onValueChange={(v) => setSelectedQuoteTemplate(v === "__latest__" ? "" : v)}
>
<Select value={selectedQuoteTemplate || "__latest__"} onValueChange={(v) => setSelectedQuoteTemplate(v === "__latest__" ? "" : v)}>
<SelectTrigger className="w-[180px] h-8">
<SelectValue placeholder="使用最新上传" />
</SelectTrigger>
@@ -416,58 +482,27 @@ export default function WorkspacePage() {
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSaveToArchive}
disabled={saving || projectId == null}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
<Button variant="outline" 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>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDraftQuote}
disabled={generatingQuote || projectId == null}
>
{generatingQuote ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileSpreadsheet className="h-4 w-4" />
)}
<Button variant="outline" 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>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExportPdf}
disabled={generatingQuote || projectId == null}
>
{generatingQuote ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileDown className="h-4 w-4" />
)}
<Button variant="outline" 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>
</Button>
<Select
value="__none__"
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}
>
<SelectTrigger className="w-[140px] h-8">
{pushToCloudLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CloudUpload className="h-4 w-4 mr-1" />
)}
{pushToCloudLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudUpload className="h-4 w-4 mr-1" />}
<SelectValue placeholder="推送到云文档" />
</SelectTrigger>
<SelectContent>
@@ -479,12 +514,9 @@ export default function WorkspacePage() {
<SelectItem value="tencent"></SelectItem>
</SelectContent>
</Select>
{projectId != null && (
<span className="text-xs text-muted-foreground ml-2">
#{projectId}
</span>
)}
{projectId != null && <span className="text-xs text-muted-foreground ml-2"> #{projectId}</span>}
</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 { AppSidebar } from "@/components/app-sidebar";
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
export default function MainLayout() {
return (
<div className="flex h-screen overflow-hidden">
<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 />
</div>
);
}

View File

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

View File

@@ -2,11 +2,7 @@ import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {
colors: {
@@ -55,3 +51,4 @@ const config: 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;
}
}