import json as _json
from typing import List, Optional, Union
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings
from app.base_schema import PaginatedResponse, ResponseModel
from zq_smart_table.schema import (
SmartTableCreate, SmartTableUpdate, SmartTableResponse, SmartTableSimple, SmartTableFull,
SmartTableMove,
SmartFieldCreate, SmartFieldUpdate, SmartFieldResponse, SmartFieldReorder,
SmartRecordCreate, SmartRecordUpdate, SmartRecordResponse,
SmartRecordCellUpdate, SmartRecordBatchCellUpdate, SmartRecordBatchDelete,
SmartRecordBatchRestore, TrashListResponse,
MultiRecordBatchUpdate,
CursorPaginatedRecords,
SmartViewCreate, SmartViewUpdate, SmartViewResponse,
RecordQuery, RecordFilterRule, RecordSortRule,
SmartRecordReorder, GroupedRecordsResponse, RecordGroupItem,
RecordSearchResult, RecordSearchQuery,
SummaryRequest, SummaryResponse,
CommentCreate, CommentUpdate, CommentResponse,
DocumentContentUpdate,
DocumentVersionCreate, DocumentVersionResponse, DocumentVersionDetail, DocumentVersionCompare,
DocumentTemplateCreate, DocumentTemplateUpdate, DocumentTemplateResponse, DocumentTemplateDetail,
WikiSpaceCreate, WikiSpaceUpdate, WikiSpaceResponse, WikiSpaceListItem, WikiSpaceDetail,
)
from zq_smart_table.service import (
SmartTableService, SmartFieldService, SmartRecordService, SmartViewService,
SmartExportService, SmartImportService, SmartLinkService, SmartSummaryService,
SmartFormulaService, SmartValidationService, SmartCommentService,
SmartDocumentVersionService, SmartDocumentTemplateService,
WikiSpaceService,
)
from zq_smart_table.permission.service import PermissionService
from utils.context import get_current_user_id_from_context, get_current_user_info_from_context
from core.user.service import UserService
router = APIRouter(tags=["多维表格"])
INITIAL_RECORD_LIMIT = 500
# ==================== Table ====================
@router.post("/tables", response_model=SmartTableResponse, summary="创建表")
async def create_table(data: SmartTableCreate, db: AsyncSession = Depends(get_db)):
table = await SmartTableService.create(db, data)
user_id = get_current_user_id_from_context()
if user_id:
await PermissionService.init_table_owner(db, table.id, user_id)
return table
@router.get("/tables", response_model=List[SmartTableSimple], summary="获取表列表")
async def get_table_list(
wiki_space_id: Optional[str] = Query(default=None, description="按文档库ID过滤"),
db: AsyncSession = Depends(get_db),
):
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
dept_id = user_info.get("dept_id") if user_info else None
role_ids = user_info.get("role_ids", []) if user_info else []
is_superuser = user_info.get("is_superuser", False) if user_info else False
items, _ = await SmartTableService.get_user_tables(
db, wiki_space_id=wiki_space_id,
user_id=user_id, dept_id=dept_id,
role_ids=role_ids, is_superuser=is_superuser,
)
return items
@router.get("/tables/{table_id}", response_model=SmartTableResponse, summary="获取表详情")
async def get_table(table_id: str, db: AsyncSession = Depends(get_db)):
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
await PermissionService.require_table_access(db, table_id)
return table
@router.get("/tables/{table_id}/full", response_model=SmartTableFull, summary="获取完整表数据")
async def get_table_full(
table_id: str,
limit: int = Query(default=INITIAL_RECORD_LIMIT, ge=1, le=10000, description="首次加载记录数"),
filters: Optional[str] = Query(default=None, description="筛选条件 JSON 数组"),
filter_logic: str = Query(default="and", description="筛选逻辑: and / or"),
sorts: Optional[str] = Query(default=None, description="排序规则 JSON 数组"),
search: Optional[str] = Query(default=None, description="搜索关键词"),
db: AsyncSession = Depends(get_db),
):
"""返回表的 fields + views + 首批 records(游标分页),前端按需加载更多"""
role_info = await PermissionService.require_table_access(db, table_id)
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
creator_name = None
creator_avatar = None
if table.sys_creator_id:
creator = await UserService.get_by_id(db, table.sys_creator_id)
if creator:
creator_name = getattr(creator, "name", None) or getattr(creator, "username", None)
creator_avatar = getattr(creator, "avatar", None)
if getattr(table, "type", "table") == "document":
return SmartTableFull(
id=table.id,
name=table.name,
icon=table.icon,
description=table.description,
active_view_id=None,
type="document",
parent_id=getattr(table, "parent_id", None),
content=table.content,
fields=[],
records=[],
views=[],
record_total=0,
next_cursor=None,
has_more=False,
sys_create_datetime=table.sys_create_datetime,
sys_update_datetime=table.sys_update_datetime,
sys_creator_id=table.sys_creator_id,
creator_name=creator_name,
creator_avatar=creator_avatar,
)
all_fields = await SmartFieldService.get_by_table(db, table_id)
accessible = await PermissionService.get_accessible_field_ids(
db, table_id, role_info.get("role_id"), mode="read"
)
fields = [f for f in all_fields if f.id in accessible] if accessible is not None else all_fields
parsed_filters = None
if filters:
try:
parsed_filters = [RecordFilterRule(**f) for f in _json.loads(filters)]
except Exception:
raise HTTPException(status_code=400, detail="筛选条件格式错误")
parsed_sorts = None
if sorts:
try:
parsed_sorts = [RecordSortRule(**s) for s in _json.loads(sorts)]
except Exception:
raise HTTPException(status_code=400, detail="排序规则格式错误")
user_info = get_current_user_info_from_context() or {}
row_conditions = await PermissionService.build_row_filter_conditions(
db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view"
)
records_list, next_cursor, total = await SmartRecordService.get_by_table_cursor_filtered(
db, table_id,
filters=parsed_filters,
filter_logic=filter_logic,
sorts=parsed_sorts,
search=search,
extra_conditions=row_conditions or None,
cursor=None,
limit=limit,
)
await SmartLinkService.resolve_link_values(db, all_fields, records_list)
await SmartLinkService.resolve_lookup_values(db, all_fields, records_list)
await SmartLinkService.resolve_rollup_values(db, all_fields, records_list)
SmartFormulaService.resolve_formula_values(all_fields, records_list)
if accessible is not None:
for r in records_list:
r.values = PermissionService.filter_record_values(r.values or {}, accessible)
views = await SmartViewService.get_by_table(db, table_id)
return SmartTableFull(
id=table.id,
name=table.name,
icon=table.icon,
description=table.description,
active_view_id=table.active_view_id,
type=getattr(table, "type", "table"),
parent_id=getattr(table, "parent_id", None),
fields=fields,
records=records_list,
views=views,
record_total=total,
next_cursor=next_cursor,
has_more=next_cursor is not None,
sys_create_datetime=table.sys_create_datetime,
sys_update_datetime=table.sys_update_datetime,
sys_creator_id=table.sys_creator_id,
creator_name=creator_name,
creator_avatar=creator_avatar,
)
@router.put("/tables/{table_id}", response_model=SmartTableResponse, summary="更新表")
async def update_table(table_id: str, data: SmartTableUpdate, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_table")
table = await SmartTableService.update(db, record_id=table_id, data=data)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
return table
@router.delete("/tables/{table_id}", response_model=ResponseModel, summary="删除表")
async def delete_table(table_id: str, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_table")
success = await SmartTableService.delete(db, record_id=table_id)
if not success:
raise HTTPException(status_code=404, detail="表不存在")
return ResponseModel(message="删除成功")
@router.put("/tables/{table_id}/move", response_model=SmartTableResponse, summary="移动表/文档到新的父级")
async def move_table(table_id: str, data: SmartTableMove, db: AsyncSession = Depends(get_db)):
from sqlalchemy import select as sa_select
await PermissionService.require_capability(db, table_id, "manage_table")
if data.parent_id and data.parent_id == table_id:
raise HTTPException(status_code=400, detail="不能将页面移动到自身下")
if data.parent_id:
parent = await SmartTableService.get_by_id(db, data.parent_id)
if not parent:
raise HTTPException(status_code=404, detail="目标父页面不存在")
ancestor_id = getattr(parent, "parent_id", None)
while ancestor_id:
if ancestor_id == table_id:
raise HTTPException(status_code=400, detail="不能将页面移动到其子页面下")
ancestor = await SmartTableService.get_by_id(db, ancestor_id)
ancestor_id = getattr(ancestor, "parent_id", None) if ancestor else None
table = await SmartTableService.update(
db, record_id=table_id, data=SmartTableUpdate(parent_id=data.parent_id)
)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
# Reorder siblings at the target parent level
from zq_smart_table.model import SmartTable
if data.parent_id:
q = sa_select(SmartTable).where(
SmartTable.parent_id == data.parent_id,
SmartTable.is_deleted == False, # noqa: E712
).order_by(SmartTable.sort)
else:
q = sa_select(SmartTable).where(
SmartTable.parent_id.is_(None),
SmartTable.is_deleted == False, # noqa: E712
).order_by(SmartTable.sort)
result = await db.execute(q)
siblings = list(result.scalars().all())
ordered_ids = [s.id for s in siblings if s.id != table_id]
if data.after_id and data.after_id in ordered_ids:
idx = ordered_ids.index(data.after_id) + 1
ordered_ids.insert(idx, table_id)
else:
ordered_ids.insert(0, table_id)
id_to_obj = {s.id: s for s in siblings}
for i, sid in enumerate(ordered_ids):
obj = id_to_obj.get(sid)
if obj and obj.sort != i:
obj.sort = i
await db.commit()
await db.refresh(table)
return table
# ==================== Document Content ====================
@router.patch("/tables/{table_id}/content", response_model=ResponseModel, summary="更新文档内容")
async def update_document_content(
table_id: str,
data: DocumentContentUpdate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "edit_record")
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="文档不存在")
if getattr(table, "type", "table") != "document":
raise HTTPException(status_code=400, detail="该项目不是文档类型")
user_id = get_current_user_id_from_context()
if table.content:
try:
await SmartDocumentVersionService.create_version(
db, table_id, table.content,
title=table.name,
change_summary="自动保存",
user_id=user_id,
)
except Exception:
pass
await SmartTableService.update(db, record_id=table_id, data=SmartTableUpdate(content=data.content))
return ResponseModel(message="保存成功")
@router.post("/tables/{table_id}/export-pdf", summary="导出文档为 PDF")
async def export_document_pdf(
table_id: str,
data: dict,
db: AsyncSession = Depends(get_db),
):
"""接收编辑器 HTML 内容,使用 WeasyPrint 生成 PDF 并返回下载流"""
import io
from online_dev.document_generator.generator import pdf_generator, WEASYPRINT_AVAILABLE, HTML
await PermissionService.require_table_access(db, table_id)
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="文档不存在")
html_content = data.get("html", "")
title = data.get("title", table.name or "文档")
if not html_content:
raise HTTPException(status_code=400, detail="导出内容不能为空")
if not WEASYPRINT_AVAILABLE:
raise HTTPException(status_code=500, detail="WeasyPrint 不可用,无法生成 PDF")
doc_css = """
h1 { font-size: 22pt; font-weight: 700; margin: 0 0 14pt; padding-bottom: 8pt; border-bottom: 1px solid #e5e5e5; }
h2 { font-size: 17pt; font-weight: 600; margin: 18pt 0 8pt; }
h3 { font-size: 13pt; font-weight: 600; margin: 14pt 0 6pt; }
p { margin: 5pt 0; }
ul, ol { padding-left: 20pt; margin: 5pt 0; }
li { margin: 2pt 0; }
blockquote {
border-left: 3pt solid #d0d0d0; padding-left: 10pt;
margin: 6pt 0; color: #555; font-style: italic;
}
pre {
background: #f6f8fa; padding: 8pt; border-radius: 4pt;
border: 1px solid #e1e4e8; font-size: 9pt; line-height: 1.5;
}
code { background: #f0f0f0; padding: 1pt 3pt; border-radius: 3pt; font-size: 9.5pt; }
pre code { background: none; padding: 0; font-size: inherit; }
table { border-collapse: collapse; width: 100%; margin: 8pt 0; }
th, td { border: 1px solid #d0d0d0; padding: 5pt 8pt; text-align: left; font-size: 10pt; }
th { background: #f5f5f5; font-weight: 600; }
img { max-width: 100%; }
a { color: #1a73e8; text-decoration: none; }
hr { border: none; border-top: 1px solid #e5e5e5; margin: 10pt 0; }
"""
body_html = f"
{title}
\n{html_content}"
full_html = pdf_generator._wrap_html(
body_html,
css=doc_css,
page_config={"size": "A4", "orientation": "portrait", "margin": {"top": 20, "right": 15, "bottom": 20, "left": 15}},
)
try:
html_doc = HTML(string=full_html)
pdf_bytes = html_doc.write_pdf(font_config=pdf_generator.font_config)
except Exception as e:
raise HTTPException(status_code=500, detail=f"PDF 生成失败: {str(e)}")
from urllib.parse import quote
safe_name = table.name.replace("/", "_").replace("\\", "_")
encoded_name = quote(f"{safe_name}.pdf")
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}",
},
)
# ==================== Field ====================
@router.get("/tables/{table_id}/fields", response_model=List[SmartFieldResponse], summary="获取字段列表")
async def get_field_list(table_id: str, db: AsyncSession = Depends(get_db)):
await PermissionService.require_table_access(db, table_id)
return await SmartFieldService.get_by_table(db, table_id)
@router.post("/tables/{table_id}/fields", response_model=SmartFieldResponse, summary="添加字段")
async def create_field(table_id: str, data: SmartFieldCreate, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_field")
data.table_id = table_id
if data.type == "link":
linked_table_id = (data.config or {}).get("linkedTableId")
if not linked_table_id:
raise HTTPException(status_code=400, detail="Link 字段必须指定 linkedTableId")
source_field, _ = await SmartLinkService.create_link_field(
db, table_id, data.name, linked_table_id, sort=data.sort,
)
return source_field
if data.sort == 0:
data.sort = await SmartFieldService.get_next_sort(db, table_id)
field = await SmartFieldService.create(db, data)
return field
@router.put("/fields/{field_id}", response_model=SmartFieldResponse, summary="更新字段")
async def update_field(field_id: str, data: SmartFieldUpdate, db: AsyncSession = Depends(get_db)):
field = await SmartFieldService.get_by_id(db, field_id)
if not field:
raise HTTPException(status_code=404, detail="字段不存在")
await PermissionService.require_capability(db, field.table_id, "manage_field")
field = await SmartFieldService.update(db, record_id=field_id, data=data)
return field
@router.delete("/fields/{field_id}", response_model=ResponseModel, summary="删除字段")
async def delete_field(field_id: str, db: AsyncSession = Depends(get_db)):
field = await SmartFieldService.get_by_id(db, field_id)
if not field:
raise HTTPException(status_code=404, detail="字段不存在")
await PermissionService.require_capability(db, field.table_id, "manage_field")
if field.type == "link":
success = await SmartLinkService.delete_link_field(db, field_id)
else:
success = await SmartFieldService.delete_and_clean(db, field_id)
if not success:
raise HTTPException(status_code=404, detail="字段不存在")
return ResponseModel(message="删除成功")
@router.put("/tables/{table_id}/fields/reorder", response_model=ResponseModel, summary="字段排序")
async def reorder_fields(table_id: str, data: SmartFieldReorder, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_field")
await SmartFieldService.reorder(db, table_id, data.field_ids)
return ResponseModel(message="排序成功")
# ==================== Record ====================
@router.get(
"/tables/{table_id}/records",
response_model=CursorPaginatedRecords,
summary="获取记录列表(游标分页,支持筛选/排序/搜索)",
)
async def get_record_list(
table_id: str,
cursor: Optional[str] = Query(default=None, description="游标(上一页最后一条记录的 ID)"),
limit: int = Query(default=200, ge=1, le=5000, description="每页数量"),
filters: Optional[str] = Query(default=None, description="筛选条件 JSON 数组"),
filter_logic: str = Query(default="and", description="筛选逻辑: and / or"),
sorts: Optional[str] = Query(default=None, description="排序规则 JSON 数组"),
search: Optional[str] = Query(default=None, description="搜索关键词"),
db: AsyncSession = Depends(get_db),
):
role_info = await PermissionService.require_table_access(db, table_id)
parsed_filters = None
if filters:
try:
parsed_filters = [RecordFilterRule(**f) for f in _json.loads(filters)]
except Exception:
raise HTTPException(status_code=400, detail="筛选条件格式错误")
parsed_sorts = None
if sorts:
try:
parsed_sorts = [RecordSortRule(**s) for s in _json.loads(sorts)]
except Exception:
raise HTTPException(status_code=400, detail="排序规则格式错误")
user_info = get_current_user_info_from_context() or {}
row_conditions = await PermissionService.build_row_filter_conditions(
db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view"
)
skip_count = cursor is not None
items, next_cursor, total = await SmartRecordService.get_by_table_cursor_filtered(
db, table_id,
filters=parsed_filters,
filter_logic=filter_logic,
sorts=parsed_sorts,
search=search,
extra_conditions=row_conditions or None,
cursor=cursor,
limit=limit,
skip_count=skip_count,
)
all_fields = await SmartFieldService.get_by_table(db, table_id)
await SmartLinkService.resolve_link_values(db, all_fields, items)
await SmartLinkService.resolve_lookup_values(db, all_fields, items)
await SmartLinkService.resolve_rollup_values(db, all_fields, items)
SmartFormulaService.resolve_formula_values(all_fields, items)
accessible = await PermissionService.get_accessible_field_ids(
db, table_id, role_info.get("role_id"), mode="read"
)
if accessible is not None:
for r in items:
r.values = PermissionService.filter_record_values(r.values or {}, accessible)
return CursorPaginatedRecords(
items=items,
total=total,
next_cursor=next_cursor,
has_more=next_cursor is not None,
)
@router.post(
"/tables/{table_id}/records/query",
response_model=Union[GroupedRecordsResponse, CursorPaginatedRecords],
summary="复杂查询记录(POST Body 传递筛选/排序/搜索条件)",
)
async def query_records(
table_id: str,
query: RecordQuery,
db: AsyncSession = Depends(get_db),
):
role_info = await PermissionService.require_table_access(db, table_id)
user_info = get_current_user_info_from_context() or {}
row_conditions = await PermissionService.build_row_filter_conditions(
db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view"
)
skip_count = query.cursor is not None
items, next_cursor, total = await SmartRecordService.get_by_table_cursor_filtered(
db, table_id,
filters=query.filters or None,
filter_logic=query.filter_logic,
sorts=query.sorts or None,
search=query.search,
search_field_ids=query.search_field_ids,
extra_conditions=row_conditions or None,
cursor=query.cursor,
limit=query.limit,
skip_count=skip_count,
)
all_fields = await SmartFieldService.get_by_table(db, table_id)
await SmartLinkService.resolve_link_values(db, all_fields, items)
await SmartLinkService.resolve_lookup_values(db, all_fields, items)
await SmartLinkService.resolve_rollup_values(db, all_fields, items)
SmartFormulaService.resolve_formula_values(all_fields, items)
accessible = await PermissionService.get_accessible_field_ids(
db, table_id, role_info.get("role_id"), mode="read"
)
if accessible is not None:
for r in items:
r.values = PermissionService.filter_record_values(r.values or {}, accessible)
if query.group_field_id:
field = next((f for f in all_fields if f.id == query.group_field_id), None)
groups_map: dict[str, list] = {}
for r in items:
val = str((r.values or {}).get(query.group_field_id, "") or "")
groups_map.setdefault(val, []).append(r)
group_items = []
for key, recs in groups_map.items():
label = key or "(空)"
if field and field.config and field.config.get("options"):
opt = next((o for o in field.config["options"] if o.get("id") == key), None)
if opt:
label = opt.get("label", key)
group_items.append(RecordGroupItem(key=key, label=label, records=recs))
return GroupedRecordsResponse(groups=group_items, total=total)
return CursorPaginatedRecords(
items=items,
total=total,
next_cursor=next_cursor,
has_more=next_cursor is not None,
)
@router.post("/tables/{table_id}/records", response_model=SmartRecordResponse, summary="添加记录")
async def create_record(table_id: str, data: SmartRecordCreate, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "add_record")
data.table_id = table_id
all_fields = await SmartFieldService.get_by_table(db, table_id)
auto_number_fields = [f for f in all_fields if f.type == "autoNumber"]
if auto_number_fields:
if not data.values:
data.values = {}
for af in auto_number_fields:
if af.id not in data.values:
next_val = await SmartRecordService.get_next_auto_number(db, table_id, af.id)
data.values[af.id] = next_val
record = await SmartRecordService.create(db, data)
return record
@router.put("/records/{record_id}", response_model=SmartRecordResponse, summary="更新记录")
async def update_record(record_id: str, data: SmartRecordUpdate, db: AsyncSession = Depends(get_db)):
record = await SmartRecordService.get_by_id(db, record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
role_info = await PermissionService.require_capability(db, record.table_id, "edit_record")
user_info = get_current_user_info_from_context() or {}
can_edit = await PermissionService.check_row_edit_permission(
db, record.table_id, record, role_info.get("role_id"), user_info.get("user_id", "")
)
if not can_edit:
raise HTTPException(status_code=403, detail="无权编辑此记录")
record = await SmartRecordService.update(db, record_id=record_id, data=data)
return record
@router.patch("/records/{record_id}/cells", response_model=SmartRecordResponse, summary="更新单元格")
async def update_cell(record_id: str, data: SmartRecordCellUpdate, db: AsyncSession = Depends(get_db)):
record = await SmartRecordService.get_by_id(db, record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
role_info = await PermissionService.require_capability(db, record.table_id, "edit_record")
user_info = get_current_user_info_from_context() or {}
can_edit = await PermissionService.check_row_edit_permission(
db, record.table_id, record, role_info.get("role_id"), user_info.get("user_id", "")
)
if not can_edit:
raise HTTPException(status_code=403, detail="无权编辑此记录")
writable = await PermissionService.get_accessible_field_ids(
db, record.table_id, role_info.get("role_id"), mode="write"
)
if writable is not None and data.field_id not in writable:
raise HTTPException(status_code=403, detail="无权编辑此字段")
field = await SmartFieldService.get_by_id(db, data.field_id)
if not field:
raise HTTPException(status_code=404, detail="字段不存在")
validation_error = await SmartValidationService.validate_cell(db, field, data.value, record_id)
if validation_error:
raise HTTPException(status_code=422, detail=validation_error)
if field.type == "link":
target_ids = data.value if isinstance(data.value, list) else []
await SmartLinkService.set_linked_records(db, data.field_id, record_id, target_ids)
record = await SmartRecordService.get_by_id(db, record_id)
all_fields = await SmartFieldService.get_by_table(db, record.table_id)
await SmartLinkService.resolve_link_values(db, all_fields, [record])
await SmartLinkService.resolve_lookup_values(db, all_fields, [record])
await SmartLinkService.resolve_rollup_values(db, all_fields, [record])
return record
record = await SmartRecordService.update_cell(db, record_id, data.field_id, data.value)
return record
@router.patch("/records/{record_id}/cells/batch", response_model=SmartRecordResponse, summary="批量更新单元格")
async def batch_update_cells(record_id: str, data: SmartRecordBatchCellUpdate, db: AsyncSession = Depends(get_db)):
record = await SmartRecordService.get_by_id(db, record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
role_info = await PermissionService.require_capability(db, record.table_id, "edit_record")
user_info = get_current_user_info_from_context() or {}
can_edit = await PermissionService.check_row_edit_permission(
db, record.table_id, record, role_info.get("role_id"), user_info.get("user_id", "")
)
if not can_edit:
raise HTTPException(status_code=403, detail="无权编辑此记录")
writable = await PermissionService.get_accessible_field_ids(
db, record.table_id, role_info.get("role_id"), mode="write"
)
filtered_cells = data.cells
if writable is not None:
filtered_cells = {k: v for k, v in data.cells.items() if k in writable}
if not filtered_cells:
raise HTTPException(status_code=403, detail="无权编辑任何提交的字段")
link_cells = {}
normal_cells = {}
fields_cache = {}
for field_id, val in filtered_cells.items():
if field_id not in fields_cache:
f = await SmartFieldService.get_by_id(db, field_id)
fields_cache[field_id] = f
f = fields_cache[field_id]
if f and f.type == "link":
link_cells[field_id] = val
else:
normal_cells[field_id] = val
validation_errors = await SmartValidationService.validate_cells_batch(
db, record.table_id, normal_cells, record_id
)
if validation_errors:
first_err = next(iter(validation_errors.values()))
raise HTTPException(status_code=422, detail=first_err)
if normal_cells:
record = await SmartRecordService.batch_update_cells(db, record_id, normal_cells)
for fid, val in link_cells.items():
target_ids = val if isinstance(val, list) else []
await SmartLinkService.set_linked_records(db, fid, record_id, target_ids)
record = await SmartRecordService.get_by_id(db, record_id)
if link_cells:
all_fields = await SmartFieldService.get_by_table(db, record.table_id)
await SmartLinkService.resolve_link_values(db, all_fields, [record])
await SmartLinkService.resolve_lookup_values(db, all_fields, [record])
await SmartLinkService.resolve_rollup_values(db, all_fields, [record])
return record
@router.delete("/records/{record_id}", response_model=ResponseModel, summary="删除记录")
async def delete_record(record_id: str, db: AsyncSession = Depends(get_db)):
record = await SmartRecordService.get_by_id(db, record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
await PermissionService.require_capability(db, record.table_id, "delete_record")
await SmartLinkService.cleanup_record_links(db, record_id)
success = await SmartRecordService.delete(db, record_id=record_id)
if not success:
raise HTTPException(status_code=404, detail="记录不存在")
return ResponseModel(message="删除成功")
@router.post("/tables/{table_id}/records/batch-delete", response_model=ResponseModel, summary="批量删除记录")
async def batch_delete_records(table_id: str, data: SmartRecordBatchDelete, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "delete_record")
for rid in data.ids:
await SmartLinkService.cleanup_record_links(db, rid)
success, fail = await SmartRecordService.batch_delete(db, data.ids)
return ResponseModel(message=f"成功删除{success}条", data={"success": success, "fail": fail})
# ==================== Trash / Recycle Bin ====================
@router.get("/tables/{table_id}/trash", response_model=TrashListResponse, summary="获取回收站记录列表")
async def get_trash_records(
table_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "delete_record")
items, total = await SmartRecordService.get_deleted_records(db, table_id, page, page_size)
return TrashListResponse(items=items, total=total)
@router.post("/tables/{table_id}/trash/restore", response_model=ResponseModel, summary="批量恢复回收站记录")
async def restore_trash_records(
table_id: str,
data: SmartRecordBatchRestore,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "delete_record")
count = await SmartRecordService.batch_restore_records(db, table_id, data.ids)
return ResponseModel(message=f"成功恢复{count}条记录", data={"restored": count})
@router.delete("/tables/{table_id}/trash/{record_id}", response_model=ResponseModel, summary="永久删除回收站记录")
async def permanent_delete_record(
table_id: str,
record_id: str,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "delete_record")
success = await SmartRecordService.permanent_delete(db, table_id, record_id)
if not success:
raise HTTPException(status_code=404, detail="记录不存在或未被删除")
return ResponseModel(message="永久删除成功")
@router.delete("/tables/{table_id}/trash", response_model=ResponseModel, summary="清空回收站")
async def empty_trash(
table_id: str,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "delete_record")
count = await SmartRecordService.empty_trash(db, table_id)
return ResponseModel(message=f"已清空{count}条记录", data={"deleted": count})
@router.patch(
"/tables/{table_id}/records/batch-cells",
response_model=ResponseModel,
summary="批量更新多条记录的单元格(合并为单次事务)",
)
async def batch_update_multi_record_cells(
table_id: str,
data: MultiRecordBatchUpdate,
db: AsyncSession = Depends(get_db),
):
role_info = await PermissionService.require_capability(db, table_id, "edit_record")
user_info = get_current_user_info_from_context() or {}
user_id = user_info.get("user_id", "")
writable_fields = await PermissionService.get_accessible_field_ids(
db, table_id, role_info.get("role_id"), mode="write"
)
fields_cache: dict = {}
valid_updates = []
for u in data.updates:
record = await SmartRecordService.get_by_id(db, u.record_id)
if not record or record.table_id != table_id:
continue
can_edit = await PermissionService.check_row_edit_permission(
db, table_id, record, role_info.get("role_id"), user_id
)
if not can_edit:
continue
filtered_cells = {}
for field_id, val in u.cells.items():
if writable_fields is not None and field_id not in writable_fields:
continue
if field_id not in fields_cache:
fields_cache[field_id] = await SmartFieldService.get_by_id(db, field_id)
field = fields_cache[field_id]
if not field:
continue
err = await SmartValidationService.validate_cell(db, field, val, u.record_id)
if err:
continue
filtered_cells[field_id] = val
if filtered_cells:
valid_updates.append({"record_id": u.record_id, "cells": filtered_cells})
count = await SmartRecordService.batch_update_multi_records(db, valid_updates)
return ResponseModel(message=f"成功更新{count}条记录", data={"updated": count})
@router.put("/tables/{table_id}/records/reorder", response_model=ResponseModel, summary="记录排序")
async def reorder_records(table_id: str, data: SmartRecordReorder, db: AsyncSession = Depends(get_db)):
await SmartRecordService.reorder(db, table_id, data.record_ids)
return ResponseModel(message="排序成功")
@router.post(
"/tables/{table_id}/records/search",
response_model=List[RecordSearchResult],
summary="搜索表记录(供 Link 字段选择器使用)",
)
async def search_records(
table_id: str,
query: RecordSearchQuery,
db: AsyncSession = Depends(get_db),
):
items = await SmartLinkService.search_records(
db, table_id, keyword=query.keyword, limit=query.limit,
)
return [RecordSearchResult(**i) for i in items]
# ==================== Summary ====================
@router.post(
"/tables/{table_id}/summary",
response_model=SummaryResponse,
summary="获取字段汇总统计",
)
async def get_table_summary(
table_id: str,
data: SummaryRequest,
db: AsyncSession = Depends(get_db),
):
role_info = await PermissionService.require_table_access(db, table_id)
parsed_filters = data.filters or None
user_info = get_current_user_info_from_context() or {}
row_conditions = await PermissionService.build_row_filter_conditions(
db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view"
)
summaries, total = await SmartSummaryService.compute(
db, table_id,
field_agg_map=data.aggregations,
filters=parsed_filters,
filter_logic=data.filter_logic,
search=data.search,
extra_conditions=row_conditions or None,
)
return SummaryResponse(summaries=summaries, total_count=total)
# ==================== View ====================
@router.get("/tables/{table_id}/views", response_model=List[SmartViewResponse], summary="获取视图列表")
async def get_view_list(table_id: str, db: AsyncSession = Depends(get_db)):
await PermissionService.require_table_access(db, table_id)
return await SmartViewService.get_by_table(db, table_id)
@router.post("/tables/{table_id}/views", response_model=SmartViewResponse, summary="创建视图")
async def create_view(table_id: str, data: SmartViewCreate, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_view")
data.table_id = table_id
view = await SmartViewService.create(db, data)
return view
@router.put("/views/{view_id}", response_model=SmartViewResponse, summary="更新视图")
async def update_view(view_id: str, data: SmartViewUpdate, db: AsyncSession = Depends(get_db)):
existing_view = await SmartViewService.get_by_id(db, view_id)
if not existing_view:
raise HTTPException(status_code=404, detail="视图不存在")
await PermissionService.require_capability(db, existing_view.table_id, "manage_view")
view = await SmartViewService.update(db, record_id=view_id, data=data)
return view
@router.delete("/views/{view_id}", response_model=ResponseModel, summary="删除视图")
async def delete_view(view_id: str, db: AsyncSession = Depends(get_db)):
existing_view = await SmartViewService.get_by_id(db, view_id)
if not existing_view:
raise HTTPException(status_code=404, detail="视图不存在")
await PermissionService.require_capability(db, existing_view.table_id, "manage_view")
success = await SmartViewService.delete(db, record_id=view_id)
if not success:
raise HTTPException(status_code=404, detail="视图不存在")
return ResponseModel(message="删除成功")
# ==================== Export / Import ====================
@router.get("/tables/{table_id}/export", summary="导出表数据")
async def export_table(
table_id: str,
format: str = Query(default="csv", description="导出格式: csv / xlsx"),
db: AsyncSession = Depends(get_db),
):
role_info = await PermissionService.require_capability(db, table_id, "export_data")
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
fields = await SmartFieldService.get_by_table(db, table_id)
accessible = await PermissionService.get_accessible_field_ids(
db, table_id, role_info.get("role_id"), mode="read"
)
if accessible is not None:
fields = [f for f in fields if f.id in accessible]
user_info = get_current_user_info_from_context() or {}
row_conditions = await PermissionService.build_row_filter_conditions(
db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view"
)
records, _, _ = await SmartRecordService.get_by_table_cursor_filtered(
db, table_id, extra_conditions=row_conditions or None, limit=50000
)
all_fields = await SmartFieldService.get_by_table(db, table_id)
await SmartLinkService.resolve_link_values(db, all_fields, records)
await SmartLinkService.resolve_lookup_values(db, all_fields, records)
await SmartLinkService.resolve_rollup_values(db, all_fields, records)
import io
from urllib.parse import quote
safe_name = table.name.replace("/", "_").replace("\\", "_")
def _content_disposition(filename: str) -> str:
"""Build Content-Disposition with RFC 5987 filename* for non-ASCII names."""
ascii_name = filename.encode("ascii", "ignore").decode("ascii") or "export"
encoded_name = quote(filename)
return (
f'attachment; filename="{ascii_name}"; '
f"filename*=UTF-8''{encoded_name}"
)
if format == "xlsx":
content = await SmartExportService.export_xlsx(db, table_id, fields, records)
return StreamingResponse(
io.BytesIO(content),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": _content_disposition(f"{safe_name}.xlsx")},
)
else:
content = await SmartExportService.export_csv(db, table_id, fields, records)
return StreamingResponse(
io.BytesIO(content.encode("utf-8-sig")),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": _content_disposition(f"{safe_name}.csv")},
)
@router.post("/tables/{table_id}/import", response_model=ResponseModel, summary="导入数据")
async def import_table(
table_id: str,
file: UploadFile = File(..., description="CSV 或 Excel 文件"),
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "import_data")
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
fields = await SmartFieldService.get_by_table(db, table_id)
file_content = await file.read()
filename = file.filename or ""
if filename.endswith(".xlsx") or filename.endswith(".xls"):
result = await SmartImportService.import_xlsx(db, table_id, file_content, fields)
elif filename.endswith(".csv"):
result = await SmartImportService.import_csv(db, table_id, file_content, fields)
else:
raise HTTPException(status_code=400, detail="仅支持 CSV 和 Excel (.xlsx) 文件")
return ResponseModel(
message=f"导入完成:成功 {result['success']} 条,失败 {result['fail']} 条",
data=result,
)
# ==================== Comment ====================
@router.get("/records/{record_id}/comments", response_model=List[CommentResponse], summary="获取记录评论")
async def get_comments(record_id: str, db: AsyncSession = Depends(get_db)):
from sqlalchemy import select as sa_select
comments = await SmartCommentService.get_by_record(db, record_id)
user_ids = list({c.user_id for c in comments})
user_map: dict = {}
if user_ids:
try:
from core.model import CoreUser
result = await db.execute(
sa_select(CoreUser.id, CoreUser.name, CoreUser.avatar)
.where(CoreUser.id.in_(user_ids))
)
for row in result.all():
user_map[row[0]] = {"name": row[1], "avatar": row[2]}
except Exception:
pass
top_level: list[CommentResponse] = []
reply_map: dict[str, list[CommentResponse]] = {}
for c in comments:
u = user_map.get(c.user_id, {})
item = CommentResponse(
id=c.id,
record_id=c.record_id,
user_id=c.user_id,
content=c.content,
mentions=c.mentions or [],
parent_id=c.parent_id,
sys_create_datetime=c.sys_create_datetime,
sys_update_datetime=c.sys_update_datetime,
user_name=u.get("name"),
user_avatar=u.get("avatar"),
)
if c.parent_id:
reply_map.setdefault(c.parent_id, []).append(item)
else:
top_level.append(item)
for item in top_level:
item.replies = reply_map.get(item.id, [])
return top_level
@router.post("/records/{record_id}/comments", response_model=CommentResponse, summary="创建评论")
async def create_comment(record_id: str, data: CommentCreate, db: AsyncSession = Depends(get_db)):
user_id = get_current_user_id_from_context()
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
comment = await SmartCommentService.create(
db, record_id, user_id, data.content, data.mentions, data.parent_id,
)
user_map: dict = {}
try:
from sqlalchemy import select as sa_select
from core.model import CoreUser
result = await db.execute(
sa_select(CoreUser.id, CoreUser.name, CoreUser.avatar).where(CoreUser.id == user_id)
)
row = result.first()
if row:
user_map[row[0]] = {"name": row[1], "avatar": row[2]}
except Exception:
pass
u = user_map.get(user_id, {})
return CommentResponse(
id=comment.id,
record_id=comment.record_id,
user_id=comment.user_id,
content=comment.content,
mentions=comment.mentions or [],
parent_id=comment.parent_id,
sys_create_datetime=comment.sys_create_datetime,
sys_update_datetime=comment.sys_update_datetime,
user_name=u.get("name"),
user_avatar=u.get("avatar"),
)
@router.put("/comments/{comment_id}", response_model=CommentResponse, summary="更新评论")
async def update_comment(comment_id: str, data: CommentUpdate, db: AsyncSession = Depends(get_db)):
user_id = get_current_user_id_from_context()
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
comment = await SmartCommentService.update(db, comment_id, user_id, data.content, data.mentions)
if not comment:
raise HTTPException(status_code=404, detail="评论不存在或无权编辑")
return CommentResponse(
id=comment.id,
record_id=comment.record_id,
user_id=comment.user_id,
content=comment.content,
mentions=comment.mentions or [],
parent_id=comment.parent_id,
sys_create_datetime=comment.sys_create_datetime,
sys_update_datetime=comment.sys_update_datetime,
)
@router.delete("/comments/{comment_id}", response_model=ResponseModel, summary="删除评论")
async def delete_comment(comment_id: str, db: AsyncSession = Depends(get_db)):
user_id = get_current_user_id_from_context()
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
ok = await SmartCommentService.delete(db, comment_id, user_id)
if not ok:
raise HTTPException(status_code=404, detail="评论不存在或无权删除")
return ResponseModel(message="删除成功")
# ==================== Document Version ====================
@router.get("/tables/{table_id}/versions", summary="获取版本列表")
async def get_document_versions(
table_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_table_access(db, table_id)
items, total = await SmartDocumentVersionService.get_versions(db, table_id, page, page_size)
user_ids = list({v.sys_creator_id for v in items if v.sys_creator_id})
user_map: dict = {}
if user_ids:
users = await UserService.get_by_ids(db, user_ids)
for u in users:
user_map[u.id] = u
result = []
for v in items:
resp = DocumentVersionResponse(
id=v.id, document_id=v.document_id, version=v.version,
title=v.title, change_summary=v.change_summary,
content_size=v.content_size,
sys_create_datetime=v.sys_create_datetime,
sys_creator_id=v.sys_creator_id,
)
u = user_map.get(v.sys_creator_id) if v.sys_creator_id else None
if u:
resp.creator_name = getattr(u, "name", None) or getattr(u, "username", None)
resp.creator_avatar = getattr(u, "avatar", None)
result.append(resp)
return {"items": result, "total": total}
@router.get("/versions/{version_id}", response_model=DocumentVersionDetail, summary="获取版本详情")
async def get_document_version_detail(
version_id: str,
db: AsyncSession = Depends(get_db),
):
version = await SmartDocumentVersionService.get_version_detail(db, version_id)
if not version:
raise HTTPException(status_code=404, detail="版本不存在")
await PermissionService.require_table_access(db, version.document_id)
u = None
if version.sys_creator_id:
u = await UserService.get_by_id(db, version.sys_creator_id)
return DocumentVersionDetail(
id=version.id, document_id=version.document_id, version=version.version,
title=version.title, change_summary=version.change_summary,
content_size=version.content_size, content=version.content or {},
sys_create_datetime=version.sys_create_datetime,
sys_creator_id=version.sys_creator_id,
creator_name=(getattr(u, "name", None) or getattr(u, "username", None)) if u else None,
creator_avatar=getattr(u, "avatar", None) if u else None,
)
@router.post("/tables/{table_id}/versions", response_model=DocumentVersionResponse, summary="手动创建版本快照")
async def create_document_version(
table_id: str,
data: DocumentVersionCreate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "edit_record")
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="文档不存在")
if getattr(table, "type", "table") != "document":
raise HTTPException(status_code=400, detail="该项目不是文档类型")
if not table.content:
raise HTTPException(status_code=400, detail="文档内容为空,无法创建版本")
user_id = get_current_user_id_from_context()
version = await SmartDocumentVersionService.create_version(
db, table_id, table.content,
title=table.name,
change_summary=data.change_summary,
user_id=user_id,
)
return DocumentVersionResponse(
id=version.id, document_id=version.document_id, version=version.version,
title=version.title, change_summary=version.change_summary,
content_size=version.content_size,
sys_create_datetime=version.sys_create_datetime,
sys_creator_id=version.sys_creator_id,
)
@router.post("/tables/{table_id}/versions/{version_id}/restore", response_model=ResponseModel, summary="恢复到指定版本")
async def restore_document_version(
table_id: str,
version_id: str,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "edit_record")
user_id = get_current_user_id_from_context()
result = await SmartDocumentVersionService.restore_version(db, table_id, version_id, user_id)
if not result:
raise HTTPException(status_code=404, detail="版本不存在或文档不存在")
return ResponseModel(message="版本恢复成功")
@router.get("/tables/{table_id}/versions/compare", response_model=DocumentVersionCompare, summary="版本对比")
async def compare_document_versions(
table_id: str,
version_from: str = Query(..., alias="from", description="源版本ID"),
version_to: str = Query(..., alias="to", description="目标版本ID"),
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_table_access(db, table_id)
v_from = await SmartDocumentVersionService.get_version_detail(db, version_from)
v_to = await SmartDocumentVersionService.get_version_detail(db, version_to)
if not v_from or not v_to:
raise HTTPException(status_code=404, detail="版本不存在")
if v_from.document_id != table_id or v_to.document_id != table_id:
raise HTTPException(status_code=400, detail="版本不属于该文档")
return DocumentVersionCompare(
version_from=DocumentVersionDetail(
id=v_from.id, document_id=v_from.document_id, version=v_from.version,
title=v_from.title, change_summary=v_from.change_summary,
content_size=v_from.content_size, content=v_from.content or {},
sys_create_datetime=v_from.sys_create_datetime, sys_creator_id=v_from.sys_creator_id,
),
version_to=DocumentVersionDetail(
id=v_to.id, document_id=v_to.document_id, version=v_to.version,
title=v_to.title, change_summary=v_to.change_summary,
content_size=v_to.content_size, content=v_to.content or {},
sys_create_datetime=v_to.sys_create_datetime, sys_creator_id=v_to.sys_creator_id,
),
)
@router.delete("/versions/{version_id}", response_model=ResponseModel, summary="删除版本")
async def delete_document_version(
version_id: str,
db: AsyncSession = Depends(get_db),
):
version = await SmartDocumentVersionService.get_version_detail(db, version_id)
if not version:
raise HTTPException(status_code=404, detail="版本不存在")
await PermissionService.require_capability(db, version.document_id, "edit_record")
ok = await SmartDocumentVersionService.delete_version(db, version_id)
if not ok:
raise HTTPException(status_code=404, detail="版本不存在")
return ResponseModel(message="删除成功")
# ==================== Document Template ====================
@router.get("/document-templates", summary="获取模板列表")
async def get_document_templates(
category: Optional[str] = Query(default=None, description="分类筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=100, alias="pageSize"),
db: AsyncSession = Depends(get_db),
):
items, total = await SmartDocumentTemplateService.get_list(
db, category=category, keyword=keyword, page=page, page_size=page_size,
)
user_ids = list({t.sys_creator_id for t in items if t.sys_creator_id})
user_map: dict = {}
if user_ids:
users = await UserService.get_by_ids(db, user_ids)
for u in users:
user_map[u.id] = u
result = []
for t in items:
resp = DocumentTemplateResponse(
id=t.id, name=t.name, description=t.description,
icon=t.icon, category=t.category,
preview_image=t.preview_image,
is_system=t.is_system, use_count=t.use_count,
sys_create_datetime=t.sys_create_datetime,
sys_update_datetime=t.sys_update_datetime,
sys_creator_id=t.sys_creator_id,
)
u = user_map.get(t.sys_creator_id) if t.sys_creator_id else None
if u:
resp.creator_name = getattr(u, "name", None) or getattr(u, "username", None)
result.append(resp)
return {"items": result, "total": total}
@router.get("/document-templates/categories", summary="获取模板分类")
async def get_template_categories(db: AsyncSession = Depends(get_db)):
return await SmartDocumentTemplateService.get_categories(db)
@router.get("/document-templates/{template_id}", response_model=DocumentTemplateDetail, summary="获取模板详情")
async def get_document_template_detail(
template_id: str,
db: AsyncSession = Depends(get_db),
):
template = await SmartDocumentTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
u = None
if template.sys_creator_id:
u = await UserService.get_by_id(db, template.sys_creator_id)
return DocumentTemplateDetail(
id=template.id, name=template.name, description=template.description,
icon=template.icon, category=template.category,
preview_image=template.preview_image,
is_system=template.is_system, use_count=template.use_count,
content=template.content or {},
sys_create_datetime=template.sys_create_datetime,
sys_update_datetime=template.sys_update_datetime,
sys_creator_id=template.sys_creator_id,
creator_name=(getattr(u, "name", None) or getattr(u, "username", None)) if u else None,
)
@router.post("/document-templates", response_model=DocumentTemplateResponse, summary="创建模板")
async def create_document_template(
data: DocumentTemplateCreate,
db: AsyncSession = Depends(get_db),
):
user_id = get_current_user_id_from_context()
template = await SmartDocumentTemplateService.create(
db, name=data.name, content=data.content,
description=data.description, icon=data.icon,
category=data.category, preview_image=data.preview_image,
user_id=user_id,
)
return DocumentTemplateResponse(
id=template.id, name=template.name, description=template.description,
icon=template.icon, category=template.category,
preview_image=template.preview_image,
is_system=template.is_system, use_count=template.use_count,
sys_create_datetime=template.sys_create_datetime,
sys_update_datetime=template.sys_update_datetime,
sys_creator_id=template.sys_creator_id,
)
@router.post("/document-templates/from-document/{document_id}", response_model=DocumentTemplateResponse, summary="从文档创建模板")
async def create_template_from_document(
document_id: str,
data: DocumentTemplateCreate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_table_access(db, document_id)
user_id = get_current_user_id_from_context()
template = await SmartDocumentTemplateService.create_from_document(
db, document_id=document_id, name=data.name,
description=data.description, category=data.category,
user_id=user_id,
)
if not template:
raise HTTPException(status_code=400, detail="文档不存在或内容为空")
return DocumentTemplateResponse(
id=template.id, name=template.name, description=template.description,
icon=template.icon, category=template.category,
preview_image=template.preview_image,
is_system=template.is_system, use_count=template.use_count,
sys_create_datetime=template.sys_create_datetime,
sys_update_datetime=template.sys_update_datetime,
sys_creator_id=template.sys_creator_id,
)
@router.put("/document-templates/{template_id}", response_model=DocumentTemplateResponse, summary="更新模板")
async def update_document_template(
template_id: str,
data: DocumentTemplateUpdate,
db: AsyncSession = Depends(get_db),
):
user_id = get_current_user_id_from_context()
update_data = data.model_dump(exclude_unset=True)
template = await SmartDocumentTemplateService.update(db, template_id, update_data, user_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return DocumentTemplateResponse(
id=template.id, name=template.name, description=template.description,
icon=template.icon, category=template.category,
preview_image=template.preview_image,
is_system=template.is_system, use_count=template.use_count,
sys_create_datetime=template.sys_create_datetime,
sys_update_datetime=template.sys_update_datetime,
sys_creator_id=template.sys_creator_id,
)
@router.delete("/document-templates/{template_id}", response_model=ResponseModel, summary="删除模板")
async def delete_document_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
ok = await SmartDocumentTemplateService.delete(db, template_id)
if not ok:
raise HTTPException(status_code=404, detail="模板不存在或为系统模板不可删除")
return ResponseModel(message="删除成功")
@router.post("/document-templates/{template_id}/use", response_model=ResponseModel, summary="使用模板(增加使用计数)")
async def use_document_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
template = await SmartDocumentTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
await SmartDocumentTemplateService.increment_use_count(db, template_id)
return ResponseModel(message="OK")
# ==================== Wiki Space ====================
@router.post("/wiki-spaces", response_model=WikiSpaceResponse, summary="创建文档库")
async def create_wiki_space(data: WikiSpaceCreate, db: AsyncSession = Depends(get_db)):
user_id = get_current_user_id_from_context()
space = await WikiSpaceService.create(db, data, current_user_id=user_id)
creator_name = None
if user_id:
user = await UserService.get_by_id(db, user_id)
if user:
creator_name = getattr(user, "real_name", None) or getattr(user, "username", None)
resp = WikiSpaceResponse.model_validate(space)
resp.creator_name = creator_name
return resp
@router.get("/wiki-spaces", response_model=List[WikiSpaceListItem], summary="获取文档库列表")
async def get_wiki_spaces(db: AsyncSession = Depends(get_db)):
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
is_superuser = user_info.get("is_superuser", False) if user_info else False
items, _ = await WikiSpaceService.get_list_with_count(
db, user_id=user_id, is_superuser=is_superuser,
)
result = []
for item in items:
space = item["space"]
creator_name = None
if space.sys_creator_id:
user = await UserService.get_by_id(db, space.sys_creator_id)
if user:
creator_name = getattr(user, "real_name", None) or getattr(user, "username", None)
resp = WikiSpaceListItem(
**WikiSpaceResponse.model_validate(space).model_dump(),
document_count=item["document_count"],
)
resp.creator_name = creator_name
result.append(resp)
return result
@router.get("/wiki-spaces/{space_id}", response_model=WikiSpaceDetail, summary="获取文档库详情")
async def get_wiki_space_detail(space_id: str, db: AsyncSession = Depends(get_db)):
space = await WikiSpaceService.get_by_id(db, space_id)
if not space:
raise HTTPException(status_code=404, detail="文档库不存在")
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
dept_id = user_info.get("dept_id") if user_info else None
role_ids = user_info.get("role_ids", []) if user_info else []
is_superuser = user_info.get("is_superuser", False) if user_info else False
has_access = await WikiSpaceService.check_space_access(
space, user_id=user_id, is_superuser=is_superuser,
)
if not has_access:
raise HTTPException(status_code=403, detail="无权访问此文档库")
documents = await WikiSpaceService.get_documents(
db, space_id,
user_id=user_id, dept_id=dept_id,
role_ids=role_ids, is_superuser=is_superuser,
)
from zq_smart_table.schema import SmartTableSimple
doc_list = [SmartTableSimple.model_validate(d) for d in documents]
creator_name = None
if space.sys_creator_id:
user = await UserService.get_by_id(db, space.sys_creator_id)
if user:
creator_name = getattr(user, "real_name", None) or getattr(user, "username", None)
resp = WikiSpaceDetail(
**WikiSpaceResponse.model_validate(space).model_dump(),
documents=doc_list,
)
resp.creator_name = creator_name
return resp
@router.put("/wiki-spaces/{space_id}", response_model=WikiSpaceResponse, summary="更新文档库")
async def update_wiki_space(
space_id: str,
data: WikiSpaceUpdate,
db: AsyncSession = Depends(get_db),
):
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
is_superuser = user_info.get("is_superuser", False) if user_info else False
space = await WikiSpaceService.get_by_id(db, space_id)
if not space:
raise HTTPException(status_code=404, detail="文档库不存在")
if not is_superuser and space.sys_creator_id != user_id:
raise HTTPException(status_code=403, detail="仅创建者可修改文档库")
space = await WikiSpaceService.update(db, space_id, data, current_user_id=user_id)
return WikiSpaceResponse.model_validate(space)
@router.delete("/wiki-spaces/{space_id}", response_model=ResponseModel, summary="删除文档库")
async def delete_wiki_space(space_id: str, db: AsyncSession = Depends(get_db)):
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
is_superuser = user_info.get("is_superuser", False) if user_info else False
space = await WikiSpaceService.get_by_id(db, space_id)
if not space:
raise HTTPException(status_code=404, detail="文档库不存在")
if not is_superuser and space.sys_creator_id != user_id:
raise HTTPException(status_code=403, detail="仅创建者可删除文档库")
ok = await WikiSpaceService.delete_with_documents(db, space_id)
if not ok:
raise HTTPException(status_code=404, detail="文档库不存在")
return ResponseModel(message="删除成功")
@router.get("/wiki-spaces/{space_id}/documents", response_model=List[SmartTableSimple], summary="获取文档库文档列表")
async def get_wiki_space_documents(space_id: str, db: AsyncSession = Depends(get_db)):
space = await WikiSpaceService.get_by_id(db, space_id)
if not space:
raise HTTPException(status_code=404, detail="文档库不存在")
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
dept_id = user_info.get("dept_id") if user_info else None
role_ids = user_info.get("role_ids", []) if user_info else []
is_superuser = user_info.get("is_superuser", False) if user_info else False
has_access = await WikiSpaceService.check_space_access(
space, user_id=user_id, is_superuser=is_superuser,
)
if not has_access:
raise HTTPException(status_code=403, detail="无权访问此文档库")
documents = await WikiSpaceService.get_documents(
db, space_id,
user_id=user_id, dept_id=dept_id,
role_ids=role_ids, is_superuser=is_superuser,
)
return [SmartTableSimple.model_validate(d) for d in documents]
@router.post("/wiki-spaces/{space_id}/documents", response_model=SmartTableResponse, summary="在文档库中创建文档")
async def create_wiki_document(
space_id: str,
data: SmartTableCreate,
db: AsyncSession = Depends(get_db),
):
space = await WikiSpaceService.get_by_id(db, space_id)
if not space:
raise HTTPException(status_code=404, detail="文档库不存在")
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
is_superuser = user_info.get("is_superuser", False) if user_info else False
has_access = await WikiSpaceService.check_space_access(
space, user_id=user_id, is_superuser=is_superuser,
)
if not has_access:
raise HTTPException(status_code=403, detail="无权在此文档库中创建文档")
doc = await WikiSpaceService.add_document(
db, space_id,
name=data.name,
parent_id=data.parent_id,
content=data.content,
user_id=user_id,
)
if user_id:
await PermissionService.init_table_owner(db, doc.id, user_id)
return SmartTableResponse.model_validate(doc)