feat: finish ticket create visual experience
This commit is contained in:
@@ -45,7 +45,7 @@ Frontend:
|
|||||||
- Modify: `backend/apps/tickets/services.py`
|
- Modify: `backend/apps/tickets/services.py`
|
||||||
- Modify: `backend/apps/tickets/tests/test_ticket_api.py`
|
- Modify: `backend/apps/tickets/tests/test_ticket_api.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing pagination/filter/display tests**
|
- [x] **Step 1: Write failing pagination/filter/display tests**
|
||||||
|
|
||||||
Add these tests to `backend/apps/tickets/tests/test_ticket_api.py`:
|
Add these tests to `backend/apps/tickets/tests/test_ticket_api.py`:
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ def test_transition_ticket_requires_non_empty_remark(admin_client, db):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify RED**
|
- [x] **Step 2: Run tests to verify RED**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ python -m pytest apps/tickets/tests/test_ticket_api.py -v
|
|||||||
|
|
||||||
Expected: fails because list response is currently a list, display users are missing, advanced filters are missing, and empty transition remark is allowed.
|
Expected: fails because list response is currently a list, display users are missing, advanced filters are missing, and empty transition remark is allowed.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement schemas and serialization**
|
- [x] **Step 3: Implement schemas and serialization**
|
||||||
|
|
||||||
Add schemas to `backend/apps/tickets/schemas.py`:
|
Add schemas to `backend/apps/tickets/schemas.py`:
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ def serialize_ticket(ticket: Ticket) -> dict:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Implement paginated list and filters**
|
- [x] **Step 4: Implement paginated list and filters**
|
||||||
|
|
||||||
Change `list_tickets` to return `TicketListOut` and accept:
|
Change `list_tickets` to return `TicketListOut` and accept:
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ page = max(page, 1)
|
|||||||
page_size = min(max(page_size, 1), 100)
|
page_size = min(max(page_size, 1), 100)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Require transition remarks**
|
- [x] **Step 5: Require transition remarks**
|
||||||
|
|
||||||
In `update_ticket_status`, reject blank remarks:
|
In `update_ticket_status`, reject blank remarks:
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ if not payload.remark.strip():
|
|||||||
|
|
||||||
Import `HttpError` from `ninja.errors`.
|
Import `HttpError` from `ninja.errors`.
|
||||||
|
|
||||||
- [ ] **Step 6: Update existing tests for new list shape**
|
- [x] **Step 6: Update existing tests for new list shape**
|
||||||
|
|
||||||
Change existing list assertions in `test_list_tickets_filters_by_status`:
|
Change existing list assertions in `test_list_tickets_filters_by_status`:
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ assert payload["total"] == 1
|
|||||||
assert payload["items"][0]["ticket_no"] == "TK202606300001"
|
assert payload["items"][0]["ticket_no"] == "TK202606300001"
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 7: Verify GREEN**
|
- [x] **Step 7: Verify GREEN**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ python -m ruff check .
|
|||||||
|
|
||||||
Expected: all selected tests pass and ruff has no errors.
|
Expected: all selected tests pass and ruff has no errors.
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
- [x] **Step 8: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/apps/tickets
|
git add backend/apps/tickets
|
||||||
@@ -278,7 +278,7 @@ git commit -m "feat: add ticket list visual support api"
|
|||||||
- Create: `frontend/src/features/tickets/TicketMetricCards.tsx`
|
- Create: `frontend/src/features/tickets/TicketMetricCards.tsx`
|
||||||
- Create: `frontend/src/features/tickets/TicketFilterBar.tsx`
|
- Create: `frontend/src/features/tickets/TicketFilterBar.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1: Refresh shadcn docs**
|
- [x] **Step 1: Refresh shadcn docs**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ npx shadcn@latest docs tabs table card badge select input pagination skeleton so
|
|||||||
|
|
||||||
Use installed components first. Do not overwrite existing locally patched components.
|
Use installed components first. Do not overwrite existing locally patched components.
|
||||||
|
|
||||||
- [ ] **Step 2: Update ticket frontend types**
|
- [x] **Step 2: Update ticket frontend types**
|
||||||
|
|
||||||
In `frontend/src/features/tickets/types.ts`, add:
|
In `frontend/src/features/tickets/types.ts`, add:
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ export type TicketListParams = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Update ticket API**
|
- [x] **Step 3: Update ticket API**
|
||||||
|
|
||||||
In `frontend/src/features/tickets/api.ts`, update `listTickets` to return `TicketListResponse` and include all query fields:
|
In `frontend/src/features/tickets/api.ts`, update `listTickets` to return `TicketListResponse` and include all query fields:
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ export function listTickets(params: TicketListParams = {}) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Create TicketMetricCards**
|
- [x] **Step 4: Create TicketMetricCards**
|
||||||
|
|
||||||
Create `frontend/src/features/tickets/TicketMetricCards.tsx` with compact shadcn `Card` metrics for:
|
Create `frontend/src/features/tickets/TicketMetricCards.tsx` with compact shadcn `Card` metrics for:
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ type TicketMetricCardsProps = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Create TicketFilterBar**
|
- [x] **Step 5: Create TicketFilterBar**
|
||||||
|
|
||||||
Create `frontend/src/features/tickets/TicketFilterBar.tsx` using `AppFilterBar`, `Input`, `Select`, and `Button`.
|
Create `frontend/src/features/tickets/TicketFilterBar.tsx` using `AppFilterBar`, `Input`, `Select`, and `Button`.
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ type TicketFilterBarProps = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 6: Redesign TicketListPage**
|
- [x] **Step 6: Redesign TicketListPage**
|
||||||
|
|
||||||
Update `TicketListPage`:
|
Update `TicketListPage`:
|
||||||
|
|
||||||
@@ -400,7 +400,7 @@ Update `TicketListPage`:
|
|||||||
- Table columns must be Chinese and include requester/assignee display when available.
|
- Table columns must be Chinese and include requester/assignee display when available.
|
||||||
- Empty/error/loading states must be Chinese.
|
- Empty/error/loading states must be Chinese.
|
||||||
|
|
||||||
- [ ] **Step 7: Verify frontend GREEN**
|
- [x] **Step 7: Verify frontend GREEN**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -412,7 +412,7 @@ npm run build
|
|||||||
|
|
||||||
Expected: both pass.
|
Expected: both pass.
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
- [x] **Step 8: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add frontend/src/features/tickets
|
git add frontend/src/features/tickets
|
||||||
@@ -428,7 +428,7 @@ git commit -m "feat: redesign ticket list experience"
|
|||||||
- Create: `frontend/src/features/tickets/TicketTransitionDialog.tsx`
|
- Create: `frontend/src/features/tickets/TicketTransitionDialog.tsx`
|
||||||
- Create: `frontend/src/features/tickets/TicketMetaPanel.tsx`
|
- Create: `frontend/src/features/tickets/TicketMetaPanel.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1: Refresh shadcn docs**
|
- [x] **Step 1: Refresh shadcn docs**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -437,7 +437,7 @@ cd frontend
|
|||||||
npx shadcn@latest docs dialog textarea card badge separator tabs sonner
|
npx shadcn@latest docs dialog textarea card badge separator tabs sonner
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Create TicketTransitionDialog**
|
- [x] **Step 2: Create TicketTransitionDialog**
|
||||||
|
|
||||||
Create a dialog component with:
|
Create a dialog component with:
|
||||||
|
|
||||||
@@ -461,7 +461,7 @@ Visible text:
|
|||||||
|
|
||||||
The submit button is disabled until `remark.trim()` is non-empty.
|
The submit button is disabled until `remark.trim()` is non-empty.
|
||||||
|
|
||||||
- [ ] **Step 3: Create TicketMetaPanel**
|
- [x] **Step 3: Create TicketMetaPanel**
|
||||||
|
|
||||||
Create `TicketMetaPanel` with shadcn `Card`, `Badge`, `Separator`, `AppField`, `AppStatusBadge`, and `AppPriorityBadge`.
|
Create `TicketMetaPanel` with shadcn `Card`, `Badge`, `Separator`, `AppField`, `AppStatusBadge`, and `AppPriorityBadge`.
|
||||||
|
|
||||||
@@ -477,7 +477,7 @@ Display:
|
|||||||
影响范围
|
影响范围
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Update TicketStatusTimeline**
|
- [x] **Step 4: Update TicketStatusTimeline**
|
||||||
|
|
||||||
Make every event label Chinese:
|
Make every event label Chinese:
|
||||||
|
|
||||||
@@ -491,7 +491,7 @@ const EVENT_LABELS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Update TicketComments**
|
- [x] **Step 5: Update TicketComments**
|
||||||
|
|
||||||
Use Chinese labels and distinguish:
|
Use Chinese labels and distinguish:
|
||||||
|
|
||||||
@@ -506,7 +506,7 @@ Use shadcn `Tabs` or `Select` to choose visibility. Existing API can still recei
|
|||||||
{ comment_type: 'internal_note' | 'user_reply', visibility: 'internal' | 'public' }
|
{ comment_type: 'internal_note' | 'user_reply', visibility: 'internal' | 'public' }
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 6: Redesign TicketDetailPage**
|
- [x] **Step 6: Redesign TicketDetailPage**
|
||||||
|
|
||||||
Update page:
|
Update page:
|
||||||
|
|
||||||
@@ -516,7 +516,7 @@ Update page:
|
|||||||
- Main column contains `问题描述`, `处理记录`, `评论与协作`.
|
- Main column contains `问题描述`, `处理记录`, `评论与协作`.
|
||||||
- Side column uses `TicketMetaPanel`, `SolutionLinkPanel`, and restrained AI placeholder.
|
- Side column uses `TicketMetaPanel`, `SolutionLinkPanel`, and restrained AI placeholder.
|
||||||
|
|
||||||
- [ ] **Step 7: Verify frontend GREEN**
|
- [x] **Step 7: Verify frontend GREEN**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -528,7 +528,7 @@ npm run build
|
|||||||
|
|
||||||
Expected: both pass.
|
Expected: both pass.
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
- [x] **Step 8: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add frontend/src/features/tickets
|
git add frontend/src/features/tickets
|
||||||
@@ -540,7 +540,7 @@ git commit -m "feat: redesign ticket detail experience"
|
|||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `frontend/src/features/tickets/TicketCreatePage.tsx`
|
- Modify: `frontend/src/features/tickets/TicketCreatePage.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1: Refresh shadcn docs**
|
- [x] **Step 1: Refresh shadcn docs**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -549,7 +549,7 @@ cd frontend
|
|||||||
npx shadcn@latest docs card input select textarea badge alert
|
npx shadcn@latest docs card input select textarea badge alert
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Redesign create page**
|
- [x] **Step 2: Redesign create page**
|
||||||
|
|
||||||
Update `TicketCreatePage` into grouped sections:
|
Update `TicketCreatePage` into grouped sections:
|
||||||
|
|
||||||
@@ -563,7 +563,7 @@ Update `TicketCreatePage` into grouped sections:
|
|||||||
|
|
||||||
Visible copy must be Chinese. Use `Card`, `Input`, `Select`, `Textarea`, `Badge`, and `Button`.
|
Visible copy must be Chinese. Use `Card`, `Input`, `Select`, `Textarea`, `Badge`, and `Button`.
|
||||||
|
|
||||||
- [ ] **Step 3: Use Chinese error feedback**
|
- [x] **Step 3: Use Chinese error feedback**
|
||||||
|
|
||||||
On create failure, show:
|
On create failure, show:
|
||||||
|
|
||||||
@@ -571,7 +571,7 @@ On create failure, show:
|
|||||||
创建工单失败,请检查必填信息或稍后重试。
|
创建工单失败,请检查必填信息或稍后重试。
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Run full verification**
|
- [x] **Step 4: Run full verification**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -588,7 +588,7 @@ npx shadcn@latest info
|
|||||||
|
|
||||||
Expected: all commands pass.
|
Expected: all commands pass.
|
||||||
|
|
||||||
- [ ] **Step 5: Browser visual smoke test**
|
- [x] **Step 5: Browser visual smoke test**
|
||||||
|
|
||||||
With local backend/frontend running:
|
With local backend/frontend running:
|
||||||
|
|
||||||
@@ -597,7 +597,7 @@ With local backend/frontend running:
|
|||||||
- Open a ticket detail page and verify transition dialog requires handling note.
|
- Open a ticket detail page and verify transition dialog requires handling note.
|
||||||
- Open `#/tickets/new` and verify grouped create form has no obvious overlap.
|
- Open `#/tickets/new` and verify grouped create form has no obvious overlap.
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [x] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add frontend/src/features/tickets
|
git add frontend/src/features/tickets
|
||||||
@@ -606,15 +606,15 @@ git commit -m "feat: redesign ticket create experience"
|
|||||||
|
|
||||||
## Final Review Checklist
|
## Final Review Checklist
|
||||||
|
|
||||||
- [ ] Ticket list uses shadcn tabs, cards, table, filters, and pagination.
|
- [x] Ticket list uses shadcn tabs, cards, table, filters, and pagination.
|
||||||
- [ ] Ticket detail uses a Chinese transition dialog and no English loading/error copy.
|
- [x] Ticket detail uses a Chinese transition dialog and no English loading/error copy.
|
||||||
- [ ] Ticket comments distinguish internal notes and user-visible replies.
|
- [x] Ticket comments distinguish internal notes and user-visible replies.
|
||||||
- [ ] Ticket create page uses grouped Chinese form sections.
|
- [x] Ticket create page uses grouped Chinese form sections.
|
||||||
- [ ] Backend ticket list supports pagination and visual filters.
|
- [x] Backend ticket list supports pagination and visual filters.
|
||||||
- [ ] Ticket responses include requester and assignee display fields.
|
- [x] Ticket responses include requester and assignee display fields.
|
||||||
- [ ] Empty transition remarks are rejected.
|
- [x] Empty transition remarks are rejected.
|
||||||
- [ ] Backend ticket tests pass.
|
- [x] Backend ticket tests pass.
|
||||||
- [ ] Backend ruff passes.
|
- [x] Backend ruff passes.
|
||||||
- [ ] Frontend typecheck passes.
|
- [x] Frontend typecheck passes.
|
||||||
- [ ] Frontend build passes.
|
- [x] Frontend build passes.
|
||||||
- [ ] shadcn info still reports Vite/Tailwind v3 and installed components.
|
- [x] shadcn info still reports Vite/Tailwind v3 and installed components.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type AppDataTableProps<Row> = {
|
|||||||
emptyDescription?: string
|
emptyDescription?: string
|
||||||
toolbar?: React.ReactNode
|
toolbar?: React.ReactNode
|
||||||
footer?: React.ReactNode
|
footer?: React.ReactNode
|
||||||
|
tableClassName?: string
|
||||||
onRowClick?: (row: Row) => void
|
onRowClick?: (row: Row) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export function AppDataTable<Row>({
|
|||||||
emptyDescription,
|
emptyDescription,
|
||||||
toolbar,
|
toolbar,
|
||||||
footer,
|
footer,
|
||||||
|
tableClassName,
|
||||||
onRowClick
|
onRowClick
|
||||||
}: AppDataTableProps<Row>) {
|
}: AppDataTableProps<Row>) {
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +43,7 @@ export function AppDataTable<Row>({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table className={tableClassName}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { Bot } from 'lucide-react'
|
import { AlertCircle, Bot, CheckCircle2, Lightbulb, Send } from 'lucide-react'
|
||||||
|
|
||||||
import { AppField } from '@/components/app/AppField'
|
import { AppField } from '@/components/app/AppField'
|
||||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||||
import { AppSection } from '@/components/app/AppSection'
|
import { AppSection } from '@/components/app/AppSection'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -25,6 +26,7 @@ const initialForm: TicketInput = {
|
|||||||
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('')
|
||||||
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
|
|
||||||
function updateField<Key extends keyof TicketInput>(field: Key, value: TicketInput[Key]) {
|
function updateField<Key extends keyof TicketInput>(field: Key, value: TicketInput[Key]) {
|
||||||
setForm((current) => ({ ...current, [field]: value }))
|
setForm((current) => ({ ...current, [field]: value }))
|
||||||
@@ -33,21 +35,35 @@ export function TicketCreatePage() {
|
|||||||
async function handleSubmit(event: React.FormEvent) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const ticket = await createTicket(form)
|
const ticket = await createTicket(form)
|
||||||
window.location.hash = `/tickets/${ticket.id}`
|
window.location.hash = `/tickets/${ticket.id}`
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : '创建失败')
|
setError(reason instanceof Error ? reason.message : '创建工单失败,请检查必填信息或稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
<div className="mx-auto grid max-w-6xl gap-4">
|
<div className="mx-auto grid max-w-6xl gap-5">
|
||||||
<AppPageHeader title="创建工单" description="记录明确的技术支持请求,便于后续分派、排查和复用方案。" />
|
<AppPageHeader
|
||||||
|
title="创建工单"
|
||||||
|
description="记录明确的技术支持请求,便于后续分派、排查和复用方案。"
|
||||||
|
/>
|
||||||
<form className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]" onSubmit={handleSubmit}>
|
<form className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]" onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<AppSection title="问题描述" description="请先说明现象、出现时间和可复现条件。">
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
<AlertTitle>创建工单失败</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AppSection title="基础信息" description="先说明这张工单要解决什么问题。">
|
||||||
<AppField label="工单标题">
|
<AppField label="工单标题">
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
@@ -56,18 +72,9 @@ export function TicketCreatePage() {
|
|||||||
onChange={(event) => updateField('title', event.target.value)}
|
onChange={(event) => updateField('title', event.target.value)}
|
||||||
/>
|
/>
|
||||||
</AppField>
|
</AppField>
|
||||||
<div className="mt-4">
|
|
||||||
<AppField label="问题描述">
|
|
||||||
<Textarea
|
|
||||||
required
|
|
||||||
placeholder="请描述影响范围、报错信息、操作步骤和已尝试的处理方式。"
|
|
||||||
value={form.description}
|
|
||||||
onChange={(event) => updateField('description', event.target.value)}
|
|
||||||
/>
|
|
||||||
</AppField>
|
|
||||||
</div>
|
|
||||||
</AppSection>
|
</AppSection>
|
||||||
<AppSection title="处理属性" description="用于技术支持人员快速判断优先级和处理路径。">
|
|
||||||
|
<AppSection title="影响与优先级" description="帮助支持团队判断响应顺序和处理风险。">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<AppField label="优先级">
|
<AppField label="优先级">
|
||||||
<Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}>
|
<Select value={form.priority} onValueChange={(value) => updateField('priority', value as TicketInput['priority'])}>
|
||||||
@@ -82,13 +89,6 @@ export function TicketCreatePage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</AppField>
|
</AppField>
|
||||||
<AppField label="关联系统">
|
|
||||||
<Input
|
|
||||||
placeholder="例如:CRM、财务系统、数据中台"
|
|
||||||
value={form.related_system}
|
|
||||||
onChange={(event) => updateField('related_system', event.target.value)}
|
|
||||||
/>
|
|
||||||
</AppField>
|
|
||||||
<AppField label="影响范围">
|
<AppField label="影响范围">
|
||||||
<Input
|
<Input
|
||||||
placeholder="例如:单个用户、某部门、全部用户"
|
placeholder="例如:单个用户、某部门、全部用户"
|
||||||
@@ -96,6 +96,18 @@ export function TicketCreatePage() {
|
|||||||
onChange={(event) => updateField('impact_scope', event.target.value)}
|
onChange={(event) => updateField('impact_scope', event.target.value)}
|
||||||
/>
|
/>
|
||||||
</AppField>
|
</AppField>
|
||||||
|
</div>
|
||||||
|
</AppSection>
|
||||||
|
|
||||||
|
<AppSection title="环境与系统" description="补充问题发生的系统域和运行环境。">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<AppField label="关联系统">
|
||||||
|
<Input
|
||||||
|
placeholder="例如:CRM、财务系统、数据中台"
|
||||||
|
value={form.related_system}
|
||||||
|
onChange={(event) => updateField('related_system', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
<AppField label="运行环境">
|
<AppField label="运行环境">
|
||||||
<Input
|
<Input
|
||||||
placeholder="例如:生产环境、测试环境、Windows 终端"
|
placeholder="例如:生产环境、测试环境、Windows 终端"
|
||||||
@@ -105,20 +117,60 @@ export function TicketCreatePage() {
|
|||||||
</AppField>
|
</AppField>
|
||||||
</div>
|
</div>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
{error ? <p className="text-sm text-danger">{error}</p> : null}
|
|
||||||
<div className="flex justify-end">
|
<AppSection title="问题描述" description="请描述现象、出现时间、复现步骤和已尝试的处理方式。">
|
||||||
<Button type="submit">提交工单</Button>
|
<AppField label="详细描述">
|
||||||
|
<Textarea
|
||||||
|
required
|
||||||
|
placeholder="请描述影响范围、报错信息、操作步骤和已尝试的处理方式。"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(event) => updateField('description', event.target.value)}
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
</AppSection>
|
||||||
|
|
||||||
|
<AppSection title="提交确认" description="提交后工单会进入待处理队列。">
|
||||||
|
<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">
|
||||||
|
<CheckCircle2 className="mt-0.5 size-4" />
|
||||||
|
<span>请确认标题和问题描述已足够支持技术人员开始排查。</span>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
<Send className="size-4" />
|
||||||
|
{submitting ? '提交中...' : '提交工单'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</AppSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid content-start gap-4">
|
||||||
|
<AppSection title="建单提示" description="高质量工单能减少来回确认。">
|
||||||
|
<div className="grid gap-3 text-sm text-muted-foreground">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Lightbulb className="mt-0.5 size-4" />
|
||||||
|
<span>标题建议包含系统、场景和异常现象。</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Lightbulb className="mt-0.5 size-4" />
|
||||||
|
<span>描述中写清楚复现步骤、报错信息和影响范围。</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Lightbulb className="mt-0.5 size-4" />
|
||||||
|
<span>紧急优先级应只用于阻断生产或大范围影响问题。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppSection>
|
||||||
|
|
||||||
|
<AppSection
|
||||||
|
title="AI 建单辅助"
|
||||||
|
description="后续阶段能力预留"
|
||||||
|
action={<Badge variant="ai"><Bot className="size-3" /> 即将接入</Badge>}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
后续阶段将接入建单 Agent,用于补全字段、追问缺失信息和推荐相似问题。
|
||||||
|
</p>
|
||||||
|
</AppSection>
|
||||||
</div>
|
</div>
|
||||||
<AppSection
|
|
||||||
title="AI 建单辅助"
|
|
||||||
description="后续阶段能力预留"
|
|
||||||
action={<Badge variant="ai"><Bot className="size-3" /> 即将接入</Badge>}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
后续阶段将接入建单 Agent,用于补全字段、追问缺失信息和推荐相似问题。
|
|
||||||
</p>
|
|
||||||
</AppSection>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
} from '@/components/ui/pagination'
|
} from '@/components/ui/pagination'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { formatDateTime } from '@/lib/format'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import { listTickets } from './api'
|
import { listTickets } from './api'
|
||||||
@@ -27,6 +26,15 @@ import type { Ticket, TicketListResponse, TicketListView } from './types'
|
|||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
function formatTicketTableDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(new Date(value))
|
||||||
|
}
|
||||||
|
|
||||||
const viewTabs: Array<{ value: TicketListView; label: string }> = [
|
const viewTabs: Array<{ value: TicketListView; label: string }> = [
|
||||||
{ value: 'all', label: '全部工单' },
|
{ value: 'all', label: '全部工单' },
|
||||||
{ value: 'assigned_to_me', label: '我的待办' },
|
{ value: 'assigned_to_me', label: '我的待办' },
|
||||||
@@ -38,13 +46,15 @@ const columns: AppDataTableColumn<Ticket>[] = [
|
|||||||
{
|
{
|
||||||
key: 'ticket_no',
|
key: 'ticket_no',
|
||||||
header: '编号',
|
header: '编号',
|
||||||
|
className: 'w-[14%]',
|
||||||
cell: (ticket) => <span className="font-medium text-foreground">{ticket.ticket_no}</span>
|
cell: (ticket) => <span className="font-medium text-foreground">{ticket.ticket_no}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'title',
|
key: 'title',
|
||||||
header: '标题',
|
header: '标题',
|
||||||
|
className: 'w-[24%] whitespace-normal',
|
||||||
cell: (ticket) => (
|
cell: (ticket) => (
|
||||||
<div className="min-w-[240px]">
|
<div className="min-w-0">
|
||||||
<p className="line-clamp-1 font-medium text-foreground">{ticket.title}</p>
|
<p className="line-clamp-1 font-medium text-foreground">{ticket.title}</p>
|
||||||
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
|
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
|
||||||
{ticket.requester?.display_name ? `请求人:${ticket.requester.display_name}` : ticket.environment || '暂无环境信息'}
|
{ticket.requester?.display_name ? `请求人:${ticket.requester.display_name}` : ticket.environment || '暂无环境信息'}
|
||||||
@@ -55,46 +65,54 @@ const columns: AppDataTableColumn<Ticket>[] = [
|
|||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
header: '状态',
|
header: '状态',
|
||||||
|
className: 'w-[8%]',
|
||||||
cell: (ticket) => <AppStatusBadge status={ticket.status} />
|
cell: (ticket) => <AppStatusBadge status={ticket.status} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'priority',
|
key: 'priority',
|
||||||
header: '优先级',
|
header: '优先级',
|
||||||
|
className: 'w-[7%]',
|
||||||
cell: (ticket) => <AppPriorityBadge priority={ticket.priority} />
|
cell: (ticket) => <AppPriorityBadge priority={ticket.priority} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'related_system',
|
key: 'related_system',
|
||||||
header: '关联系统',
|
header: '关联系统',
|
||||||
cell: (ticket) => <span className="text-muted-foreground">{ticket.related_system || '-'}</span>
|
className: 'w-[10%]',
|
||||||
|
cell: (ticket) => <span className="block truncate text-muted-foreground">{ticket.related_system || '-'}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'impact_scope',
|
key: 'impact_scope',
|
||||||
header: '影响范围',
|
header: '影响范围',
|
||||||
cell: (ticket) => <span className="text-muted-foreground">{ticket.impact_scope || '-'}</span>
|
className: 'w-[11%]',
|
||||||
|
cell: (ticket) => <span className="block truncate text-muted-foreground">{ticket.impact_scope || '-'}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'assignee',
|
key: 'assignee',
|
||||||
header: '处理人',
|
header: '处理人',
|
||||||
cell: (ticket) => <span className="text-muted-foreground">{ticket.assignee?.display_name || '未分派'}</span>
|
className: 'w-[8%]',
|
||||||
|
cell: (ticket) => <span className="block truncate text-muted-foreground">{ticket.assignee?.display_name || '未分派'}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'created_at',
|
key: 'created_at',
|
||||||
header: '创建时间',
|
header: '创建时间',
|
||||||
cell: (ticket) => <span className="text-muted-foreground">{formatDateTime(ticket.created_at)}</span>
|
className: 'w-[10%]',
|
||||||
|
cell: (ticket) => <span className="text-muted-foreground">{formatTicketTableDate(ticket.created_at)}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '操作',
|
header: '操作',
|
||||||
|
className: 'w-[8%] text-right',
|
||||||
cell: (ticket) => (
|
cell: (ticket) => (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="gap-1"
|
size="sm"
|
||||||
|
className="gap-1 px-2"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
window.location.hash = `/tickets/${ticket.id}`
|
window.location.hash = `/tickets/${ticket.id}`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
查看详情
|
详情
|
||||||
<ArrowRight className="size-4" />
|
<ArrowRight className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@@ -264,6 +282,7 @@ export function TicketListPage() {
|
|||||||
</Pagination>
|
</Pagination>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
tableClassName="table-fixed"
|
||||||
onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)}
|
onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user