177 lines
7.2 KiB
TypeScript
177 lines
7.2 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
||
import { Gavel, Clock, CheckCircle2, XCircle, Loader2, MessageSquare, X } from 'lucide-react'
|
||
import { useRole } from '../../context'
|
||
import { useToast } from '../../context'
|
||
import { api } from '../../api'
|
||
import { EmptyState } from '../../components/ui/EmptyState'
|
||
import type { Appeal } from '../../types'
|
||
|
||
const statusLabels: Record<string, { label: string; color: string }> = {
|
||
pending: { label: '待处理', color: 'amber' },
|
||
accepted: { label: '已通过', color: 'emerald' },
|
||
rejected: { label: '已驳回', color: 'rose' },
|
||
}
|
||
|
||
/**
|
||
* 申诉复议页面 — 记者提交申诉,分站/总部处理
|
||
*/
|
||
export function AppealsPage() {
|
||
const { role } = useRole()
|
||
const { showToast } = useToast()
|
||
const [appeals, setAppeals] = useState<Appeal[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [filterStatus, setFilterStatus] = useState('')
|
||
const [selected, setSelected] = useState<Appeal | null>(null)
|
||
const [response, setResponse] = useState('')
|
||
const [handling, setHandling] = useState(false)
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const data = await api.appeals.list(role, filterStatus || undefined)
|
||
setAppeals(data)
|
||
} catch (err) {
|
||
showToast(err instanceof Error ? err.message : '加载失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [role, filterStatus, showToast])
|
||
|
||
useEffect(() => { load() }, [load])
|
||
|
||
const handleAppeal = async (decision: 'uphold' | 'overturn') => {
|
||
if (!selected) return
|
||
setHandling(true)
|
||
try {
|
||
await api.appeals.handle(role, selected.id, decision, response)
|
||
showToast(decision === 'uphold' ? '申诉已驳回' : '申诉已通过')
|
||
setSelected(null)
|
||
setResponse('')
|
||
load()
|
||
} catch (err) {
|
||
showToast(err instanceof Error ? err.message : '操作失败')
|
||
} finally {
|
||
setHandling(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="page-content">
|
||
<div className="page-heading small">
|
||
<div>
|
||
<div className="page-heading-icon"><Gavel size={20} /></div>
|
||
<div>
|
||
<h1>申诉复议</h1>
|
||
<span>对审核结果有异议的工作记录进行申诉与复议</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filter-bar">
|
||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||
<option value="">全部状态</option>
|
||
<option value="pending">待处理</option>
|
||
<option value="accepted">已通过</option>
|
||
<option value="rejected">已驳回</option>
|
||
</select>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="loading-center"><Loader2 size={24} className="spin" /></div>
|
||
) : appeals.length === 0 ? (
|
||
<EmptyState icon={Gavel} text="暂无申诉记录" />
|
||
) : (
|
||
<div className="appeal-list">
|
||
{appeals.map(a => {
|
||
const st = statusLabels[a.status] || statusLabels.pending
|
||
return (
|
||
<div key={a.id} className={`appeal-card status-${st.color}`} onClick={() => { setSelected(a); setResponse('') }}>
|
||
<div className="appeal-header">
|
||
<span className="appeal-code">{a.code}</span>
|
||
<span className={`appeal-status ${st.color}`}>{st.label}</span>
|
||
</div>
|
||
<div className="appeal-body">
|
||
<div className="appeal-meta">
|
||
<span>申诉人:{a.appellant}</span>
|
||
<span>站点:{a.station}</span>
|
||
<span>记录ID:{a.recordId}</span>
|
||
</div>
|
||
<div className="appeal-reason">
|
||
<MessageSquare size={14} />
|
||
<span>{a.reason}</span>
|
||
</div>
|
||
{a.response && (
|
||
<div className="appeal-response">
|
||
<strong>处理意见:</strong>{a.response}
|
||
</div>
|
||
)}
|
||
<div className="appeal-footer">
|
||
<Clock size={13} />
|
||
<span>提交于 {a.createdAt}</span>
|
||
{a.handledAt && <span>处理于 {a.handledAt}</span>}
|
||
{a.handler && <span>处理人:{a.handler}</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{selected && (
|
||
<div className="modal-layer" onClick={() => { setSelected(null); setResponse('') }}>
|
||
<div className="modal" role="dialog" aria-modal="true" onClick={e => e.stopPropagation()} style={{ maxWidth: 520 }}>
|
||
<div className="modal-head">
|
||
<h2 style={{ fontSize: 16, margin: 0 }}>申诉详情 — {selected.code}</h2>
|
||
<button className="icon-button" onClick={() => { setSelected(null); setResponse('') }}>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
<div className="modal-body" style={{ padding: 16 }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
|
||
<div><strong>申诉人:</strong>{selected.appellant}({selected.station})</div>
|
||
<div><strong>记录ID:</strong>{selected.recordId}</div>
|
||
<div><strong>申诉原因:</strong>{selected.reason}</div>
|
||
<div><strong>状态:</strong>{statusLabels[selected.status]?.label}</div>
|
||
{selected.response && <div><strong>处理意见:</strong>{selected.response}</div>}
|
||
{selected.handler && <div><strong>处理人:</strong>{selected.handler}</div>}
|
||
<div><strong>提交时间:</strong>{selected.createdAt}</div>
|
||
{selected.handledAt && <div><strong>处理时间:</strong>{selected.handledAt}</div>}
|
||
</div>
|
||
|
||
{role !== 'reporter' && selected.status === 'pending' && (
|
||
<>
|
||
<textarea
|
||
placeholder="输入处理意见..."
|
||
value={response}
|
||
onChange={e => setResponse(e.target.value)}
|
||
rows={3}
|
||
style={{ width: '100%', marginBottom: 12, padding: 8, borderRadius: 6, border: '1px solid var(--border)' }}
|
||
/>
|
||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||
<button
|
||
className="danger-button"
|
||
style={{ height: 38, padding: '0 15px', fontSize: 13, fontWeight: 600, borderRadius: 4, border: '1px solid var(--red)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 7 }}
|
||
disabled={handling}
|
||
onClick={() => handleAppeal('uphold')}
|
||
>
|
||
<XCircle size={16} /> 驳回申诉
|
||
</button>
|
||
<button
|
||
className="primary-button"
|
||
disabled={handling}
|
||
onClick={() => handleAppeal('overturn')}
|
||
>
|
||
<CheckCircle2 size={16} /> 通过申诉
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|