feat: Phase 3-5 - workflow, labeling, reports, dashboard enhancement, tests

This commit is contained in:
hiderfong
2026-04-22 17:22:11 +08:00
parent e71b13fe39
commit fb4aaad9fc
50 changed files with 741 additions and 323 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, user, datasource, metadata, classification, project, task from app.api.v1 import auth, user, datasource, metadata, classification, project, task, report
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["认证"]) api_router.include_router(auth.router, prefix="/auth", tags=["认证"])
@@ -10,3 +10,4 @@ api_router.include_router(metadata.router, prefix="/metadata", tags=["元数据
api_router.include_router(classification.router, prefix="/classifications", tags=["分类分级标准"]) api_router.include_router(classification.router, prefix="/classifications", tags=["分类分级标准"])
api_router.include_router(project.router, prefix="/projects", tags=["项目管理"]) api_router.include_router(project.router, prefix="/projects", tags=["项目管理"])
api_router.include_router(task.router, prefix="/tasks", tags=["任务管理"]) api_router.include_router(task.router, prefix="/tasks", tags=["任务管理"])
api_router.include_router(report.router, prefix="/reports", tags=["报告管理"])
+23
View File
@@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.user import User
from app.services import report_service
from app.api.deps import get_current_user
router = APIRouter()
@router.get("/projects/{project_id}/download")
def download_report(
project_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
content = report_service.generate_classification_report(db, project_id)
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f"attachment; filename=report_project_{project_id}.docx"},
)
+70 -40
View File
@@ -6,6 +6,7 @@ from app.core.database import get_db
from app.models.user import User from app.models.user import User
from app.schemas.common import ResponseModel, ListResponse from app.schemas.common import ResponseModel, ListResponse
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.services import task_service, project_service
router = APIRouter() router = APIRouter()
@@ -16,17 +17,15 @@ def my_tasks(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
from app.models.project import ClassificationTask items, _ = task_service.list_tasks(db, assignee_id=current_user.id, status=status)
query = db.query(ClassificationTask).filter(ClassificationTask.assignee_id == current_user.id)
if status:
query = query.filter(ClassificationTask.status == status)
items = query.order_by(ClassificationTask.created_at.desc()).all()
data = [] data = []
for t in items: for t in items:
project = project_service.get_project(db, t.project_id)
data.append({ data.append({
"id": t.id, "id": t.id,
"name": t.name, "name": t.name or (project.name if project else f"任务#{t.id}"),
"project_id": t.project_id, "project_id": t.project_id,
"project_name": project.name if project else None,
"status": t.status, "status": t.status,
"deadline": t.deadline.isoformat() if t.deadline else None, "deadline": t.deadline.isoformat() if t.deadline else None,
"created_at": t.created_at.isoformat() if t.created_at else None, "created_at": t.created_at.isoformat() if t.created_at else None,
@@ -34,47 +33,78 @@ def my_tasks(
return ResponseModel(data=data) return ResponseModel(data=data)
@router.get("/my-tasks/{task_id}/items") @router.post("/my-tasks/{task_id}/start")
def task_items( def start_task(
task_id: int, task_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
from app.models.project import ClassificationTask, ClassificationResult task = task_service.get_task(db, task_id)
from app.models.metadata import DataColumn, DataTable, Database as MetaDatabase, DataSource
from app.models.classification import Category, DataLevel
task = db.query(ClassificationTask).filter(ClassificationTask.id == task_id).first()
if not task: if not task:
from fastapi import HTTPException, status from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
task = task_service.update_task_status(db, task, "in_progress")
return ResponseModel(data={"id": task.id, "status": task.status})
results = db.query(ClassificationResult).filter(
ClassificationResult.project_id == task.project_id,
).join(DataColumn).all()
data = [] @router.post("/my-tasks/{task_id}/complete")
for r in results: def complete_task(
col = r.column task_id: int,
table = col.table if col else None db: Session = Depends(get_db),
database = table.database if table else None current_user: User = Depends(get_current_user),
source = database.source if database else None ):
data.append({ task = task_service.get_task(db, task_id)
"result_id": r.id, if not task:
"column_id": col.id if col else None, from fastapi import HTTPException, status
"column_name": col.name if col else None, raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
"data_type": col.data_type if col else None, task = task_service.update_task_status(db, task, "completed")
"comment": col.comment if col else None, return ResponseModel(data={"id": task.id, "status": task.status})
"table_name": table.name if table else None,
"database_name": database.name if database else None,
"source_name": source.name if source else None, @router.get("/my-tasks/{task_id}/items")
"category_id": r.category_id, def task_items(
"category_name": r.category.name if r.category else None, task_id: int,
"level_id": r.level_id, keyword: Optional[str] = Query(None),
"level_name": r.level.name if r.level else None, db: Session = Depends(get_db),
"level_color": r.level.color if r.level else None, current_user: User = Depends(get_current_user),
"source": r.source, ):
"confidence": r.confidence, task = task_service.get_task(db, task_id)
"status": r.status, if not task:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务不存在")
items = task_service.get_task_label_items(db, task.project_id, keyword=keyword)
return ResponseModel(data=items)
@router.post("/results/{result_id}/label")
def label_result(
result_id: int,
category_id: int,
level_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = project_service.update_result_label(db, result_id, category_id, level_id, current_user.id)
return ResponseModel(data={
"result_id": result.id,
"category_id": result.category_id,
"level_id": result.level_id,
"status": result.status,
}) })
return ResponseModel(data=data)
@router.post("/projects/{project_id}/create-task")
def create_task_for_project(
project_id: int,
name: str,
assignee_id: int,
target_type: str = Query("column"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
task = task_service.create_task(
db, project_id=project_id, name=name,
assigner_id=current_user.id, assignee_id=assignee_id,
target_type=target_type,
)
return ResponseModel(data={"id": task.id, "name": task.name})
+96
View File
@@ -0,0 +1,96 @@
from io import BytesIO
from typing import Optional
from sqlalchemy.orm import Session
from datetime import datetime
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from app.models.project import ClassificationProject, ClassificationResult
from app.models.classification import Category, DataLevel
def generate_classification_report(db: Session, project_id: int) -> bytes:
"""Generate a Word report for a classification project."""
project = db.query(ClassificationProject).filter(ClassificationProject.id == project_id).first()
if not project:
raise ValueError("项目不存在")
doc = Document()
# Title
title = doc.add_heading('数据分类分级项目报告', 0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Basic info
doc.add_heading('一、项目基本信息', level=1)
info_table = doc.add_table(rows=4, cols=2)
info_table.style = 'Light Grid Accent 1'
info_data = [
('项目名称', project.name),
('报告生成时间', datetime.now().strftime('%Y-%m-%d %H:%M:%S')),
('项目状态', project.status),
('模板版本', project.template.version if project.template else 'N/A'),
]
for i, (k, v) in enumerate(info_data):
info_table.rows[i].cells[0].text = k
info_table.rows[i].cells[1].text = str(v)
# Statistics
doc.add_heading('二、分类分级统计', level=1)
results = db.query(ClassificationResult).filter(ClassificationResult.project_id == project_id).all()
total = len(results)
auto_count = sum(1 for r in results if r.source == 'auto')
manual_count = sum(1 for r in results if r.source == 'manual')
level_stats = {}
for r in results:
if r.level:
level_stats[r.level.name] = level_stats.get(r.level.name, 0) + 1
doc.add_paragraph(f'总字段数: {total}')
doc.add_paragraph(f'自动识别: {auto_count}')
doc.add_paragraph(f'人工打标: {manual_count}')
doc.add_heading('三、分级分布', level=1)
level_table = doc.add_table(rows=1, cols=3)
level_table.style = 'Light Grid Accent 1'
hdr_cells = level_table.rows[0].cells
hdr_cells[0].text = '分级'
hdr_cells[1].text = '数量'
hdr_cells[2].text = '占比'
for level_name, count in sorted(level_stats.items(), key=lambda x: -x[1]):
row_cells = level_table.add_row().cells
row_cells[0].text = level_name
row_cells[1].text = str(count)
row_cells[2].text = f'{count / total * 100:.1f}%' if total > 0 else '0%'
# High risk data
doc.add_heading('四、高敏感数据清单(L4/L5', level=1)
high_risk = [r for r in results if r.level and r.level.code in ('L4', 'L5')]
if high_risk:
risk_table = doc.add_table(rows=1, cols=5)
risk_table.style = 'Light Grid Accent 1'
hdr = risk_table.rows[0].cells
hdr[0].text = '字段名'
hdr[1].text = '所属表'
hdr[2].text = '分类'
hdr[3].text = '分级'
hdr[4].text = '来源'
for r in high_risk[:100]: # limit to 100 rows
row = risk_table.add_row().cells
row[0].text = r.column.name if r.column else 'N/A'
row[1].text = r.column.table.name if r.column and r.column.table else 'N/A'
row[2].text = r.category.name if r.category else 'N/A'
row[3].text = r.level.name if r.level else 'N/A'
row[4].text = '自动' if r.source == 'auto' else '人工'
else:
doc.add_paragraph('暂无L4/L5级高敏感数据。')
# Save to bytes
buffer = BytesIO()
doc.save(buffer)
buffer.seek(0)
return buffer.read()
+122
View File
@@ -0,0 +1,122 @@
from typing import Optional, List, Tuple
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.project import ClassificationTask, ClassificationProject, ClassificationResult, TaskStatus, ResultStatus
from app.models.metadata import DataColumn, DataTable, Database as MetaDatabase
def get_task(db: Session, task_id: int) -> Optional[ClassificationTask]:
return db.query(ClassificationTask).filter(ClassificationTask.id == task_id).first()
def list_tasks(
db: Session,
project_id: Optional[int] = None,
assignee_id: Optional[int] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 20,
) -> Tuple[List[ClassificationTask], int]:
query = db.query(ClassificationTask)
if project_id:
query = query.filter(ClassificationTask.project_id == project_id)
if assignee_id:
query = query.filter(ClassificationTask.assignee_id == assignee_id)
if status:
query = query.filter(ClassificationTask.status == status)
total = query.count()
items = query.order_by(ClassificationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return items, total
def create_task(
db: Session,
project_id: int,
name: str,
assigner_id: int,
assignee_id: int,
target_type: str = "column",
target_ids: Optional[str] = None,
deadline: Optional[str] = None,
) -> ClassificationTask:
from datetime import datetime
db_obj = ClassificationTask(
project_id=project_id,
name=name,
assigner_id=assigner_id,
assignee_id=assignee_id,
target_type=target_type,
target_ids=target_ids,
status=TaskStatus.PENDING.value,
deadline=datetime.fromisoformat(deadline) if deadline else None,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update_task_status(db: Session, task: ClassificationTask, status: str) -> ClassificationTask:
task.status = status
if status == TaskStatus.COMPLETED.value:
from datetime import datetime
task.completed_at = datetime.utcnow()
db.commit()
db.refresh(task)
return task
def assign_columns_to_task(db: Session, project_id: int, task_id: int, column_ids: List[int]) -> None:
"""Assign columns to a task by creating/updating classification results."""
from app.services.project_service import list_results
for col_id in column_ids:
result = db.query(ClassificationResult).filter(
ClassificationResult.project_id == project_id,
ClassificationResult.column_id == col_id,
).first()
if not result:
result = ClassificationResult(
project_id=project_id,
column_id=col_id,
status=ResultStatus.AUTO.value,
source="auto",
confidence=0.0,
)
db.add(result)
db.commit()
def get_task_label_items(db: Session, project_id: int, keyword: Optional[str] = None) -> List[dict]:
"""Get all label items for a project (used in task labeling view)."""
query = db.query(ClassificationResult).filter(ClassificationResult.project_id == project_id)
results = query.all()
items = []
for r in results:
col = r.column
if not col:
continue
table = col.table
database = table.database if table else None
source = database.source if database else None
items.append({
"result_id": r.id,
"column_id": col.id,
"column_name": col.name,
"data_type": col.data_type,
"comment": col.comment,
"table_name": table.name if table else None,
"database_name": database.name if database else None,
"source_name": source.name if source else None,
"category_id": r.category_id,
"category_name": r.category.name if r.category else None,
"level_id": r.level_id,
"level_name": r.level.name if r.level else None,
"level_color": r.level.color if r.level else None,
"source": r.source,
"confidence": r.confidence,
"status": r.status,
})
return items
+62
View File
@@ -388,3 +388,65 @@ INFO: 127.0.0.1:63058 - "GET /api/v1/classifications/categories/tree HTTP/1.
INFO: 127.0.0.1:63065 - "GET /api/v1/projects?page=1&page_size=100 HTTP/1.1" 200 OK INFO: 127.0.0.1:63065 - "GET /api/v1/projects?page=1&page_size=100 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63067 - "GET /api/v1/classifications/levels HTTP/1.1" 200 OK INFO: 127.0.0.1:63067 - "GET /api/v1/classifications/levels HTTP/1.1" 200 OK
INFO: 127.0.0.1:63069 - "GET /api/v1/projects?page=1&page_size=50 HTTP/1.1" 200 OK INFO: 127.0.0.1:63069 - "GET /api/v1/projects?page=1&page_size=50 HTTP/1.1" 200 OK
WARNING: WatchFiles detected changes in 'app/services/task_service.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [27900]
/Users/nathan/Work/DataPointer/prop-data-guard/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
INFO: Started server process [32965]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: WatchFiles detected changes in 'app/api/v1/task.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [32965]
/Users/nathan/Work/DataPointer/prop-data-guard/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
INFO: Started server process [33004]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: WatchFiles detected changes in 'app/services/report_service.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [33004]
/Users/nathan/Work/DataPointer/prop-data-guard/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
INFO: Started server process [33876]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: WatchFiles detected changes in 'app/api/v1/report.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [33876]
/Users/nathan/Work/DataPointer/prop-data-guard/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
INFO: Started server process [33886]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: WatchFiles detected changes in 'app/api/v1/__init__.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [33886]
/Users/nathan/Work/DataPointer/prop-data-guard/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
INFO: Started server process [34876]
INFO: Waiting for application startup.
INFO: Application startup complete.
WARNING: WatchFiles detected changes in 'tests/test_auth.py'. Reloading...
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [34876]
/Users/nathan/Work/DataPointer/prop-data-guard/backend/.venv/lib/python3.9/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020
warnings.warn(
INFO: Started server process [35542]
INFO: Waiting for application startup.
INFO: Application startup complete.
MinIO init warning: HTTPConnectionPool(host='localhost', port=9000): Max retries exceeded with url: /pdg-files?location= (Caused by NewConnectionError("HTTPConnection(host='localhost', port=9000): Failed to establish a new connection: [Errno 61] Connection refused"))
INFO: 127.0.0.1:50646 - "GET /health HTTP/1.1" 200 OK
+63
View File
@@ -0,0 +1,63 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.core.database import Base, get_db
from app.core.config import settings
# Use SQLite for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
@pytest.fixture(scope="module", autouse=True)
def setup_db():
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
def test_health_check():
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_login():
response = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "access_token" in data["data"]
return data["data"]["access_token"]
def test_get_me():
token = test_login()
response = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
data = response.json()
assert data["data"]["username"] == "admin"
def test_list_levels():
token = test_login()
response = client.get("/api/v1/classifications/levels", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
data = response.json()
assert len(data["data"]) == 5
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
.page-title[data-v-bd46f99b]{font-size:20px;font-weight:600;margin-bottom:16px;color:#303133}.filter-bar[data-v-bd46f99b]{padding:16px;margin-bottom:16px;display:flex;align-items:center;flex-wrap:wrap;gap:8px}.table-card[data-v-bd46f99b]{padding:16px;background:#fff;border-radius:8px}.pagination-bar[data-v-bd46f99b]{display:flex;justify-content:flex-end;margin-top:16px}.empty-text[data-v-bd46f99b]{color:#c0c4cc}.confidence-high[data-v-bd46f99b]{color:#67c23a;font-weight:600}.confidence-mid[data-v-bd46f99b]{color:#e6a23c;font-weight:600}.confidence-low[data-v-bd46f99b]{color:#f56c6c;font-weight:600}
-2
View File
@@ -1,2 +0,0 @@
import{d as q,n as G,o as n,c as r,a as v,b as a,w as o,D as K,q as p,k as i,E as A,r as c,G as H,F as U,s as j,g as J,I as O,j as d,x as u,T as Q,_ as R}from"./index-DIl-pxgT.js";import{g as B}from"./project-DH-EQEsQ.js";import{g as W}from"./classification-CUKwOuh8.js";const X={class:"page-container"},Y={class:"filter-bar card-shadow"},Z={class:"table-card card-shadow"},$={key:1,class:"empty-text"},ee={key:1,class:"empty-text"},ae={key:1,class:"empty-text"},te={class:"pagination-bar"},le=q({__name:"Classification",setup(oe){const g=i(!1),h=i([]),_=i(1),b=i(50),w=i(0),x=i(void 0),k=i(void 0),m=i(""),z=i([]),V=i([]);function I(l){return l>=.8?"confidence-high":l>=.5?"confidence-mid":"confidence-low"}async function y(){g.value=!0;try{const l={page:_.value,page_size:b.value};m.value&&(l.keyword=m.value);const t=await B(l);h.value=[],w.value=0}catch(l){A.error((l==null?void 0:l.message)||"加载失败")}finally{g.value=!1}}function L(){_.value=1,y()}async function P(){try{const[l,t]=await Promise.all([B({page:1,page_size:100}),W()]);z.value=(l==null?void 0:l.data)||[],V.value=t||[]}catch{}}return G(()=>{P(),y()}),(l,t)=>{const C=c("el-option"),D=c("el-select"),f=c("el-tag"),S=c("el-input"),E=c("el-icon"),F=c("el-button"),s=c("el-table-column"),M=c("el-table"),N=c("el-pagination"),T=H("loading");return n(),r("div",X,[t[6]||(t[6]=v("h2",{class:"page-title"},"分类分级结果",-1)),v("div",Y,[a(D,{modelValue:x.value,"onUpdate:modelValue":t[0]||(t[0]=e=>x.value=e),placeholder:"选择项目",clearable:"",style:{width:"180px"}},{default:o(()=>[(n(!0),r(U,null,j(z.value,e=>(n(),p(C,{key:e.id,label:e.name,value:e.id},null,8,["label","value"]))),128))]),_:1},8,["modelValue"]),a(D,{modelValue:k.value,"onUpdate:modelValue":t[1]||(t[1]=e=>k.value=e),placeholder:"选择分级",clearable:"",style:{width:"140px","margin-left":"12px"}},{default:o(()=>[(n(!0),r(U,null,j(V.value,e=>(n(),p(C,{key:e.id,label:e.name,value:e.id},{default:o(()=>[a(f,{size:"small",color:e.color,effect:"dark"},{default:o(()=>[d(u(e.code),1)]),_:2},1032,["color"]),d(" "+u(e.name),1)]),_:2},1032,["label","value"]))),128))]),_:1},8,["modelValue"]),a(S,{modelValue:m.value,"onUpdate:modelValue":t[2]||(t[2]=e=>m.value=e),placeholder:"搜索字段名/注释",clearable:"",style:{width:"200px","margin-left":"12px"}},null,8,["modelValue"]),a(F,{type:"primary",style:{"margin-left":"12px"},onClick:L},{default:o(()=>[a(E,null,{default:o(()=>[a(J(O))]),_:1}),t[5]||(t[5]=d("查询 ",-1))]),_:1})]),v("div",Z,[K((n(),p(M,{data:h.value,stripe:"",size:"default",border:""},{default:o(()=>[a(s,{prop:"column_name",label:"字段名","min-width":"140"}),a(s,{prop:"data_type",label:"类型",width:"100"}),a(s,{prop:"comment",label:"注释","min-width":"150","show-overflow-tooltip":""}),a(s,{prop:"table_name",label:"所属表",width:"140"}),a(s,{prop:"database_name",label:"所属库",width:"120"}),a(s,{prop:"source_name",label:"数据源",width:"120"}),a(s,{label:"分类",width:"140"},{default:o(({row:e})=>[e.category_name?(n(),p(f,{key:0,size:"small",type:"info"},{default:o(()=>[d(u(e.category_name),1)]),_:2},1024)):(n(),r("span",$,"--"))]),_:1}),a(s,{label:"分级",width:"100"},{default:o(({row:e})=>[e.level_name?(n(),p(f,{key:0,size:"small",color:e.level_color,effect:"dark"},{default:o(()=>[d(u(e.level_name),1)]),_:2},1032,["color"])):(n(),r("span",ee,"--"))]),_:1}),a(s,{label:"来源",width:"90"},{default:o(({row:e})=>[a(f,{size:"small",type:e.source==="auto"?"warning":"success"},{default:o(()=>[d(u(e.source==="auto"?"自动":"人工"),1)]),_:2},1032,["type"])]),_:1}),a(s,{prop:"confidence",label:"置信度",width:"90"},{default:o(({row:e})=>[e.confidence>0?(n(),r("span",{key:0,class:Q(I(e.confidence))},u((e.confidence*100).toFixed(0))+"% ",3)):(n(),r("span",ae,"--"))]),_:1})]),_:1},8,["data"])),[[T,g.value]]),v("div",te,[a(N,{"current-page":_.value,"onUpdate:currentPage":t[3]||(t[3]=e=>_.value=e),"page-size":b.value,"onUpdate:pageSize":t[4]||(t[4]=e=>b.value=e),total:w.value,layout:"total, prev, pager, next",onChange:y},null,8,["current-page","page-size","total"])])])])}}}),ce=R(le,[["__scopeId","data-v-bd46f99b"]]);export{ce as default};
//# sourceMappingURL=Classification-DrtlaA73.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
.dashboard .page-title[data-v-ad5b3874]{font-size:20px;font-weight:600;margin-bottom:20px;color:#303133}.stat-row[data-v-ad5b3874]{margin-bottom:16px}.stat-card[data-v-ad5b3874]{display:flex;align-items:center;padding:20px;margin-bottom:16px}.stat-card .stat-icon[data-v-ad5b3874]{width:56px;height:56px;border-radius:12px;display:flex;align-items:center;justify-content:center;margin-right:16px;flex-shrink:0}.stat-card .stat-info .stat-value[data-v-ad5b3874]{font-size:24px;font-weight:700;color:#303133;line-height:1.2}.stat-card .stat-info .stat-label[data-v-ad5b3874]{font-size:13px;color:#909399;margin-top:4px}.chart-row[data-v-ad5b3874]{margin-bottom:16px}.chart-card[data-v-ad5b3874]{padding:20px;margin-bottom:16px}.chart-card .chart-title[data-v-ad5b3874]{font-size:16px;font-weight:600;margin-bottom:16px;color:#303133}.chart-card .chart[data-v-ad5b3874]{width:100%;height:300px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
import{d as H,u as J,n as K,p as O,o as u,q as i,w as e,r as a,k as L,a as l,b as t,g as c,h as R,c as B,s as D,F as I,t as N,v as Q,x as p,y as X,z as Y,j as Z,A as v,f as ee,B as te,C as S,_ as oe}from"./index-DIl-pxgT.js";const ne={class:"logo"},ae={class:"header-left"},le={class:"header-title"},se={class:"header-right"},de={class:"user-info"},ue={class:"username"},re={class:"logo"},ie=H({__name:"Layout",setup(ce){const w=te(),h=ee(),_=J(),m=L(!1),x=L(window.innerWidth),g=v(()=>x.value<768),y=v(()=>w.path),U=v(()=>{var s;return((s=w.meta)==null?void 0:s.title)||"PropDataGuard"}),b=v(()=>{const s=h.getRoutes().find(o=>o.name==="Layout");return(s==null?void 0:s.children.filter(o=>{var d;return(d=o.meta)==null?void 0:d.title}))||[]});function E(s){s==="logout"&&(_.logout(),h.push("/login"))}function k(){x.value=window.innerWidth}return K(()=>{window.addEventListener("resize",k),!_.userInfo&&_.token&&_.fetchUserInfo()}),O(()=>{window.removeEventListener("resize",k)}),(s,o)=>{const d=a("el-icon"),z=a("el-menu-item"),C=a("el-menu"),G=a("el-aside"),P=a("el-button"),W=a("el-avatar"),F=a("el-dropdown-item"),M=a("el-dropdown-menu"),T=a("el-dropdown"),$=a("el-header"),j=a("router-view"),q=a("el-main"),V=a("el-container"),A=a("el-drawer");return u(),i(V,{class:"layout-container"},{default:e(()=>[g.value?N("",!0):(u(),i(G,{key:0,width:"220px",class:"layout-aside"},{default:e(()=>[l("div",ne,[t(d,{size:"28",color:"#fff"},{default:e(()=>[t(c(R))]),_:1}),o[3]||(o[3]=l("span",{class:"logo-text"},"PropDataGuard",-1))]),t(C,{"default-active":y.value,router:"","background-color":"#1a2b4a","text-color":"#b0c4de","active-text-color":"#fff",class:"layout-menu"},{default:e(()=>[(u(!0),B(I,null,D(b.value,n=>(u(),i(z,{key:n.path,index:n.path},{default:e(()=>{var r;return[t(d,null,{default:e(()=>{var f;return[(u(),i(S((f=n.meta)==null?void 0:f.icon)))]}),_:2},1024),l("span",null,p((r=n.meta)==null?void 0:r.title),1)]}),_:2},1032,["index"]))),128))]),_:1},8,["default-active"])]),_:1})),t(V,{direction:"vertical"},{default:e(()=>[t($,{class:"layout-header",height:"56px"},{default:e(()=>[l("div",ae,[g.value?(u(),i(P,{key:0,type:"primary",text:"",onClick:o[0]||(o[0]=n=>m.value=!0)},{default:e(()=>[t(d,{size:"22"},{default:e(()=>[t(c(Q))]),_:1})]),_:1})):N("",!0),l("span",le,p(U.value),1)]),l("div",se,[t(T,{onCommand:E},{dropdown:e(()=>[t(M,null,{default:e(()=>[t(F,{command:"logout"},{default:e(()=>[...o[4]||(o[4]=[Z("退出登录",-1)])]),_:1})]),_:1})]),default:e(()=>{var n,r;return[l("span",de,[t(W,{size:32,icon:c(X)},null,8,["icon"]),l("span",ue,p(((n=c(_).userInfo)==null?void 0:n.real_name)||((r=c(_).userInfo)==null?void 0:r.username)),1),t(d,null,{default:e(()=>[t(c(Y))]),_:1})])]}),_:1})])]),_:1}),t(q,{class:"layout-main"},{default:e(()=>[t(j)]),_:1})]),_:1}),t(A,{modelValue:m.value,"onUpdate:modelValue":o[2]||(o[2]=n=>m.value=n),direction:"ltr",size:"220px","with-header":!1,class:"mobile-drawer"},{default:e(()=>[l("div",re,[t(d,{size:"28",color:"#fff"},{default:e(()=>[t(c(R))]),_:1}),o[5]||(o[5]=l("span",{class:"logo-text"},"PropDataGuard",-1))]),t(C,{"default-active":y.value,router:"","background-color":"#1a2b4a","text-color":"#b0c4de","active-text-color":"#fff",class:"layout-menu",onSelect:o[1]||(o[1]=n=>m.value=!1)},{default:e(()=>[(u(!0),B(I,null,D(b.value,n=>(u(),i(z,{key:n.path,index:n.path},{default:e(()=>{var r;return[t(d,null,{default:e(()=>{var f;return[(u(),i(S((f=n.meta)==null?void 0:f.icon)))]}),_:2},1024),l("span",null,p((r=n.meta)==null?void 0:r.title),1)]}),_:2},1032,["index"]))),128))]),_:1},8,["default-active"])]),_:1},8,["modelValue"])]),_:1})}}}),fe=oe(ie,[["__scopeId","data-v-6b05a74f"]]);export{fe as default};
//# sourceMappingURL=Layout-DzZsTvlW.js.map
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
import{d as V,u as k,o as h,c as C,a,b as o,w as l,e as B,r as n,f as E,g as u,h as L,i as N,l as R,j as U,k as f,m as q,E as _,_ as z}from"./index-DIl-pxgT.js";const K={class:"login-page"},S={class:"login-box card-shadow"},j={class:"login-header"},D=V({__name:"Login",setup(G){const g=E(),w=k(),r=f(!1),p=f(),s=q({username:"admin",password:"admin123"}),v={username:[{required:!0,message:"请输入用户名",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"}]};async function c(){var e;if(await((e=p.value)==null?void 0:e.validate().catch(()=>!1))){r.value=!0;try{await w.login(s.username,s.password),_.success("登录成功"),g.push("/")}catch(t){_.error((t==null?void 0:t.message)||"登录失败")}finally{r.value=!1}}}return(b,e)=>{const t=n("el-icon"),m=n("el-input"),i=n("el-form-item"),x=n("el-button"),y=n("el-form");return h(),C("div",K,[a("div",S,[a("div",j,[o(t,{size:"48",color:"#1a2b4a"},{default:l(()=>[o(u(L))]),_:1}),e[2]||(e[2]=a("h1",{class:"login-title"},"PropDataGuard",-1)),e[3]||(e[3]=a("p",{class:"login-subtitle"},"财险数据分级分类管理平台",-1))]),o(y,{ref_key:"formRef",ref:p,model:s,rules:v,size:"large",onKeyup:B(c,["enter"])},{default:l(()=>[o(i,{prop:"username"},{default:l(()=>[o(m,{modelValue:s.username,"onUpdate:modelValue":e[0]||(e[0]=d=>s.username=d),placeholder:"用户名","prefix-icon":u(N),clearable:""},null,8,["modelValue","prefix-icon"])]),_:1}),o(i,{prop:"password"},{default:l(()=>[o(m,{modelValue:s.password,"onUpdate:modelValue":e[1]||(e[1]=d=>s.password=d),type:"password",placeholder:"密码","prefix-icon":u(R),"show-password":"",clearable:""},null,8,["modelValue","prefix-icon"])]),_:1}),o(i,null,{default:l(()=>[o(x,{type:"primary",loading:r.value,class:"login-btn",onClick:c},{default:l(()=>[...e[4]||(e[4]=[U(" 登录 ",-1)])]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"]),e[5]||(e[5]=a("div",{class:"login-footer"},[a("p",null,"默认管理员:admin / admin123")],-1))])])}}}),M=z(D,[["__scopeId","data-v-8c2e034d"]]);export{M as default};
//# sourceMappingURL=Login-Blg9KWw-.js.map
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
import{K as T,d as I,L as D,n as O,o,c as v,a as c,b as a,w as l,E as A,r as d,k as i,q as u,g as b,M as F,N as H,O as J,x as p,t as k,j as B,D as P,G as Q,_ as W}from"./index-DIl-pxgT.js";function X(f){return T.get("/metadata/tree",{params:{source_id:f}})}function Y(f){return T.get("/metadata/columns",{params:f})}const Z={class:"page-container metadata-page"},$={class:"tree-card card-shadow"},ee={class:"custom-tree-node"},te={class:"node-label"},ae={key:3,class:"node-badge"},le={class:"detail-card card-shadow"},oe={class:"detail-header"},se={class:"detail-title"},ne={key:0},ce={key:1,class:"placeholder"},de={class:"sample-text"},re=I({__name:"Metadata",setup(f){const x=i([]),y=i(""),C=i(),s=i(null),V=i([]),w=i(!1),h=i("");D(y,e=>{var t;(t=C.value)==null||t.filter(e)});function E(e,t){return e?t.name.toLowerCase().includes(e.toLowerCase()):!0}async function K(){try{const e=await X();x.value=e||[]}catch(e){A.error((e==null?void 0:e.message)||"加载失败")}}async function q(e){e.type==="table"&&(s.value=e,await z(e.id))}async function z(e){w.value=!0;try{const t=await Y({table_id:e,keyword:h.value||void 0,page:1,page_size:500});V.value=t.data||[]}finally{w.value=!1}}return D(h,()=>{s.value&&z(s.value.id)}),O(K),(e,t)=>{const N=d("el-input"),g=d("el-icon"),R=d("el-tree"),L=d("el-col"),M=d("el-tag"),m=d("el-table-column"),S=d("el-table"),U=d("el-empty"),j=d("el-row"),G=Q("loading");return o(),v("div",Z,[t[3]||(t[3]=c("h2",{class:"page-title"},"数据资产",-1)),a(j,{gutter:16,class:"content-row"},{default:l(()=>[a(L,{xs:24,md:7,lg:6},{default:l(()=>[c("div",$,[t[2]||(t[2]=c("div",{class:"tree-header"},"资产目录",-1)),a(N,{modelValue:y.value,"onUpdate:modelValue":t[0]||(t[0]=_=>y.value=_),placeholder:"搜索表名",clearable:"",size:"small",class:"tree-search"},null,8,["modelValue"]),a(R,{data:x.value,props:{children:"children",label:"name"},"filter-node-method":E,ref_key:"treeRef",ref:C,"highlight-current":"","default-expand-all":"",onNodeClick:q},{default:l(({node:_,data:r})=>{var n;return[c("span",ee,[r.type==="source"?(o(),u(g,{key:0,size:"14"},{default:l(()=>[a(b(F))]),_:1})):r.type==="database"?(o(),u(g,{key:1,size:"14"},{default:l(()=>[a(b(H))]),_:1})):(o(),u(g,{key:2,size:"14"},{default:l(()=>[a(b(J))]),_:1})),c("span",te,p(_.label),1),r.type==="table"&&((n=r.meta)!=null&&n.column_count)?(o(),v("span",ae,p(r.meta.column_count),1)):k("",!0)])]}),_:1},8,["data"])])]),_:1}),a(L,{xs:24,md:17,lg:18},{default:l(()=>{var _,r;return[c("div",le,[c("div",oe,[c("div",se,[s.value?(o(),v("span",ne,p(s.value.name),1)):(o(),v("span",ce,"请从左侧选择数据表")),(r=(_=s.value)==null?void 0:_.meta)!=null&&r.row_count?(o(),u(M,{key:2,size:"small",type:"info"},{default:l(()=>[B(" 约 "+p(s.value.meta.row_count.toLocaleString())+" 行 ",1)]),_:1})):k("",!0)]),s.value?(o(),u(N,{key:0,modelValue:h.value,"onUpdate:modelValue":t[1]||(t[1]=n=>h.value=n),placeholder:"搜索字段",clearable:"",size:"small",style:{width:"200px"}},null,8,["modelValue"])):k("",!0)]),s.value?P((o(),u(S,{key:0,data:V.value,stripe:"",size:"default",height:"calc(100vh - 280px)"},{default:l(()=>[a(m,{prop:"name",label:"字段名","min-width":"140"}),a(m,{prop:"data_type",label:"类型",width:"120"}),a(m,{prop:"comment",label:"注释","min-width":"180","show-overflow-tooltip":""}),a(m,{prop:"is_nullable",label:"可空",width:"80"},{default:l(({row:n})=>[a(M,{size:"small",type:n.is_nullable?"info":"success"},{default:l(()=>[B(p(n.is_nullable?"是":"否"),1)]),_:2},1032,["type"])]),_:1}),a(m,{prop:"sample_data",label:"采样数据","min-width":"200","show-overflow-tooltip":""},{default:l(({row:n})=>[c("span",de,p(n.sample_data),1)]),_:1})]),_:1},8,["data"])),[[G,w.value]]):(o(),u(U,{key:1,description:"请选择左侧数据表查看字段详情"}))])]}),_:1})]),_:1})])}}}),ue=W(re,[["__scopeId","data-v-866ea4fc"]]);export{ue as default};
//# sourceMappingURL=Metadata-DQHjLzoq.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
.page-title[data-v-5f4e75fc]{font-size:20px;font-weight:600;margin-bottom:20px;color:#303133}.chart-row[data-v-5f4e75fc]{margin-bottom:16px}.chart-card[data-v-5f4e75fc]{padding:20px;margin-bottom:16px;background:#fff;border-radius:8px}.chart-card .chart-title[data-v-5f4e75fc]{font-size:16px;font-weight:600;margin-bottom:16px;color:#303133}.chart-card .chart[data-v-5f4e75fc]{width:100%;height:300px}
-2
View File
@@ -1,2 +0,0 @@
import{d as I,n as S,o as d,c as y,a as i,b as e,w as a,k as c,E as U,r as s,g as E,I as L,j as _,D as N,q as w,x as m,F as j,s as F,G as M,_ as T}from"./index-DIl-pxgT.js";const $={class:"page-container"},q={class:"table-card card-shadow"},A={class:"table-header"},G={class:"table-card card-shadow"},K=I({__name:"System",setup(R){const f=c("users"),b=c([]),u=c(!1),p=c("");async function v(){u.value=!0;try{const t=await(await fetch(`/api/v1/users?keyword=${encodeURIComponent(p.value)}`,{headers:{Authorization:`Bearer ${localStorage.getItem("pdg_token")||""}`}})).json();b.value=t.data||[]}catch(n){U.error((n==null?void 0:n.message)||"加载失败")}finally{u.value=!1}}return S(v),(n,t)=>{const V=s("el-input"),k=s("el-icon"),x=s("el-button"),o=s("el-table-column"),g=s("el-tag"),z=s("el-table"),h=s("el-tab-pane"),B=s("el-empty"),C=s("el-tabs"),D=M("loading");return d(),y("div",$,[t[3]||(t[3]=i("h2",{class:"page-title"},"系统管理",-1)),e(C,{modelValue:f.value,"onUpdate:modelValue":t[1]||(t[1]=l=>f.value=l),class:"system-tabs"},{default:a(()=>[e(h,{label:"用户管理",name:"users"},{default:a(()=>[i("div",q,[i("div",A,[e(V,{modelValue:p.value,"onUpdate:modelValue":t[0]||(t[0]=l=>p.value=l),placeholder:"搜索用户",clearable:"",style:{width:"220px"}},null,8,["modelValue"]),e(x,{type:"primary",size:"small",onClick:v},{default:a(()=>[e(k,null,{default:a(()=>[e(E(L))]),_:1}),t[2]||(t[2]=_("查询 ",-1))]),_:1})]),N((d(),w(z,{data:b.value,stripe:"",size:"default"},{default:a(()=>[e(o,{prop:"username",label:"用户名","min-width":"120"}),e(o,{prop:"real_name",label:"真实姓名","min-width":"120"}),e(o,{prop:"email",label:"邮箱","min-width":"160"}),e(o,{prop:"dept.name",label:"部门","min-width":"120"},{default:a(({row:l})=>{var r;return[_(m(((r=l.dept)==null?void 0:r.name)||"--"),1)]}),_:1}),e(o,{prop:"roles",label:"角色","min-width":"180"},{default:a(({row:l})=>[(d(!0),y(j,null,F(l.roles,r=>(d(),w(g,{key:r.id,size:"small",style:{"margin-right":"4px"}},{default:a(()=>[_(m(r.name),1)]),_:2},1024))),128))]),_:1}),e(o,{prop:"is_active",label:"状态",width:"90"},{default:a(({row:l})=>[e(g,{type:l.is_active?"success":"danger",size:"small"},{default:a(()=>[_(m(l.is_active?"正常":"禁用"),1)]),_:2},1032,["type"])]),_:1})]),_:1},8,["data"])),[[D,u.value]])])]),_:1}),e(h,{label:"操作日志",name:"logs"},{default:a(()=>[i("div",G,[e(B,{description:"日志功能开发中"})])]),_:1})]),_:1},8,["modelValue"])])}}}),J=T(K,[["__scopeId","data-v-d05c8e34"]]);export{J as default};
//# sourceMappingURL=System-BEPbsg-j.js.map
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
.page-title[data-v-ac23f7b2]{font-size:20px;font-weight:600;margin-bottom:16px;color:#303133}.task-tabs[data-v-ac23f7b2]{background:#fff;padding:16px;border-radius:8px}.label-header[data-v-ac23f7b2]{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.empty-text[data-v-ac23f7b2]{color:#c0c4cc}.inline-select[data-v-ac23f7b2] .el-input__wrapper{padding:0 4px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
import{K as t}from"./index-DIl-pxgT.js";function i(){return t.get("/classifications/categories/tree")}function n(e){return t.post("/classifications/categories",e)}function r(e,s){return t.put(`/classifications/categories/${e}`,s)}function c(e){return t.delete(`/classifications/categories/${e}`)}function u(){return t.get("/classifications/levels")}function o(e){return t.get("/classifications/rules",{params:e})}function l(e){return t.post("/classifications/rules",e)}function f(e,s){return t.put(`/classifications/rules/${e}`,s)}function g(e){return t.delete(`/classifications/rules/${e}`)}function p(){return t.get("/classifications/templates")}export{i as a,o as b,p as c,n as d,f as e,l as f,u as g,g as h,c as i,r as u};
//# sourceMappingURL=classification-CUKwOuh8.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"classification-CUKwOuh8.js","sources":["../../src/api/classification.ts"],"sourcesContent":["import request from './request'\n\nexport interface CategoryItem {\n id: number\n parent_id?: number\n level: number\n code: string\n name: string\n description?: string\n sort_order: number\n children?: CategoryItem[]\n}\n\nexport interface DataLevel {\n id: number\n code: string\n name: string\n description?: string\n color: string\n control_requirements?: Record<string, any>\n}\n\nexport interface RecognitionRule {\n id: number\n template_id: number\n category_id?: number\n level_id?: number\n rule_type: string\n rule_name?: string\n rule_content: string\n target_field: string\n priority: number\n is_active: boolean\n hit_count: number\n category_name?: string\n level_name?: string\n level_color?: string\n}\n\nexport function getCategoryTree() {\n return request.get('/classifications/categories/tree')\n}\n\nexport function createCategory(data: Partial<CategoryItem>) {\n return request.post('/classifications/categories', data)\n}\n\nexport function updateCategory(id: number, data: Partial<CategoryItem>) {\n return request.put(`/classifications/categories/${id}`, data)\n}\n\nexport function deleteCategory(id: number) {\n return request.delete(`/classifications/categories/${id}`)\n}\n\nexport function getDataLevels() {\n return request.get('/classifications/levels')\n}\n\nexport function getRules(params?: { template_id?: number; keyword?: string; page?: number; page_size?: number }) {\n return request.get('/classifications/rules', { params })\n}\n\nexport function createRule(data: Partial<RecognitionRule>) {\n return request.post('/classifications/rules', data)\n}\n\nexport function updateRule(id: number, data: Partial<RecognitionRule>) {\n return request.put(`/classifications/rules/${id}`, data)\n}\n\nexport function deleteRule(id: number) {\n return request.delete(`/classifications/rules/${id}`)\n}\n\nexport function getTemplates() {\n return request.get('/classifications/templates')\n}\n"],"names":["getCategoryTree","request","createCategory","data","updateCategory","id","deleteCategory","getDataLevels","getRules","params","createRule","updateRule","deleteRule","getTemplates"],"mappings":"wCAuCO,SAASA,GAAkB,CAChC,OAAOC,EAAQ,IAAI,kCAAkC,CACvD,CAEO,SAASC,EAAeC,EAA6B,CAC1D,OAAOF,EAAQ,KAAK,8BAA+BE,CAAI,CACzD,CAEO,SAASC,EAAeC,EAAYF,EAA6B,CACtE,OAAOF,EAAQ,IAAI,+BAA+BI,CAAE,GAAIF,CAAI,CAC9D,CAEO,SAASG,EAAeD,EAAY,CACzC,OAAOJ,EAAQ,OAAO,+BAA+BI,CAAE,EAAE,CAC3D,CAEO,SAASE,GAAgB,CAC9B,OAAON,EAAQ,IAAI,yBAAyB,CAC9C,CAEO,SAASO,EAASC,EAAwF,CAC/G,OAAOR,EAAQ,IAAI,yBAA0B,CAAE,OAAAQ,EAAQ,CACzD,CAEO,SAASC,EAAWP,EAAgC,CACzD,OAAOF,EAAQ,KAAK,yBAA0BE,CAAI,CACpD,CAEO,SAASQ,EAAWN,EAAYF,EAAgC,CACrE,OAAOF,EAAQ,IAAI,0BAA0BI,CAAE,GAAIF,CAAI,CACzD,CAEO,SAASS,EAAWP,EAAY,CACrC,OAAOJ,EAAQ,OAAO,0BAA0BI,CAAE,EAAE,CACtD,CAEO,SAASQ,GAAe,CAC7B,OAAOZ,EAAQ,IAAI,4BAA4B,CACjD"}
-2
View File
@@ -1,2 +0,0 @@
import{K as e}from"./index-DIl-pxgT.js";function n(t){return e.get("/datasources",{params:t})}function r(t){return e.post("/datasources",t)}function o(t,a){return e.put(`/datasources/${t}`,a)}function u(t){return e.delete(`/datasources/${t}`)}function c(t){return e.post("/datasources/test-connection",t)}function d(t){return e.post(`/metadata/sync/${t}`)}export{r as c,u as d,n as g,d as s,c as t,o as u};
//# sourceMappingURL=datasource-idO2fJD3.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"datasource-idO2fJD3.js","sources":["../../src/api/datasource.ts"],"sourcesContent":["import request from './request'\n\nexport interface DataSourceItem {\n id: number\n name: string\n source_type: string\n host?: string\n port?: number\n database_name?: string\n username?: string\n status: string\n created_at: string\n}\n\nexport interface DataSourceForm {\n name: string\n source_type: string\n host?: string\n port?: number\n database_name?: string\n username?: string\n password?: string\n extra_params?: string\n dept_id?: number\n}\n\nexport function getDataSources(params?: { page?: number; page_size?: number; keyword?: string }) {\n return request.get('/datasources', { params })\n}\n\nexport function createDataSource(data: DataSourceForm) {\n return request.post('/datasources', data)\n}\n\nexport function updateDataSource(id: number, data: Partial<DataSourceForm>) {\n return request.put(`/datasources/${id}`, data)\n}\n\nexport function deleteDataSource(id: number) {\n return request.delete(`/datasources/${id}`)\n}\n\nexport function testConnection(data: Partial<DataSourceForm>) {\n return request.post('/datasources/test-connection', data)\n}\n\nexport function syncMetadata(sourceId: number) {\n return request.post(`/metadata/sync/${sourceId}`)\n}\n"],"names":["getDataSources","params","request","createDataSource","data","updateDataSource","id","deleteDataSource","testConnection","syncMetadata","sourceId"],"mappings":"wCA0BO,SAASA,EAAeC,EAAkE,CAC/F,OAAOC,EAAQ,IAAI,eAAgB,CAAE,OAAAD,EAAQ,CAC/C,CAEO,SAASE,EAAiBC,EAAsB,CACrD,OAAOF,EAAQ,KAAK,eAAgBE,CAAI,CAC1C,CAEO,SAASC,EAAiBC,EAAYF,EAA+B,CAC1E,OAAOF,EAAQ,IAAI,gBAAgBI,CAAE,GAAIF,CAAI,CAC/C,CAEO,SAASG,EAAiBD,EAAY,CAC3C,OAAOJ,EAAQ,OAAO,gBAAgBI,CAAE,EAAE,CAC5C,CAEO,SAASE,EAAeJ,EAA+B,CAC5D,OAAOF,EAAQ,KAAK,+BAAgCE,CAAI,CAC1D,CAEO,SAASK,EAAaC,EAAkB,CAC7C,OAAOR,EAAQ,KAAK,kBAAkBQ,CAAQ,EAAE,CAClD"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
import{K as e}from"./index-DIl-pxgT.js";function o(t){return e.get("/projects",{params:t})}function s(t){return e.post("/projects",null,{params:t})}function c(t){return e.delete(`/projects/${t}`)}function a(t){return e.post(`/projects/${t}/auto-classify`)}export{a,s as c,c as d,o as g};
//# sourceMappingURL=project-DH-EQEsQ.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"project-DH-EQEsQ.js","sources":["../../src/api/project.ts"],"sourcesContent":["import request from './request'\n\nexport interface ProjectItem {\n id: number\n name: string\n template_id: number\n status: string\n description?: string\n target_source_ids?: string\n planned_start?: string\n planned_end?: string\n created_at: string\n stats?: {\n total: number\n auto: number\n manual: number\n reviewed: number\n }\n}\n\nexport function getProjects(params?: { page?: number; page_size?: number; keyword?: string }) {\n return request.get('/projects', { params })\n}\n\nexport function getProject(id: number) {\n return request.get(`/projects/${id}`)\n}\n\nexport function createProject(data: { name: string; template_id: number; target_source_ids?: string; description?: string }) {\n return request.post('/projects', null, { params: data })\n}\n\nexport function deleteProject(id: number) {\n return request.delete(`/projects/${id}`)\n}\n\nexport function autoClassifyProject(id: number) {\n return request.post(`/projects/${id}/auto-classify`)\n}\n"],"names":["getProjects","params","request","createProject","data","deleteProject","id","autoClassifyProject"],"mappings":"wCAoBO,SAASA,EAAYC,EAAkE,CAC5F,OAAOC,EAAQ,IAAI,YAAa,CAAE,OAAAD,EAAQ,CAC5C,CAMO,SAASE,EAAcC,EAA+F,CAC3H,OAAOF,EAAQ,KAAK,YAAa,KAAM,CAAE,OAAQE,EAAM,CACzD,CAEO,SAASC,EAAcC,EAAY,CACxC,OAAOJ,EAAQ,OAAO,aAAaI,CAAE,EAAE,CACzC,CAEO,SAASC,EAAoBD,EAAY,CAC9C,OAAOJ,EAAQ,KAAK,aAAaI,CAAE,gBAAgB,CACrD"}
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>PropDataGuard - 财险数据分级分类平台</title> <title>PropDataGuard - 财险数据分级分类平台</title>
<script type="module" crossorigin src="/assets/index-DIl-pxgT.js"></script> <script type="module" crossorigin src="/assets/index-DveMB2K5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-s_XEM0GP.css"> <link rel="stylesheet" crossorigin href="/assets/index-s_XEM0GP.css">
</head> </head>
<body> <body>
+9
View File
@@ -57,3 +57,12 @@ DEPRECATION WARNING [legacy-js-api]: The legacy JS API is deprecated and will be
More info: https://sass-lang.com/d/legacy-js-api More info: https://sass-lang.com/d/legacy-js-api
17:11:45 [vite] hmr update /src/views/task/Task.vue
17:13:45 [vite] hmr update /src/views/task/Task.vue, /src/views/task/Task.vue?vue&type=style&index=0&scoped=71d31924&lang.scss
DEPRECATION WARNING [legacy-js-api]: The legacy JS API is deprecated and will be removed in Dart Sass 2.0.0.
More info: https://sass-lang.com/d/legacy-js-api
17:15:44 [vite] hmr update /src/views/dashboard/Dashboard.vue, /src/views/dashboard/Dashboard.vue?vue&type=style&index=0&scoped=1c6c8216&lang.scss
17:18:49 [vite] hmr update /src/views/report/Report.vue, /src/views/report/Report.vue?vue&type=style&index=0&scoped=977d41b6&lang.scss
17:19:54 [vite] hmr update /src/views/classification/Classification.vue
+19 -2
View File
@@ -4,6 +4,7 @@ export interface TaskItem {
id: number id: number
name: string name: string
project_id: number project_id: number
project_name?: string
status: string status: string
deadline?: string deadline?: string
created_at: string created_at: string
@@ -32,6 +33,22 @@ export function getMyTasks(params?: { status?: string }) {
return request.get('/tasks/my-tasks', { params }) return request.get('/tasks/my-tasks', { params })
} }
export function getTaskItems(taskId: number) { export function getTaskItems(taskId: number, params?: { keyword?: string }) {
return request.get(`/tasks/my-tasks/${taskId}/items`) return request.get(`/tasks/my-tasks/${taskId}/items`, { params })
}
export function startTask(taskId: number) {
return request.post(`/tasks/my-tasks/${taskId}/start`)
}
export function completeTask(taskId: number) {
return request.post(`/tasks/my-tasks/${taskId}/complete`)
}
export function labelResult(resultId: number, data: { category_id: number; level_id: number }) {
return request.post(`/tasks/results/${resultId}/label`, null, { params: data })
}
export function createTask(projectId: number, data: { name: string; assignee_id: number; target_type?: string }) {
return request.post(`/tasks/projects/${projectId}/create-task`, null, { params: data })
} }
@@ -76,6 +76,7 @@ import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import { getProjects } from '@/api/project' import { getProjects } from '@/api/project'
import { getDataLevels } from '@/api/classification' import { getDataLevels } from '@/api/classification'
import { getTaskItems } from '@/api/task'
const loading = ref(false) const loading = ref(false)
const resultList = ref<any[]>([]) const resultList = ref<any[]>([])
@@ -99,12 +100,14 @@ function confidenceClass(v: number) {
async function fetchData() { async function fetchData() {
loading.value = true loading.value = true
try { try {
// Use project API to get results (simplified for demo) if (!filterProjectId.value) {
const params: any = { page: page.value, page_size: pageSize.value } resultList.value = []
if (filterKeyword.value) params.keyword = filterKeyword.value total.value = 0
// In full implementation, call dedicated results API return
const res: any = await getProjects(params) }
// Mock data for demo const res: any = await getTaskItems(-1) // use a special endpoint or fetch from project
// Actually we need a dedicated results API. For now, fetch all items for project via task workaround
// In real implementation, call: GET /api/v1/projects/{id}/results
resultList.value = [] resultList.value = []
total.value = 0 total.value = 0
} catch (e: any) { } catch (e: any) {
@@ -131,7 +134,6 @@ async function fetchMeta() {
onMounted(() => { onMounted(() => {
fetchMeta() fetchMeta()
fetchData()
}) })
</script> </script>
+50 -6
View File
@@ -85,18 +85,28 @@
</div> </div>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="16" class="chart-row">
<el-col :xs="24">
<div class="chart-card card-shadow">
<div class="chart-title">敏感数据分布热力图按数据源</div>
<v-chart class="chart" :option="heatmapOption" autoresize />
</div>
</el-col>
</el-row>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { use } from 'echarts/core' import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { PieChart, BarChart } from 'echarts/charts' import { PieChart, BarChart, HeatmapChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent, GridComponent } from 'echarts/components' import { TooltipComponent, LegendComponent, GridComponent, VisualMapComponent } from 'echarts/components'
import VChart from 'vue-echarts' import VChart from 'vue-echarts'
import { getProjects } from '@/api/project'
use([CanvasRenderer, PieChart, BarChart, TooltipComponent, LegendComponent, GridComponent]) use([CanvasRenderer, PieChart, BarChart, HeatmapChart, TooltipComponent, LegendComponent, GridComponent, VisualMapComponent])
const stats = reactive({ const stats = reactive({
dataSources: 12, dataSources: 12,
@@ -144,6 +154,20 @@ const categoryOption = ref({
], ],
}) })
const heatmapOption = ref({
tooltip: { position: 'top' },
grid: { height: '50%', top: '10%' },
xAxis: { type: 'category', data: ['L1', 'L2', 'L3', 'L4', 'L5'], splitArea: { show: true } },
yAxis: { type: 'category', data: ['核心系统', '理赔系统', '保单系统', '财务系统', '渠道系统'], splitArea: { show: true } },
visualMap: { min: 0, max: 10000, calculable: true, orient: 'horizontal', left: 'center', bottom: '15%', inRange: { color: ['#e0f3f8', '#abd9e9', '#74add1', '#4575b4', '#313695'] } },
series: [{
type: 'heatmap',
data: [[0,0,500],[0,1,1200],[0,2,800],[0,3,600],[0,4,400],[1,0,2000],[1,1,3500],[1,2,2800],[1,3,2200],[1,4,1800],[2,0,1500],[2,1,4200],[2,2,3100],[2,3,2600],[2,4,1400],[3,0,800],[3,1,5800],[3,2,2100],[3,3,1900],[3,4,1200],[4,0,200],[4,1,900],[4,2,400],[4,3,300],[4,4,100]],
label: { show: true },
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' } }
}]
})
const projectList = ref([ const projectList = ref([
{ name: '2024年数据分类分级专项', status: 'labeling', progress: 68, planned_end: '2024-08-30' }, { name: '2024年数据分类分级专项', status: 'labeling', progress: 68, planned_end: '2024-08-30' },
{ name: '核心系统敏感数据梳理', status: 'reviewing', progress: 92, planned_end: '2024-07-15' }, { name: '核心系统敏感数据梳理', status: 'reviewing', progress: 92, planned_end: '2024-07-15' },
@@ -175,6 +199,24 @@ function statusText(status: string) {
} }
return map[status] || status return map[status] || status
} }
async function fetchProjects() {
try {
const res: any = await getProjects({ page: 1, page_size: 10 })
if (res?.data?.length) {
projectList.value = res.data.map((p: any) => ({
name: p.name,
status: p.status,
progress: p.stats?.total ? Math.round((p.stats.reviewed / p.stats.total) * 100) : 0,
planned_end: p.planned_end ? p.planned_end.slice(0, 10) : '--',
}))
}
} catch (e) {
// ignore
}
}
onMounted(fetchProjects)
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -195,7 +237,8 @@ function statusText(status: string) {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 20px; padding: 20px;
margin-bottom: 16px; background: #fff;
border-radius: 8px;
.stat-icon { .stat-icon {
width: 56px; width: 56px;
@@ -229,7 +272,8 @@ function statusText(status: string) {
.chart-card { .chart-card {
padding: 20px; padding: 20px;
margin-bottom: 16px; background: #fff;
border-radius: 8px;
.chart-title { .chart-title {
font-size: 16px; font-size: 16px;
+49 -3
View File
@@ -1,6 +1,14 @@
<template> <template>
<div class="page-container"> <div class="page-container">
<div class="page-header">
<h2 class="page-title">报表统计</h2> <h2 class="page-title">报表统计</h2>
<el-select v-model="selectedProject" placeholder="选择项目生成报告" clearable style="width: 260px">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-button type="primary" :disabled="!selectedProject" @click="downloadReport">
<el-icon><Download /></el-icon>下载报告
</el-button>
</div>
<el-row :gutter="16" class="chart-row"> <el-row :gutter="16" class="chart-row">
<el-col :xs="24" :md="12"> <el-col :xs="24" :md="12">
@@ -35,15 +43,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { use } from 'echarts/core' import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { PieChart, BarChart, LineChart } from 'echarts/charts' import { PieChart, BarChart, LineChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent, GridComponent } from 'echarts/components' import { TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import VChart from 'vue-echarts' import VChart from 'vue-echarts'
import { Download } from '@element-plus/icons-vue'
import { getProjects } from '@/api/project'
use([CanvasRenderer, PieChart, BarChart, LineChart, TooltipComponent, LegendComponent, GridComponent]) use([CanvasRenderer, PieChart, BarChart, LineChart, TooltipComponent, LegendComponent, GridComponent])
const selectedProject = ref<number | undefined>(undefined)
const projects = ref<any[]>([])
const levelOption = ref({ const levelOption = ref({
tooltip: { trigger: 'item' }, tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' }, legend: { bottom: '0%', left: 'center' },
@@ -114,14 +127,48 @@ const trendOption = ref({
}, },
], ],
}) })
function downloadReport() {
if (!selectedProject.value) return
const token = localStorage.getItem('pdg_token')
const url = `/api/v1/reports/projects/${selectedProject.value}/download`
const a = document.createElement('a')
a.href = url
a.download = `report_project_${selectedProject.value}.docx`
if (token) {
a.setAttribute('data-token', token)
}
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
async function fetchProjects() {
try {
const res: any = await getProjects({ page: 1, page_size: 100 })
projects.value = res?.data || []
} catch (e) {
// ignore
}
}
onMounted(fetchProjects)
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.page-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
.page-title { .page-title {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
margin-bottom: 20px;
color: #303133; color: #303133;
margin-right: auto;
}
} }
.chart-row { .chart-row {
@@ -130,7 +177,6 @@ const trendOption = ref({
.chart-card { .chart-card {
padding: 20px; padding: 20px;
margin-bottom: 16px;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
+158 -50
View File
@@ -4,80 +4,107 @@
<el-tabs v-model="activeTab" class="task-tabs"> <el-tabs v-model="activeTab" class="task-tabs">
<el-tab-pane label="待处理" name="pending"> <el-tab-pane label="待处理" name="pending">
<TaskTable :tasks="pendingTasks" @refresh="fetchData" /> <TaskTable :tasks="pendingTasks" @refresh="fetchData" @label="openLabel" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="进行中" name="in_progress"> <el-tab-pane label="进行中" name="in_progress">
<TaskTable :tasks="inProgressTasks" @refresh="fetchData" /> <TaskTable :tasks="inProgressTasks" @refresh="fetchData" @label="openLabel" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="已完成" name="completed"> <el-tab-pane label="已完成" name="completed">
<TaskTable :tasks="completedTasks" @refresh="fetchData" /> <TaskTable :tasks="completedTasks" @refresh="fetchData" @label="openLabel" />
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<!-- Label Dialog --> <!-- Label Dialog -->
<el-dialog <el-dialog
v-model="labelDialogVisible" v-model="labelDialogVisible"
title="数据打标" :title="`数据打标 - ${currentTask?.name || ''}`"
width="90%" width="92%"
top="5vh" top="4vh"
destroy-on-close destroy-on-close
class="label-dialog" class="label-dialog"
> >
<div class="label-header"> <div class="label-header">
<div class="label-stats">
<span> {{ labelItems.length }} 个字段</span> <span> {{ labelItems.length }} 个字段</span>
<el-input v-model="labelKeyword" placeholder="搜索字段" clearable size="small" style="width: 200px" /> <el-tag type="success" size="small">已保存: {{ savedCount }}</el-tag>
<el-tag type="warning" size="small">待保存: {{ unsavedCount }}</el-tag>
</div> </div>
<el-table :data="filteredLabelItems" height="60vh" stripe size="default" border> <el-input v-model="labelKeyword" placeholder="搜索字段/表/注释" clearable size="small" style="width: 220px" />
</div>
<el-table
:data="filteredLabelItems"
height="calc(100vh - 260px)"
stripe
size="default"
border
@cell-click="handleCellClick"
>
<el-table-column prop="column_name" label="字段名" width="150" /> <el-table-column prop="column_name" label="字段名" width="150" />
<el-table-column prop="data_type" label="类型" width="100" /> <el-table-column prop="data_type" label="类型" width="90" />
<el-table-column prop="comment" label="注释" min-width="150" show-overflow-tooltip /> <el-table-column prop="comment" label="注释" min-width="140" show-overflow-tooltip />
<el-table-column prop="table_name" label="所属表" width="140" /> <el-table-column prop="table_name" label="所属表" width="130" />
<el-table-column prop="source_name" label="数据源" width="120" /> <el-table-column prop="source_name" label="数据源" width="110" />
<el-table-column label="当前分类" width="140"> <el-table-column label="分类" width="150">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row.category_name" size="small">{{ row.category_name }}</el-tag> <el-select
<span v-else class="empty-text">--</span> v-model="row._category_id"
placeholder="分类"
size="small"
style="width: 130px"
@change="markUnsaved(row)"
>
<el-option
v-for="c in flatCategories"
:key="c.id"
:label="c.name"
:value="c.id"
/>
</el-select>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="当前分级" width="100"> <el-table-column label="分级" width="110">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row.level_name" size="small" :color="row.level_color" effect="dark">{{ row.level_name }}</el-tag> <el-select
<span v-else class="empty-text">--</span> v-model="row._level_id"
placeholder="分级"
size="small"
style="width: 90px"
@change="markUnsaved(row)"
>
<el-option v-for="l in levels" :key="l.id" :value="l.id">
<el-tag size="small" :color="l.color" effect="dark">{{ l.code }}</el-tag>
</el-option>
</el-select>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="来源" width="90"> <el-table-column label="来源" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-tag size="small" :type="row.source === 'auto' ? 'warning' : 'success'"> <el-tag size="small" :type="row.source === 'auto' ? 'warning' : 'success'">
{{ row.source === 'auto' ? '自动' : '人工' }} {{ row.source === 'auto' ? '自动' : '人工' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="置信度" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-select <span v-if="row.confidence > 0" :class="confidenceClass(row.confidence)">
v-model="row._category_id" {{ (row.confidence * 100).toFixed(0) }}%
placeholder="分类" </span>
size="small" <span v-else class="empty-text">--</span>
style="width: 90px" </template>
class="inline-select" </el-table-column>
> <el-table-column label="状态" width="80">
<el-option v-for="c in flatCategories" :key="c.id" :label="c.name" :value="c.id" /> <template #default="{ row }">
</el-select> <el-tag v-if="row._unsaved" size="small" type="danger">待保存</el-tag>
<el-select <el-tag v-else size="small" type="success">已保存</el-tag>
v-model="row._level_id"
placeholder="分级"
size="small"
style="width: 70px; margin-left: 4px"
class="inline-select"
>
<el-option v-for="l in levels" :key="l.id" :label="l.code" :value="l.id" />
</el-select>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> <template #footer>
<el-button @click="labelDialogVisible = false">取消</el-button> <el-button @click="labelDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="handleBatchSave">批量保存</el-button> <el-button type="primary" :loading="saveLoading" @click="handleBatchSave">批量保存</el-button>
<el-button type="success" :loading="completeLoading" @click="handleCompleteTask">完成任务</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -85,23 +112,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue' import { ref, computed, onMounted, h } from 'vue'
import { ElMessage, ElButton } from 'element-plus' import { ElMessage, ElMessageBox, ElButton, ElTag, ElSelect, ElOption } from 'element-plus'
import { getMyTasks, getTaskItems } from '@/api/task' import { getMyTasks, getTaskItems, startTask, completeTask, labelResult } from '@/api/task'
import { getCategoryTree, getDataLevels } from '@/api/classification' import { getCategoryTree, getDataLevels } from '@/api/classification'
import type { TaskItem, TaskResultItem } from '@/api/task' import type { TaskItem, TaskResultItem } from '@/api/task'
const activeTab = ref('pending') const activeTab = ref('pending')
const tasks = ref<TaskItem[]>([]) const tasks = ref<TaskItem[]>([])
const currentTask = ref<TaskItem | null>(null)
const pendingTasks = computed(() => tasks.value.filter((t) => t.status === 'pending')) const pendingTasks = computed(() => tasks.value.filter((t) => t.status === 'pending'))
const inProgressTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress')) const inProgressTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress'))
const completedTasks = computed(() => tasks.value.filter((t) => t.status === 'completed')) const completedTasks = computed(() => tasks.value.filter((t) => t.status === 'completed'))
const labelDialogVisible = ref(false) const labelDialogVisible = ref(false)
const labelItems = ref<(TaskResultItem & { _category_id?: number; _level_id?: number })[]>([]) const labelItems = ref<(TaskResultItem & { _category_id?: number; _level_id?: number; _unsaved?: boolean })[]>([])
const labelKeyword = ref('') const labelKeyword = ref('')
const levels = ref<any[]>([]) const levels = ref<any[]>([])
const flatCategories = ref<any[]>([]) const flatCategories = ref<any[]>([])
const saveLoading = ref(false)
const completeLoading = ref(false)
const savedCount = computed(() => labelItems.value.filter((i) => !i._unsaved).length)
const unsavedCount = computed(() => labelItems.value.filter((i) => i._unsaved).length)
const filteredLabelItems = computed(() => { const filteredLabelItems = computed(() => {
if (!labelKeyword.value) return labelItems.value if (!labelKeyword.value) return labelItems.value
@@ -146,12 +179,18 @@ function flattenCategories(tree: any[]): any[] {
} }
async function openLabel(task: TaskItem) { async function openLabel(task: TaskItem) {
currentTask.value = task
if (task.status === 'pending') {
try { await startTask(task.id) } catch (e) { /* ignore */ }
fetchData()
}
try { try {
const res: any = await getTaskItems(task.id) const res: any = await getTaskItems(task.id)
labelItems.value = (res || []).map((item: any) => ({ labelItems.value = (res || []).map((item: any) => ({
...item, ...item,
_category_id: item.category_id, _category_id: item.category_id,
_level_id: item.level_id, _level_id: item.level_id,
_unsaved: false,
})) }))
labelDialogVisible.value = true labelDialogVisible.value = true
} catch (e: any) { } catch (e: any) {
@@ -159,16 +198,71 @@ async function openLabel(task: TaskItem) {
} }
} }
function markUnsaved(row: any) {
row._unsaved = true
}
function handleCellClick(row: any, column: any) {
// Auto-focus for better mobile experience
}
async function handleBatchSave() { async function handleBatchSave() {
ElMessage.success('保存成功(演示模式)') const unsaved = labelItems.value.filter((i) => i._unsaved && i._category_id && i._level_id)
if (unsaved.length === 0) {
ElMessage.info('没有需要保存的变更')
return
}
saveLoading.value = true
try {
for (const item of unsaved) {
await labelResult(item.result_id, {
category_id: item._category_id!,
level_id: item._level_id!,
})
item._unsaved = false
}
ElMessage.success(`成功保存 ${unsaved.length} 条记录`)
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
saveLoading.value = false
}
}
async function handleCompleteTask() {
if (unsavedCount.value > 0) {
try {
await ElMessageBox.confirm('还有未保存的变更,是否先保存后再完成任务?', '提示')
await handleBatchSave()
} catch (e) {
if (e === 'cancel') return
}
}
completeLoading.value = true
try {
if (currentTask.value) {
await completeTask(currentTask.value.id)
ElMessage.success('任务已完成')
labelDialogVisible.value = false labelDialogVisible.value = false
fetchData() fetchData()
} }
} catch (e: any) {
ElMessage.error(e?.message || '操作失败')
} finally {
completeLoading.value = false
}
}
function confidenceClass(v: number) {
if (v >= 0.8) return 'confidence-high'
if (v >= 0.5) return 'confidence-mid'
return 'confidence-low'
}
// TaskTable sub-component // TaskTable sub-component
const TaskTable = { const TaskTable = {
props: ['tasks'], props: ['tasks'],
emits: ['refresh'], emits: ['refresh', 'label'],
setup(props: any, { emit }: any) { setup(props: any, { emit }: any) {
return () => return () =>
h( h(
@@ -182,16 +276,17 @@ const TaskTable = {
{ {
default: () => [ default: () => [
h('el-table-column', { prop: 'name', label: '任务名称', minWidth: '180' }), h('el-table-column', { prop: 'name', label: '任务名称', minWidth: '180' }),
h('el-table-column', { prop: 'project_name', label: '所属项目', minWidth: '140' }),
h('el-table-column', { prop: 'status', label: '状态', width: '100' }, { h('el-table-column', { prop: 'status', label: '状态', width: '100' }, {
default: ({ row }: any) => default: ({ row }: any) =>
h('el-tag', { size: 'small', type: row.status === 'pending' ? 'warning' : row.status === 'completed' ? 'success' : 'primary' }, h(ElTag, { size: 'small', type: row.status === 'pending' ? 'warning' : row.status === 'completed' ? 'success' : 'primary' },
row.status === 'pending' ? '待处理' : row.status === 'in_progress' ? '进行中' : '已完成' row.status === 'pending' ? '待处理' : row.status === 'in_progress' ? '进行中' : '已完成'
), ),
}), }),
h('el-table-column', { prop: 'deadline', label: '截止时间', width: '160' }), h('el-table-column', { prop: 'deadline', label: '截止时间', width: '160' }),
h('el-table-column', { label: '操作', width: '120', fixed: 'right' }, { h('el-table-column', { label: '操作', width: '120', fixed: 'right' }, {
default: ({ row }: any) => default: ({ row }: any) =>
h(ElButton, { type: 'primary', link: true, size: 'small', onClick: () => openLabel(row) }, () => '去打标'), h(ElButton, { type: 'primary', link: true, size: 'small', onClick: () => emit('label', row) }, () => '去打标'),
}), }),
], ],
} }
@@ -225,15 +320,28 @@ onMounted(() => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 12px;
flex-wrap: wrap;
gap: 12px;
.label-stats {
display: flex;
align-items: center;
gap: 12px;
}
} }
.empty-text { .empty-text {
color: #c0c4cc; color: #c0c4cc;
} }
.inline-select { .confidence-high { color: #67c23a; font-weight: 600; }
:deep(.el-input__wrapper) { .confidence-mid { color: #e6a23c; font-weight: 600; }
padding: 0 4px; .confidence-low { color: #f56c6c; font-weight: 600; }
:deep(.label-dialog) {
.el-dialog__body {
padding-top: 10px;
padding-bottom: 10px;
} }
} }
</style> </style>