feat(admin): 敏感词管理页与多语种补全

新增词库/命中列表、平台配置敏感词策略 Tab 与权限文案;补齐六语及东盟四语 risk/sensitiveWord 与菜单标题,并增加 locale key 完整性检查脚本。
This commit is contained in:
DaxPay Dev
2026-07-16 09:09:03 +08:00
parent ba8224d86f
commit b5b410ab67
34 changed files with 1974 additions and 10 deletions

View File

@@ -0,0 +1,30 @@
import type { Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 平台敏感词策略配置 API平台配置 Tab
*/
export const SensitiveWordConfigApi = {
/** 获取策略 */
get(): Promise<Result<SensitiveWordConfig>> {
return defHttp.get({ url: '/platform/config/sensitive-word/get' });
},
/** 更新策略 */
update(data: SensitiveWordConfig): Promise<Result<void>> {
return defHttp.post({ url: '/platform/config/sensitive-word/update', data });
},
};
/** 敏感词策略 */
export interface SensitiveWordConfig {
/** 是否启用过滤 */
enabled?: boolean;
/** 是否回显命中词 */
revealWord?: boolean;
/** 是否写命中审计 */
recordHit?: boolean;
/** 原文摘要最大长度 */
contentPreviewMaxLen?: number;
}

View File

@@ -0,0 +1,43 @@
import type { BaseEntity, PageResult, Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 敏感词命中记录 API运营端 /admin/system/sensitive-word-hit
*/
export const SensitiveWordHitApi = {
/** 分页查询 */
page(
params: SensitiveWordHitQuery & { current: number; size: number },
): Promise<Result<PageResult<SensitiveWordHitVo>>> {
return defHttp.get({ url: '/admin/system/sensitive-word-hit/page', params });
},
/** 详情 */
getById(id: string): Promise<Result<SensitiveWordHitVo>> {
return defHttp.get({ url: '/admin/system/sensitive-word-hit/get-by-id', params: { id } });
},
};
/** 查询参数 */
export interface SensitiveWordHitQuery {
hitWord?: string;
scene?: string;
source?: string;
mchNo?: string;
}
/** 列表/详情 VO */
export interface SensitiveWordHitVo extends BaseEntity {
wordId?: string;
hitWord?: string;
contentPreview?: string;
scene?: string;
source?: string;
mchNo?: string;
appId?: string;
operatorId?: string;
clientIp?: string;
requestPath?: string;
remark?: string;
}

View File

@@ -0,0 +1,75 @@
import type { BaseEntity, PageResult, Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 敏感词词库 API运营端 /admin/system/sensitive-word
*/
export const SensitiveWordApi = {
/** 分页查询 */
page(
params: SensitiveWordQuery & { current: number; size: number },
): Promise<Result<PageResult<SensitiveWordVo>>> {
return defHttp.get({ url: '/admin/system/sensitive-word/page', params });
},
/** 详情 */
getById(id: string): Promise<Result<SensitiveWordVo>> {
return defHttp.get({ url: '/admin/system/sensitive-word/get-by-id', params: { id } });
},
/** 新增 */
add(data: SensitiveWordParam): Promise<Result<SensitiveWordVo>> {
return defHttp.post({ url: '/admin/system/sensitive-word/add', data });
},
/** 修改 */
update(data: SensitiveWordParam): Promise<Result<void>> {
return defHttp.post({ url: '/admin/system/sensitive-word/update', data });
},
/** 删除 */
delete(id: string): Promise<Result<void>> {
return defHttp.post({ url: '/admin/system/sensitive-word/delete', params: { id } });
},
/** 词面是否存在 */
existsByWord(word: string, id?: string): Promise<Result<boolean>> {
return defHttp.get({ url: '/admin/system/sensitive-word/exists-by-word', params: { word, id } });
},
/** 试检文本 */
checkText(data: { text: string; recordHit?: boolean }): Promise<
Result<{ hits: string[]; hit: boolean }>
> {
return defHttp.post({ url: '/admin/system/sensitive-word/check-text', data });
},
};
/** 查询参数 */
export interface SensitiveWordQuery {
word?: string;
category?: string;
status?: string;
matchMode?: string;
}
/** 表单参数 */
export interface SensitiveWordParam {
id?: string;
word?: string;
category?: string;
matchMode?: string;
status?: string;
remark?: string;
}
/** 列表/详情 VO */
export interface SensitiveWordVo extends BaseEntity {
word?: string;
category?: string;
matchMode?: string;
level?: string;
status?: string;
remark?: string;
}

View File

@@ -248,5 +248,14 @@ export const PermCodes = {
MANAGE: 'system:protocol:manage',
PUBLISH: 'system:protocol:publish',
},
/** 敏感词词库 */
SensitiveWord: {
VIEW: 'system:sensitive-word:view',
MANAGE: 'system:sensitive-word:manage',
},
/** 敏感词命中记录 */
SensitiveWordHit: {
VIEW: 'system:sensitive-word-hit:view',
},
},
} as const;

View File

@@ -0,0 +1,62 @@
{
"tip": "Prefer Simplified Chinese words. Matching applies traditional-to-simplified and full-width normalization.",
"word": {
"add": "Add word",
"edit": "Edit word",
"view": "View word",
"confirmDelete": "Delete this sensitive word?",
"field": {
"word": "Word",
"category": "Category",
"matchMode": "Match mode",
"status": "Status",
"remark": "Remark",
"createTime": "Created at"
},
"category": {
"politic": "Political",
"porn": "Pornography",
"violence": "Violence",
"ad": "Ads",
"custom": "Custom"
},
"matchMode": {
"contains": "Contains",
"exact": "Exact"
},
"status": {
"enable": "Enabled",
"disable": "Disabled"
},
"check": {
"title": "Check text",
"placeholder": "Enter text to check",
"button": "Check",
"hit": "Hit: {0}",
"clean": "No sensitive words",
"recordHit": "Record hit"
}
},
"hit": {
"field": {
"hitWord": "Hit word",
"contentPreview": "Content preview",
"scene": "Scene",
"source": "Source",
"mchNo": "Merchant no.",
"clientIp": "Client IP",
"createTime": "Hit time"
}
},
"config": {
"title": "Sensitive words",
"description": "Global switches for sensitive-word filtering (CN-focused content)",
"enabled": "Enable filtering",
"enabledTip": "When off, all text checks pass (can disable for overseas)",
"revealWord": "Reveal hit word in errors",
"revealWordTip": "Shows the matched word in API errors (probing risk)",
"recordHit": "Record hits",
"recordHitTip": "Write hit audit rows on block",
"confirmSaveContent": "Saving will affect online filtering immediately. Continue?"
}
}

View File

@@ -0,0 +1,78 @@
{
"blacklist": {
"title": "Daftar hitam",
"add": "Tambah daftar hitam",
"editTitle": "Edit daftar hitam",
"viewTitle": "Lihat daftar hitam",
"confirmDelete": "Hapus entri daftar hitam ini?",
"field": {
"type": "Jenis",
"value": "Nilai",
"channel": "Saluran",
"channelAppId": "AppId saluran",
"status": "Status",
"reason": "Alasan",
"expireTime": "Waktu kedaluwarsa",
"remark": "Catatan",
"createTime": "Dibuat pada"
},
"type": {
"ip": "IP",
"open_id": "OpenId"
},
"status": {
"enable": "Aktif",
"disable": "Nonaktif"
},
"tip": {
"typeValueImmutable": "Jenis dan nilai tidak dapat diubah setelah dibuat",
"expireEmpty": "Kosongkan untuk permanen"
}
},
"hit": {
"title": "Hit risiko",
"handleTitle": "Tangani hit",
"viewTitle": "Lihat hit",
"field": {
"phase": "Fase",
"hitType": "Jenis hit",
"hitValue": "Nilai hit",
"mchNo": "No. merchant",
"appId": "App ID",
"tradeNo": "No. transaksi",
"orderNo": "No. order",
"bizOrderNo": "No. order bisnis",
"method": "Metode",
"channel": "Saluran",
"clientIp": "IP klien",
"openid": "OpenId",
"buyerId": "BuyerId",
"scene": "Skenario",
"handleStatus": "Status penanganan",
"handleRemark": "Catatan penanganan",
"handleTime": "Waktu penanganan",
"createTime": "Dibuat pada"
},
"phase": {
"before_pay": "Sebelum bayar",
"after_pay": "Setelah bayar"
},
"handleStatus": {
"pending": "Menunggu",
"ignored": "Diabaikan",
"added_blacklist": "Ditambah ke daftar hitam",
"merchant_disabled": "Merchant dinonaktifkan",
"other": "Lainnya"
},
"scene": {
"api": "API",
"gateway": "Gateway",
"code": "Kode QR",
"manual": "Manual",
"unknown": "Tidak diketahui"
},
"action": {
"handle": "Tangani"
}
}
}

View File

@@ -0,0 +1,62 @@
{
"tip": "Disarankan mendaftarkan kata dalam bahasa Mandarin sederhana; sistem menormalisasi ke sederhana dan half-width sebelum pencocokan.",
"word": {
"add": "Tambah kata sensitif",
"edit": "Edit kata sensitif",
"view": "Lihat kata sensitif",
"confirmDelete": "Hapus kata sensitif ini?",
"field": {
"word": "Kata",
"category": "Kategori",
"matchMode": "Mode pencocokan",
"status": "Status",
"remark": "Catatan",
"createTime": "Dibuat pada"
},
"category": {
"politic": "Politik",
"porn": "Pornografi",
"violence": "Kekerasan",
"ad": "Iklan",
"custom": "Kustom"
},
"matchMode": {
"contains": "Mengandung",
"exact": "Persis"
},
"status": {
"enable": "Aktif",
"disable": "Nonaktif"
},
"check": {
"title": "Uji teks",
"placeholder": "Masukkan teks untuk diperiksa",
"button": "Periksa",
"hit": "Terdeteksi: {0}",
"clean": "Tidak ada kata sensitif",
"recordHit": "Catat hit"
}
},
"hit": {
"field": {
"hitWord": "Kata terdeteksi",
"contentPreview": "Ringkasan teks",
"scene": "Skenario",
"source": "Sumber",
"mchNo": "No. merchant",
"clientIp": "IP klien",
"createTime": "Waktu hit"
}
},
"config": {
"title": "Kata sensitif",
"description": "Saklar global dan kebijakan filter kata sensitif teks platform",
"enabled": "Aktifkan filter",
"enabledTip": "Jika dimatikan, semua teks lolos (dapat dimatikan untuk deployment luar negeri)",
"revealWord": "Tampilkan kata terdeteksi pada error",
"revealWordTip": "Jika aktif, pesan error API memuat kata konkret (risiko probing)",
"recordHit": "Catat audit hit",
"recordHitTip": "Menulis ke tabel hit saat diblokir",
"confirmSaveContent": "Penyimpanan akan langsung memengaruhi filter online. Lanjutkan?"
}
}

View File

@@ -0,0 +1,62 @@
{
"tip": "辞書は簡体字中国語での登録を推奨します。照合前に繁体字→簡体字変換と全角正規化を行います。",
"word": {
"add": "センシティブワード追加",
"edit": "センシティブワード編集",
"view": "センシティブワード詳細",
"confirmDelete": "このセンシティブワードを削除しますか?",
"field": {
"word": "ワード",
"category": "分類",
"matchMode": "一致モード",
"status": "状態",
"remark": "備考",
"createTime": "作成日時"
},
"category": {
"politic": "政治",
"porn": "ポルノ",
"violence": "暴力",
"ad": "広告",
"custom": "カスタム"
},
"matchMode": {
"contains": "含む",
"exact": "完全一致"
},
"status": {
"enable": "有効",
"disable": "無効"
},
"check": {
"title": "テキスト検査",
"placeholder": "検査するテキストを入力",
"button": "検査",
"hit": "ヒット:{0}",
"clean": "センシティブワードなし",
"recordHit": "ヒット記録を保存"
}
},
"hit": {
"field": {
"hitWord": "ヒットワード",
"contentPreview": "原文要約",
"scene": "シーン",
"source": "ソース",
"mchNo": "加盟店番号",
"clientIp": "クライアントIP",
"createTime": "ヒット日時"
}
},
"config": {
"title": "センシティブワード",
"description": "プラットフォームのテキスト用センシティブワードフィルタの全体スイッチと方針",
"enabled": "フィルタを有効化",
"enabledTip": "オフにするとすべてのテキスト検証を通過します(海外展開時は無効化可)",
"revealWord": "エラーにヒットワードを表示",
"revealWordTip": "有効にするとAPIエラー文に具体的なワードが含まれるため、探査リスクがあります",
"recordHit": "ヒット監査を記録",
"recordHitTip": "遮断時にヒット記録テーブルへ書き込みます",
"confirmSaveContent": "保存後はオンラインの遮断方針に即時反映されます。保存しますか?"
}
}

View File

@@ -0,0 +1,62 @@
{
"tip": "단어는 간체 중국어로 등록하는 것을 권장합니다. 매칭 전 번체→간체 변환 및 전각 정규화를 수행합니다.",
"word": {
"add": "민감어 추가",
"edit": "민감어 편집",
"view": "민감어 보기",
"confirmDelete": "이 민감어를 삭제하시겠습니까?",
"field": {
"word": "민감어",
"category": "분류",
"matchMode": "일치 모드",
"status": "상태",
"remark": "비고",
"createTime": "생성 시간"
},
"category": {
"politic": "정치",
"porn": "음란",
"violence": "폭력",
"ad": "광고",
"custom": "사용자 정의"
},
"matchMode": {
"contains": "포함",
"exact": "정확 일치"
},
"status": {
"enable": "사용",
"disable": "중지"
},
"check": {
"title": "텍스트 검사",
"placeholder": "검사할 텍스트를 입력하세요",
"button": "검사",
"hit": "적중: {0}",
"clean": "민감어 없음",
"recordHit": "적중 기록 저장"
}
},
"hit": {
"field": {
"hitWord": "적중 단어",
"contentPreview": "원문 요약",
"scene": "장면",
"source": "출처",
"mchNo": "가맹점 번호",
"clientIp": "클라이언트 IP",
"createTime": "적중 시간"
}
},
"config": {
"title": "민감어",
"description": "플랫폼 텍스트 민감어 필터의 전역 스위치 및 정책",
"enabled": "필터 사용",
"enabledTip": "끄면 모든 텍스트 검증이 통과됩니다(해외 배포 시 비활성화 가능)",
"revealWord": "오류에 적중 단어 표시",
"revealWordTip": "켜면 API 오류 메시지에 구체적 단어가 포함되어 탐색 위험이 있습니다",
"recordHit": "적중 감사 기록",
"recordHitTip": "차단 시 적중 기록 테이블에 기록합니다",
"confirmSaveContent": "저장 후 온라인 차단 정책에 즉시 반영됩니다. 저장하시겠습니까?"
}
}

View File

@@ -0,0 +1,78 @@
{
"blacklist": {
"title": "Senarai hitam",
"add": "Tambah senarai hitam",
"editTitle": "Edit senarai hitam",
"viewTitle": "Lihat senarai hitam",
"confirmDelete": "Padam entri senarai hitam ini?",
"field": {
"type": "Jenis",
"value": "Nilai",
"channel": "Saluran",
"channelAppId": "AppId saluran",
"status": "Status",
"reason": "Sebab",
"expireTime": "Tamat tempoh",
"remark": "Catatan",
"createTime": "Dicipta pada"
},
"type": {
"ip": "IP",
"open_id": "OpenId"
},
"status": {
"enable": "Aktif",
"disable": "Nyahaktif"
},
"tip": {
"typeValueImmutable": "Jenis dan nilai tidak boleh diubah selepas dicipta",
"expireEmpty": "Biarkan kosong untuk kekal"
}
},
"hit": {
"title": "Hit risiko",
"handleTitle": "Urus hit",
"viewTitle": "Lihat hit",
"field": {
"phase": "Fasa",
"hitType": "Jenis hit",
"hitValue": "Nilai hit",
"mchNo": "No. peniaga",
"appId": "App ID",
"tradeNo": "No. dagangan",
"orderNo": "No. pesanan",
"bizOrderNo": "No. pesanan perniagaan",
"method": "Kaedah",
"channel": "Saluran",
"clientIp": "IP klien",
"openid": "OpenId",
"buyerId": "BuyerId",
"scene": "Senario",
"handleStatus": "Status urusan",
"handleRemark": "Catatan urusan",
"handleTime": "Masa urusan",
"createTime": "Dicipta pada"
},
"phase": {
"before_pay": "Sebelum bayar",
"after_pay": "Selepas bayar"
},
"handleStatus": {
"pending": "Menunggu",
"ignored": "Diabaikan",
"added_blacklist": "Ditambah ke senarai hitam",
"merchant_disabled": "Peniaga dinyahaktif",
"other": "Lain-lain"
},
"scene": {
"api": "API",
"gateway": "Gateway",
"code": "Kod QR",
"manual": "Manual",
"unknown": "Tidak diketahui"
},
"action": {
"handle": "Urus"
}
}
}

View File

@@ -0,0 +1,62 @@
{
"tip": "Disyorkan daftar perkataan dalam Cina ringkas; sistem menormalkan ke ringkas dan separuh lebar sebelum padanan.",
"word": {
"add": "Tambah perkataan sensitif",
"edit": "Edit perkataan sensitif",
"view": "Lihat perkataan sensitif",
"confirmDelete": "Padam perkataan sensitif ini?",
"field": {
"word": "Perkataan",
"category": "Kategori",
"matchMode": "Mod padanan",
"status": "Status",
"remark": "Catatan",
"createTime": "Dicipta pada"
},
"category": {
"politic": "Politik",
"porn": "Pornografi",
"violence": "Keganasan",
"ad": "Iklan",
"custom": "Tersuai"
},
"matchMode": {
"contains": "Mengandungi",
"exact": "Tepat"
},
"status": {
"enable": "Aktif",
"disable": "Nyahaktif"
},
"check": {
"title": "Uji teks",
"placeholder": "Masukkan teks untuk diperiksa",
"button": "Periksa",
"hit": "Dikesan: {0}",
"clean": "Tiada perkataan sensitif",
"recordHit": "Rekod hit"
}
},
"hit": {
"field": {
"hitWord": "Perkataan dikesan",
"contentPreview": "Ringkasan teks",
"scene": "Senario",
"source": "Sumber",
"mchNo": "No. peniaga",
"clientIp": "IP klien",
"createTime": "Masa hit"
}
},
"config": {
"title": "Perkataan sensitif",
"description": "Suis global dan polisi penapis perkataan sensitif platform",
"enabled": "Dayakan penapis",
"enabledTip": "Jika dimatikan, semua teks lulus (boleh dimatikan untuk luar negara)",
"revealWord": "Papar perkataan dikesan dalam ralat",
"revealWordTip": "Jika aktif, mesej ralat API mengandungi perkataan sebenar (risiko probing)",
"recordHit": "Rekod audit hit",
"recordHitTip": "Tulis ke jadual hit apabila disekat",
"confirmSaveContent": "Simpan akan menjejaskan penapis dalam talian serta-merta. Teruskan?"
}
}

View File

@@ -0,0 +1,78 @@
{
"blacklist": {
"title": "บัญชีดำ",
"add": "เพิ่มบัญชีดำ",
"editTitle": "แก้ไขบัญชีดำ",
"viewTitle": "ดูบัญชีดำ",
"confirmDelete": "ลบรายการบัญชีดำนี้?",
"field": {
"type": "ประเภท",
"value": "ค่า",
"channel": "ช่องทาง",
"channelAppId": "AppId ช่องทาง",
"status": "สถานะ",
"reason": "เหตุผล",
"expireTime": "หมดอายุ",
"remark": "หมายเหตุ",
"createTime": "สร้างเมื่อ"
},
"type": {
"ip": "IP",
"open_id": "OpenId"
},
"status": {
"enable": "เปิดใช้",
"disable": "ปิดใช้"
},
"tip": {
"typeValueImmutable": "ประเภทและค่าไม่สามารถแก้ไขหลังสร้าง",
"expireEmpty": "เว้นว่างหากถาวร"
}
},
"hit": {
"title": "การตรวจจับความเสี่ยง",
"handleTitle": "จัดการรายการ",
"viewTitle": "ดูรายการ",
"field": {
"phase": "ช่วง",
"hitType": "ประเภทการตรวจจับ",
"hitValue": "ค่าที่ตรวจจับ",
"mchNo": "รหัสร้านค้า",
"appId": "App ID",
"tradeNo": "เลขธุรกรรม",
"orderNo": "เลขออเดอร์",
"bizOrderNo": "เลขออเดอร์ธุรกิจ",
"method": "วิธีชำระ",
"channel": "ช่องทาง",
"clientIp": "IP ลูกค้า",
"openid": "OpenId",
"buyerId": "BuyerId",
"scene": "ฉาก",
"handleStatus": "สถานะจัดการ",
"handleRemark": "หมายเหตุจัดการ",
"handleTime": "เวลาจัดการ",
"createTime": "สร้างเมื่อ"
},
"phase": {
"before_pay": "ก่อนชำระ",
"after_pay": "หลังชำระ"
},
"handleStatus": {
"pending": "รอดำเนินการ",
"ignored": "เพิกเฉย",
"added_blacklist": "เพิ่มเข้าบัญชีดำแล้ว",
"merchant_disabled": "ปิดร้านค้าแล้ว",
"other": "อื่นๆ"
},
"scene": {
"api": "API",
"gateway": "Gateway",
"code": "คิวอาร์โค้ด",
"manual": "ด้วยตนเอง",
"unknown": "ไม่ทราบ"
},
"action": {
"handle": "จัดการ"
}
}
}

View File

@@ -0,0 +1,62 @@
{
"tip": "แนะนำลงทะเบียนคำด้วยภาษาจีนตัวย่อ ระบบจะแปลงตัวเต็ม→ตัวย่อและทำให้ครึ่งความกว้างก่อนจับคู่",
"word": {
"add": "เพิ่มคำละเอียดอ่อน",
"edit": "แก้ไขคำละเอียดอ่อน",
"view": "ดูคำละเอียดอ่อน",
"confirmDelete": "ลบคำละเอียดอ่อนนี้?",
"field": {
"word": "คำ",
"category": "หมวดหมู่",
"matchMode": "โหมดจับคู่",
"status": "สถานะ",
"remark": "หมายเหตุ",
"createTime": "สร้างเมื่อ"
},
"category": {
"politic": "การเมือง",
"porn": "ลามก",
"violence": "ความรุนแรง",
"ad": "โฆษณา",
"custom": "กำหนดเอง"
},
"matchMode": {
"contains": "มีคำ",
"exact": "ตรงทั้งหมด"
},
"status": {
"enable": "เปิดใช้",
"disable": "ปิดใช้"
},
"check": {
"title": "ตรวจข้อความ",
"placeholder": "ป้อนข้อความเพื่อตรวจ",
"button": "ตรวจ",
"hit": "พบ: {0}",
"clean": "ไม่พบคำละเอียดอ่อน",
"recordHit": "บันทึกการพบ"
}
},
"hit": {
"field": {
"hitWord": "คำที่พบ",
"contentPreview": "สรุปข้อความ",
"scene": "ฉาก",
"source": "แหล่งที่มา",
"mchNo": "รหัสร้านค้า",
"clientIp": "IP ลูกค้า",
"createTime": "เวลาที่พบ"
}
},
"config": {
"title": "คำละเอียดอ่อน",
"description": "สวิตช์และนโยบายตัวกรองคำละเอียดอ่อนทั้งระบบ",
"enabled": "เปิดตัวกรอง",
"enabledTip": "ปิดแล้วข้อความทั้งหมดจะผ่าน (ปิดได้เมื่อใช้งานต่างประเทศ)",
"revealWord": "แสดงคำที่พบในข้อผิดพลาด",
"revealWordTip": "เปิดแล้วข้อความ error API จะมีคำจริง (มีความเสี่ยงถูกสำรวจ)",
"recordHit": "บันทึกการตรวจจับ",
"recordHitTip": "เขียนตาราง hit เมื่อถูกบล็อก",
"confirmSaveContent": "การบันทึกจะมีผลกับตัวกรองออนไลน์ทันที ดำเนินการต่อ?"
}
}

View File

@@ -0,0 +1,78 @@
{
"blacklist": {
"title": "Danh sách đen",
"add": "Thêm danh sách đen",
"editTitle": "Sửa danh sách đen",
"viewTitle": "Xem danh sách đen",
"confirmDelete": "Xóa mục danh sách đen này?",
"field": {
"type": "Loại",
"value": "Giá trị",
"channel": "Kênh",
"channelAppId": "AppId kênh",
"status": "Trạng thái",
"reason": "Lý do",
"expireTime": "Hết hạn",
"remark": "Ghi chú",
"createTime": "Thời gian tạo"
},
"type": {
"ip": "IP",
"open_id": "OpenId"
},
"status": {
"enable": "Bật",
"disable": "Tắt"
},
"tip": {
"typeValueImmutable": "Loại và giá trị không thể sửa sau khi tạo",
"expireEmpty": "Để trống nếu vĩnh viễn"
}
},
"hit": {
"title": "Lượt chặn rủi ro",
"handleTitle": "Xử lý lượt chặn",
"viewTitle": "Xem lượt chặn",
"field": {
"phase": "Giai đoạn",
"hitType": "Loại chặn",
"hitValue": "Giá trị chặn",
"mchNo": "Mã merchant",
"appId": "App ID",
"tradeNo": "Mã giao dịch",
"orderNo": "Mã đơn",
"bizOrderNo": "Mã đơn nghiệp vụ",
"method": "Phương thức",
"channel": "Kênh",
"clientIp": "IP client",
"openid": "OpenId",
"buyerId": "BuyerId",
"scene": "Kịch bản",
"handleStatus": "Trạng thái xử lý",
"handleRemark": "Ghi chú xử lý",
"handleTime": "Thời gian xử lý",
"createTime": "Thời gian tạo"
},
"phase": {
"before_pay": "Trước thanh toán",
"after_pay": "Sau thanh toán"
},
"handleStatus": {
"pending": "Chờ xử lý",
"ignored": "Bỏ qua",
"added_blacklist": "Đã thêm vào danh sách đen",
"merchant_disabled": "Merchant đã khóa",
"other": "Khác"
},
"scene": {
"api": "API",
"gateway": "Gateway",
"code": "Mã QR",
"manual": "Thủ công",
"unknown": "Không rõ"
},
"action": {
"handle": "Xử lý"
}
}
}

View File

@@ -0,0 +1,62 @@
{
"tip": "Nên đăng ký từ bằng tiếng Trung giản thể; hệ thống chuẩn hóa phồn thể→giản thể và full-width trước khi khớp.",
"word": {
"add": "Thêm từ nhạy cảm",
"edit": "Sửa từ nhạy cảm",
"view": "Xem từ nhạy cảm",
"confirmDelete": "Xóa từ nhạy cảm này?",
"field": {
"word": "Từ",
"category": "Phân loại",
"matchMode": "Chế độ khớp",
"status": "Trạng thái",
"remark": "Ghi chú",
"createTime": "Thời gian tạo"
},
"category": {
"politic": "Chính trị",
"porn": "Khiêu dâm",
"violence": "Bạo lực",
"ad": "Quảng cáo",
"custom": "Tùy chỉnh"
},
"matchMode": {
"contains": "Chứa",
"exact": "Chính xác"
},
"status": {
"enable": "Bật",
"disable": "Tắt"
},
"check": {
"title": "Kiểm tra văn bản",
"placeholder": "Nhập văn bản để kiểm tra",
"button": "Kiểm tra",
"hit": "Trúng: {0}",
"clean": "Không có từ nhạy cảm",
"recordHit": "Ghi nhận lượt trúng"
}
},
"hit": {
"field": {
"hitWord": "Từ trúng",
"contentPreview": "Tóm tắt nội dung",
"scene": "Kịch bản",
"source": "Nguồn",
"mchNo": "Mã merchant",
"clientIp": "IP client",
"createTime": "Thời gian trúng"
}
},
"config": {
"title": "Từ nhạy cảm",
"description": "Công tắc và chính sách lọc từ nhạy cảm toàn cục cho văn bản nền tảng",
"enabled": "Bật bộ lọc",
"enabledTip": "Tắt thì mọi kiểm tra văn bản đều qua (có thể tắt khi triển khai nước ngoài)",
"revealWord": "Hiện từ trúng trong lỗi",
"revealWordTip": "Bật thì thông báo lỗi API có từ cụ thể (rủi ro dò quét)",
"recordHit": "Ghi nhật ký trúng",
"recordHitTip": "Ghi vào bảng hit khi chặn",
"confirmSaveContent": "Lưu sẽ ảnh hưởng ngay bộ lọc online. Tiếp tục?"
}
}

View File

@@ -0,0 +1,62 @@
{
"tip": "词库建议录入简体中文;系统匹配前会做繁转简与全角规范化。",
"word": {
"add": "新增敏感词",
"edit": "编辑敏感词",
"view": "查看敏感词",
"confirmDelete": "确认删除该敏感词?",
"field": {
"word": "敏感词",
"category": "分类",
"matchMode": "匹配模式",
"status": "状态",
"remark": "备注",
"createTime": "创建时间"
},
"category": {
"politic": "政治",
"porn": "色情",
"violence": "暴力",
"ad": "广告",
"custom": "自定义"
},
"matchMode": {
"contains": "包含",
"exact": "精确"
},
"status": {
"enable": "启用",
"disable": "禁用"
},
"check": {
"title": "试检文本",
"placeholder": "输入文本检测是否命中敏感词",
"button": "检测",
"hit": "命中:{0}",
"clean": "未命中敏感词",
"recordHit": "写入命中记录"
}
},
"hit": {
"field": {
"hitWord": "命中词",
"contentPreview": "原文摘要",
"scene": "场景",
"source": "来源",
"mchNo": "商户号",
"clientIp": "客户端IP",
"createTime": "命中时间"
}
},
"config": {
"title": "敏感词",
"description": "控制平台文本敏感词过滤的总开关与策略(国内主场景)",
"enabled": "启用敏感词过滤",
"enabledTip": "关闭后所有文本校验放行(海外部署可关)",
"revealWord": "错误回显命中词",
"revealWordTip": "开启后对外错误文案会带出具体敏感词,有探测风险",
"recordHit": "记录命中审计",
"recordHitTip": "拦截时写入命中记录表",
"confirmSaveContent": "保存后将立即影响线上敏感词拦截策略,确认保存?"
}
}

View File

@@ -0,0 +1,62 @@
{
"tip": "詞庫建議錄入簡體中文;系統匹配前會做繁轉簡與全角規範化。",
"word": {
"add": "新增敏感詞",
"edit": "編輯敏感詞",
"view": "查看敏感詞",
"confirmDelete": "確認刪除該敏感詞?",
"field": {
"word": "敏感詞",
"category": "分類",
"matchMode": "匹配模式",
"status": "狀態",
"remark": "備註",
"createTime": "建立時間"
},
"category": {
"politic": "政治",
"porn": "色情",
"violence": "暴力",
"ad": "廣告",
"custom": "自訂"
},
"matchMode": {
"contains": "包含",
"exact": "精確"
},
"status": {
"enable": "啟用",
"disable": "停用"
},
"check": {
"title": "試檢文字",
"placeholder": "輸入文字檢測是否命中敏感詞",
"button": "檢測",
"hit": "命中:{0}",
"clean": "未命中敏感詞",
"recordHit": "寫入命中記錄"
}
},
"hit": {
"field": {
"hitWord": "命中詞",
"contentPreview": "原文摘要",
"scene": "場景",
"source": "來源",
"mchNo": "商戶號",
"clientIp": "用戶端IP",
"createTime": "命中時間"
}
},
"config": {
"title": "敏感詞",
"description": "控制平台文字敏感詞過濾的總開關與策略(國內主場景)",
"enabled": "啟用敏感詞過濾",
"enabledTip": "關閉後所有文字校驗放行(海外部署可關)",
"revealWord": "錯誤回顯命中詞",
"revealWordTip": "開啟後對外錯誤文案會帶出具體敏感詞,有探測風險",
"recordHit": "記錄命中審計",
"recordHitTip": "攔截時寫入命中記錄表",
"confirmSaveContent": "儲存後將立即影響線上敏感詞攔截策略,確認儲存?"
}
}

View File

@@ -0,0 +1,62 @@
{
"tip": "詞庫建議錄入簡體中文;系統匹配前會做繁轉簡與全角規範化。",
"word": {
"add": "新增敏感詞",
"edit": "編輯敏感詞",
"view": "查看敏感詞",
"confirmDelete": "確認刪除該敏感詞?",
"field": {
"word": "敏感詞",
"category": "分類",
"matchMode": "匹配模式",
"status": "狀態",
"remark": "備註",
"createTime": "建立時間"
},
"category": {
"politic": "政治",
"porn": "色情",
"violence": "暴力",
"ad": "廣告",
"custom": "自訂"
},
"matchMode": {
"contains": "包含",
"exact": "精確"
},
"status": {
"enable": "啟用",
"disable": "停用"
},
"check": {
"title": "試檢文字",
"placeholder": "輸入文字檢測是否命中敏感詞",
"button": "檢測",
"hit": "命中:{0}",
"clean": "未命中敏感詞",
"recordHit": "寫入命中記錄"
}
},
"hit": {
"field": {
"hitWord": "命中詞",
"contentPreview": "原文摘要",
"scene": "場景",
"source": "來源",
"mchNo": "商戶號",
"clientIp": "用戶端IP",
"createTime": "命中時間"
}
},
"config": {
"title": "敏感詞",
"description": "控制平台文字敏感詞過濾的總開關與策略(國內主場景)",
"enabled": "啟用敏感詞過濾",
"enabledTip": "關閉後所有文字校驗放行(海外部署可關)",
"revealWord": "錯誤回顯命中詞",
"revealWordTip": "開啟後對外錯誤文案會帶出具體敏感詞,有探測風險",
"recordHit": "記錄命中審計",
"recordHitTip": "攔截時寫入命中記錄表",
"confirmSaveContent": "儲存後將立即影響線上敏感詞攔截策略,確認儲存?"
}
}

View File

@@ -83,5 +83,8 @@
"menu.payment.merchant.codePayConfig": "QR Code Pay Config",
"menu.payment.risk": "Payment Risk",
"menu.payment.risk.blacklist": "Blacklist",
"menu.payment.risk.hit": "Risk Hits"
"menu.payment.risk.hit": "Risk Hits",
"menu.system.sensitive": "Sensitive Words",
"menu.system.sensitive.word": "Word List",
"menu.system.sensitive.hit": "Hit Records"
}

