feat: add category-driven ticket workflows
This commit is contained in:
@@ -10,7 +10,7 @@ from ninja.errors import HttpError
|
|||||||
|
|
||||||
from apps.accounts.api import require_permission
|
from apps.accounts.api import require_permission
|
||||||
from apps.audit.models import OperationAuditLog
|
from apps.audit.models import OperationAuditLog
|
||||||
from apps.tickets.models import Ticket, TicketAttachment, TicketComment, TicketEvent
|
from apps.tickets.models import Ticket, TicketAttachment, TicketCategory, TicketComment, TicketEvent
|
||||||
from apps.tickets.schemas import (
|
from apps.tickets.schemas import (
|
||||||
TicketAttachmentIn,
|
TicketAttachmentIn,
|
||||||
TicketAttachmentOut,
|
TicketAttachmentOut,
|
||||||
@@ -23,7 +23,7 @@ from apps.tickets.schemas import (
|
|||||||
TicketStatusIn,
|
TicketStatusIn,
|
||||||
TicketUpdateIn,
|
TicketUpdateIn,
|
||||||
)
|
)
|
||||||
from apps.tickets.services import transition_ticket
|
from apps.tickets.services import TicketStatusError, transition_requires_remark, transition_ticket
|
||||||
|
|
||||||
router = Router(tags=["tickets"])
|
router = Router(tags=["tickets"])
|
||||||
|
|
||||||
@@ -44,14 +44,35 @@ def serialize_ticket_user(user):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_ticket_category(category):
|
||||||
|
if category is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": category.id,
|
||||||
|
"code": category.code,
|
||||||
|
"name": category.name,
|
||||||
|
"description": category.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_ticket(ticket: Ticket) -> dict:
|
def serialize_ticket(ticket: Ticket) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": ticket.id,
|
"id": ticket.id,
|
||||||
"ticket_no": ticket.ticket_no,
|
"ticket_no": ticket.ticket_no,
|
||||||
"title": ticket.title,
|
"title": ticket.title,
|
||||||
"description": ticket.description,
|
"description": ticket.description,
|
||||||
|
"category": serialize_ticket_category(ticket.category),
|
||||||
|
"category_code": ticket.category.code if ticket.category_id else None,
|
||||||
"status": ticket.status,
|
"status": ticket.status,
|
||||||
"priority": ticket.priority,
|
"priority": ticket.priority,
|
||||||
|
"problem_performance": ticket.problem_performance,
|
||||||
|
"problem_object": ticket.problem_object,
|
||||||
|
"submitter_name": ticket.submitter_name,
|
||||||
|
"submitter_org": ticket.submitter_org,
|
||||||
|
"ai_solution": ticket.ai_solution,
|
||||||
|
"resolution": ticket.resolution_summary,
|
||||||
|
"is_resolved": ticket.is_resolved,
|
||||||
|
"replied_to_customer": ticket.replied_to_customer,
|
||||||
"impact_scope": ticket.impact_scope,
|
"impact_scope": ticket.impact_scope,
|
||||||
"environment": ticket.environment,
|
"environment": ticket.environment,
|
||||||
"related_system": ticket.related_system,
|
"related_system": ticket.related_system,
|
||||||
@@ -82,11 +103,21 @@ def create_ticket(request, payload: TicketIn):
|
|||||||
assignee = None
|
assignee = None
|
||||||
if payload.assignee_id:
|
if payload.assignee_id:
|
||||||
assignee = get_user_model().objects.filter(id=payload.assignee_id).first()
|
assignee = get_user_model().objects.filter(id=payload.assignee_id).first()
|
||||||
|
category = None
|
||||||
|
if payload.category_code:
|
||||||
|
category = TicketCategory.objects.filter(code=payload.category_code, enabled=True).first()
|
||||||
|
if category is None:
|
||||||
|
raise HttpError(400, "Unknown ticket category")
|
||||||
ticket = Ticket.objects.create(
|
ticket = Ticket.objects.create(
|
||||||
ticket_no=next_ticket_no(),
|
ticket_no=next_ticket_no(),
|
||||||
title=payload.title,
|
title=payload.title,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
|
category=category,
|
||||||
priority=payload.priority,
|
priority=payload.priority,
|
||||||
|
problem_performance=payload.problem_performance,
|
||||||
|
problem_object=payload.problem_object,
|
||||||
|
submitter_name=payload.submitter_name,
|
||||||
|
submitter_org=payload.submitter_org,
|
||||||
impact_scope=payload.impact_scope,
|
impact_scope=payload.impact_scope,
|
||||||
environment=payload.environment,
|
environment=payload.environment,
|
||||||
related_system=payload.related_system,
|
related_system=payload.related_system,
|
||||||
@@ -97,7 +128,7 @@ def create_ticket(request, payload: TicketIn):
|
|||||||
TicketEvent.objects.create(
|
TicketEvent.objects.create(
|
||||||
ticket=ticket,
|
ticket=ticket,
|
||||||
event_type="created",
|
event_type="created",
|
||||||
to_value={"status": ticket.status},
|
to_value={"status": ticket.status, "category_code": payload.category_code},
|
||||||
operator=request.user if request.user.is_authenticated else None,
|
operator=request.user if request.user.is_authenticated else None,
|
||||||
)
|
)
|
||||||
return serialize_ticket(ticket)
|
return serialize_ticket(ticket)
|
||||||
@@ -110,22 +141,29 @@ def list_tickets(
|
|||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
priority: str | None = None,
|
priority: str | None = None,
|
||||||
|
category_code: str | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
related_system: str | None = None,
|
related_system: str | None = None,
|
||||||
assignee_id: int | None = None,
|
assignee_id: int | None = None,
|
||||||
view: str = "all",
|
view: str = "all",
|
||||||
):
|
):
|
||||||
require_permission(request, "ticket.view")
|
require_permission(request, "ticket.view")
|
||||||
queryset = Ticket.objects.select_related("requester", "assignee").all()
|
queryset = Ticket.objects.select_related("requester", "assignee", "category").all()
|
||||||
if status:
|
if status:
|
||||||
queryset = queryset.filter(status=status)
|
queryset = queryset.filter(status=status)
|
||||||
if priority:
|
if priority:
|
||||||
queryset = queryset.filter(priority=priority)
|
queryset = queryset.filter(priority=priority)
|
||||||
|
if category_code:
|
||||||
|
queryset = queryset.filter(category__code=category_code)
|
||||||
if q:
|
if q:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(ticket_no__icontains=q)
|
Q(ticket_no__icontains=q)
|
||||||
| Q(title__icontains=q)
|
| Q(title__icontains=q)
|
||||||
| Q(description__icontains=q)
|
| Q(description__icontains=q)
|
||||||
|
| Q(problem_performance__icontains=q)
|
||||||
|
| Q(problem_object__icontains=q)
|
||||||
|
| Q(submitter_name__icontains=q)
|
||||||
|
| Q(submitter_org__icontains=q)
|
||||||
)
|
)
|
||||||
if related_system:
|
if related_system:
|
||||||
queryset = queryset.filter(related_system__icontains=related_system)
|
queryset = queryset.filter(related_system__icontains=related_system)
|
||||||
@@ -185,7 +223,7 @@ def export_tickets(request):
|
|||||||
def get_ticket(request, ticket_id: int):
|
def get_ticket(request, ticket_id: int):
|
||||||
require_permission(request, "ticket.view")
|
require_permission(request, "ticket.view")
|
||||||
return serialize_ticket(
|
return serialize_ticket(
|
||||||
Ticket.objects.select_related("requester", "assignee").get(id=ticket_id)
|
Ticket.objects.select_related("requester", "assignee", "category").get(id=ticket_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -202,7 +240,15 @@ def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
|||||||
ticket = Ticket.objects.get(id=ticket_id)
|
ticket = Ticket.objects.get(id=ticket_id)
|
||||||
before = {
|
before = {
|
||||||
"title": ticket.title,
|
"title": ticket.title,
|
||||||
|
"category_code": ticket.category.code if ticket.category_id else None,
|
||||||
"priority": ticket.priority,
|
"priority": ticket.priority,
|
||||||
|
"problem_performance": ticket.problem_performance,
|
||||||
|
"problem_object": ticket.problem_object,
|
||||||
|
"submitter_name": ticket.submitter_name,
|
||||||
|
"submitter_org": ticket.submitter_org,
|
||||||
|
"resolution": ticket.resolution_summary,
|
||||||
|
"is_resolved": ticket.is_resolved,
|
||||||
|
"replied_to_customer": ticket.replied_to_customer,
|
||||||
"impact_scope": ticket.impact_scope,
|
"impact_scope": ticket.impact_scope,
|
||||||
"environment": ticket.environment,
|
"environment": ticket.environment,
|
||||||
"related_system": ticket.related_system,
|
"related_system": ticket.related_system,
|
||||||
@@ -211,6 +257,15 @@ def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
|||||||
if field == "assignee_id":
|
if field == "assignee_id":
|
||||||
user = get_user_model().objects.filter(id=value).first() if value else None
|
user = get_user_model().objects.filter(id=value).first() if value else None
|
||||||
ticket.assignee = user
|
ticket.assignee = user
|
||||||
|
elif field == "category_code":
|
||||||
|
category = (
|
||||||
|
TicketCategory.objects.filter(code=value, enabled=True).first() if value else None
|
||||||
|
)
|
||||||
|
if value and category is None:
|
||||||
|
raise HttpError(400, "Unknown ticket category")
|
||||||
|
ticket.category = category
|
||||||
|
elif field == "resolution":
|
||||||
|
ticket.resolution_summary = value or ""
|
||||||
else:
|
else:
|
||||||
setattr(ticket, field, value)
|
setattr(ticket, field, value)
|
||||||
ticket.save()
|
ticket.save()
|
||||||
@@ -227,15 +282,29 @@ def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
|||||||
@router.post("/{ticket_id}/status", response=TicketOut)
|
@router.post("/{ticket_id}/status", response=TicketOut)
|
||||||
def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
|
def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
|
||||||
require_permission(request, "ticket.edit")
|
require_permission(request, "ticket.edit")
|
||||||
if not payload.remark.strip():
|
ticket = Ticket.objects.select_related("category").get(id=ticket_id)
|
||||||
raise HttpError(400, "Transition remark is required")
|
category_code = ticket.category.code if ticket.category_id else None
|
||||||
ticket = Ticket.objects.get(id=ticket_id)
|
has_resolution = bool(payload.resolution.strip())
|
||||||
updated = transition_ticket(
|
if transition_requires_remark(
|
||||||
ticket,
|
ticket.status,
|
||||||
payload.status,
|
payload.status,
|
||||||
payload.remark,
|
category_code,
|
||||||
request.user if request.user.is_authenticated else None,
|
) and not payload.remark.strip():
|
||||||
)
|
raise HttpError(400, "Transition remark is required")
|
||||||
|
if payload.status == "resolved" and not (payload.remark.strip() or has_resolution):
|
||||||
|
raise HttpError(400, "Resolution or transition remark is required")
|
||||||
|
try:
|
||||||
|
updated = transition_ticket(
|
||||||
|
ticket,
|
||||||
|
payload.status,
|
||||||
|
payload.remark,
|
||||||
|
request.user if request.user.is_authenticated else None,
|
||||||
|
resolution=payload.resolution.strip(),
|
||||||
|
is_resolved=payload.is_resolved,
|
||||||
|
replied_to_customer=payload.replied_to_customer,
|
||||||
|
)
|
||||||
|
except TicketStatusError as error:
|
||||||
|
raise HttpError(400, str(error)) from error
|
||||||
return serialize_ticket(updated)
|
return serialize_ticket(updated)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
STANDARD_CATEGORIES = [
|
||||||
|
("business_issue", "业务问题", "业务进度、业务规则、业务状态确认等支持请求。", 10),
|
||||||
|
("technical_issue", "技术问题", "系统异常、接口故障、服务不可用等技术排查请求。", 20),
|
||||||
|
("data_issue", "数据问题", "数据缺失、数据不一致、采集或同步结果异常。", 30),
|
||||||
|
("operation_issue", "操作问题", "用户操作指导、配置入口、权限使用等支持请求。", 40),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_standard_categories(apps, schema_editor):
|
||||||
|
TicketCategory = apps.get_model("tickets", "TicketCategory")
|
||||||
|
for code, name, description, sort_order in STANDARD_CATEGORIES:
|
||||||
|
TicketCategory.objects.update_or_create(
|
||||||
|
code=code,
|
||||||
|
defaults={
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"enabled": True,
|
||||||
|
"sort_order": sort_order,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_standard_categories(apps, schema_editor):
|
||||||
|
TicketCategory = apps.get_model("tickets", "TicketCategory")
|
||||||
|
category_codes = [category[0] for category in STANDARD_CATEGORIES]
|
||||||
|
TicketCategory.objects.filter(code__in=category_codes).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("tickets", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ticket",
|
||||||
|
name="ai_solution",
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ticket",
|
||||||
|
name="is_resolved",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ticket",
|
||||||
|
name="problem_object",
|
||||||
|
field=models.CharField(blank=True, max_length=160),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ticket",
|
||||||
|
name="problem_performance",
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ticket",
|
||||||
|
name="replied_to_customer",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ticket",
|
||||||
|
name="resolution_summary",
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ticket",
|
||||||
|
name="submitter_name",
|
||||||
|
field=models.CharField(blank=True, max_length=80),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ticket",
|
||||||
|
name="submitter_org",
|
||||||
|
field=models.CharField(blank=True, max_length=160),
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_standard_categories, remove_standard_categories),
|
||||||
|
]
|
||||||
@@ -48,6 +48,14 @@ class Ticket(models.Model):
|
|||||||
choices=TicketPriority.choices,
|
choices=TicketPriority.choices,
|
||||||
default=TicketPriority.MEDIUM,
|
default=TicketPriority.MEDIUM,
|
||||||
)
|
)
|
||||||
|
problem_performance = models.TextField(blank=True)
|
||||||
|
problem_object = models.CharField(max_length=160, blank=True)
|
||||||
|
submitter_name = models.CharField(max_length=80, blank=True)
|
||||||
|
submitter_org = models.CharField(max_length=160, blank=True)
|
||||||
|
ai_solution = models.TextField(blank=True)
|
||||||
|
resolution_summary = models.TextField(blank=True)
|
||||||
|
is_resolved = models.BooleanField(default=False)
|
||||||
|
replied_to_customer = models.BooleanField(default=False)
|
||||||
impact_scope = models.CharField(max_length=80, blank=True)
|
impact_scope = models.CharField(max_length=80, blank=True)
|
||||||
environment = models.CharField(max_length=80, blank=True)
|
environment = models.CharField(max_length=80, blank=True)
|
||||||
related_system = models.CharField(max_length=120, blank=True)
|
related_system = models.CharField(max_length=120, blank=True)
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ from ninja import Schema
|
|||||||
class TicketIn(Schema):
|
class TicketIn(Schema):
|
||||||
title: str
|
title: str
|
||||||
description: str
|
description: str
|
||||||
|
category_code: str | None = None
|
||||||
priority: str = "medium"
|
priority: str = "medium"
|
||||||
|
problem_performance: str = ""
|
||||||
|
problem_object: str = ""
|
||||||
|
submitter_name: str = ""
|
||||||
|
submitter_org: str = ""
|
||||||
impact_scope: str = ""
|
impact_scope: str = ""
|
||||||
environment: str = ""
|
environment: str = ""
|
||||||
related_system: str = ""
|
related_system: str = ""
|
||||||
@@ -16,7 +21,15 @@ class TicketIn(Schema):
|
|||||||
class TicketUpdateIn(Schema):
|
class TicketUpdateIn(Schema):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
category_code: str | None = None
|
||||||
priority: str | None = None
|
priority: str | None = None
|
||||||
|
problem_performance: str | None = None
|
||||||
|
problem_object: str | None = None
|
||||||
|
submitter_name: str | None = None
|
||||||
|
submitter_org: str | None = None
|
||||||
|
resolution: str | None = None
|
||||||
|
is_resolved: bool | None = None
|
||||||
|
replied_to_customer: bool | None = None
|
||||||
impact_scope: str | None = None
|
impact_scope: str | None = None
|
||||||
environment: str | None = None
|
environment: str | None = None
|
||||||
related_system: str | None = None
|
related_system: str | None = None
|
||||||
@@ -26,6 +39,9 @@ class TicketUpdateIn(Schema):
|
|||||||
class TicketStatusIn(Schema):
|
class TicketStatusIn(Schema):
|
||||||
status: str
|
status: str
|
||||||
remark: str = ""
|
remark: str = ""
|
||||||
|
resolution: str = ""
|
||||||
|
is_resolved: bool | None = None
|
||||||
|
replied_to_customer: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class TicketUserOut(Schema):
|
class TicketUserOut(Schema):
|
||||||
@@ -34,13 +50,30 @@ class TicketUserOut(Schema):
|
|||||||
display_name: str
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCategoryOut(Schema):
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
class TicketOut(Schema):
|
class TicketOut(Schema):
|
||||||
id: int
|
id: int
|
||||||
ticket_no: str
|
ticket_no: str
|
||||||
title: str
|
title: str
|
||||||
description: str
|
description: str
|
||||||
|
category: TicketCategoryOut | None = None
|
||||||
|
category_code: str | None = None
|
||||||
status: str
|
status: str
|
||||||
priority: str
|
priority: str
|
||||||
|
problem_performance: str
|
||||||
|
problem_object: str
|
||||||
|
submitter_name: str
|
||||||
|
submitter_org: str
|
||||||
|
ai_solution: str
|
||||||
|
resolution: str
|
||||||
|
is_resolved: bool
|
||||||
|
replied_to_customer: bool
|
||||||
impact_scope: str
|
impact_scope: str
|
||||||
environment: str
|
environment: str
|
||||||
related_system: str
|
related_system: str
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class TicketStatusError(ValueError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
|
STANDARD_TRANSITIONS: dict[str, set[str]] = {
|
||||||
"pending_info": {"pending", "closed"},
|
"pending_info": {"pending", "closed"},
|
||||||
"pending": {"in_progress", "closed"},
|
"pending": {"in_progress", "closed"},
|
||||||
"in_progress": {"pending_confirm"},
|
"in_progress": {"pending_confirm"},
|
||||||
@@ -17,26 +17,113 @@ ALLOWED_TRANSITIONS: dict[str, set[str]] = {
|
|||||||
"closed": set(),
|
"closed": set(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SHORT_TRANSITIONS: dict[str, set[str]] = {
|
||||||
|
"pending_info": {"pending", "resolved", "closed"},
|
||||||
|
"pending": {"resolved", "closed"},
|
||||||
|
"in_progress": {"resolved", "closed"},
|
||||||
|
"pending_confirm": {"resolved", "closed"},
|
||||||
|
"resolved": {"closed"},
|
||||||
|
"closed": set(),
|
||||||
|
}
|
||||||
|
|
||||||
def validate_status_transition(from_status: str, to_status: str) -> None:
|
MEDIUM_TRANSITIONS: dict[str, set[str]] = {
|
||||||
if to_status not in ALLOWED_TRANSITIONS.get(from_status, set()):
|
"pending_info": {"pending", "closed"},
|
||||||
|
"pending": {"in_progress", "resolved", "closed"},
|
||||||
|
"in_progress": {"resolved", "pending_confirm", "closed"},
|
||||||
|
"pending_confirm": {"resolved", "in_progress", "closed"},
|
||||||
|
"resolved": {"closed"},
|
||||||
|
"closed": set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
CATEGORY_TRANSITION_POLICIES = {
|
||||||
|
"business_issue": SHORT_TRANSITIONS,
|
||||||
|
"operation_issue": SHORT_TRANSITIONS,
|
||||||
|
"data_issue": MEDIUM_TRANSITIONS,
|
||||||
|
"technical_issue": STANDARD_TRANSITIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backward compatible export used by older tests and internal imports.
|
||||||
|
ALLOWED_TRANSITIONS = STANDARD_TRANSITIONS
|
||||||
|
|
||||||
|
|
||||||
|
def get_ticket_category_code(ticket: Ticket) -> str | None:
|
||||||
|
if ticket.category_id and ticket.category:
|
||||||
|
return ticket.category.code
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_allowed_transitions(from_status: str, category_code: str | None = None) -> set[str]:
|
||||||
|
transitions = CATEGORY_TRANSITION_POLICIES.get(category_code or "", STANDARD_TRANSITIONS)
|
||||||
|
return transitions.get(from_status, set())
|
||||||
|
|
||||||
|
|
||||||
|
def validate_status_transition(
|
||||||
|
from_status: str,
|
||||||
|
to_status: str,
|
||||||
|
category_code: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if to_status not in get_allowed_transitions(from_status, category_code):
|
||||||
raise TicketStatusError(f"Invalid ticket status transition: {from_status} -> {to_status}")
|
raise TicketStatusError(f"Invalid ticket status transition: {from_status} -> {to_status}")
|
||||||
|
|
||||||
|
|
||||||
def transition_ticket(ticket: Ticket, to_status: str, remark: str = "", operator=None) -> Ticket:
|
def transition_requires_remark(
|
||||||
|
from_status: str,
|
||||||
|
to_status: str,
|
||||||
|
category_code: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if to_status == "resolved":
|
||||||
|
return False
|
||||||
|
if category_code in {"business_issue", "operation_issue"} and to_status == "closed":
|
||||||
|
return False
|
||||||
|
return to_status in {"in_progress", "pending_confirm", "closed"}
|
||||||
|
|
||||||
|
|
||||||
|
def transition_ticket(
|
||||||
|
ticket: Ticket,
|
||||||
|
to_status: str,
|
||||||
|
remark: str = "",
|
||||||
|
operator=None,
|
||||||
|
*,
|
||||||
|
resolution: str = "",
|
||||||
|
is_resolved: bool | None = None,
|
||||||
|
replied_to_customer: bool | None = None,
|
||||||
|
) -> Ticket:
|
||||||
from_status = ticket.status
|
from_status = ticket.status
|
||||||
validate_status_transition(from_status, to_status)
|
category_code = get_ticket_category_code(ticket)
|
||||||
|
validate_status_transition(from_status, to_status, category_code)
|
||||||
ticket.status = to_status
|
ticket.status = to_status
|
||||||
if to_status == "resolved":
|
if to_status == "resolved":
|
||||||
ticket.resolved_at = timezone.now()
|
ticket.resolved_at = timezone.now()
|
||||||
|
ticket.is_resolved = True if is_resolved is None else is_resolved
|
||||||
|
elif is_resolved is not None:
|
||||||
|
ticket.is_resolved = is_resolved
|
||||||
if to_status == "closed":
|
if to_status == "closed":
|
||||||
ticket.closed_at = timezone.now()
|
ticket.closed_at = timezone.now()
|
||||||
ticket.save(update_fields=["status", "resolved_at", "closed_at", "updated_at"])
|
if resolution:
|
||||||
|
ticket.resolution_summary = resolution
|
||||||
|
if replied_to_customer is not None:
|
||||||
|
ticket.replied_to_customer = replied_to_customer
|
||||||
|
ticket.save(
|
||||||
|
update_fields=[
|
||||||
|
"status",
|
||||||
|
"resolved_at",
|
||||||
|
"closed_at",
|
||||||
|
"resolution_summary",
|
||||||
|
"is_resolved",
|
||||||
|
"replied_to_customer",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
)
|
||||||
TicketEvent.objects.create(
|
TicketEvent.objects.create(
|
||||||
ticket=ticket,
|
ticket=ticket,
|
||||||
event_type="status_change",
|
event_type="status_change",
|
||||||
from_value={"status": from_status},
|
from_value={"status": from_status},
|
||||||
to_value={"status": to_status},
|
to_value={
|
||||||
|
"status": to_status,
|
||||||
|
"resolution": ticket.resolution_summary,
|
||||||
|
"is_resolved": ticket.is_resolved,
|
||||||
|
"replied_to_customer": ticket.replied_to_customer,
|
||||||
|
},
|
||||||
operator=operator,
|
operator=operator,
|
||||||
remark=remark,
|
remark=remark,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from apps.tickets.models import Ticket, TicketEvent
|
from apps.tickets.models import Ticket, TicketCategory, TicketEvent
|
||||||
|
|
||||||
|
|
||||||
def test_create_ticket_returns_ticket_detail(admin_client, db):
|
def test_create_ticket_returns_ticket_detail(admin_client, db):
|
||||||
|
TicketCategory.objects.get_or_create(code="business_issue", defaults={"name": "业务问题"})
|
||||||
response = admin_client.post(
|
response = admin_client.post(
|
||||||
"/api/tickets",
|
"/api/tickets",
|
||||||
data={
|
data={
|
||||||
"title": "Payment callback fails",
|
"title": "Payment callback fails",
|
||||||
"description": "Callback returns HTTP 500 in production",
|
"description": "Callback returns HTTP 500 in production",
|
||||||
|
"category_code": "business_issue",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
|
"problem_performance": "采集进度未更新",
|
||||||
|
"problem_object": "客户 A",
|
||||||
|
"submitter_name": "张三",
|
||||||
|
"submitter_org": "华东分公司",
|
||||||
"impact_scope": "payment users",
|
"impact_scope": "payment users",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"related_system": "payment",
|
"related_system": "payment",
|
||||||
@@ -21,6 +27,10 @@ def test_create_ticket_returns_ticket_detail(admin_client, db):
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert payload["ticket_no"].startswith("TK")
|
assert payload["ticket_no"].startswith("TK")
|
||||||
assert payload["title"] == "Payment callback fails"
|
assert payload["title"] == "Payment callback fails"
|
||||||
|
assert payload["category"]["name"] == "业务问题"
|
||||||
|
assert payload["category_code"] == "business_issue"
|
||||||
|
assert payload["problem_object"] == "客户 A"
|
||||||
|
assert payload["submitter_org"] == "华东分公司"
|
||||||
assert payload["status"] == "pending"
|
assert payload["status"] == "pending"
|
||||||
assert TicketEvent.objects.filter(ticket_id=payload["id"], event_type="created").exists()
|
assert TicketEvent.objects.filter(ticket_id=payload["id"], event_type="created").exists()
|
||||||
|
|
||||||
@@ -129,6 +139,59 @@ def test_transition_ticket_requires_non_empty_remark(admin_client, db):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_business_ticket_can_be_resolved_directly_with_resolution(admin_client, db):
|
||||||
|
category, _ = TicketCategory.objects.get_or_create(
|
||||||
|
code="business_issue",
|
||||||
|
defaults={"name": "业务问题"},
|
||||||
|
)
|
||||||
|
ticket = Ticket.objects.create(
|
||||||
|
ticket_no="TK202607010104",
|
||||||
|
title="查询采集进度",
|
||||||
|
description="客户需要确认采集进度",
|
||||||
|
status="pending",
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
f"/api/tickets/{ticket.id}/status",
|
||||||
|
data={
|
||||||
|
"status": "resolved",
|
||||||
|
"resolution": "已查询采集进度,当前已完成 80%。",
|
||||||
|
"replied_to_customer": True,
|
||||||
|
},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
ticket.refresh_from_db()
|
||||||
|
assert ticket.status == "resolved"
|
||||||
|
assert ticket.resolution_summary == "已查询采集进度,当前已完成 80%。"
|
||||||
|
assert ticket.is_resolved is True
|
||||||
|
assert ticket.replied_to_customer is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_technical_ticket_cannot_be_resolved_directly(admin_client, db):
|
||||||
|
category, _ = TicketCategory.objects.get_or_create(
|
||||||
|
code="technical_issue",
|
||||||
|
defaults={"name": "技术问题"},
|
||||||
|
)
|
||||||
|
ticket = Ticket.objects.create(
|
||||||
|
ticket_no="TK202607010105",
|
||||||
|
title="生产接口异常",
|
||||||
|
description="接口 500",
|
||||||
|
status="pending",
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
f"/api/tickets/{ticket.id}/status",
|
||||||
|
data={"status": "resolved", "resolution": "已恢复"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_get_ticket_events_returns_real_timeline_with_operator(admin_client, db):
|
def test_get_ticket_events_returns_real_timeline_with_operator(admin_client, db):
|
||||||
user_model = get_user_model()
|
user_model = get_user_model()
|
||||||
operator = user_model.objects.get(username="admin")
|
operator = user_model.objects.get(username="admin")
|
||||||
|
|||||||
@@ -20,3 +20,16 @@ def test_in_progress_ticket_must_not_resolve_directly():
|
|||||||
def test_resolved_ticket_cannot_move_back_to_in_progress():
|
def test_resolved_ticket_cannot_move_back_to_in_progress():
|
||||||
with pytest.raises(TicketStatusError, match="resolved -> in_progress"):
|
with pytest.raises(TicketStatusError, match="resolved -> in_progress"):
|
||||||
validate_status_transition("resolved", "in_progress")
|
validate_status_transition("resolved", "in_progress")
|
||||||
|
|
||||||
|
|
||||||
|
def test_business_ticket_can_resolve_directly_from_pending():
|
||||||
|
validate_status_transition("pending", "resolved", "business_issue")
|
||||||
|
|
||||||
|
|
||||||
|
def test_technical_ticket_cannot_resolve_directly_from_pending():
|
||||||
|
with pytest.raises(TicketStatusError, match="pending -> resolved"):
|
||||||
|
validate_status_transition("pending", "resolved", "technical_issue")
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_ticket_can_resolve_from_in_progress():
|
||||||
|
validate_status_transition("in_progress", "resolved", "data_issue")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { AlertCircle, Bot, CheckCircle2, Lightbulb, Send } from 'lucide-react'
|
import { AlertCircle, CheckCircle2, Lightbulb, Send } from 'lucide-react'
|
||||||
|
|
||||||
import { AppField } from '@/components/app/AppField'
|
import { AppField } from '@/components/app/AppField'
|
||||||
import { AppPage } from '@/components/app/AppPage'
|
import { AppPage } from '@/components/app/AppPage'
|
||||||
@@ -13,17 +13,25 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
|
||||||
import { createTicket } from './api'
|
import { createTicket } from './api'
|
||||||
|
import { TICKET_CATEGORIES } from './ticketCategories'
|
||||||
import type { TicketInput } from './types'
|
import type { TicketInput } from './types'
|
||||||
|
|
||||||
const initialForm: TicketInput = {
|
const initialForm: TicketInput = {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
category_code: 'business_issue',
|
||||||
priority: 'medium',
|
priority: 'medium',
|
||||||
|
problem_performance: '',
|
||||||
|
problem_object: '',
|
||||||
|
submitter_name: '',
|
||||||
|
submitter_org: '',
|
||||||
impact_scope: '',
|
impact_scope: '',
|
||||||
environment: '',
|
environment: '',
|
||||||
related_system: ''
|
related_system: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ENVIRONMENT_OPTIONS = ['生产环境', '测试环境', '预发布环境', '本地环境']
|
||||||
|
|
||||||
export function TicketCreatePage() {
|
export function TicketCreatePage() {
|
||||||
const [form, setForm] = React.useState<TicketInput>(initialForm)
|
const [form, setForm] = React.useState<TicketInput>(initialForm)
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
@@ -36,6 +44,10 @@ export function TicketCreatePage() {
|
|||||||
async function handleSubmit(event: React.FormEvent) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
if (!form.environment || !form.problem_performance || !form.problem_object || !form.submitter_name || !form.submitter_org) {
|
||||||
|
setError('请完整填写问题表现、问题对象、提出人、提出机构和环境。')
|
||||||
|
return
|
||||||
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const ticket = await createTicket(form)
|
const ticket = await createTicket(form)
|
||||||
@@ -61,18 +73,94 @@ export function TicketCreatePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<AppSection title="基础信息">
|
<AppSection title="基础信息">
|
||||||
<AppField label="工单标题" htmlFor="ticket-title" required>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Input
|
<AppField label="问题分类" required>
|
||||||
id="ticket-title"
|
<Select
|
||||||
|
value={form.category_code}
|
||||||
|
onValueChange={(value) => updateField('category_code', value as TicketInput['category_code'])}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="请选择问题分类" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{TICKET_CATEGORIES.map((category) => (
|
||||||
|
<SelectItem key={category.code} value={category.code}>
|
||||||
|
{category.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="工单标题" htmlFor="ticket-title" required>
|
||||||
|
<Input
|
||||||
|
id="ticket-title"
|
||||||
|
required
|
||||||
|
placeholder="例如:查询客户采集进度"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(event) => updateField('title', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
</div>
|
||||||
|
</AppSection>
|
||||||
|
|
||||||
|
<AppSection title="问题信息">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<AppField label="问题表现" htmlFor="ticket-problem-performance" required>
|
||||||
|
<Input
|
||||||
|
id="ticket-problem-performance"
|
||||||
|
required
|
||||||
|
placeholder="例如:采集进度长时间未更新"
|
||||||
|
value={form.problem_performance}
|
||||||
|
onChange={(event) => updateField('problem_performance', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="问题对象" htmlFor="ticket-problem-object" required>
|
||||||
|
<Input
|
||||||
|
id="ticket-problem-object"
|
||||||
|
required
|
||||||
|
placeholder="例如:客户名称、账号、机构或任务编号"
|
||||||
|
value={form.problem_object}
|
||||||
|
onChange={(event) => updateField('problem_object', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
</div>
|
||||||
|
<AppField label="问题描述" htmlFor="ticket-description" required>
|
||||||
|
<Textarea
|
||||||
|
id="ticket-description"
|
||||||
required
|
required
|
||||||
placeholder="例如:生产环境登录后跳转失败"
|
placeholder="请填写问题背景、期望结果、已知线索或需要支持团队确认的内容。"
|
||||||
value={form.title}
|
value={form.description}
|
||||||
onChange={(event) => updateField('title', event.target.value)}
|
onChange={(event) => updateField('description', event.target.value)}
|
||||||
/>
|
/>
|
||||||
</AppField>
|
</AppField>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
|
|
||||||
<AppSection title="影响与优先级">
|
<AppSection title="提出信息">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<AppField label="提出人" htmlFor="ticket-submitter-name" required>
|
||||||
|
<Input
|
||||||
|
id="ticket-submitter-name"
|
||||||
|
required
|
||||||
|
placeholder="请输入提出人"
|
||||||
|
value={form.submitter_name}
|
||||||
|
onChange={(event) => updateField('submitter_name', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="提出机构" htmlFor="ticket-submitter-org" required>
|
||||||
|
<Input
|
||||||
|
id="ticket-submitter-org"
|
||||||
|
required
|
||||||
|
placeholder="请输入提出机构"
|
||||||
|
value={form.submitter_org}
|
||||||
|
onChange={(event) => updateField('submitter_org', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
</div>
|
||||||
|
</AppSection>
|
||||||
|
|
||||||
|
<AppSection title="影响与环境">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<AppField label="优先级" required>
|
<AppField label="优先级" required>
|
||||||
<Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}>
|
<Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}>
|
||||||
@@ -92,15 +180,12 @@ export function TicketCreatePage() {
|
|||||||
<AppField label="影响范围" htmlFor="ticket-impact-scope">
|
<AppField label="影响范围" htmlFor="ticket-impact-scope">
|
||||||
<Input
|
<Input
|
||||||
id="ticket-impact-scope"
|
id="ticket-impact-scope"
|
||||||
placeholder="例如:单个用户、某部门、全部用户"
|
placeholder="例如:单个客户、某部门、全部用户"
|
||||||
value={form.impact_scope}
|
value={form.impact_scope}
|
||||||
onChange={(event) => updateField('impact_scope', event.target.value)}
|
onChange={(event) => updateField('impact_scope', event.target.value)}
|
||||||
/>
|
/>
|
||||||
</AppField>
|
</AppField>
|
||||||
</div>
|
</div>
|
||||||
</AppSection>
|
|
||||||
|
|
||||||
<AppSection title="环境与系统">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<AppField label="关联系统" htmlFor="ticket-related-system">
|
<AppField label="关联系统" htmlFor="ticket-related-system">
|
||||||
<Input
|
<Input
|
||||||
@@ -110,34 +195,30 @@ export function TicketCreatePage() {
|
|||||||
onChange={(event) => updateField('related_system', event.target.value)}
|
onChange={(event) => updateField('related_system', event.target.value)}
|
||||||
/>
|
/>
|
||||||
</AppField>
|
</AppField>
|
||||||
<AppField label="运行环境" htmlFor="ticket-environment">
|
<AppField label="环境" required>
|
||||||
<Input
|
<Select value={form.environment} onValueChange={(value) => updateField('environment', value)}>
|
||||||
id="ticket-environment"
|
<SelectTrigger className="w-full">
|
||||||
placeholder="例如:生产环境、测试环境、Windows 终端"
|
<SelectValue placeholder="请选择问题环境" />
|
||||||
value={form.environment}
|
</SelectTrigger>
|
||||||
onChange={(event) => updateField('environment', event.target.value)}
|
<SelectContent>
|
||||||
/>
|
<SelectGroup>
|
||||||
|
{ENVIRONMENT_OPTIONS.map((environment) => (
|
||||||
|
<SelectItem key={environment} value={environment}>
|
||||||
|
{environment}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</AppField>
|
</AppField>
|
||||||
</div>
|
</div>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
|
|
||||||
<AppSection title="问题描述">
|
|
||||||
<AppField label="详细描述" htmlFor="ticket-description" required>
|
|
||||||
<Textarea
|
|
||||||
id="ticket-description"
|
|
||||||
required
|
|
||||||
placeholder="请描述影响范围、报错信息、操作步骤和已尝试的处理方式。"
|
|
||||||
value={form.description}
|
|
||||||
onChange={(event) => updateField('description', event.target.value)}
|
|
||||||
/>
|
|
||||||
</AppField>
|
|
||||||
</AppSection>
|
|
||||||
|
|
||||||
<AppSection title="提交确认">
|
<AppSection title="提交确认">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||||
<CheckCircle2 className="mt-0.5 size-4" />
|
<CheckCircle2 className="mt-0.5 size-4" />
|
||||||
<span>请确认标题和问题描述已足够支持技术人员开始排查。</span>
|
<span>请确认分类、对象和描述已足够支持处理人员判断下一步动作。</span>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={submitting}>
|
<Button type="submit" disabled={submitting}>
|
||||||
<Send data-icon="inline-start" />
|
<Send data-icon="inline-start" />
|
||||||
@@ -152,25 +233,25 @@ export function TicketCreatePage() {
|
|||||||
<div className="grid gap-3 text-sm text-muted-foreground">
|
<div className="grid gap-3 text-sm text-muted-foreground">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Lightbulb className="mt-0.5 size-4" />
|
<Lightbulb className="mt-0.5 size-4" />
|
||||||
<span>标题建议包含系统、场景和异常现象。</span>
|
<span>业务、操作类问题可在详情页直接填写解决方案并完成。</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Lightbulb className="mt-0.5 size-4" />
|
<Lightbulb className="mt-0.5 size-4" />
|
||||||
<span>描述中写清楚复现步骤、报错信息和影响范围。</span>
|
<span>技术类问题会保留开始处理、提交确认、解决和关闭流程。</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Lightbulb className="mt-0.5 size-4" />
|
<Lightbulb className="mt-0.5 size-4" />
|
||||||
<span>紧急优先级应只用于阻断生产或大范围影响问题。</span>
|
<span>数据类问题支持处理后解决,也支持明确后直接解决。</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
|
|
||||||
<AppSection
|
<AppSection
|
||||||
title="AI 建单辅助"
|
title="AI 解决方案"
|
||||||
action={<Badge variant="ai">即将接入</Badge>}
|
action={<Badge variant="ai">即将接入</Badge>}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
后续阶段将接入建单 Agent,用于补全字段、追问缺失信息和推荐相似问题。
|
后续阶段将由 AI 识别问题后自动回填,当前不作为人工必填项。
|
||||||
</p>
|
</p>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,20 +17,12 @@ import { formatDateTime } from '@/lib/format'
|
|||||||
|
|
||||||
import { getTicket, listTicketEvents, transitionTicket } from './api'
|
import { getTicket, listTicketEvents, transitionTicket } from './api'
|
||||||
import { TicketComments } from './TicketComments'
|
import { TicketComments } from './TicketComments'
|
||||||
|
import { getTicketCategoryLabel, getTicketNextStatuses } from './ticketCategories'
|
||||||
import { TicketMetaPanel } from './TicketMetaPanel'
|
import { TicketMetaPanel } from './TicketMetaPanel'
|
||||||
import { TicketStatusTimeline } from './TicketStatusTimeline'
|
import { TicketStatusTimeline } from './TicketStatusTimeline'
|
||||||
import { TicketTransitionDialog } from './TicketTransitionDialog'
|
import { TicketTransitionDialog } from './TicketTransitionDialog'
|
||||||
import type { Ticket, TicketEvent, TicketStatus } from './types'
|
import type { Ticket, TicketEvent, TicketStatus } from './types'
|
||||||
|
|
||||||
const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
|
|
||||||
pending_info: ['pending', 'closed'],
|
|
||||||
pending: ['in_progress', 'closed'],
|
|
||||||
in_progress: ['pending_confirm'],
|
|
||||||
pending_confirm: ['resolved', 'in_progress', 'closed'],
|
|
||||||
resolved: ['closed'],
|
|
||||||
closed: []
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.ComponentType<{ className?: string }> }>> = {
|
const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.ComponentType<{ className?: string }> }>> = {
|
||||||
pending: { label: '转为待处理', icon: CircleDot },
|
pending: { label: '转为待处理', icon: CircleDot },
|
||||||
in_progress: { label: '开始处理', icon: CircleDot },
|
in_progress: { label: '开始处理', icon: CircleDot },
|
||||||
@@ -93,10 +85,18 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
setTransitionDialogOpen(true)
|
setTransitionDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTransition(status: TicketStatus, remark: string) {
|
async function handleTransition(
|
||||||
|
status: TicketStatus,
|
||||||
|
values: {
|
||||||
|
remark: string
|
||||||
|
resolution: string
|
||||||
|
is_resolved: boolean
|
||||||
|
replied_to_customer: boolean
|
||||||
|
}
|
||||||
|
) {
|
||||||
if (!ticket) return
|
if (!ticket) return
|
||||||
try {
|
try {
|
||||||
const updated = await transitionTicket(ticket.id, status, remark)
|
const updated = await transitionTicket(ticket.id, status, values)
|
||||||
setTicket(updated)
|
setTicket(updated)
|
||||||
setTransitionError('')
|
setTransitionError('')
|
||||||
refreshEvents()
|
refreshEvents()
|
||||||
@@ -122,6 +122,8 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextStatuses = getTicketNextStatuses(ticket.category_code, ticket.status)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppPage maxWidth="6xl">
|
<AppPage maxWidth="6xl">
|
||||||
<AppPageHeader
|
<AppPageHeader
|
||||||
@@ -135,7 +137,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{NEXT_STATUSES[ticket.status].map((status) => {
|
{nextStatuses.map((status) => {
|
||||||
const action = STATUS_ACTIONS[status]
|
const action = STATUS_ACTIONS[status]
|
||||||
const Icon = action?.icon
|
const Icon = action?.icon
|
||||||
return (
|
return (
|
||||||
@@ -153,10 +155,10 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TicketOverviewCard ticket={ticket} />
|
<TicketOverviewCard ticket={ticket} />
|
||||||
{NEXT_STATUSES[ticket.status].length ? (
|
{nextStatuses.length ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="grid gap-3 p-4 md:grid-cols-2">
|
<CardContent className="grid gap-3 p-4 md:grid-cols-2">
|
||||||
{NEXT_STATUSES[ticket.status].map((status) => {
|
{nextStatuses.map((status) => {
|
||||||
const action = STATUS_ACTIONS[status]
|
const action = STATUS_ACTIONS[status]
|
||||||
const Icon = action?.icon ?? CircleDot
|
const Icon = action?.icon ?? CircleDot
|
||||||
return (
|
return (
|
||||||
@@ -202,7 +204,37 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AppSection title="问题描述">
|
<AppSection title="问题描述">
|
||||||
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
<div className="grid gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">问题分类</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-foreground">{getTicketCategoryLabel(ticket.category_code)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">问题表现</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-sm leading-6">{ticket.problem_performance || '未填写'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">问题描述</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppSection>
|
||||||
|
<AppSection title="解决方案">
|
||||||
|
{ticket.resolution ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.resolution}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant={ticket.is_resolved ? 'success' : 'outline'}>
|
||||||
|
{ticket.is_resolved ? '已解决' : '未解决'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={ticket.replied_to_customer ? 'info' : 'outline'}>
|
||||||
|
{ticket.replied_to_customer ? '已回复客户' : '未回复客户'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AppEmptyState title="暂无解决方案" description="解决工单后将在这里展示处理结论。" />
|
||||||
|
)}
|
||||||
</AppSection>
|
</AppSection>
|
||||||
<TicketStatusTimeline ticket={ticket} events={events} />
|
<TicketStatusTimeline ticket={ticket} events={events} />
|
||||||
<TicketComments ticketId={ticket.id} onCommentAdded={refreshEvents} />
|
<TicketComments ticketId={ticket.id} onCommentAdded={refreshEvents} />
|
||||||
@@ -221,6 +253,10 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
|
|
||||||
function TicketOverviewCard({ ticket }: { ticket: Ticket }) {
|
function TicketOverviewCard({ ticket }: { ticket: Ticket }) {
|
||||||
const items = [
|
const items = [
|
||||||
|
{ label: '问题分类', value: getTicketCategoryLabel(ticket.category_code) },
|
||||||
|
{ label: '问题对象', value: ticket.problem_object || '未填写' },
|
||||||
|
{ label: '提出人', value: ticket.submitter_name || displayUser(ticket.requester) },
|
||||||
|
{ label: '提出机构', value: ticket.submitter_org || '未填写' },
|
||||||
{ label: '请求人', value: displayUser(ticket.requester) },
|
{ label: '请求人', value: displayUser(ticket.requester) },
|
||||||
{ label: '处理人', value: displayUser(ticket.assignee) },
|
{ label: '处理人', value: displayUser(ticket.assignee) },
|
||||||
{ label: '关联系统', value: ticket.related_system || '未填写' },
|
{ label: '关联系统', value: ticket.related_system || '未填写' },
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
|
||||||
|
import { TICKET_CATEGORIES } from './ticketCategories'
|
||||||
|
|
||||||
type TicketFilterBarProps = {
|
type TicketFilterBarProps = {
|
||||||
q: string
|
q: string
|
||||||
status: string
|
status: string
|
||||||
|
categoryCode: string
|
||||||
priority: string
|
priority: string
|
||||||
relatedSystem: string
|
relatedSystem: string
|
||||||
onQChange: (value: string) => void
|
onQChange: (value: string) => void
|
||||||
onStatusChange: (value: string) => void
|
onStatusChange: (value: string) => void
|
||||||
|
onCategoryCodeChange: (value: string) => void
|
||||||
onPriorityChange: (value: string) => void
|
onPriorityChange: (value: string) => void
|
||||||
onRelatedSystemChange: (value: string) => void
|
onRelatedSystemChange: (value: string) => void
|
||||||
onReset: () => void
|
onReset: () => void
|
||||||
@@ -20,10 +24,12 @@ type TicketFilterBarProps = {
|
|||||||
export function TicketFilterBar({
|
export function TicketFilterBar({
|
||||||
q,
|
q,
|
||||||
status,
|
status,
|
||||||
|
categoryCode,
|
||||||
priority,
|
priority,
|
||||||
relatedSystem,
|
relatedSystem,
|
||||||
onQChange,
|
onQChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
onCategoryCodeChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
onRelatedSystemChange,
|
onRelatedSystemChange,
|
||||||
onReset
|
onReset
|
||||||
@@ -54,6 +60,21 @@ export function TicketFilterBar({
|
|||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select onValueChange={onCategoryCodeChange} value={categoryCode}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="全部分类" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="all">全部分类</SelectItem>
|
||||||
|
{TICKET_CATEGORIES.map((category) => (
|
||||||
|
<SelectItem key={category.code} value={category.code}>
|
||||||
|
{category.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Select onValueChange={onPriorityChange} value={priority}>
|
<Select onValueChange={onPriorityChange} value={priority}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="全部优先级" />
|
<SelectValue placeholder="全部优先级" />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { formatDateTime } from '@/lib/format'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import { listTickets } from './api'
|
import { listTickets } from './api'
|
||||||
|
import { getTicketCategoryLabel } from './ticketCategories'
|
||||||
import { TicketFilterBar } from './TicketFilterBar'
|
import { TicketFilterBar } from './TicketFilterBar'
|
||||||
import { TicketMetricCards } from './TicketMetricCards'
|
import { TicketMetricCards } from './TicketMetricCards'
|
||||||
import type { Ticket, TicketListResponse, TicketListView } from './types'
|
import type { Ticket, TicketListResponse, TicketListView } from './types'
|
||||||
@@ -67,10 +68,13 @@ const columns: AppDataTableColumn<Ticket>[] = [
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="outline">{ticket.ticket_no}</Badge>
|
<Badge variant="outline">{ticket.ticket_no}</Badge>
|
||||||
|
<Badge variant="secondary">{getTicketCategoryLabel(ticket.category_code)}</Badge>
|
||||||
<span className="text-xs text-muted-foreground">请求人:{displayUser(ticket.requester)}</span>
|
<span className="text-xs text-muted-foreground">请求人:{displayUser(ticket.requester)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 line-clamp-1 font-medium text-foreground">{ticket.title}</p>
|
<p className="mt-2 line-clamp-1 font-medium text-foreground">{ticket.title}</p>
|
||||||
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{ticket.description || '暂无问题描述'}</p>
|
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
|
||||||
|
{ticket.problem_performance || ticket.description || '暂无问题描述'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -92,7 +96,9 @@ const columns: AppDataTableColumn<Ticket>[] = [
|
|||||||
cell: (ticket) => (
|
cell: (ticket) => (
|
||||||
<div className="grid min-w-0 gap-1">
|
<div className="grid min-w-0 gap-1">
|
||||||
<p className="truncate text-sm text-foreground">{ticket.related_system || '未填写系统'}</p>
|
<p className="truncate text-sm text-foreground">{ticket.related_system || '未填写系统'}</p>
|
||||||
<p className="line-clamp-1 text-xs text-muted-foreground">{ticket.impact_scope || ticket.environment || '暂无影响范围'}</p>
|
<p className="line-clamp-1 text-xs text-muted-foreground">
|
||||||
|
{ticket.problem_object || ticket.impact_scope || ticket.environment || '暂无影响范围'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -149,6 +155,7 @@ export function TicketListPage() {
|
|||||||
})
|
})
|
||||||
const [view, setView] = React.useState<TicketListView>('all')
|
const [view, setView] = React.useState<TicketListView>('all')
|
||||||
const [status, setStatus] = React.useState('all')
|
const [status, setStatus] = React.useState('all')
|
||||||
|
const [categoryCode, setCategoryCode] = React.useState('all')
|
||||||
const [priority, setPriority] = React.useState('all')
|
const [priority, setPriority] = React.useState('all')
|
||||||
const [relatedSystem, setRelatedSystem] = React.useState('')
|
const [relatedSystem, setRelatedSystem] = React.useState('')
|
||||||
const [q, setQ] = React.useState('')
|
const [q, setQ] = React.useState('')
|
||||||
@@ -163,6 +170,7 @@ export function TicketListPage() {
|
|||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
view,
|
view,
|
||||||
status: status === 'all' ? '' : status,
|
status: status === 'all' ? '' : status,
|
||||||
|
category_code: categoryCode === 'all' ? '' : categoryCode,
|
||||||
priority: priority === 'all' ? '' : priority,
|
priority: priority === 'all' ? '' : priority,
|
||||||
related_system: relatedSystem,
|
related_system: relatedSystem,
|
||||||
q
|
q
|
||||||
@@ -173,10 +181,11 @@ export function TicketListPage() {
|
|||||||
})
|
})
|
||||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [page, priority, q, relatedSystem, status, view])
|
}, [categoryCode, page, priority, q, relatedSystem, status, view])
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
setStatus('all')
|
setStatus('all')
|
||||||
|
setCategoryCode('all')
|
||||||
setPriority('all')
|
setPriority('all')
|
||||||
setRelatedSystem('')
|
setRelatedSystem('')
|
||||||
setQ('')
|
setQ('')
|
||||||
@@ -198,6 +207,11 @@ export function TicketListPage() {
|
|||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateCategoryCode(value: string) {
|
||||||
|
setCategoryCode(value)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
function updateRelatedSystem(value: string) {
|
function updateRelatedSystem(value: string) {
|
||||||
setRelatedSystem(value)
|
setRelatedSystem(value)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
@@ -213,7 +227,7 @@ export function TicketListPage() {
|
|||||||
setPage(target)
|
setPage(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveFilters = status !== 'all' || priority !== 'all' || relatedSystem.trim() || q.trim()
|
const hasActiveFilters = status !== 'all' || categoryCode !== 'all' || priority !== 'all' || relatedSystem.trim() || q.trim()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppPage contentClassName="gap-5">
|
<AppPage contentClassName="gap-5">
|
||||||
@@ -252,10 +266,12 @@ export function TicketListPage() {
|
|||||||
<TicketFilterBar
|
<TicketFilterBar
|
||||||
q={q}
|
q={q}
|
||||||
status={status}
|
status={status}
|
||||||
|
categoryCode={categoryCode}
|
||||||
priority={priority}
|
priority={priority}
|
||||||
relatedSystem={relatedSystem}
|
relatedSystem={relatedSystem}
|
||||||
onQChange={updateQuery}
|
onQChange={updateQuery}
|
||||||
onStatusChange={updateStatus}
|
onStatusChange={updateStatus}
|
||||||
|
onCategoryCodeChange={updateCategoryCode}
|
||||||
onPriorityChange={updatePriority}
|
onPriorityChange={updatePriority}
|
||||||
onRelatedSystemChange={updateRelatedSystem}
|
onRelatedSystemChange={updateRelatedSystem}
|
||||||
onReset={resetFilters}
|
onReset={resetFilters}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
|
||||||
|
import { getTicketCategoryLabel } from './ticketCategories'
|
||||||
import type { Ticket } from './types'
|
import type { Ticket } from './types'
|
||||||
|
|
||||||
type TicketMetaPanelProps = {
|
type TicketMetaPanelProps = {
|
||||||
@@ -29,6 +30,19 @@ export function TicketMetaPanel({ ticket }: TicketMetaPanelProps) {
|
|||||||
<AppField label="优先级">
|
<AppField label="优先级">
|
||||||
<AppPriorityBadge priority={ticket.priority} />
|
<AppPriorityBadge priority={ticket.priority} />
|
||||||
</AppField>
|
</AppField>
|
||||||
|
<AppField label="问题分类">
|
||||||
|
<span className="text-sm text-foreground">{getTicketCategoryLabel(ticket.category_code)}</span>
|
||||||
|
</AppField>
|
||||||
|
<Separator />
|
||||||
|
<AppField label="提出人">
|
||||||
|
<span className="text-sm text-foreground">{ticket.submitter_name || '未填写'}</span>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="提出机构">
|
||||||
|
<span className="text-sm text-foreground">{ticket.submitter_org || '未填写'}</span>
|
||||||
|
</AppField>
|
||||||
|
<AppField label="问题对象">
|
||||||
|
<span className="text-sm text-foreground">{ticket.problem_object || '未填写'}</span>
|
||||||
|
</AppField>
|
||||||
<Separator />
|
<Separator />
|
||||||
<AppField label="处理人">
|
<AppField label="处理人">
|
||||||
<span className="text-sm text-foreground">{displayUser(ticket.assignee)}</span>
|
<span className="text-sm text-foreground">{displayUser(ticket.assignee)}</span>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle
|
DialogTitle
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
|
||||||
import type { TicketStatus } from './types'
|
import type { TicketStatus } from './types'
|
||||||
@@ -26,7 +27,15 @@ type TicketTransitionDialogProps = {
|
|||||||
status: TicketStatus | null
|
status: TicketStatus | null
|
||||||
actionLabel?: string
|
actionLabel?: string
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSubmit: (status: TicketStatus, remark: string) => Promise<void>
|
onSubmit: (
|
||||||
|
status: TicketStatus,
|
||||||
|
values: {
|
||||||
|
remark: string
|
||||||
|
resolution: string
|
||||||
|
is_resolved: boolean
|
||||||
|
replied_to_customer: boolean
|
||||||
|
}
|
||||||
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketTransitionDialog({
|
export function TicketTransitionDialog({
|
||||||
@@ -37,22 +46,33 @@ export function TicketTransitionDialog({
|
|||||||
onSubmit
|
onSubmit
|
||||||
}: TicketTransitionDialogProps) {
|
}: TicketTransitionDialogProps) {
|
||||||
const [remark, setRemark] = React.useState('')
|
const [remark, setRemark] = React.useState('')
|
||||||
|
const [resolution, setResolution] = React.useState('')
|
||||||
|
const [repliedToCustomer, setRepliedToCustomer] = React.useState(true)
|
||||||
const [submitting, setSubmitting] = React.useState(false)
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
const actionLabel = customActionLabel ?? (status ? STATUS_ACTION_LABELS[status] ?? '调整状态' : '调整状态')
|
const actionLabel = customActionLabel ?? (status ? STATUS_ACTION_LABELS[status] ?? '调整状态' : '调整状态')
|
||||||
|
const isResolveAction = status === 'resolved'
|
||||||
|
const canSubmit = isResolveAction ? Boolean((resolution || remark).trim()) : Boolean(remark.trim())
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setRemark('')
|
setRemark('')
|
||||||
|
setResolution('')
|
||||||
|
setRepliedToCustomer(true)
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!status || !remark.trim()) return
|
if (!status || !canSubmit) return
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await onSubmit(status, remark.trim())
|
await onSubmit(status, {
|
||||||
|
remark: remark.trim(),
|
||||||
|
resolution: resolution.trim(),
|
||||||
|
is_resolved: status === 'resolved',
|
||||||
|
replied_to_customer: repliedToCustomer
|
||||||
|
})
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
@@ -65,23 +85,46 @@ export function TicketTransitionDialog({
|
|||||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{actionLabel}</DialogTitle>
|
<DialogTitle>{actionLabel}</DialogTitle>
|
||||||
<DialogDescription>请说明本次流转的原因、处理动作或待确认事项。</DialogDescription>
|
<DialogDescription>
|
||||||
|
{isResolveAction ? '填写本次处理结论,系统会同步记录到工单详情和流转时间线。' : '请说明本次流转的原因、处理动作或待确认事项。'}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<AppField label="处理说明" htmlFor="transition-remark" required>
|
{isResolveAction ? (
|
||||||
|
<AppField label="解决方案" htmlFor="transition-resolution" required>
|
||||||
|
<Textarea
|
||||||
|
id="transition-resolution"
|
||||||
|
required
|
||||||
|
minLength={2}
|
||||||
|
placeholder="例如:已查询采集进度,当前已完成 80%,预计今日完成。"
|
||||||
|
value={resolution}
|
||||||
|
onChange={(event) => setResolution(event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
) : null}
|
||||||
|
<AppField label={isResolveAction ? '处理说明' : '处理说明'} htmlFor="transition-remark" required={!isResolveAction}>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="transition-remark"
|
id="transition-remark"
|
||||||
required
|
required={!isResolveAction}
|
||||||
minLength={2}
|
minLength={2}
|
||||||
placeholder="例如:已完成日志排查,定位为配置变更导致,提交业务侧确认。"
|
placeholder={isResolveAction ? '可补充内部排查说明。' : '例如:已完成日志排查,定位为配置变更导致,提交业务侧确认。'}
|
||||||
value={remark}
|
value={remark}
|
||||||
onChange={(event) => setRemark(event.target.value)}
|
onChange={(event) => setRemark(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</AppField>
|
</AppField>
|
||||||
|
{isResolveAction ? (
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border p-3">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span className="text-sm font-medium text-foreground">已回复客户</span>
|
||||||
|
<span className="text-xs text-muted-foreground">开启后会在工单结果中记录为已回复客户。</span>
|
||||||
|
</div>
|
||||||
|
<Switch checked={repliedToCustomer} onCheckedChange={setRepliedToCustomer} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={!remark.trim() || submitting}>
|
<Button type="submit" disabled={!canSubmit || submitting}>
|
||||||
{submitting ? '提交中...' : '确认提交'}
|
{submitting ? '提交中...' : '确认提交'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -36,10 +36,25 @@ export function listTicketEvents(ticketId: number) {
|
|||||||
return apiRequest<TicketEvent[]>(`/tickets/${ticketId}/events`)
|
return apiRequest<TicketEvent[]>(`/tickets/${ticketId}/events`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transitionTicket(ticketId: number, status: TicketStatus, remark = '') {
|
export function transitionTicket(
|
||||||
|
ticketId: number,
|
||||||
|
status: TicketStatus,
|
||||||
|
options: {
|
||||||
|
remark?: string
|
||||||
|
resolution?: string
|
||||||
|
is_resolved?: boolean
|
||||||
|
replied_to_customer?: boolean
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
return apiRequest<Ticket>(`/tickets/${ticketId}/status`, {
|
return apiRequest<Ticket>(`/tickets/${ticketId}/status`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ status, remark })
|
body: JSON.stringify({
|
||||||
|
status,
|
||||||
|
remark: options.remark ?? '',
|
||||||
|
resolution: options.resolution ?? '',
|
||||||
|
is_resolved: options.is_resolved,
|
||||||
|
replied_to_customer: options.replied_to_customer
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
frontend/src/features/tickets/ticketCategories.ts
Normal file
88
frontend/src/features/tickets/ticketCategories.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { TicketCategoryCode, TicketStatus } from './types'
|
||||||
|
|
||||||
|
export const TICKET_CATEGORIES: Array<{
|
||||||
|
code: TicketCategoryCode
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
policy: '短流程' | '标准流程' | '中流程'
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
code: 'business_issue',
|
||||||
|
label: '业务问题',
|
||||||
|
description: '采集进度、业务规则、业务状态确认',
|
||||||
|
policy: '短流程'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'technical_issue',
|
||||||
|
label: '技术问题',
|
||||||
|
description: '系统报错、接口异常、服务不可用',
|
||||||
|
policy: '标准流程'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'data_issue',
|
||||||
|
label: '数据问题',
|
||||||
|
description: '数据缺失、不一致、采集或同步异常',
|
||||||
|
policy: '中流程'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'operation_issue',
|
||||||
|
label: '操作问题',
|
||||||
|
description: '操作指导、配置入口、权限使用',
|
||||||
|
policy: '短流程'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TICKET_CATEGORY_LABELS = Object.fromEntries(
|
||||||
|
TICKET_CATEGORIES.map((category) => [category.code, category.label])
|
||||||
|
) as Record<TicketCategoryCode, string>
|
||||||
|
|
||||||
|
export const CATEGORY_NEXT_STATUSES: Record<TicketCategoryCode | 'default', Record<TicketStatus, TicketStatus[]>> = {
|
||||||
|
default: {
|
||||||
|
pending_info: ['pending', 'closed'],
|
||||||
|
pending: ['in_progress', 'closed'],
|
||||||
|
in_progress: ['pending_confirm'],
|
||||||
|
pending_confirm: ['resolved', 'in_progress', 'closed'],
|
||||||
|
resolved: ['closed'],
|
||||||
|
closed: []
|
||||||
|
},
|
||||||
|
business_issue: {
|
||||||
|
pending_info: ['pending', 'resolved', 'closed'],
|
||||||
|
pending: ['resolved', 'closed'],
|
||||||
|
in_progress: ['resolved', 'closed'],
|
||||||
|
pending_confirm: ['resolved', 'closed'],
|
||||||
|
resolved: ['closed'],
|
||||||
|
closed: []
|
||||||
|
},
|
||||||
|
operation_issue: {
|
||||||
|
pending_info: ['pending', 'resolved', 'closed'],
|
||||||
|
pending: ['resolved', 'closed'],
|
||||||
|
in_progress: ['resolved', 'closed'],
|
||||||
|
pending_confirm: ['resolved', 'closed'],
|
||||||
|
resolved: ['closed'],
|
||||||
|
closed: []
|
||||||
|
},
|
||||||
|
data_issue: {
|
||||||
|
pending_info: ['pending', 'closed'],
|
||||||
|
pending: ['in_progress', 'resolved', 'closed'],
|
||||||
|
in_progress: ['resolved', 'pending_confirm', 'closed'],
|
||||||
|
pending_confirm: ['resolved', 'in_progress', 'closed'],
|
||||||
|
resolved: ['closed'],
|
||||||
|
closed: []
|
||||||
|
},
|
||||||
|
technical_issue: {
|
||||||
|
pending_info: ['pending', 'closed'],
|
||||||
|
pending: ['in_progress', 'closed'],
|
||||||
|
in_progress: ['pending_confirm'],
|
||||||
|
pending_confirm: ['resolved', 'in_progress', 'closed'],
|
||||||
|
resolved: ['closed'],
|
||||||
|
closed: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTicketCategoryLabel(code?: TicketCategoryCode | null) {
|
||||||
|
return code ? TICKET_CATEGORY_LABELS[code] : '未分类'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTicketNextStatuses(code: TicketCategoryCode | null, status: TicketStatus) {
|
||||||
|
return CATEGORY_NEXT_STATUSES[code ?? 'default'][status]
|
||||||
|
}
|
||||||
@@ -8,19 +8,38 @@ export type TicketStatus =
|
|||||||
|
|
||||||
export type TicketPriority = 'low' | 'medium' | 'high' | 'urgent'
|
export type TicketPriority = 'low' | 'medium' | 'high' | 'urgent'
|
||||||
|
|
||||||
|
export type TicketCategoryCode = 'business_issue' | 'technical_issue' | 'data_issue' | 'operation_issue'
|
||||||
|
|
||||||
export type TicketUser = {
|
export type TicketUser = {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
display_name: string
|
display_name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TicketCategory = {
|
||||||
|
id: number
|
||||||
|
code: TicketCategoryCode
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
export type Ticket = {
|
export type Ticket = {
|
||||||
id: number
|
id: number
|
||||||
ticket_no: string
|
ticket_no: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
category: TicketCategory | null
|
||||||
|
category_code: TicketCategoryCode | null
|
||||||
status: TicketStatus
|
status: TicketStatus
|
||||||
priority: TicketPriority
|
priority: TicketPriority
|
||||||
|
problem_performance: string
|
||||||
|
problem_object: string
|
||||||
|
submitter_name: string
|
||||||
|
submitter_org: string
|
||||||
|
ai_solution: string
|
||||||
|
resolution: string
|
||||||
|
is_resolved: boolean
|
||||||
|
replied_to_customer: boolean
|
||||||
impact_scope: string
|
impact_scope: string
|
||||||
environment: string
|
environment: string
|
||||||
related_system: string
|
related_system: string
|
||||||
@@ -47,6 +66,7 @@ export type TicketListParams = {
|
|||||||
page_size?: number
|
page_size?: number
|
||||||
status?: string
|
status?: string
|
||||||
priority?: string
|
priority?: string
|
||||||
|
category_code?: string
|
||||||
q?: string
|
q?: string
|
||||||
related_system?: string
|
related_system?: string
|
||||||
assignee_id?: number
|
assignee_id?: number
|
||||||
@@ -56,7 +76,12 @@ export type TicketListParams = {
|
|||||||
export type TicketInput = {
|
export type TicketInput = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
category_code: TicketCategoryCode
|
||||||
priority: TicketPriority
|
priority: TicketPriority
|
||||||
|
problem_performance: string
|
||||||
|
problem_object: string
|
||||||
|
submitter_name: string
|
||||||
|
submitter_org: string
|
||||||
impact_scope: string
|
impact_scope: string
|
||||||
environment: string
|
environment: string
|
||||||
related_system: string
|
related_system: string
|
||||||
|
|||||||
Reference in New Issue
Block a user