lili 5acb2503b5 feat(studio): 补齐深面入口
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:26:47 -07:00

223 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}