View File

@@ -83,5 +83,8 @@
"menu.payment.merchant.codePayConfig": "Konfigurasi Bayar Kode QR",
"menu.payment.risk": "Payment Risk",
"menu.payment.risk.blacklist": "Blacklist",
"menu.payment.risk.hit": "Risk Hits"
"menu.payment.risk.hit": "Risk Hits",
"menu.system.sensitive": "Kata sensitif",
"menu.system.sensitive.word": "Daftar kata",
"menu.system.sensitive.hit": "Catatan hit"
}

View File

@@ -83,5 +83,8 @@
"menu.payment.merchant.codePayConfig": "コード支払設定",
"menu.payment.risk": "支払リスク",
"menu.payment.risk.blacklist": "ブラックリスト",
"menu.payment.risk.hit": "リスクヒット"
"menu.payment.risk.hit": "リスクヒット",
"menu.system.sensitive": "センシティブワード",
"menu.system.sensitive.word": "ワード一覧",
"menu.system.sensitive.hit": "ヒット記録"
}

View File

@@ -83,5 +83,8 @@
"menu.payment.merchant.codePayConfig": "코드 결제 구성",
"menu.payment.risk": "결제 리스크",
"menu.payment.risk.blacklist": "블랙리스트",
"menu.payment.risk.hit": "리스크 적중"
"menu.payment.risk.hit": "리스크 적중",
"menu.system.sensitive": "민감어",
"menu.system.sensitive.word": "단어 관리",
"menu.system.sensitive.hit": "적중 기록"
}

