From 860f6b087012f57dccdb32a7e4f7e873434d4a3c Mon Sep 17 00:00:00 2001
From: CEQ151
Date: Mon, 27 Apr 2026 16:06:05 +0800
Subject: [PATCH 1/2] feat(mlwm): key neural payload by password
---
blind_watermark/mlwm/__init__.py | 8 +++
blind_watermark/mlwm/codec.py | 67 +++++++++++++++++++---
blind_watermark/mlwm/infer.py | 5 +-
blind_watermark/rwm_engine.py | 28 ++++++---
blind_watermark/tests/test_mlwm_codec.py | 26 +++++++++
blind_watermark/tests/test_mlwm_runtime.py | 5 ++
6 files changed, 122 insertions(+), 17 deletions(-)
diff --git a/blind_watermark/mlwm/__init__.py b/blind_watermark/mlwm/__init__.py
index 6b395dc..723ef21 100644
--- a/blind_watermark/mlwm/__init__.py
+++ b/blind_watermark/mlwm/__init__.py
@@ -2,16 +2,24 @@
from .codec import (
ENCODED_BYTES,
+ KEYED_PROTOCOL,
PAYLOAD_BITS,
MAX_TEXT_BYTES,
decode_payload_bits,
+ decode_payload_logits,
encode_text_payload,
+ key_payload_bits,
+ unkey_payload_bits,
)
__all__ = [
'ENCODED_BYTES',
+ 'KEYED_PROTOCOL',
'PAYLOAD_BITS',
'MAX_TEXT_BYTES',
'decode_payload_bits',
+ 'decode_payload_logits',
'encode_text_payload',
+ 'key_payload_bits',
+ 'unkey_payload_bits',
]
diff --git a/blind_watermark/mlwm/codec.py b/blind_watermark/mlwm/codec.py
index ae59808..61a1f5c 100644
--- a/blind_watermark/mlwm/codec.py
+++ b/blind_watermark/mlwm/codec.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import hashlib
+import struct
import zlib
from dataclasses import dataclass
from typing import Any
@@ -15,6 +17,7 @@
ENCODED_BYTES = FRAME_BYTES + RS_NSYM
PAYLOAD_BITS = ENCODED_BYTES * 8
WHITENING_SEED = 0x4d4c574d
+KEYED_PROTOCOL = 'keyed-v2'
@dataclass
@@ -25,6 +28,8 @@ class PayloadEnvelope:
encoded: bytes
bits: np.ndarray
flags: int = 0
+ protocol: str = KEYED_PROTOCOL
+ password_protected: bool = False
def _whitening_mask() -> np.ndarray:
@@ -35,6 +40,25 @@ def _whitening_mask() -> np.ndarray:
_PAYLOAD_WHITENING_MASK = _whitening_mask()
+def _normalize_password(password: int | None) -> int | None:
+ if password is None:
+ return None
+ return int(password) & 0xffffffff
+
+
+def _password_mask(password: int | None) -> np.ndarray:
+ normalized = _normalize_password(password)
+ if normalized is None:
+ return np.zeros(PAYLOAD_BITS, dtype=np.float32)
+ seed = b'LuminCrypt-MLWM-keyed-v2' + struct.pack('>I', normalized)
+ stream = bytearray()
+ counter = 0
+ while len(stream) < ENCODED_BYTES:
+ stream.extend(hashlib.sha256(seed + struct.pack('>I', counter)).digest())
+ counter += 1
+ return bytes_to_bits(bytes(stream[:ENCODED_BYTES]))
+
+
def _rs_codec() -> reedsolo.RSCodec:
return reedsolo.RSCodec(RS_NSYM)
@@ -72,6 +96,27 @@ def unwhiten_payload_bits(bits: np.ndarray | list[float] | list[int]) -> np.ndar
return whiten_payload_bits(bits)
+def key_payload_bits(bits: np.ndarray | list[float] | list[int], password: int | None = None) -> np.ndarray:
+ flat = np.asarray(bits, dtype=np.float32).reshape(-1)
+ if flat.size != PAYLOAD_BITS:
+ raise ValueError(f'expected {PAYLOAD_BITS} payload bits, got {flat.size}')
+ hard = (flat >= 0.5).astype(np.float32)
+ return np.abs(hard - _password_mask(password)).astype(np.float32)
+
+
+def unkey_payload_bits(bits: np.ndarray | list[float] | list[int], password: int | None = None) -> np.ndarray:
+ return key_payload_bits(bits, password)
+
+
+def unkey_payload_logits(logits: np.ndarray, password: int | None = None) -> np.ndarray:
+ arr = np.asarray(logits, dtype=np.float32).reshape(-1)
+ if arr.size != PAYLOAD_BITS:
+ raise ValueError(f'expected {PAYLOAD_BITS} logits, got {arr.size}')
+ mask = _password_mask(password)
+ signs = np.where(mask >= 0.5, -1.0, 1.0).astype(np.float32)
+ return arr * signs
+
+
def build_frame(payload_bytes: bytes, *, flags: int = 0) -> bytes:
if len(payload_bytes) > MAX_TEXT_BYTES:
raise ValueError(f'neural payload supports up to {MAX_TEXT_BYTES} UTF-8 bytes')
@@ -96,17 +141,19 @@ def encode_frame(frame: bytes) -> bytes:
return bytes(_rs_codec().encode(frame))
-def encode_text_payload(text: str, *, flags: int = 0) -> PayloadEnvelope:
+def encode_text_payload(text: str, password: int | None = None, *, flags: int = 0) -> PayloadEnvelope:
payload_bytes = text.encode('utf-8')
frame = build_frame(payload_bytes, flags=flags)
encoded = encode_frame(frame)
+ whitened = whiten_payload_bits(bytes_to_bits(encoded))
return PayloadEnvelope(
text=text,
text_bytes=payload_bytes,
frame=frame,
encoded=encoded,
- bits=whiten_payload_bits(bytes_to_bits(encoded)),
+ bits=key_payload_bits(whitened, password),
flags=flags,
+ password_protected=password is not None,
)
@@ -135,22 +182,28 @@ def decode_frame(encoded_bytes: bytes) -> dict[str, Any]:
}
-def decode_payload_bits(bits: np.ndarray | list[float] | list[int]) -> dict[str, Any]:
- raw_bits = unwhiten_payload_bits(bits)
+def decode_payload_bits(bits: np.ndarray | list[float] | list[int], password: int | None = None) -> dict[str, Any]:
+ unkeyed = unkey_payload_bits(bits, password)
+ raw_bits = unwhiten_payload_bits(unkeyed)
encoded = bits_to_bytes(raw_bits)
result = decode_frame(encoded)
result['encoded'] = encoded
result['rawBits'] = raw_bits
+ result['protocol'] = KEYED_PROTOCOL if password is not None else 'unkeyed-v1'
+ result['passwordProtected'] = password is not None
return result
-def decode_payload_logits(logits: np.ndarray) -> dict[str, Any]:
+def decode_payload_logits(logits: np.ndarray, password: int | None = None) -> dict[str, Any]:
logits = np.asarray(logits, dtype=np.float32).reshape(-1)
if logits.size != PAYLOAD_BITS:
raise ValueError(f'expected {PAYLOAD_BITS} logits, got {logits.size}')
- probs = 1.0 / (1.0 + np.exp(-logits))
+ decoded_logits = unkey_payload_logits(logits, password)
+ probs = 1.0 / (1.0 + np.exp(-decoded_logits))
bits = (probs >= 0.5).astype(np.float32)
- decoded = decode_payload_bits(bits)
+ decoded = decode_payload_bits(bits, None)
decoded['probabilities'] = probs
decoded['bitConfidence'] = float(np.mean(np.maximum(probs, 1.0 - probs)))
+ decoded['protocol'] = KEYED_PROTOCOL if password is not None else 'unkeyed-v1'
+ decoded['passwordProtected'] = password is not None
return decoded
diff --git a/blind_watermark/mlwm/infer.py b/blind_watermark/mlwm/infer.py
index b265b48..38c983a 100644
--- a/blind_watermark/mlwm/infer.py
+++ b/blind_watermark/mlwm/infer.py
@@ -146,6 +146,7 @@ def neural_decode_views(
*,
models_dir: str | None = None,
use_cuda: bool = False,
+ password: int | None = None,
) -> dict[str, Any]:
status = probe_runtime(models_dir)
if not status.ready:
@@ -167,7 +168,7 @@ def neural_decode_views(
weighted_logits += logits * confidence
total_weight += confidence
try:
- decoded = decode_payload_logits(logits)
+ decoded = decode_payload_logits(logits, password=password)
decoded['confidence'] = confidence
decoded['attemptIndex'] = index
decoded['strategy'] = 'single-view'
@@ -180,7 +181,7 @@ def neural_decode_views(
raise NeuralRuntimeUnavailable('decoder produced no usable confidence scores')
aggregated_logits = weighted_logits / total_weight
- decoded = decode_payload_logits(aggregated_logits)
+ decoded = decode_payload_logits(aggregated_logits, password=password)
decoded['confidence'] = float(total_weight / max(len(views_rgb), 1))
decoded['attempts'] = [{'index': a['index'], 'confidence': a['confidence']} for a in attempts]
decoded['strategy'] = 'weighted-aggregate'
diff --git a/blind_watermark/rwm_engine.py b/blind_watermark/rwm_engine.py
index f56c745..55c5bdc 100644
--- a/blind_watermark/rwm_engine.py
+++ b/blind_watermark/rwm_engine.py
@@ -82,17 +82,23 @@
# v2 compat constants
_V2_RS_NSYM = 20
_V2_REDUNDANCY = 3
-RWM_VERSION = '3.1.0'
+RWM_VERSION = '3.2.0'
NEURAL_MAX_TEXT_BYTES = 16
NEURAL_PROFILES = {
+ 'invisible': {
+ 'residual_strength': 0.35,
+ 'template_strength': 0.0,
+ 'template_peaks': 0,
+ 'sync_enabled': False,
+ },
'balanced': {
- 'residual_strength': 1.0,
+ 'residual_strength': 0.55,
'template_strength': 0.0,
'template_peaks': 0,
'sync_enabled': False,
},
- 'aggressive': {
- 'residual_strength': 1.35,
+ 'robust': {
+ 'residual_strength': 1.0,
'template_strength': 0.0,
'template_peaks': 0,
'sync_enabled': False,
@@ -517,8 +523,8 @@ def _try_all_presets(gray_ch, preset_order=None):
def _resolve_neural_profile(quality):
- if quality == 'robust':
- return 'aggressive'
+ if quality in NEURAL_PROFILES:
+ return quality
return 'balanced'
@@ -600,7 +606,7 @@ def _neural_embed_impl(img, text, password=1, quality='balanced', models_dir=Non
profile = NEURAL_PROFILES[profile_name]
alpha = img[:, :, 3].copy() if img.ndim == 3 and img.shape[2] == 4 else None
base = img[:, :, :3] if alpha is not None else img.copy()
- payload = encode_text_payload(text)
+ payload = encode_text_payload(text, password=password)
rgb = cv2.cvtColor(base, cv2.COLOR_BGR2RGB)
try:
encoded = neural_encode_residual(rgb, payload.bits, models_dir=models_dir, use_cuda=False)
@@ -622,6 +628,9 @@ def _neural_embed_impl(img, text, password=1, quality='balanced', models_dir=Non
diagnostics = {
'profile': profile_name,
+ 'protocol': payload.protocol,
+ 'passwordProtected': payload.password_protected,
+ 'visualStrength': profile['residual_strength'],
'payloadBytes': len(payload.text_bytes),
'modelVersion': encoded.get('modelVersion'),
'modelsDir': models_dir,
@@ -658,7 +667,7 @@ def _neural_extract_impl(img, password=1, quality='balanced', models_dir=None):
geo = {'angle': 0.0, 'scale': 1.0, 'confidence': 0.0, 'peaks': 0, 'syncEnabled': False}
views = _build_neural_views(corrected)
try:
- decoded = neural_decode_views(views, models_dir=models_dir, use_cuda=False)
+ decoded = neural_decode_views(views, models_dir=models_dir, use_cuda=False, password=password)
except NeuralRuntimeUnavailable:
raise
@@ -671,6 +680,9 @@ def _neural_extract_impl(img, password=1, quality='balanced', models_dir=None):
'confidence': confidence,
'diagnostics': {
'profile': profile_name,
+ 'protocol': decoded.get('protocol', 'keyed-v2'),
+ 'passwordProtected': bool(decoded.get('passwordProtected', True)),
+ 'visualStrength': profile['residual_strength'],
'bitConfidence': float(decoded.get('bitConfidence', 0.0)),
'decodeStrategy': decoded.get('strategy'),
'geometricCorrection': geo,
diff --git a/blind_watermark/tests/test_mlwm_codec.py b/blind_watermark/tests/test_mlwm_codec.py
index 443855d..f25b5b1 100644
--- a/blind_watermark/tests/test_mlwm_codec.py
+++ b/blind_watermark/tests/test_mlwm_codec.py
@@ -8,6 +8,7 @@
bits_to_bytes,
bytes_to_bits,
decode_payload_bits,
+ decode_payload_logits,
encode_frame,
encode_text_payload,
unwhiten_payload_bits,
@@ -22,6 +23,31 @@ def test_roundtrip_short_text(self):
self.assertEqual(decoded['text'], 'TRACE-42')
self.assertEqual(len(envelope.encoded), ENCODED_BYTES)
+ def test_password_protected_payload_roundtrip(self):
+ envelope = encode_text_payload('LOCKED-42', password=2468)
+ decoded = decode_payload_bits(envelope.bits, password=2468)
+ self.assertEqual(decoded['text'], 'LOCKED-42')
+ self.assertTrue(envelope.password_protected)
+ self.assertTrue(decoded['passwordProtected'])
+
+ def test_wrong_password_rejects_payload(self):
+ envelope = encode_text_payload('LOCKED-42', password=2468)
+ with self.assertRaises(Exception):
+ decode_payload_bits(envelope.bits, password=2469)
+
+ def test_different_passwords_produce_different_payload_bits(self):
+ a = encode_text_payload('LOCKED-42', password=2468)
+ b = encode_text_payload('LOCKED-42', password=2469)
+ self.assertFalse(np.array_equal(a.bits, b.bits))
+
+ def test_password_protected_logits_roundtrip(self):
+ envelope = encode_text_payload('LOGITS-42', password=2468)
+ logits = (envelope.bits * 2.0 - 1.0) * 8.0
+ decoded = decode_payload_logits(logits, password=2468)
+ self.assertEqual(decoded['text'], 'LOGITS-42')
+ with self.assertRaises(Exception):
+ decode_payload_logits(logits, password=2469)
+
def test_reject_long_text(self):
with self.assertRaises(ValueError):
encode_text_payload('X' * (MAX_TEXT_BYTES + 1))
diff --git a/blind_watermark/tests/test_mlwm_runtime.py b/blind_watermark/tests/test_mlwm_runtime.py
index f8c714d..21a6b53 100644
--- a/blind_watermark/tests/test_mlwm_runtime.py
+++ b/blind_watermark/tests/test_mlwm_runtime.py
@@ -9,6 +9,11 @@ def test_alpha1_neural_profiles_do_not_inject_untrained_sync_template(self):
self.assertFalse(profile.get('sync_enabled', False))
self.assertEqual(profile.get('template_strength'), 0.0)
+ def test_neural_profiles_expose_three_visual_strengths(self):
+ self.assertEqual(set(rwm_engine.NEURAL_PROFILES.keys()), {'invisible', 'balanced', 'robust'})
+ self.assertLess(rwm_engine.NEURAL_PROFILES['invisible']['residual_strength'], rwm_engine.NEURAL_PROFILES['balanced']['residual_strength'])
+ self.assertLess(rwm_engine.NEURAL_PROFILES['balanced']['residual_strength'], rwm_engine.NEURAL_PROFILES['robust']['residual_strength'])
+
if __name__ == '__main__':
unittest.main()
From 0428006c1f212a0008809cdb2ffb9e0d8a87b35f Mon Sep 17 00:00:00 2001
From: CEQ151
Date: Mon, 27 Apr 2026 16:06:58 +0800
Subject: [PATCH 2/2] feat(ui): polish watermark UI and add i18n
---
src/renderer/src/App.tsx | 30 +-
.../src/components/BackgroundSwitcher.tsx | 14 +-
src/renderer/src/components/BatchMode.tsx | 59 +-
src/renderer/src/components/CharTooltip.tsx | 23 +-
.../src/components/ClipboardNotification.tsx | 4 +-
src/renderer/src/components/CompareView.tsx | 10 +-
src/renderer/src/components/DropZone.tsx | 14 +-
src/renderer/src/components/ExportMenu.tsx | 10 +-
.../src/components/ImageWatermarkPanel.tsx | 335 ++++++-----
src/renderer/src/components/ScanButton.tsx | 6 +-
src/renderer/src/components/SettingsPage.tsx | 109 ++--
src/renderer/src/components/Sidebar.tsx | 25 +-
src/renderer/src/components/StatsPanel.tsx | 47 +-
src/renderer/src/components/WatermarkTab.tsx | 203 ++++---
src/renderer/src/contexts/SettingsContext.tsx | 3 +
src/renderer/src/i18n.ts | 552 ++++++++++++++++++
16 files changed, 1064 insertions(+), 380 deletions(-)
create mode 100644 src/renderer/src/i18n.ts
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 421550a..1ab6511 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -19,6 +19,7 @@ import { useClipboard } from './hooks/useClipboard'
import { useSettings } from './contexts/SettingsContext'
import { useBatch } from './hooks/useBatch'
import { cleanAll } from './core/cleaner'
+import { useI18n } from './i18n'
const CompareView = lazy(() => import('./components/CompareView'))
const SettingsPage = lazy(() => import('./components/SettingsPage'))
@@ -30,6 +31,7 @@ function App(): React.JSX.Element {
const [cleaned, setCleaned] = useState(null)
const [bgImageUrl, setBgImageUrl] = useState(null)
const [showBgPanel, setShowBgPanel] = useState(false)
+ const { t } = useI18n()
const { settings, updateSettings } = useSettings()
const batchHook = useBatch()
@@ -152,7 +154,7 @@ function App(): React.JSX.Element {
className="cosmic-btn flex items-center gap-1.5 text-xs px-3 py-2 rounded-xl cursor-pointer no-drag"
>
- 清理
+ {t('common.clean')}
)}
@@ -164,7 +166,7 @@ function App(): React.JSX.Element {
className="flex items-center gap-1.5 text-xs text-zinc-600 hover:text-cyan-300 transition-colors duration-300 cursor-pointer no-drag"
>
- 重置
+ {t('common.reset')}
)}
@@ -210,11 +212,9 @@ function App(): React.JSX.Element {
- 粘贴文本,按{' '}
- Ctrl+↵
- {' '}开始扫描
+ {t('detect.empty', { shortcut: 'Ctrl+Enter' })}
-
检测零宽字符、同形字、BiDi 控制码、Tags 区块等
+
{t('detect.emptyHint')}
)}
@@ -236,7 +236,7 @@ function App(): React.JSX.Element {
-
正在分析字符
+
{t('detect.analyzing')}
SCANNING UNICODE STREAM...
@@ -254,8 +254,8 @@ function App(): React.JSX.Element {
-
扫描失败
-
{errorMessage || '未知错误,请重试'}
+
{t('detect.error')}
+
{errorMessage || t('detect.unknownError')}
- 重置
+ {t('common.reset')}
)}
@@ -280,10 +280,10 @@ function App(): React.JSX.Element {
{([
- { id: 'highlight' as const, label: '标注视图' },
+ { id: 'highlight' as const, label: t('detect.highlight') },
...(cleaned ? [
- { id: 'cleaned' as const, label: '净化文本' },
- { id: 'compare' as const, label: '对比' },
+ { id: 'cleaned' as const, label: t('detect.cleaned') },
+ { id: 'compare' as const, label: t('detect.compare') },
] : []),
]).map(({ id, label }) => (
diff --git a/src/renderer/src/components/BackgroundSwitcher.tsx b/src/renderer/src/components/BackgroundSwitcher.tsx
index 1f92a6a..843b270 100644
--- a/src/renderer/src/components/BackgroundSwitcher.tsx
+++ b/src/renderer/src/components/BackgroundSwitcher.tsx
@@ -1,6 +1,7 @@
import { useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, ImagesSquare, UploadSimple, Trash } from '@phosphor-icons/react'
+import { useI18n } from '../i18n'
interface BackgroundSwitcherProps {
open: boolean
@@ -15,6 +16,7 @@ export default function BackgroundSwitcher({
onSelect,
onClose,
}: BackgroundSwitcherProps) {
+ const { t } = useI18n()
const fileInputRef = useRef
(null)
const handleFileChange = (e: React.ChangeEvent) => {
@@ -78,7 +80,7 @@ export default function BackgroundSwitcher({
- 背景图片
+ {t('background.title')}
- 当前自定义背景
+ {t('background.currentCustom')}
) : (
@@ -110,7 +112,7 @@ export default function BackgroundSwitcher({
style={{ aspectRatio: '16/9', background: 'rgba(255,255,255,0.03)', border: '1px dashed rgba(255,255,255,0.08)' }}
>
- 当前:动态宇宙背景
+ {t('background.currentCosmic')}
)}
@@ -133,7 +135,7 @@ export default function BackgroundSwitcher({
"
>
- 上传背景图片
+ {t('background.upload')}
{/* Remove button (only when custom bg is set) */}
@@ -149,12 +151,12 @@ export default function BackgroundSwitcher({
"
>
- 恢复动态背景
+ {t('background.restore')}
)}
- 支持 PNG · JPG · WebP · GIF
+ {t('background.supports')}
diff --git a/src/renderer/src/components/BatchMode.tsx b/src/renderer/src/components/BatchMode.tsx
index ea2c06e..bc9c7c1 100644
--- a/src/renderer/src/components/BatchMode.tsx
+++ b/src/renderer/src/components/BatchMode.tsx
@@ -16,10 +16,12 @@ import {
} from '@phosphor-icons/react'
import { BatchItem, useBatch } from '../hooks/useBatch'
import StatsPanel from './StatsPanel'
+import { useI18n } from '../i18n'
type BatchHook = ReturnType
export default function BatchMode({ batchHook }: { batchHook: BatchHook }) {
+ const { t } = useI18n()
const {
items,
scanning,
@@ -77,9 +79,9 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) {
- 批量检测
+ {t('batch.title')}
-
同时检测多个文件或多段文本
+
{t('batch.subtitle')}
{items.length > 0 && (
@@ -87,10 +89,10 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) {
{doneCount > 0 && (
- {doneCount}/{items.length} 已扫描
+ {t('batch.scanned', { done: doneCount, total: items.length })}
- 平均风险{' '}
+ {t('batch.avgRisk')}{' '}
- 全部清理
+ {t('batch.cleanAll')}
)}
{/* Scan selected / scan all */}
@@ -119,7 +121,7 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) {
className="flex items-center gap-1.5 text-xs text-white bg-[#3b7cd4] hover:bg-[#4d8de0] disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 rounded-lg transition-all cursor-pointer no-drag font-medium"
>
- {scanning ? '扫描中…' : `扫描已选 (${selectedCount})`}
+ {scanning ? t('common.scanning') : t('batch.scanSelected', { count: selectedCount })}
) : (
)}
)}
@@ -148,8 +150,8 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) {
{/* Mode toggle */}
{([
- { id: 'file' as const, label: '文件', icon: },
- { id: 'text' as const, label: '文本', icon: },
+ { id: 'file' as const, label: t('batch.file'), icon: },
+ { id: 'text' as const, label: t('batch.text'), icon: },
]).map(({ id, label, icon }) => (
-
拖入多个文件
-
点击或拖放 · .txt .md .docx .pdf
+
{t('batch.dropFiles')}
+
{t('batch.dropHint')}
setSegmentText(e.target.value)}
- placeholder={`粘贴多段文本,用\n---\n分隔各段`}
+ placeholder={t('batch.segmentPlaceholder')}
className="flex-1 bg-white/[0.03] rounded-xl border border-white/[0.07] text-sm text-zinc-200 leading-relaxed font-['Geist_Mono',monospace] resize-none px-4 py-3 focus:outline-none focus:border-[#3b7cd4]/40 placeholder:text-zinc-600"
spellCheck={false}
/>
@@ -210,7 +212,7 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) {
disabled={!segmentText.trim()}
className="flex items-center justify-center gap-1.5 text-xs text-zinc-200 bg-white/[0.06] hover:bg-white/[0.09] disabled:opacity-40 disabled:cursor-not-allowed border border-white/[0.08] px-3 py-2 rounded-lg transition-all cursor-pointer no-drag"
>
- 添加文本段落
+ {t('batch.addSegments')}
)}
@@ -223,7 +225,7 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) {
- 拖入文件或粘贴文本段落开始批量检测
+ {t('batch.empty')}
) : (
@@ -240,10 +242,10 @@ export default function BatchMode({ batchHook }: { batchHook: BatchHook }) {
) : (
)}
- {allSelected ? '取消全选' : '全选'}
+ {allSelected ? t('batch.unselectAll') : t('batch.selectAll')}
{selectedCount > 0 && (
- 已选 {selectedCount} 项
+ {t('batch.selected', { count: selectedCount })}
)}
@@ -283,10 +285,18 @@ function BatchItemCard({
onRemove: () => void
onSelect: () => void
}) {
+ const { t } = useI18n()
const score = item.result?.riskScore ?? 0
const scoreColor = score < 30 ? '#10b981' : score < 65 ? '#f59e0b' : '#ef4444'
- const riskLabel = score < 30 ? '低风险' : score < 65 ? '中等' : '高风险'
- const actionLabel = score === 0 ? '无异常' : score < 30 ? '可忽略' : score < 65 ? '建议清理' : '建议重写'
+ const riskLabel = score < 30 ? t('stats.low') : score < 65 ? t('stats.medium') : t('stats.high')
+ const actionLabel =
+ score === 0
+ ? t('risk.none')
+ : score < 30
+ ? t('stats.action.ignore')
+ : score < 65
+ ? t('stats.action.clean')
+ : t('stats.action.rewrite')
return (
{item.name}
{item.status === 'done' && item.result && (
- {item.result.totalChars.toLocaleString()} 字符 · 发现 {item.result.suspiciousCount} 处
+ {t('batch.itemSummary', {
+ chars: item.result.totalChars.toLocaleString(),
+ count: item.result.suspiciousCount,
+ })}
)}
{item.status === 'error' && (
@@ -381,7 +394,7 @@ function BatchItemCard({
@@ -389,7 +402,7 @@ function BatchItemCard({
@@ -410,7 +423,7 @@ function BatchItemCard({
{item.cleanedText && (
-
净化结果
+
{t('batch.cleanResult')}