fix: mock
This commit is contained in:
@@ -65,7 +65,13 @@ async def upload_images(
|
|||||||
|
|
||||||
# trigger OCR tasks in-process background (non-blocking for API response)
|
# trigger OCR tasks in-process background (non-blocking for API response)
|
||||||
from app.workers.ocr_tasks import process_images_ocr_batch_async
|
from app.workers.ocr_tasks import process_images_ocr_batch_async
|
||||||
pending_ids = [str(img.id) for img in results if img.ocr_status.value == "pending"]
|
pending_imgs = [img for img in results if img.ocr_status.value == "pending"]
|
||||||
|
for img in pending_imgs:
|
||||||
|
img.ocr_status = OcrStatus.processing
|
||||||
|
if pending_imgs:
|
||||||
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
|
pending_ids = [str(img.id) for img in pending_imgs]
|
||||||
if pending_ids:
|
if pending_ids:
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
process_images_ocr_batch_async(
|
process_images_ocr_batch_async(
|
||||||
@@ -171,10 +177,14 @@ async def start_case_ocr(
|
|||||||
image_ids = payload.image_ids if payload else []
|
image_ids = payload.image_ids if payload else []
|
||||||
if image_ids:
|
if image_ids:
|
||||||
images = await repo.list_by_ids_in_case(case_id, image_ids)
|
images = await repo.list_by_ids_in_case(case_id, image_ids)
|
||||||
|
# Never submit images that are already processing: this prevents
|
||||||
|
# duplicate OCR tasks when users trigger OCR from multiple pages.
|
||||||
|
images = [img for img in images if img.ocr_status != OcrStatus.processing]
|
||||||
# For explicit re-run, mark selected images as processing immediately
|
# For explicit re-run, mark selected images as processing immediately
|
||||||
# so frontend can reflect state transition without full page refresh.
|
# so frontend can reflect state transition without full page refresh.
|
||||||
for img in images:
|
for img in images:
|
||||||
img.ocr_status = OcrStatus.processing
|
img.ocr_status = OcrStatus.processing
|
||||||
|
if images:
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
else:
|
else:
|
||||||
@@ -183,6 +193,7 @@ async def start_case_ocr(
|
|||||||
# triggered from workspace page, so UI can show progress right away.
|
# triggered from workspace page, so UI can show progress right away.
|
||||||
for img in images:
|
for img in images:
|
||||||
img.ocr_status = OcrStatus.processing
|
img.ocr_status = OcrStatus.processing
|
||||||
|
if images:
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ from pathlib import Path
|
|||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
ENV_FILE_PATH = Path(__file__).resolve().parents[2] / ".env"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=ENV_FILE_PATH,
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
@@ -18,6 +21,7 @@ class Settings(BaseSettings):
|
|||||||
OCR_API_KEY: str = ""
|
OCR_API_KEY: str = ""
|
||||||
OCR_API_URL: str = ""
|
OCR_API_URL: str = ""
|
||||||
OCR_MODEL: str = ""
|
OCR_MODEL: str = ""
|
||||||
|
OCR_ALLOW_MOCK_FALLBACK: bool = False
|
||||||
OCR_PARALLELISM: int = 4
|
OCR_PARALLELISM: int = 4
|
||||||
LLM_API_KEY: str = ""
|
LLM_API_KEY: str = ""
|
||||||
LLM_API_URL: str = ""
|
LLM_API_URL: str = ""
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ class ImageRepository(BaseRepository[EvidenceImage]):
|
|||||||
|
|
||||||
async def list_for_ocr(self, case_id: UUID, include_done: bool = False) -> list[EvidenceImage]:
|
async def list_for_ocr(self, case_id: UUID, include_done: bool = False) -> list[EvidenceImage]:
|
||||||
query = select(EvidenceImage).where(EvidenceImage.case_id == case_id)
|
query = select(EvidenceImage).where(EvidenceImage.case_id == case_id)
|
||||||
if not include_done:
|
# Always exclude currently-processing images to avoid duplicate OCR
|
||||||
query = query.where(EvidenceImage.ocr_status != OcrStatus.done)
|
# submission from different trigger paths (upload/workspace/screenshots).
|
||||||
|
if include_done:
|
||||||
|
query = query.where(EvidenceImage.ocr_status != OcrStatus.processing)
|
||||||
|
else:
|
||||||
|
query = query.where(EvidenceImage.ocr_status.in_([OcrStatus.pending, OcrStatus.failed]))
|
||||||
result = await self.session.execute(query.order_by(EvidenceImage.uploaded_at.desc()))
|
result = await self.session.execute(query.order_by(EvidenceImage.uploaded_at.desc()))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ def _ocr_available() -> bool:
|
|||||||
return bool(url and key and model)
|
return bool(url and key and model)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_ocr_fields() -> list[str]:
|
||||||
|
missing: list[str] = []
|
||||||
|
if not (settings.OCR_API_URL or settings.LLM_API_URL):
|
||||||
|
missing.append("OCR_API_URL(or LLM_API_URL)")
|
||||||
|
if not (settings.OCR_API_KEY or settings.LLM_API_KEY):
|
||||||
|
missing.append("OCR_API_KEY(or LLM_API_KEY)")
|
||||||
|
if not (settings.OCR_MODEL or settings.LLM_MODEL):
|
||||||
|
missing.append("OCR_MODEL(or LLM_MODEL)")
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
def _llm_available() -> bool:
|
def _llm_available() -> bool:
|
||||||
url, key, model = _llm_config()
|
url, key, model = _llm_config()
|
||||||
return bool(url and key and model)
|
return bool(url and key and model)
|
||||||
@@ -54,6 +65,10 @@ async def classify_page(image_path: str) -> tuple[SourceApp, PageType]:
|
|||||||
"""Identify the source app and page type of a screenshot."""
|
"""Identify the source app and page type of a screenshot."""
|
||||||
if _ocr_available():
|
if _ocr_available():
|
||||||
return await _classify_via_api(image_path)
|
return await _classify_via_api(image_path)
|
||||||
|
if not settings.OCR_ALLOW_MOCK_FALLBACK:
|
||||||
|
missing = ", ".join(_missing_ocr_fields()) or "unknown"
|
||||||
|
raise RuntimeError(f"OCR configuration missing: {missing}")
|
||||||
|
logger.warning("OCR unavailable, falling back to mock classification for image: %s", image_path)
|
||||||
return _classify_mock(image_path)
|
return _classify_mock(image_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +78,10 @@ async def extract_transaction_fields(
|
|||||||
"""Extract structured transaction fields from a screenshot."""
|
"""Extract structured transaction fields from a screenshot."""
|
||||||
if _ocr_available():
|
if _ocr_available():
|
||||||
return await _extract_via_api(image_path, source_app, page_type)
|
return await _extract_via_api(image_path, source_app, page_type)
|
||||||
|
if not settings.OCR_ALLOW_MOCK_FALLBACK:
|
||||||
|
missing = ", ".join(_missing_ocr_fields()) or "unknown"
|
||||||
|
raise RuntimeError(f"OCR configuration missing: {missing}")
|
||||||
|
logger.warning("OCR unavailable, falling back to mock extraction for image: %s", image_path)
|
||||||
mock_data = _extract_mock(image_path, source_app, page_type)
|
mock_data = _extract_mock(image_path, source_app, page_type)
|
||||||
return mock_data, json.dumps(mock_data, ensure_ascii=False)
|
return mock_data, json.dumps(mock_data, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ async def process_images_ocr_batch_async(image_ids: list[str], max_concurrency:
|
|||||||
"""Process many images with bounded OCR concurrency."""
|
"""Process many images with bounded OCR concurrency."""
|
||||||
if not image_ids:
|
if not image_ids:
|
||||||
return
|
return
|
||||||
|
# De-duplicate in-memory to prevent repeated processing of same image id
|
||||||
|
# in a single batch submission.
|
||||||
|
image_ids = list(dict.fromkeys(image_ids))
|
||||||
concurrency = max(1, max_concurrency)
|
concurrency = max(1, max_concurrency)
|
||||||
semaphore = asyncio.Semaphore(concurrency)
|
semaphore = asyncio.Semaphore(concurrency)
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,40 @@ const Analysis: React.FC = () => {
|
|||||||
a.tradeTime.localeCompare(b.tradeTime),
|
a.tradeTime.localeCompare(b.tradeTime),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const payeeAgg = useMemo(() => {
|
||||||
|
const targetTx = validTx.filter((t) => t.direction === 'out' && !t.isTransit);
|
||||||
|
const map = new Map<string, { name: string; amount: number; count: number; hasKeyword: boolean }>();
|
||||||
|
const riskKeywords = ['刷单', '客服', '店铺', '商家', '返利', '任务', '垫付', '佣金', '淘宝'];
|
||||||
|
for (const tx of targetTx) {
|
||||||
|
const name = (tx.counterpartyName || '未知收款方').trim() || '未知收款方';
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
const cur = map.get(key) ?? {
|
||||||
|
name,
|
||||||
|
amount: 0,
|
||||||
|
count: 0,
|
||||||
|
hasKeyword: false,
|
||||||
|
};
|
||||||
|
cur.amount += tx.amount;
|
||||||
|
cur.count += 1;
|
||||||
|
if (!cur.hasKeyword) {
|
||||||
|
cur.hasKeyword = riskKeywords.some((kw) => name.includes(kw) || (tx.remark || '').includes(kw));
|
||||||
|
}
|
||||||
|
map.set(key, cur);
|
||||||
|
}
|
||||||
|
return Array.from(map.values())
|
||||||
|
.map((item) => {
|
||||||
|
const risk =
|
||||||
|
item.hasKeyword || item.amount >= 50000 || item.count >= 3
|
||||||
|
? 'high'
|
||||||
|
: item.amount >= 20000 || item.count >= 2
|
||||||
|
? 'medium'
|
||||||
|
: 'low';
|
||||||
|
return { ...item, risk };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.amount - a.amount)
|
||||||
|
.slice(0, 10);
|
||||||
|
}, [validTx]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card style={{ marginBottom: 16 }}>
|
<Card style={{ marginBottom: 16 }}>
|
||||||
@@ -221,7 +255,7 @@ const Analysis: React.FC = () => {
|
|||||||
<Card variant="borderless">
|
<Card variant="borderless">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="涉诈对手方"
|
title="涉诈对手方"
|
||||||
value={3}
|
value={payeeAgg.length}
|
||||||
suffix="个"
|
suffix="个"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -280,11 +314,10 @@ const Analysis: React.FC = () => {
|
|||||||
|
|
||||||
<Card title="收款方聚合" style={{ marginTop: 24 }}>
|
<Card title="收款方聚合" style={{ marginTop: 24 }}>
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
{[
|
{payeeAgg.length === 0 && (
|
||||||
{ name: '李*华 (138****5678)', amount: 70000, count: 2, risk: 'high' },
|
<Typography.Text type="secondary">暂无可聚合的收款方数据</Typography.Text>
|
||||||
{ name: 'USDT地址 T9yD...Xk3m', amount: 86500, count: 1, risk: 'medium' },
|
)}
|
||||||
{ name: '财富管家-客服', amount: 30000, count: 1, risk: 'high' },
|
{payeeAgg.map((item, idx) => (
|
||||||
].map((item, idx) => (
|
|
||||||
<Card key={idx} size="small" variant="borderless" style={{ background: '#fafafa' }}>
|
<Card key={idx} size="small" variant="borderless" style={{ background: '#fafafa' }}>
|
||||||
<Row justify="space-between" align="middle">
|
<Row justify="space-between" align="middle">
|
||||||
<Col>
|
<Col>
|
||||||
@@ -299,8 +332,8 @@ const Analysis: React.FC = () => {
|
|||||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||||
¥{item.amount.toLocaleString()}
|
¥{item.amount.toLocaleString()}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Tag color={item.risk === 'high' ? 'red' : 'orange'}>
|
<Tag color={item.risk === 'high' ? 'red' : item.risk === 'medium' ? 'orange' : 'default'}>
|
||||||
{item.risk === 'high' ? '高风险' : '中风险'}
|
{item.risk === 'high' ? '高风险' : item.risk === 'medium' ? '中风险' : '一般'}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Segmented,
|
Segmented,
|
||||||
Divider,
|
Divider,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
|
Modal,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
@@ -154,6 +155,8 @@ const Review: React.FC = () => {
|
|||||||
const [editableTx, setEditableTx] = useState<FraudAssessment['transaction'] | null>(null);
|
const [editableTx, setEditableTx] = useState<FraudAssessment['transaction'] | null>(null);
|
||||||
const [reviewAction, setReviewAction] = useState<ReviewAction>('confirmed');
|
const [reviewAction, setReviewAction] = useState<ReviewAction>('confirmed');
|
||||||
const [reviewNote, setReviewNote] = useState('');
|
const [reviewNote, setReviewNote] = useState('');
|
||||||
|
const [supplementModal, setSupplementModal] = useState<FraudAssessment | null>(null);
|
||||||
|
const [supplementNote, setSupplementNote] = useState('');
|
||||||
|
|
||||||
const { data: assessData } = useQuery({
|
const { data: assessData } = useQuery({
|
||||||
queryKey: ['assessments', id],
|
queryKey: ['assessments', id],
|
||||||
@@ -181,6 +184,8 @@ const Review: React.FC = () => {
|
|||||||
message.success('复核结果已保存');
|
message.success('复核结果已保存');
|
||||||
qc.invalidateQueries({ queryKey: ['assessments', id] });
|
qc.invalidateQueries({ queryKey: ['assessments', id] });
|
||||||
setReviewModal(null);
|
setReviewModal(null);
|
||||||
|
setSupplementModal(null);
|
||||||
|
setSupplementNote('');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,6 +237,11 @@ const Review: React.FC = () => {
|
|||||||
(a) => a.reviewStatus === 'confirmed',
|
(a) => a.reviewStatus === 'confirmed',
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
const openSupplementDialog = (assessment: FraudAssessment, initialNote?: string) => {
|
||||||
|
setSupplementModal(assessment);
|
||||||
|
setSupplementNote(initialNote ?? assessment.reviewNote ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<FraudAssessment> = [
|
const columns: ColumnsType<FraudAssessment> = [
|
||||||
{
|
{
|
||||||
title: '交易时间',
|
title: '交易时间',
|
||||||
@@ -303,6 +313,8 @@ const Review: React.FC = () => {
|
|||||||
render: (_, r) => {
|
render: (_, r) => {
|
||||||
const aiAction = getAiSuggestedAction(r, allAssessments);
|
const aiAction = getAiSuggestedAction(r, allAssessments);
|
||||||
const isPending = r.reviewStatus === 'pending';
|
const isPending = r.reviewStatus === 'pending';
|
||||||
|
const isSupplemented =
|
||||||
|
r.reviewStatus === 'needs_info' && !!(r.reviewNote || '').trim();
|
||||||
const status = isPending ? aiAction : r.reviewStatus;
|
const status = isPending ? aiAction : r.reviewStatus;
|
||||||
|
|
||||||
const pendingStyle: Record<string, { bg: string; border: string; color: string }> = {
|
const pendingStyle: Record<string, { bg: string; border: string; color: string }> = {
|
||||||
@@ -322,9 +334,18 @@ const Review: React.FC = () => {
|
|||||||
const doneLabel: Record<string, string> = {
|
const doneLabel: Record<string, string> = {
|
||||||
confirmed: '已确认', rejected: '已排除', needs_info: '已补充',
|
confirmed: '已确认', rejected: '已排除', needs_info: '已补充',
|
||||||
};
|
};
|
||||||
const label = isPending ? (pendingLabel[aiAction] || '待确认') : (doneLabel[status] || status);
|
|
||||||
|
const label = isPending
|
||||||
|
? (pendingLabel[aiAction] || '待确认')
|
||||||
|
: (status === 'needs_info'
|
||||||
|
? (isSupplemented ? doneLabel.needs_info : pendingLabel.needs_info)
|
||||||
|
: (doneLabel[status] || status));
|
||||||
|
|
||||||
const submitReviewAction = (action: ReviewAction) => {
|
const submitReviewAction = (action: ReviewAction) => {
|
||||||
|
if (action === 'needs_info') {
|
||||||
|
openSupplementDialog(r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
reviewMutation.mutate({
|
reviewMutation.mutate({
|
||||||
assessmentId: r.id,
|
assessmentId: r.id,
|
||||||
body: {
|
body: {
|
||||||
@@ -343,11 +364,18 @@ const Review: React.FC = () => {
|
|||||||
<span style={{
|
<span style={{
|
||||||
color: v === 'confirmed' ? '#389e0d' : v === 'rejected' ? '#cf1322' : '#1677ff',
|
color: v === 'confirmed' ? '#389e0d' : v === 'rejected' ? '#cf1322' : '#1677ff',
|
||||||
}}>
|
}}>
|
||||||
{v === 'confirmed' ? '确认' : v === 'rejected' ? '排除' : '需补充'}
|
{v === 'confirmed' ? '确认' : v === 'rejected' ? '排除' : '补充信息'}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
if (!isPending && r.reviewStatus === 'needs_info') {
|
||||||
|
otherOptions.push({
|
||||||
|
key: 'needs_info',
|
||||||
|
label: <span style={{ color: '#1677ff' }}>更新补充</span>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
const ps = pendingStyle[aiAction] || pendingStyle.confirmed;
|
const ps = pendingStyle[aiAction] || pendingStyle.confirmed;
|
||||||
return (
|
return (
|
||||||
@@ -445,7 +473,7 @@ const Review: React.FC = () => {
|
|||||||
? getAiSuggestedAction(r, allAssessments)
|
? getAiSuggestedAction(r, allAssessments)
|
||||||
: (r.reviewStatus as ReviewAction),
|
: (r.reviewStatus as ReviewAction),
|
||||||
);
|
);
|
||||||
setReviewNote('');
|
setReviewNote(r.reviewNote || '');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
@@ -807,7 +835,7 @@ const Review: React.FC = () => {
|
|||||||
options={[
|
options={[
|
||||||
{ label: '确认 - 该笔计入被骗金额', value: 'confirmed' },
|
{ label: '确认 - 该笔计入被骗金额', value: 'confirmed' },
|
||||||
{ label: '排除 - 该笔不计入被骗金额', value: 'rejected' },
|
{ label: '排除 - 该笔不计入被骗金额', value: 'rejected' },
|
||||||
{ label: '需补充 - 需进一步调查确认', value: 'needs_info' },
|
{ label: '待补充 - 需录入补充说明', value: 'needs_info' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Typography.Text strong>备注说明:</Typography.Text>
|
<Typography.Text strong>备注说明:</Typography.Text>
|
||||||
@@ -815,7 +843,7 @@ const Review: React.FC = () => {
|
|||||||
rows={3}
|
rows={3}
|
||||||
value={reviewNote}
|
value={reviewNote}
|
||||||
onChange={(e) => setReviewNote(e.target.value)}
|
onChange={(e) => setReviewNote(e.target.value)}
|
||||||
placeholder="请输入复核意见或备注..."
|
placeholder={reviewAction === 'needs_info' ? '请填写已补充的证据/线索说明(必填)' : '请输入复核意见或备注...'}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
@@ -845,6 +873,10 @@ const Review: React.FC = () => {
|
|||||||
type="primary"
|
type="primary"
|
||||||
loading={reviewMutation.isPending}
|
loading={reviewMutation.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (reviewAction === 'needs_info') {
|
||||||
|
openSupplementDialog(reviewModal, reviewNote);
|
||||||
|
return;
|
||||||
|
}
|
||||||
reviewMutation.mutate({
|
reviewMutation.mutate({
|
||||||
assessmentId: reviewModal.id,
|
assessmentId: reviewModal.id,
|
||||||
body: {
|
body: {
|
||||||
@@ -861,6 +893,44 @@ const Review: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="补充信息"
|
||||||
|
open={!!supplementModal}
|
||||||
|
zIndex={1400}
|
||||||
|
onCancel={() => {
|
||||||
|
setSupplementModal(null);
|
||||||
|
setSupplementNote('');
|
||||||
|
}}
|
||||||
|
onOk={() => {
|
||||||
|
if (!supplementModal) return;
|
||||||
|
if (!supplementNote.trim()) {
|
||||||
|
message.warning('请填写补充说明后再提交');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reviewMutation.mutate({
|
||||||
|
assessmentId: supplementModal.id,
|
||||||
|
body: {
|
||||||
|
review_status: 'needs_info',
|
||||||
|
review_note: supplementNote.trim(),
|
||||||
|
reviewed_by: 'demo_user',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
confirmLoading={reviewMutation.isPending}
|
||||||
|
okText="提交补充"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
||||||
|
该操作将状态更新为“已补充”,并记录补充说明以便后续复核。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={4}
|
||||||
|
value={supplementNote}
|
||||||
|
onChange={(e) => setSupplementNote(e.target.value)}
|
||||||
|
placeholder="请输入补充的证据、线索、核验结果等内容..."
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user