View File

@@ -83,5 +83,8 @@
"menu.payment.merchant.codePayConfig": "Konfigurasi Bayaran Kod QR",
"menu.payment.risk": "Payment Risk",
"menu.payment.risk.blacklist": "Blacklist",
"menu.payment.risk.hit": "Risk Hits"
"menu.payment.risk.hit": "Risk Hits",
"menu.system.sensitive": "Perkataan sensitif",
"menu.system.sensitive.word": "Senarai kata",
"menu.system.sensitive.hit": "Rekod hit"
}

View File

@@ -83,5 +83,8 @@
"menu.payment.merchant.codePayConfig": "การกำหนดค่าชำระเงินรหัส",
"menu.payment.risk": "Payment Risk",
"menu.payment.risk.blacklist": "Blacklist",
"menu.payment.risk.hit": "Risk Hits"
"menu.payment.risk.hit": "Risk Hits",
"menu.system.sensitive": "คำละเอียดอ่อน",
"menu.system.sensitive.word": "รายการคำ",
"menu.system.sensitive.hit": "ประวัติการพบ"
}

View File

@@ -83,5 +83,8 @@
"menu.payment.merchant.codePayConfig": "Cấu hình thanh toán mã",
"menu.payment.risk": "Payment Risk",
"menu.payment.risk.blacklist": "Blacklist",
"menu.payment.risk.hit": "Risk Hits"
"menu.payment.risk.hit": "Risk Hits",
"menu.system.sensitive": "Từ nhạy cảm",
"menu.system.sensitive.word": "Danh sách từ",
"menu.system.sensitive.hit": "Lịch sử trúng"
}

