223 lines
9.2 KiB
TypeScript
223 lines
9.2 KiB
TypeScript
import { useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import {
|
||
useMyAppeals,
|
||
useSupplementAppeal,
|
||
useWithdrawAppeal,
|
||
type MyAppealItem,
|
||
} from '../hooks/useMarketPublish';
|
||
|
||
/** 申诉状态 → 中文徽标(对齐后端 MarketAppealServiceImpl 申诉状态机)。 */
|
||
const APPEAL_STATUS_META: Record<string, { label: string; className: string }> = {
|
||
pending: { label: '待处理', className: 'bg-amber-50 text-amber-700' },
|
||
reviewing: { label: '审核中', className: 'bg-blue-50 text-blue-700' },
|
||
supplementing: { label: '待补充材料', className: 'bg-amber-50 text-amber-700' },
|
||
maintained: { label: '维持原判', className: 'bg-slate-100 text-slate-500' },
|
||
restored: { label: '已恢复', className: 'bg-emerald-50 text-emerald-700' },
|
||
partially_restored: { label: '部分恢复', className: 'bg-emerald-50 text-emerald-700' },
|
||
closed: { label: '已关闭', className: 'bg-slate-100 text-slate-500' },
|
||
};
|
||
|
||
function appealStatusMeta(status: string): { label: string; className: string } {
|
||
return APPEAL_STATUS_META[status] ?? { label: status, className: 'bg-slate-100 text-slate-500' };
|
||
}
|
||
|
||
function formatSubmittedAt(value: number | null): string {
|
||
if (!value) return '—';
|
||
return new Date(value).toLocaleString();
|
||
}
|
||
|
||
function appealTypeLabel(type: string): string {
|
||
if (type === 'license_revocation') return '撤权申诉';
|
||
if (type === 'recall') return '召回申诉';
|
||
if (type === 'delist') return '下架申诉';
|
||
if (type === 'review_rejection') return '审核驳回申诉';
|
||
if (type === 'usage_impact') return '使用影响申诉';
|
||
return type;
|
||
}
|
||
|
||
interface AppealRowProps {
|
||
appeal: MyAppealItem;
|
||
onWithdraw: (appealId: string, expectedStatus: string) => void;
|
||
onSupplement: (appealId: string, description: string) => void;
|
||
withdrawPending: boolean;
|
||
supplementPending: boolean;
|
||
}
|
||
|
||
/** 单条申诉处理行:补充/撤回都通过后端命令接口执行,前端不直接改状态。 */
|
||
function AppealRow({
|
||
appeal,
|
||
onWithdraw,
|
||
onSupplement,
|
||
withdrawPending,
|
||
supplementPending,
|
||
}: AppealRowProps) {
|
||
const [supplementOpen, setSupplementOpen] = useState(false);
|
||
const [description, setDescription] = useState('已按要求补充材料与证据,请复核。');
|
||
const status = appealStatusMeta(appeal.status);
|
||
|
||
return (
|
||
<li className="px-5 py-4">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-bold text-slate-500">
|
||
{appealTypeLabel(appeal.appealType)}
|
||
</span>
|
||
<span className={`rounded-full px-2.5 py-1 text-[11px] font-bold ${status.className}`}>
|
||
{status.label}
|
||
</span>
|
||
<span className="text-xs text-slate-400">提交时间:{formatSubmittedAt(appeal.submittedAt)}</span>
|
||
</div>
|
||
<div className="mt-2 text-sm font-semibold text-slate-800">{appeal.reason || '未填写申诉理由'}</div>
|
||
{appeal.resolution && <div className="mt-1 text-xs text-slate-500">处理结论:{appeal.resolution}</div>}
|
||
<div className="mt-1 text-xs text-slate-400">资产 ID:{appeal.assetId}</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{appeal.canSupplement && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSupplementOpen((open) => !open)}
|
||
className="rounded-full bg-amber-500 px-3 py-1 text-[11px] font-semibold text-white transition hover:bg-amber-600"
|
||
>
|
||
补充材料
|
||
</button>
|
||
)}
|
||
{appeal.canWithdraw && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onWithdraw(appeal.appealId, appeal.status)}
|
||
disabled={withdrawPending}
|
||
className="rounded-full bg-white px-3 py-1 text-[11px] font-semibold text-slate-600 ring-1 ring-slate-200 transition hover:ring-slate-300 disabled:text-slate-300"
|
||
>
|
||
{withdrawPending ? '撤回中...' : '撤回'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{supplementOpen && (
|
||
<div className="mt-4 rounded-xl border border-amber-100 bg-amber-50/40 p-4">
|
||
<textarea
|
||
value={description}
|
||
onChange={(event) => setDescription(event.target.value)}
|
||
rows={3}
|
||
placeholder="补充说明(材料 / 证据,提交即确认隐私授权)"
|
||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-amber-400"
|
||
/>
|
||
<div className="mt-3 flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => onSupplement(appeal.appealId, description)}
|
||
disabled={supplementPending || description.trim().length === 0}
|
||
className="rounded-xl bg-amber-500 px-4 py-2 text-sm font-bold text-white transition hover:bg-amber-600 disabled:bg-slate-300"
|
||
>
|
||
{supplementPending ? '提交中...' : '提交补充'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSupplementOpen(false)}
|
||
className="rounded-xl bg-white px-4 py-2 text-sm font-bold text-slate-600 ring-1 ring-slate-200 transition hover:ring-slate-300"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 市场治理与申诉中心。
|
||
*
|
||
* 边界:这里处理发布者/使用者面对下架、召回、撤权等治理结果后的申诉、补充、撤回;
|
||
* 不暴露管理员下架/召回/撤权命令,避免普通用户越权执行治理动作。
|
||
*/
|
||
export default function MarketAppealCenter() {
|
||
const appeals = useMyAppeals();
|
||
const withdrawAppeal = useWithdrawAppeal();
|
||
const supplementAppeal = useSupplementAppeal();
|
||
const [message, setMessage] = useState<string | null>(null);
|
||
|
||
const handleWithdraw = async (appealId: string, expectedStatus: string) => {
|
||
setMessage(null);
|
||
try {
|
||
await withdrawAppeal.mutateAsync({ appealId, expectedStatus, reason: '用户从治理申诉中心撤回申诉' });
|
||
setMessage(`申诉 #${appealId} 已撤回`);
|
||
} catch (error) {
|
||
setMessage(`撤回失败:${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
};
|
||
|
||
const handleSupplement = async (appealId: string, description: string) => {
|
||
if (description.trim().length === 0) return;
|
||
setMessage(null);
|
||
try {
|
||
await supplementAppeal.mutateAsync({ appealId, description: description.trim() });
|
||
setMessage(`申诉 #${appealId} 材料已补充`);
|
||
} catch (error) {
|
||
setMessage(`补充失败:${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
};
|
||
|
||
const list = appeals.data ?? [];
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50/60 px-6 py-8">
|
||
<div className="mx-auto max-w-5xl">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-extrabold tracking-tight text-slate-950">治理与申诉</h1>
|
||
<p className="mt-2 text-sm text-slate-500">
|
||
处理下架、召回、撤权和审核驳回产生的申诉事项。
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<Link to="/market/publish" className="text-sm font-semibold text-indigo-600 hover:text-indigo-700">
|
||
发布记录
|
||
</Link>
|
||
<Link to="/market" className="text-sm font-semibold text-slate-500 hover:text-slate-800">
|
||
返回市场
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
{message && (
|
||
<p className="mt-6 rounded-lg bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-700">{message}</p>
|
||
)}
|
||
{(withdrawAppeal.isError || supplementAppeal.isError) && (
|
||
<p className="mt-6 rounded-lg bg-rose-50 px-3 py-2 text-xs font-semibold text-rose-700">
|
||
操作失败:{withdrawAppeal.error?.message ?? supplementAppeal.error?.message}
|
||
</p>
|
||
)}
|
||
|
||
<section className="mt-6 rounded-2xl border border-slate-100 bg-white shadow-sm">
|
||
{appeals.isLoading ? (
|
||
<p className="p-6 text-sm text-slate-400">加载中...</p>
|
||
) : appeals.isError ? (
|
||
<p className="p-6 text-sm font-semibold text-rose-600">申诉记录加载失败:{appeals.error.message}</p>
|
||
) : list.length === 0 ? (
|
||
<p className="p-6 text-sm text-slate-400">暂无申诉记录</p>
|
||
) : (
|
||
<ul className="divide-y divide-slate-100">
|
||
{list.map((appeal) => (
|
||
<AppealRow
|
||
key={appeal.appealId}
|
||
appeal={appeal}
|
||
onWithdraw={handleWithdraw}
|
||
onSupplement={handleSupplement}
|
||
withdrawPending={withdrawAppeal.isPending && withdrawAppeal.variables?.appealId === appeal.appealId}
|
||
supplementPending={
|
||
supplementAppeal.isPending && supplementAppeal.variables?.appealId === appeal.appealId
|
||
}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|