View File

@@ -83,5 +83,8 @@
"menu.payment.merchant.easypay": "易支付配置",
"menu.payment.risk": "支付风控",
"menu.payment.risk.blacklist": "黑名单管理",
"menu.payment.risk.hit": "风险命中记录"
"menu.payment.risk.hit": "风险命中记录",
"menu.system.sensitive": "敏感词",
"menu.system.sensitive.word": "词库管理",
"menu.system.sensitive.hit": "命中记录"
}

View File

@@ -83,5 +83,8 @@
"menu.payment.merchant.codePayConfig": "碼牌支付配置",
"menu.payment.risk": "支付風控",
"menu.payment.risk.blacklist": "黑名單管理",
"menu.payment.risk.hit": "風險命中記錄"
"menu.payment.risk.hit": "風險命中記錄",
"menu.system.sensitive": "敏感詞",
"menu.system.sensitive.word": "詞庫管理",
"menu.system.sensitive.hit": "命中記錄"
}

View File

@@ -83,5 +83,8 @@
"menu.payment.merchant.codePayConfig": "碼牌支付配置",
"menu.payment.risk": "支付風控",
"menu.payment.risk.blacklist": "黑名單管理",
"menu.payment.risk.hit": "風險命中記錄"
"menu.payment.risk.hit": "風險命中記錄",
"menu.system.sensitive": "敏感詞",
"menu.system.sensitive.word": "詞庫管理",
"menu.system.sensitive.hit": "命中記錄"
}

View File

@@ -4,6 +4,7 @@
import { $t } from '#/locales';
import OssConfigForm from './oss/OssConfigForm.vue';
import SensitiveWordConfigForm from './sensitive-word/SensitiveWordConfigForm.vue';
import UrlConfigForm from './url/UrlConfigForm.vue';
import WebsiteConfigForm from './website/WebsiteConfigForm.vue';
@@ -34,6 +35,13 @@
// OSS配置描述
description: $t('system.platform.oss.description'),
},
{
key: 'sensitive-word',
// 敏感词策略标题
label: $t('system.sensitiveWord.config.title'),
// 敏感词策略描述
description: $t('system.sensitiveWord.config.description'),
},
] as const;
</script>
@@ -66,6 +74,7 @@
<UrlConfigForm v-if="activeKey === 'url'" />
<WebsiteConfigForm v-if="activeKey === 'website'" />
<OssConfigForm v-if="activeKey === 'oss'" />
<SensitiveWordConfigForm v-if="activeKey === 'sensitive-word'" />
</div>
</section>
</div>

View File

@@ -0,0 +1,116 @@
<script lang="ts" setup>
import type { SensitiveWordConfig } from '#/api/system/sensitive-word-config.api';
import { onMounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { SensitiveWordConfigApi } from '#/api/system/sensitive-word-config.api';
import { useMessage } from '#/hooks/useMessage';
const { confirm, message } = useMessage();
const formRef = ref();
const loading = ref(false);
const saving = ref(false);
const isEditing = ref(false);
const formState = ref<SensitiveWordConfig>({
enabled: true,
revealWord: false,
recordHit: true,
contentPreviewMaxLen: 200,
});
onMounted(() => {
loadConfig();
});
async function loadConfig() {
loading.value = true;
try {
const { data } = await SensitiveWordConfigApi.get();
if (data) {
formState.value = {
enabled: data.enabled ?? true,
revealWord: data.revealWord ?? false,
recordHit: data.recordHit ?? true,
contentPreviewMaxLen: data.contentPreviewMaxLen ?? 200,
};
}
} finally {
loading.value = false;
}
}
function handleEdit() {
isEditing.value = true;
}
function handleCancel() {
confirm({
title: $t('common.confirm'),
content: $t('common.confirmCancelContent'),
okText: $t('common.okText'),
cancelText: $t('common.cancelText'),
onOk: async () => {
isEditing.value = false;
await loadConfig();
},
});
}
function handleSave() {
confirm({
title: $t('common.confirm'),
content: $t('system.sensitiveWord.config.confirmSaveContent'),
okText: $t('common.okText'),
cancelText: $t('common.cancelText'),
onOk: async () => {
saving.value = true;
try {
await SensitiveWordConfigApi.update(formState.value);
message.success($t('common.saveSuccess'));
isEditing.value = false;
await loadConfig();
} finally {
saving.value = false;
}
},
});
}
</script>
<template>
<a-spin :spinning="loading">
<div class="sensitive-word-config">
<div class="mb-4 flex items-center justify-between">
<div>
<div class="text-base font-medium">{{ $t('system.sensitiveWord.config.title') }}</div>
<div class="text-muted-foreground text-sm">{{ $t('system.sensitiveWord.config.description') }}</div>
</div>
<a-space>
<a-button v-if="!isEditing" type="primary" @click="handleEdit">{{ $t('common.edit') }}</a-button>
<template v-else>
<a-button @click="handleCancel">{{ $t('common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">{{ $t('common.save') }}</a-button>
</template>
</a-space>
</div>
<a-form ref="formRef" :model="formState" layout="vertical" :disabled="!isEditing">
<a-form-item :label="$t('system.sensitiveWord.config.enabled')">
<a-switch v-model:checked="formState.enabled" />
<div class="text-muted-foreground mt-1 text-xs">{{ $t('system.sensitiveWord.config.enabledTip') }}</div>
</a-form-item>
<a-form-item :label="$t('system.sensitiveWord.config.revealWord')">
<a-switch v-model:checked="formState.revealWord" />
<div class="text-muted-foreground mt-1 text-xs">{{ $t('system.sensitiveWord.config.revealWordTip') }}</div>
</a-form-item>
<a-form-item :label="$t('system.sensitiveWord.config.recordHit')">
<a-switch v-model:checked="formState.recordHit" />
<div class="text-muted-foreground mt-1 text-xs">{{ $t('system.sensitiveWord.config.recordHitTip') }}</div>
</a-form-item>
</a-form>
</div>
</a-spin>
</template>

View File

@@ -0,0 +1,155 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import {
SensitiveWordApi,
type SensitiveWordParam,
type SensitiveWordVo,
} from '#/api/system/sensitive-word.api';
import { FormEditType } from '#/enums/formEditType';
import { useFormEdit } from '#/hooks/useFormEdit';
import { useMessage } from '#/hooks/useMessage';
const emit = defineEmits(['ok']);
const { message } = useMessage();
const formRef = ref();
const { visible, confirmLoading, title, initFormEditType, handleCancel, showable, formEditType } =
useFormEdit();
const isAdd = computed(() => formEditType.value === FormEditType.Add);
const formState = ref<SensitiveWordParam>({
id: '',
word: '',
category: 'custom',
matchMode: 'contains',
status: 'enable',
remark: '',
});
function resetForm() {
formState.value = {
id: '',
word: '',
category: 'custom',
matchMode: 'contains',
status: 'enable',
remark: '',
};
formRef.value?.resetFields();
}
async function fillForm(record: SensitiveWordVo) {
confirmLoading.value = true;
try {
const { data } = await SensitiveWordApi.getById(record.id!);
const row = data || record;
formState.value = {
id: row.id!,
word: row.word,
category: row.category || 'custom',
matchMode: row.matchMode || 'contains',
status: row.status || 'enable',
remark: row.remark,
};
} finally {
confirmLoading.value = false;
}
}
function showAdd() {
initFormEditType(FormEditType.Add);
resetForm();
}
async function showEdit(record: SensitiveWordVo) {
initFormEditType(FormEditType.Edit);
resetForm();
await fillForm(record);
}
async function showView(record: SensitiveWordVo) {
initFormEditType(FormEditType.Show);
resetForm();
await fillForm(record);
}
async function handleOk() {
try {
await formRef.value?.validate();
} catch {
return;
}
confirmLoading.value = true;
try {
if (isAdd.value) {
await SensitiveWordApi.add(formState.value);
} else {
await SensitiveWordApi.update(formState.value);
}
message.success($t('common.saveSuccess'));
handleCancel();
emit('ok');
} finally {
confirmLoading.value = false;
}
}
defineExpose({ showAdd, showEdit, showView });
</script>
<template>
<a-drawer
v-model:open="visible"
:title="title"
:width="480"
:destroy-on-close="true"
@close="handleCancel"
>
<a-form ref="formRef" :model="formState" layout="vertical" :disabled="showable">
<a-form-item
name="word"
:label="$t('system.sensitiveWord.word.field.word')"
:rules="[{ required: true, message: $t('common.pleaseInput') }]"
>
<a-input v-model:value="formState.word" :maxlength="64" allow-clear />
</a-form-item>
<a-form-item name="category" :label="$t('system.sensitiveWord.word.field.category')">
<a-select v-model:value="formState.category">
<a-select-option value="politic">{{ $t('system.sensitiveWord.word.category.politic') }}</a-select-option>
<a-select-option value="porn">{{ $t('system.sensitiveWord.word.category.porn') }}</a-select-option>
<a-select-option value="violence">{{ $t('system.sensitiveWord.word.category.violence') }}</a-select-option>
<a-select-option value="ad">{{ $t('system.sensitiveWord.word.category.ad') }}</a-select-option>
<a-select-option value="custom">{{ $t('system.sensitiveWord.word.category.custom') }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item name="matchMode" :label="$t('system.sensitiveWord.word.field.matchMode')">
<a-radio-group v-model:value="formState.matchMode" button-style="solid">
<a-radio-button value="contains">{{ $t('system.sensitiveWord.word.matchMode.contains') }}</a-radio-button>
<a-radio-button value="exact">{{ $t('system.sensitiveWord.word.matchMode.exact') }}</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item name="status" :label="$t('system.sensitiveWord.word.field.status')">
<a-radio-group v-model:value="formState.status" button-style="solid">
<a-radio-button value="enable">{{ $t('system.sensitiveWord.word.status.enable') }}</a-radio-button>
<a-radio-button value="disable">{{ $t('system.sensitiveWord.word.status.disable') }}</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item name="remark" :label="$t('system.sensitiveWord.word.field.remark')">
<a-textarea v-model:value="formState.remark" :rows="3" :maxlength="255" allow-clear />
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleCancel">{{ $t('common.cancel') }}</a-button>
<a-button v-if="!showable" type="primary" :loading="confirmLoading" @click="handleOk">
{{ $t('common.save') }}
</a-button>
</a-space>
</template>
</a-drawer>
</template>

View File

@@ -0,0 +1,131 @@
<script lang="ts" setup>
import type { VxeTableInstance, VxeToolbarInstance } from 'vxe-table';
import { computed, onMounted, ref } from 'vue';
import { $t } from '@vben/locales';
import {
SensitiveWordHitApi,
type SensitiveWordHitQuery,
type SensitiveWordHitVo,
} from '#/api/system/sensitive-word-hit.api';
import { BQuery, type QueryField } from '#/components/query';
import { PermCodes } from '#/constants/perm-codes';
import { usePermission } from '#/hooks/usePermission';
defineOptions({ name: 'SensitiveWordHitList' });
const { hasPermission } = usePermission();
const loading = ref(false);
const xTable = ref<VxeTableInstance>();
const xToolbar = ref<VxeToolbarInstance>();
const queryForm = ref<SensitiveWordHitQuery>({});
const pageConfig = ref({ currentPage: 1, pageSize: 10, total: 0 });
const tableData = ref<SensitiveWordHitVo[]>([]);
const queryFields = computed<QueryField[]>(() => [
{
type: 'string',
field: 'hitWord',
// 命中词
name: $t('system.sensitiveWord.hit.field.hitWord'),
placeholder: $t('common.pleaseInput'),
},
{
type: 'string',
field: 'scene',
// 场景
name: $t('system.sensitiveWord.hit.field.scene'),
placeholder: $t('common.pleaseInput'),
},
{
type: 'string',
field: 'source',
// 来源
name: $t('system.sensitiveWord.hit.field.source'),
placeholder: $t('common.pleaseInput'),
},
{
type: 'string',
field: 'mchNo',
// 商户号
name: $t('system.sensitiveWord.hit.field.mchNo'),
placeholder: $t('common.pleaseInput'),
},
]);
function queryPage() {
if (!hasPermission(PermCodes.System.SensitiveWordHit.VIEW)) {
return Promise.resolve();
}
loading.value = true;
return SensitiveWordHitApi.page({
current: pageConfig.value.currentPage,
size: pageConfig.value.pageSize,
...queryForm.value,
})
.then((res) => {
tableData.value = res.data?.records || [];
pageConfig.value.total = Number(res.data?.total) || 0;
loading.value = false;
})
.catch(() => {
loading.value = false;
});
}
function resetQuery() {
queryForm.value = {};
pageConfig.value.currentPage = 1;
queryPage();
}
function handlePageChange({ currentPage, pageSize }: { currentPage: number; pageSize: number }) {
pageConfig.value.currentPage = currentPage;
pageConfig.value.pageSize = pageSize;
queryPage();
}
onMounted(() => {
xTable.value?.connectToolbar(xToolbar.value as VxeToolbarInstance);
queryPage();
});
</script>
<template>
<div class="m-3 p-3 bg-background rounded-lg list-page-compact">
<a-card>
<BQuery :fields="queryFields" :query-params="queryForm" @query="queryPage" @reset="resetQuery" />
</a-card>
<div class="mt-4">
<a-card>
<vxe-toolbar ref="xToolbar" custom refresh :refresh-options="{ queryMethod: queryPage }" />
<vxe-table ref="xTable" :row-config="{ keyField: 'id' }" :data="tableData" :loading="loading">
<vxe-column type="seq" :title="$t('common.seq')" width="60" align="center" />
<vxe-column field="hitWord" :title="$t('system.sensitiveWord.hit.field.hitWord')" width="120" />
<vxe-column
field="contentPreview"
:title="$t('system.sensitiveWord.hit.field.contentPreview')"
:min-width="180"
/>
<vxe-column field="scene" :title="$t('system.sensitiveWord.hit.field.scene')" width="120" />
<vxe-column field="source" :title="$t('system.sensitiveWord.hit.field.source')" width="100" />
<vxe-column field="mchNo" :title="$t('system.sensitiveWord.hit.field.mchNo')" width="120" />
<vxe-column field="clientIp" :title="$t('system.sensitiveWord.hit.field.clientIp')" width="130" />
<vxe-column field="createTime" :title="$t('system.sensitiveWord.hit.field.createTime')" width="170" />
</vxe-table>
<div class="mt-3 flex justify-end">
<vxe-pager
v-model:current-page="pageConfig.currentPage"
v-model:page-size="pageConfig.pageSize"
:total="pageConfig.total"
@page-change="handlePageChange"
/>
</div>
</a-card>
</div>
</div>
</template>

View File

@@ -0,0 +1,290 @@
<script lang="ts" setup>
import type { VxeTableInstance, VxeToolbarInstance } from 'vxe-table';
import { computed, onMounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { IconifyIcon } from '@vben-core/icons';
import {
SensitiveWordApi,
type SensitiveWordQuery,
type SensitiveWordVo,
} from '#/api/system/sensitive-word.api';
import { BQuery, type QueryField } from '#/components/query';
import { PermCodes } from '#/constants/perm-codes';
import { useMessage } from '#/hooks/useMessage';
import { usePermission } from '#/hooks/usePermission';
import SensitiveWordEdit from './SensitiveWordEdit.vue';
defineOptions({ name: 'SensitiveWordList' });
const { confirm, message } = useMessage();
const { hasPermission } = usePermission();
const loading = ref(false);
const xTable = ref<VxeTableInstance>();
const xToolbar = ref<VxeToolbarInstance>();
const queryForm = ref<SensitiveWordQuery>({});
const pageConfig = ref({ currentPage: 1, pageSize: 10, total: 0 });
const tableData = ref<SensitiveWordVo[]>([]);
const editRef = ref();
const checkText = ref('');
const checkRecordHit = ref(false);
const checkLoading = ref(false);
const queryFields = computed<QueryField[]>(() => [
{
type: 'string',
field: 'word',
// 敏感词
name: $t('system.sensitiveWord.word.field.word'),
placeholder: $t('common.pleaseInput'),
},
{
type: 'list',
field: 'category',
// 分类
name: $t('system.sensitiveWord.word.field.category'),
placeholder: $t('common.pleaseSelect'),
selectList: [
{ label: $t('system.sensitiveWord.word.category.politic'), value: 'politic' },
{ label: $t('system.sensitiveWord.word.category.porn'), value: 'porn' },
{ label: $t('system.sensitiveWord.word.category.violence'), value: 'violence' },
{ label: $t('system.sensitiveWord.word.category.ad'), value: 'ad' },
{ label: $t('system.sensitiveWord.word.category.custom'), value: 'custom' },
],
},
{
type: 'list',
field: 'status',
// 状态
name: $t('system.sensitiveWord.word.field.status'),
placeholder: $t('common.pleaseSelect'),
selectList: [
{ label: $t('system.sensitiveWord.word.status.enable'), value: 'enable' },
{ label: $t('system.sensitiveWord.word.status.disable'), value: 'disable' },
],
},
]);
/** 分页查询 */
function queryPage() {
loading.value = true;
return SensitiveWordApi.page({
current: pageConfig.value.currentPage,
size: pageConfig.value.pageSize,
...queryForm.value,
})
.then((res) => {
tableData.value = res.data?.records || [];
pageConfig.value.total = Number(res.data?.total) || 0;
loading.value = false;
})
.catch(() => {
loading.value = false;
});
}
function resetQuery() {
queryForm.value = {};
pageConfig.value.currentPage = 1;
queryPage();
}
function handlePageChange({ currentPage, pageSize }: { currentPage: number; pageSize: number }) {
pageConfig.value.currentPage = currentPage;
pageConfig.value.pageSize = pageSize;
queryPage();
}
function handleAdd() {
editRef.value?.showAdd();
}
function handleEdit(row: SensitiveWordVo) {
editRef.value?.showEdit(row);
}
function handleView(row: SensitiveWordVo) {
editRef.value?.showView(row);
}
function handleDelete(row: SensitiveWordVo) {
confirm({
title: $t('common.confirm'),
content: $t('system.sensitiveWord.word.confirmDelete'),
okText: $t('common.delete'),
cancelText: $t('common.cancel'),
onOk() {
return SensitiveWordApi.delete(row.id!).then(() => {
message.success($t('common.deleteSuccess'));
queryPage();
});
},
});
}
/** 试检 */
async function handleCheck() {
if (!checkText.value?.trim()) {
return;
}
checkLoading.value = true;
try {
const { data } = await SensitiveWordApi.checkText({
text: checkText.value,
recordHit: checkRecordHit.value,
});
if (data?.hit) {
message.warning($t('system.sensitiveWord.word.check.hit', [data.hits?.join(', ')]));
} else {
message.success($t('system.sensitiveWord.word.check.clean'));
}
} finally {
checkLoading.value = false;
}
}
function categoryLabel(c?: string) {
if (!c) return '';
return $t(`system.sensitiveWord.word.category.${c}` as 'system.sensitiveWord.word.category.custom');
}
function statusColor(status?: string) {
return status === 'enable' ? 'success' : 'default';
}
onMounted(() => {
xTable.value?.connectToolbar(xToolbar.value as VxeToolbarInstance);
queryPage();
});
</script>
<template>
<div class="m-3 p-3 bg-background rounded-lg list-page-compact">
<div class="mb-3">
<!-- 间距放 wrapper避免 antd cssinjs 覆盖 Tailwind margin -->
<a-alert type="info" show-icon :message="$t('system.sensitiveWord.tip')" />
</div>
<a-card>
<BQuery :fields="queryFields" :query-params="queryForm" @query="queryPage" @reset="resetQuery" />
</a-card>
<div class="mt-4">
<a-card>
<vxe-toolbar ref="xToolbar" custom refresh :refresh-options="{ queryMethod: queryPage }">
<template #buttons>
<a-button
v-if="hasPermission(PermCodes.System.SensitiveWord.MANAGE)"
type="primary"
@click="handleAdd"
>
<template #icon>
<IconifyIcon icon="ant-design:plus-outlined" />
</template>
<!-- 新增敏感词 -->
{{ $t('system.sensitiveWord.word.add') }}
</a-button>
</template>
</vxe-toolbar>
<div class="mb-3 flex flex-wrap items-center gap-2">
<a-input
v-model:value="checkText"
class="!w-80"
:placeholder="$t('system.sensitiveWord.word.check.placeholder')"
allow-clear
/>
<a-checkbox v-model:checked="checkRecordHit">
{{ $t('system.sensitiveWord.word.check.recordHit') }}
</a-checkbox>
<a-button
v-if="hasPermission(PermCodes.System.SensitiveWord.VIEW)"
:loading="checkLoading"
@click="handleCheck"
>
{{ $t('system.sensitiveWord.word.check.button') }}
</a-button>
</div>
<vxe-table ref="xTable" :row-config="{ keyField: 'id' }" :data="tableData" :loading="loading">
<vxe-column type="seq" :title="$t('common.seq')" width="60" align="center" />
<vxe-column field="word" :title="$t('system.sensitiveWord.word.field.word')" :min-width="140" />
<vxe-column field="category" :title="$t('system.sensitiveWord.word.field.category')" width="100">
<template #default="{ row }">{{ categoryLabel(row.category) }}</template>
</vxe-column>
<vxe-column field="matchMode" :title="$t('system.sensitiveWord.word.field.matchMode')" width="100">
<template #default="{ row }">
{{
row.matchMode === 'exact'
? $t('system.sensitiveWord.word.matchMode.exact')
: $t('system.sensitiveWord.word.matchMode.contains')
}}
</template>
</vxe-column>
<vxe-column field="status" :title="$t('system.sensitiveWord.word.field.status')" width="100">
<template #default="{ row }">
<a-tag :color="statusColor(row.status)">
{{
row.status === 'enable'
? $t('system.sensitiveWord.word.status.enable')
: $t('system.sensitiveWord.word.status.disable')
}}
</a-tag>
</template>
</vxe-column>
<vxe-column field="remark" :title="$t('system.sensitiveWord.word.field.remark')" :min-width="120" />
<vxe-column field="createTime" :title="$t('system.sensitiveWord.word.field.createTime')" width="170" />
<vxe-column fixed="right" :width="200" :show-overflow="false" :title="$t('common.operation')">
<template #default="{ row }">
<a-space :size="2">
<template #separator>
<a-divider type="vertical" />
</template>
<a-button
v-if="hasPermission(PermCodes.System.SensitiveWord.VIEW)"
type="link"
size="small"
@click="handleView(row)"
>
{{ $t('common.view') }}
</a-button>
<a-button
v-if="hasPermission(PermCodes.System.SensitiveWord.MANAGE)"
type="link"
size="small"
@click="handleEdit(row)"
>
{{ $t('common.edit') }}
</a-button>
<a-button
v-if="hasPermission(PermCodes.System.SensitiveWord.MANAGE)"
type="link"
size="small"
danger
@click="handleDelete(row)"
>
{{ $t('common.delete') }}
</a-button>
</a-space>
</template>
</vxe-column>
</vxe-table>
<div class="mt-3 flex justify-end">
<vxe-pager
v-model:current-page="pageConfig.currentPage"
v-model:page-size="pageConfig.pageSize"
:total="pageConfig.total"
@page-change="handlePageChange"
/>
</div>
</a-card>
</div>
<SensitiveWordEdit ref="editRef" @ok="queryPage" />
</div>
</template>

View File

@@ -0,0 +1,144 @@
/**
* 运营端业务语言包 key 完整性检查(相对 zh-CN
*
* 用法:
* node scripts/check-locale-keys.mjs
* node scripts/check-locale-keys.mjs --langs=ja-JP,ko-KR
*
* 退出码:存在 missing key 时为 1
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const adminLocales = path.resolve(__dirname, '../apps/daxpay-admin/src/locales')
const langsRoot = path.join(adminLocales, 'langs')
const menuRoot = path.join(adminLocales, 'menu-titles')
const BASELINE = 'zh-CN'
function flatten(obj, prefix = '', out = {}) {
for (const [k, v] of Object.entries(obj || {})) {
const key = prefix ? `${prefix}.${k}` : k
if (v && typeof v === 'object' && !Array.isArray(v)) {
flatten(v, key, out)
} else {
out[key] = String(v ?? '')
}
}
return out
}
function loadLangDir(dir) {
const out = {}
function walk(d, prefix = '') {
if (!fs.existsSync(d)) return
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
const fp = path.join(d, ent.name)
if (ent.isDirectory()) {
walk(fp, prefix ? `${prefix}.${ent.name}` : ent.name)
} else if (ent.name.endsWith('.json')) {
const fileKey = ent.name.replace(/\.json$/, '')
const ns = prefix ? `${prefix}.${fileKey}` : fileKey
const json = JSON.parse(fs.readFileSync(fp, 'utf8'))
const flat = flatten(json)
for (const [k, v] of Object.entries(flat)) {
out[`${ns}.${k}`] = v
}
}
}
}
walk(dir)
return out
}
function parseArgs() {
const arg = process.argv.find((a) => a.startsWith('--langs='))
if (!arg) return null
return arg
.slice('--langs='.length)
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
const allLocales = fs
.readdirSync(langsRoot)
.filter((d) => fs.statSync(path.join(langsRoot, d)).isDirectory())
.sort()
const filter = parseArgs()
const locales = filter?.length
? allLocales.filter((l) => filter.includes(l))
: allLocales
const maps = {}
for (const locale of allLocales) {
maps[locale] = loadLangDir(path.join(langsRoot, locale))
}
const baselineKeys = Object.keys(maps[BASELINE] || {}).sort()
if (!baselineKeys.length) {
console.error(`[check-locale-keys] baseline ${BASELINE} has no keys`)
process.exit(2)
}
console.log(`[check-locale-keys] baseline=${BASELINE} keys=${baselineKeys.length}`)
console.log(`[check-locale-keys] locales=${locales.join(', ')}`)
console.log('')
let hasMissing = false
const report = []
for (const locale of locales) {
if (locale === BASELINE) continue
const keys = new Set(Object.keys(maps[locale] || {}))
const missing = baselineKeys.filter((k) => !keys.has(k))
const extra = [...keys].filter((k) => !baselineKeys.includes(k)).sort()
if (missing.length) hasMissing = true
report.push({ locale, missing, extra })
console.log(
`[${locale}] missing=${missing.length} extra=${extra.length} total=${keys.size}`,
)
if (missing.length) {
const sample = missing.slice(0, 20)
console.log(` missing sample (${sample.length}/${missing.length}):`)
for (const k of sample) console.log(` - ${k}`)
if (missing.length > sample.length) {
console.log(` ... +${missing.length - sample.length} more`)
}
}
}
// menu-titles
console.log('')
console.log('[menu-titles]')
const menuFiles = fs
.readdirSync(menuRoot)
.filter((f) => f.endsWith('.json'))
.map((f) => f.replace(/\.json$/, ''))
.sort()
const menuMaps = {}
for (const locale of menuFiles) {
menuMaps[locale] = JSON.parse(
fs.readFileSync(path.join(menuRoot, `${locale}.json`), 'utf8'),
)
}
const menuBaseline = Object.keys(menuMaps[BASELINE] || {}).sort()
console.log(`baseline keys=${menuBaseline.length}`)
for (const locale of menuFiles) {
if (locale === BASELINE) continue
if (filter?.length && !filter.includes(locale)) continue
const missing = menuBaseline.filter((k) => !(k in (menuMaps[locale] || {})))
if (missing.length) hasMissing = true
console.log(`[${locale}] missing=${missing.length}`)
for (const k of missing) console.log(` - ${k}`)
}
console.log('')
if (hasMissing) {
console.error('[check-locale-keys] FAILED: missing keys detected')
process.exit(1)
}
console.log('[check-locale-keys] OK: no missing keys vs zh-CN')
process.exit(0)