feat(admin,api,player): 返水流程优化、账单详情与数据库重置
优化返水预览/确认/作废,新增玩家账变详情与后台一键重置为 seed 数据,并修复 dev 启动时 3000 端口占用。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -63,6 +63,14 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'user.page_settings': 'Tetapan global',
|
||||
'user.global_settings': 'Kata laluan & akaun (global)',
|
||||
'user.global_settings_hint': 'Kawal sama ada semua pemain boleh ubah kata laluan/nama akaun dalam app',
|
||||
'user.reset_database': 'Set semula pangkalan data',
|
||||
'user.reset_database_hint': 'Padam semua data perniagaan dan pulihkan data demo awal. Tidak boleh dibatalkan.',
|
||||
'user.reset_database_confirm_label': 'Taip RESET untuk sahkan',
|
||||
'user.reset_database_confirm_ph': 'RESET',
|
||||
'user.reset_database_btn': 'Set semula ke data awal',
|
||||
'user.reset_database_disabled_prod': 'Dilumpuhkan dalam produksi melainkan ALLOW_DB_RESET=true',
|
||||
'user.reset_database_success': 'Pangkalan data diset semula. Sila log masuk semula.',
|
||||
'user.reset_database_accounts': 'Akaun demo',
|
||||
'user.section.password_mgmt': 'Pengurusan kata laluan',
|
||||
'user.field.current_password': 'Kata laluan semasa',
|
||||
'user.msg.created_with_password': 'Pemain dicipta. Kata laluan: {password}',
|
||||
@@ -226,7 +234,24 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'cashback.stat.players': 'Pemain',
|
||||
'cashback.stat.total': 'Jumlah rebat',
|
||||
'cashback.stat.lines': 'Baris butiran',
|
||||
'cashback.stat.effective_stake': 'Jumlah stake berkesan',
|
||||
'cashback.stat.bet_count': 'Bil. pertaruhan',
|
||||
'cashback.stat.avg_rate': 'Kadar purata',
|
||||
'cashback.batch_no': 'No. kelompok',
|
||||
'cashback.history_title': 'Rekod rebat',
|
||||
'cashback.history_empty': 'Tiada kelompok rebat',
|
||||
'cashback.filter_status': 'Status',
|
||||
'cashback.status.PREVIEW': 'Menunggu',
|
||||
'cashback.status.CONFIRMED': 'Dibayar',
|
||||
'cashback.col.period': 'Tempoh',
|
||||
'cashback.col.status': 'Status',
|
||||
'cashback.col.bet_count': 'Pertaruhan',
|
||||
'cashback.col.created_at': 'Dicipta',
|
||||
'cashback.col.confirmed_at': 'Dibayar pada',
|
||||
'cashback.col.operator': 'Operator',
|
||||
'cashback.view_detail': 'Butiran',
|
||||
'cashback.detail_title': 'Butiran kelompok',
|
||||
'cashback.detail_summary': 'Ringkasan kelompok',
|
||||
'cashback.table_title': 'Butiran rebat pemain',
|
||||
'cashback.table_total': 'Jumlah',
|
||||
'cashback.empty_items': 'Tiada rebat layak dalam tempoh ini',
|
||||
@@ -237,12 +262,16 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'cashback.col.rate': 'Kadar',
|
||||
'cashback.col.amount': 'Rebat',
|
||||
'cashback.confirm_issue': 'Sahkan bayaran',
|
||||
'cashback.cancel_issue': 'Batalkan',
|
||||
'cashback.confirm_prompt': 'Bayar rebat kelompok ini ke dompet pemain? Tindakan ini tidak boleh dibatalkan.',
|
||||
'cashback.cancel_prompt': 'Batalkan kelompok menunggu ini? Tiada kredit dompet; boleh pratonton semula.',
|
||||
'cashback.status.CANCELLED': 'Dibatalkan',
|
||||
'cashback.rules_title': 'Peraturan rebat',
|
||||
'cashback.rule_period': 'Pilih julat tarikh. Taruhan dikira mengikut masa penyelesaian dalam tempoh tersebut.',
|
||||
'cashback.rule_eligible': 'Termasuk: taruhan selesai WON/LOST (tunggal ikut stake; parlay sekali ikut stake parlay). Tidak termasuk: belum selesai, dibatalkan, batal, push, dan kadar 0.',
|
||||
'cashback.rule_formula': 'Setiap taruhan: stake × kadar rebat. Jumlah diagregat mengikut pemain.',
|
||||
'cashback.rule_rate': 'Keutamaan kadar: pemain > ejen > global > kadar lalai ejen (cth. 0.01 = 1%).',
|
||||
'cashback.rule_flow': 'Aliran: pratonton → semak jumlah → sahkan bayaran (kredit dompet, entri CASHBACK).',
|
||||
'cashback.rule_flow': 'Aliran: pratonton (satu menunggu setiap tempoh) → semak → sahkan bayaran; batalkan jika tidak perlu. Tempoh dibayar tidak boleh pratonton semula.',
|
||||
'cashback.rule_note_zero': 'Jika 0, semak taruhan WON/LOST dalam tempoh dan kadar rebat > 0.',
|
||||
|
||||
'user.field.player_id': 'ID pemain',
|
||||
@@ -532,6 +561,9 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'msg.outright_teams_added': '{n} pasukan ditambah ({skipped} dilangkau)',
|
||||
'msg.load_matches_failed': 'Gagal memuatkan perlawanan',
|
||||
'msg.cashback_issued': 'Rebat telah dikeluarkan',
|
||||
'msg.cashback_cancelled': 'Kelompok rebat dibatalkan',
|
||||
'msg.cashback_preview_ready': 'Pratonton sedia — semak dan sahkan bayaran',
|
||||
'msg.cashback_preview_replaced': 'Menggantikan {n} pratonton lama untuk tempoh ini',
|
||||
'msg.freeze_confirm_title': '{action} akaun',
|
||||
'msg.freeze_confirm_body': '{action} pemain "{name}"?{extra}',
|
||||
'msg.freeze_extra': ' Mereka tidak akan dapat log masuk.',
|
||||
|
||||
@@ -63,6 +63,14 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'user.page_settings': '全局设置',
|
||||
'user.global_settings': '密码与账号管理(全局)',
|
||||
'user.global_settings_hint': '控制所有玩家是否可在 App 内改密码、改账号名',
|
||||
'user.reset_database': '重置数据库',
|
||||
'user.reset_database_hint': '清空全部业务数据并恢复为初始演示数据(用户、赛事、注单、账变等)。此操作不可撤销。',
|
||||
'user.reset_database_confirm_label': '请输入 RESET 以确认',
|
||||
'user.reset_database_confirm_ph': '输入 RESET',
|
||||
'user.reset_database_btn': '重置为初始数据',
|
||||
'user.reset_database_disabled_prod': '生产环境已禁用;需服务端设置 ALLOW_DB_RESET=true',
|
||||
'user.reset_database_success': '数据库已重置,请使用初始账号重新登录',
|
||||
'user.reset_database_accounts': '演示账号',
|
||||
'user.section.password_mgmt': '密码管理',
|
||||
'user.field.current_password': '当前密码',
|
||||
'user.msg.created_with_password': '玩家已创建,登录密码:{password}',
|
||||
@@ -215,6 +223,7 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'audit.col.time': '时间',
|
||||
'audit.action.CREATE_PLAYER': '新建玩家',
|
||||
'audit.action.UPDATE_PLAYER': '更新玩家',
|
||||
'audit.action.RESET_DATABASE': '重置数据库',
|
||||
'audit.action.CREATE_AGENT': '新建代理',
|
||||
'audit.action.UPDATE_AGENT': '更新代理',
|
||||
'audit.module.USERS': '玩家',
|
||||
@@ -227,7 +236,24 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'cashback.stat.players': '涉及玩家数',
|
||||
'cashback.stat.total': '返水总金额',
|
||||
'cashback.stat.lines': '明细条数',
|
||||
'cashback.stat.effective_stake': '有效投注总额',
|
||||
'cashback.stat.bet_count': '计入注单数',
|
||||
'cashback.stat.avg_rate': '平均返水比例',
|
||||
'cashback.batch_no': '批次号',
|
||||
'cashback.history_title': '返水记录',
|
||||
'cashback.history_empty': '暂无返水批次记录',
|
||||
'cashback.filter_status': '批次状态',
|
||||
'cashback.status.PREVIEW': '待发放',
|
||||
'cashback.status.CONFIRMED': '已发放',
|
||||
'cashback.col.period': '统计周期',
|
||||
'cashback.col.status': '状态',
|
||||
'cashback.col.bet_count': '注单数',
|
||||
'cashback.col.created_at': '生成时间',
|
||||
'cashback.col.confirmed_at': '发放时间',
|
||||
'cashback.col.operator': '操作人',
|
||||
'cashback.view_detail': '查看明细',
|
||||
'cashback.detail_title': '返水批次明细',
|
||||
'cashback.detail_summary': '批次汇总',
|
||||
'cashback.table_title': '玩家返水明细',
|
||||
'cashback.table_total': '合计',
|
||||
'cashback.empty_items': '本周期内无符合条件的返水记录',
|
||||
@@ -238,12 +264,16 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'cashback.col.rate': '返水比例',
|
||||
'cashback.col.amount': '返水金额',
|
||||
'cashback.confirm_issue': '确认发放',
|
||||
'cashback.cancel_issue': '作废',
|
||||
'cashback.confirm_prompt': '确认向玩家钱包发放本批次返水?此操作不可撤销。',
|
||||
'cashback.cancel_prompt': '确认作废该待发放批次?作废后不会入账,可重新生成预览。',
|
||||
'cashback.status.CANCELLED': '已作废',
|
||||
'cashback.rules_title': '返水规则说明',
|
||||
'cashback.rule_period': '选择开始/结束日期,统计该周期内、按注单结算时间落在区间内的有效投注。',
|
||||
'cashback.rule_eligible': '计入:已结算且结果为「赢」或「输」的注单(单关按本金,串关按整单本金计一次)。不计入:未结算、已取消、作废、走水,以及返水比例为 0 的注单。',
|
||||
'cashback.rule_formula': '单笔返水 = 投注本金 × 适用返水比例;同一玩家多笔注单汇总后生成一条返水明细。',
|
||||
'cashback.rule_rate': '返水比例优先级:玩家专属规则 > 代理线规则 > 全局规则 > 所属代理默认返水率(在代理/玩家管理中配置,如 0.01 表示 1%)。',
|
||||
'cashback.rule_flow': '操作流程:生成预览 → 核对涉及玩家数与总金额 → 确认发放(金额入账玩家钱包,并生成 CASHBACK 账变流水)。',
|
||||
'cashback.rule_flow': '操作流程:生成预览(同周期仅保留一条待发放)→ 核对明细 → 确认发放;不需要的可作废。已发放周期不可重复预览。',
|
||||
'cashback.rule_note_zero': '预览为 0 时,请检查:周期内是否有已结算输赢注单、代理/玩家是否配置了大于 0 的返水率。',
|
||||
|
||||
'user.field.player_id': '玩家 ID',
|
||||
@@ -593,6 +623,9 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'outright.hidden_reason.MARKET_CLOSED': '冠军盘口未开放,请联系技术检查盘口状态。',
|
||||
'msg.load_matches_failed': '加载赛事失败',
|
||||
'msg.cashback_issued': '返水已发放',
|
||||
'msg.cashback_cancelled': '返水批次已作废',
|
||||
'msg.cashback_preview_ready': '预览已生成,请核对后确认发放',
|
||||
'msg.cashback_preview_replaced': '已替换同周期 {n} 条旧预览',
|
||||
'msg.freeze_confirm_title': '{action}账号',
|
||||
'msg.freeze_confirm_body': '确定要{action}玩家「{name}」吗?{extra}',
|
||||
'msg.freeze_extra': '冻结后该账号将无法登录。',
|
||||
@@ -664,6 +697,14 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'user.page_settings': 'Global settings',
|
||||
'user.global_settings': 'Password & account (global)',
|
||||
'user.global_settings_hint': 'Controls whether all players can change password or username in the app',
|
||||
'user.reset_database': 'Reset database',
|
||||
'user.reset_database_hint': 'Wipes all business data and restores initial demo seed (users, matches, bets, ledger, etc.). This cannot be undone.',
|
||||
'user.reset_database_confirm_label': 'Type RESET to confirm',
|
||||
'user.reset_database_confirm_ph': 'RESET',
|
||||
'user.reset_database_btn': 'Reset to initial data',
|
||||
'user.reset_database_disabled_prod': 'Disabled in production unless ALLOW_DB_RESET=true is set on the server',
|
||||
'user.reset_database_success': 'Database reset complete. Sign in again with demo accounts.',
|
||||
'user.reset_database_accounts': 'Demo accounts',
|
||||
'user.section.password_mgmt': 'Password management',
|
||||
'user.field.current_password': 'Current password',
|
||||
'user.msg.created_with_password': 'Player created. Login password: {password}',
|
||||
@@ -816,6 +857,7 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'audit.col.time': 'Time',
|
||||
'audit.action.CREATE_PLAYER': 'Create player',
|
||||
'audit.action.UPDATE_PLAYER': 'Update player',
|
||||
'audit.action.RESET_DATABASE': 'Reset database',
|
||||
'audit.action.CREATE_AGENT': 'Create agent',
|
||||
'audit.action.UPDATE_AGENT': 'Update agent',
|
||||
'audit.module.USERS': 'Players',
|
||||
@@ -828,7 +870,24 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'cashback.stat.players': 'Players',
|
||||
'cashback.stat.total': 'Total cashback',
|
||||
'cashback.stat.lines': 'Line items',
|
||||
'cashback.stat.effective_stake': 'Total effective stake',
|
||||
'cashback.stat.bet_count': 'Eligible bets',
|
||||
'cashback.stat.avg_rate': 'Average rate',
|
||||
'cashback.batch_no': 'Batch no.',
|
||||
'cashback.history_title': 'Cashback history',
|
||||
'cashback.history_empty': 'No cashback batches yet',
|
||||
'cashback.filter_status': 'Status',
|
||||
'cashback.status.PREVIEW': 'Pending',
|
||||
'cashback.status.CONFIRMED': 'Paid out',
|
||||
'cashback.col.period': 'Period',
|
||||
'cashback.col.status': 'Status',
|
||||
'cashback.col.bet_count': 'Bets',
|
||||
'cashback.col.created_at': 'Created',
|
||||
'cashback.col.confirmed_at': 'Paid at',
|
||||
'cashback.col.operator': 'Operator',
|
||||
'cashback.view_detail': 'Details',
|
||||
'cashback.detail_title': 'Batch breakdown',
|
||||
'cashback.detail_summary': 'Batch summary',
|
||||
'cashback.table_title': 'Player cashback breakdown',
|
||||
'cashback.table_total': 'Total',
|
||||
'cashback.empty_items': 'No eligible cashback in this period',
|
||||
@@ -839,12 +898,16 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'cashback.col.rate': 'Rate',
|
||||
'cashback.col.amount': 'Cashback',
|
||||
'cashback.confirm_issue': 'Confirm payout',
|
||||
'cashback.cancel_issue': 'Void',
|
||||
'cashback.confirm_prompt': 'Pay out this cashback batch to player wallets? This cannot be undone.',
|
||||
'cashback.cancel_prompt': 'Void this pending batch? No wallet credit will be made; you can preview again.',
|
||||
'cashback.status.CANCELLED': 'Voided',
|
||||
'cashback.rules_title': 'Cashback rules',
|
||||
'cashback.rule_period': 'Pick a date range. Bets are included by settlement time within that period.',
|
||||
'cashback.rule_eligible': 'Included: settled bets with result WON or LOST (singles by stake; parlays counted once by parlay stake). Excluded: pending, cancelled, void, push, and zero-rate bets.',
|
||||
'cashback.rule_formula': 'Per bet: stake × applicable cashback rate. Amounts are summed per player into one line item.',
|
||||
'cashback.rule_rate': 'Rate priority: player rule > agent rule > global rule > agent default rate (set under Agents/Players, e.g. 0.01 = 1%).',
|
||||
'cashback.rule_flow': 'Flow: preview → verify player count and total → confirm payout (credits wallet, creates CASHBACK ledger entries).',
|
||||
'cashback.rule_flow': 'Flow: preview (one pending batch per period) → review → confirm payout; void if not needed. Paid periods cannot be previewed again.',
|
||||
'cashback.rule_note_zero': 'If preview is 0, check for settled WON/LOST bets in the period and a cashback rate above 0.',
|
||||
|
||||
'user.field.player_id': 'Player ID',
|
||||
@@ -1195,6 +1258,9 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'outright.hidden_reason.MARKET_CLOSED': 'Winner market is not open.',
|
||||
'msg.load_matches_failed': 'Failed to load matches',
|
||||
'msg.cashback_issued': 'Cashback issued',
|
||||
'msg.cashback_cancelled': 'Cashback batch voided',
|
||||
'msg.cashback_preview_ready': 'Preview ready — review and confirm payout',
|
||||
'msg.cashback_preview_replaced': 'Replaced {n} older preview(s) for this period',
|
||||
'msg.freeze_confirm_title': '{action} account',
|
||||
'msg.freeze_confirm_body': '{action} player "{name}"?{extra}',
|
||||
'msg.freeze_extra': ' They will not be able to sign in.',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { TableColumnCtx } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { formatAmount, formatAmountFull } from '../utils/format-amount';
|
||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||
import api from '../api';
|
||||
|
||||
interface CashbackBatch {
|
||||
@@ -13,6 +14,12 @@ interface CashbackBatch {
|
||||
periodEnd: string;
|
||||
playerCount: number;
|
||||
totalAmount: string;
|
||||
totalEffectiveStake?: string;
|
||||
totalBetCount?: number;
|
||||
status: string;
|
||||
operatorUsername?: string | null;
|
||||
confirmedAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface CashbackPreviewItem {
|
||||
@@ -20,6 +27,7 @@ interface CashbackPreviewItem {
|
||||
username: string;
|
||||
agentUsername: string | null;
|
||||
effectiveStake: string;
|
||||
betCount: number;
|
||||
rate: string;
|
||||
amount: string;
|
||||
}
|
||||
@@ -28,19 +36,35 @@ interface CashbackPreview {
|
||||
batch: CashbackBatch;
|
||||
items: CashbackPreviewItem[];
|
||||
totalAmount: string;
|
||||
totalEffectiveStake?: string;
|
||||
totalBetCount?: number;
|
||||
avgRate?: string;
|
||||
replacedPreviewCount?: number;
|
||||
}
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
|
||||
const preview = ref<CashbackPreview | null>(null);
|
||||
const rulesVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const detail = ref<CashbackPreview | null>(null);
|
||||
|
||||
const history = ref<CashbackBatch[]>([]);
|
||||
const historyTotal = ref(0);
|
||||
const historyPage = ref(1);
|
||||
const historyPageSize = ref(10);
|
||||
const historyStatus = ref('');
|
||||
|
||||
const period = ref({
|
||||
start: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10),
|
||||
end: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
|
||||
const previewItems = computed(() => preview.value?.items ?? []);
|
||||
const detailItems = computed(() => detail.value?.items ?? []);
|
||||
|
||||
function formatRate(value: string | number | null | undefined) {
|
||||
const n = Number(value);
|
||||
@@ -53,22 +77,75 @@ function formatPeriodDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function formatPeriodRange(row: Pick<CashbackBatch, 'periodStart' | 'periodEnd'>) {
|
||||
return `${formatPeriodDate(row.periodStart)} — ${formatPeriodDate(row.periodEnd)}`;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null | undefined) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString(localeTag.value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const key = `cashback.status.${status}`;
|
||||
const label = t(key);
|
||||
return label === key ? status : label;
|
||||
}
|
||||
|
||||
function statusTagType(status: string) {
|
||||
if (status === 'CONFIRMED') return 'success';
|
||||
if (status === 'CANCELLED') return 'info';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string) {
|
||||
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data?.message;
|
||||
if (Array.isArray(msg)) return msg.join(';');
|
||||
if (typeof msg === 'string' && msg.trim()) return msg;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function tableSummary(param: {
|
||||
columns: TableColumnCtx<CashbackPreviewItem>[];
|
||||
data: CashbackPreviewItem[];
|
||||
}) {
|
||||
const { columns, data } = param;
|
||||
const stakeSum = data.reduce((s, row) => s + Number(row.effectiveStake), 0);
|
||||
const betSum = data.reduce((s, row) => s + Number(row.betCount ?? 0), 0);
|
||||
const amountSum = data.reduce((s, row) => s + Number(row.amount), 0);
|
||||
|
||||
return columns.map((col, index) => {
|
||||
if (index === 0) return t('cashback.table_total');
|
||||
if (col.property === 'betCount') return String(betSum);
|
||||
if (col.property === 'effectiveStake') return formatAmount(stakeSum);
|
||||
if (col.property === 'amount') return formatAmount(amountSum);
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/admin/cashbacks', {
|
||||
params: {
|
||||
page: historyPage.value,
|
||||
pageSize: historyPageSize.value,
|
||||
status: historyStatus.value || undefined,
|
||||
},
|
||||
});
|
||||
history.value = (data.data.items ?? []) as CashbackBatch[];
|
||||
historyTotal.value = data.data.total ?? 0;
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePreview() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -77,17 +154,98 @@ async function generatePreview() {
|
||||
periodEnd: period.value.end,
|
||||
});
|
||||
preview.value = data.data as CashbackPreview;
|
||||
const replaced = preview.value.replacedPreviewCount ?? 0;
|
||||
if (replaced > 0) {
|
||||
ElMessage.warning(t('msg.cashback_preview_replaced', { n: replaced }));
|
||||
} else {
|
||||
ElMessage.success(t('msg.cashback_preview_ready'));
|
||||
}
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
ElMessage.error(apiErrorMessage(err, t('msg.error')));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatchId(batchId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('cashback.confirm_prompt'), t('common.confirm'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('cashback.confirm_issue'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/admin/cashbacks/${batchId}/confirm`);
|
||||
ElMessage.success(t('msg.cashback_issued'));
|
||||
if (preview.value?.batch.id === batchId) preview.value = null;
|
||||
if (detail.value?.batch.id === batchId) detailVisible.value = false;
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
ElMessage.error(apiErrorMessage(err, t('msg.error')));
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelBatchId(batchId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('cashback.cancel_prompt'), t('common.confirm'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('cashback.cancel_issue'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/admin/cashbacks/${batchId}/cancel`);
|
||||
ElMessage.success(t('msg.cashback_cancelled'));
|
||||
if (preview.value?.batch.id === batchId) preview.value = null;
|
||||
if (detail.value?.batch.id === batchId) detailVisible.value = false;
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
ElMessage.error(apiErrorMessage(err, t('msg.error')));
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!preview.value?.batch) return;
|
||||
await api.post(`/admin/cashbacks/${preview.value.batch.id}/confirm`);
|
||||
ElMessage.success(t('msg.cashback_issued'));
|
||||
preview.value = null;
|
||||
await confirmBatchId(preview.value.batch.id);
|
||||
}
|
||||
|
||||
async function openDetail(batchId: string) {
|
||||
detailLoading.value = true;
|
||||
detailVisible.value = true;
|
||||
detail.value = null;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/cashbacks/${batchId}`);
|
||||
detail.value = data.data as CashbackPreview;
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onHistoryPageChange(p: number) {
|
||||
historyPage.value = p;
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function onHistorySizeChange(size: number) {
|
||||
historyPageSize.value = size;
|
||||
historyPage.value = 1;
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function onHistoryStatusChange() {
|
||||
historyPage.value = 1;
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
onMounted(loadHistory);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -143,29 +301,46 @@ async function confirm() {
|
||||
<div class="preview-meta">
|
||||
<span>{{ t('cashback.batch_no') }}:{{ preview.batch.batchNo }}</span>
|
||||
<span class="meta-sep">·</span>
|
||||
<span>
|
||||
{{ formatPeriodDate(preview.batch.periodStart) }}
|
||||
—
|
||||
{{ formatPeriodDate(preview.batch.periodEnd) }}
|
||||
</span>
|
||||
<span>{{ formatPeriodRange(preview.batch) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag :type="statusTagType(preview.batch.status)" size="small">
|
||||
{{ statusLabel(preview.batch.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="preview-stats">
|
||||
<el-col :span="8">
|
||||
<el-row :gutter="12" class="preview-stats">
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ preview.batch.playerCount ?? 0 }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value pstat-green">{{ formatAmount(preview.totalAmount) }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ formatAmount(preview.totalEffectiveStake ?? 0) }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.effective_stake') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ preview.totalBetCount ?? 0 }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.bet_count') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ formatRate(preview.avgRate) }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.avg_rate') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ previewItems.length }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.lines') }}</div>
|
||||
@@ -200,6 +375,12 @@ async function confirm() {
|
||||
>
|
||||
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="betCount"
|
||||
:label="t('cashback.col.bet_count')"
|
||||
width="88"
|
||||
align="right"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="effectiveStake"
|
||||
:label="t('cashback.col.effective_stake')"
|
||||
@@ -225,11 +406,173 @@ async function confirm() {
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-button type="success" class="confirm-btn" :disabled="previewItems.length === 0" @click="confirm">
|
||||
{{ t('cashback.confirm_issue') }}
|
||||
</el-button>
|
||||
<div v-if="preview.batch.status === 'PREVIEW'" class="preview-actions">
|
||||
<el-button type="success" :disabled="previewItems.length === 0" @click="confirm">
|
||||
{{ t('cashback.confirm_issue') }}
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="cancelBatchId(preview.batch.id)">
|
||||
{{ t('cashback.cancel_issue') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-card class="data-card history-card" shadow="never">
|
||||
<div class="history-head">
|
||||
<div class="table-title">{{ t('cashback.history_title') }}</div>
|
||||
<el-select
|
||||
v-model="historyStatus"
|
||||
clearable
|
||||
:placeholder="t('common.all')"
|
||||
style="width: 140px"
|
||||
@change="onHistoryStatusChange"
|
||||
>
|
||||
<el-option :label="t('cashback.status.PREVIEW')" value="PREVIEW" />
|
||||
<el-option :label="t('cashback.status.CONFIRMED')" value="CONFIRMED" />
|
||||
<el-option :label="t('cashback.status.CANCELLED')" value="CANCELLED" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<el-table v-loading="historyLoading" :data="history" stripe>
|
||||
<template #empty>
|
||||
<AdminTableEmpty :text="t('cashback.history_empty')" />
|
||||
</template>
|
||||
<el-table-column prop="batchNo" :label="t('cashback.batch_no')" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column :label="t('cashback.col.period')" min-width="190">
|
||||
<template #default="{ row }">{{ formatPeriodRange(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.col.status')" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="playerCount" :label="t('cashback.stat.players')" width="96" align="right" />
|
||||
<el-table-column prop="totalBetCount" :label="t('cashback.col.bet_count')" width="88" align="right">
|
||||
<template #default="{ row }">{{ row.totalBetCount ?? 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.stat.effective_stake')" min-width="120" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.totalEffectiveStake ?? 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.stat.total')" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="amount-cell">{{ formatAmount(row.totalAmount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.col.created_at')" min-width="150">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.col.confirmed_at')" min-width="150">
|
||||
<template #default="{ row }">{{ formatTime(row.confirmedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('cashback.col.operator')" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.operatorUsername || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row.id)">
|
||||
{{ t('cashback.view_detail') }}
|
||||
</el-button>
|
||||
<template v-if="row.status === 'PREVIEW'">
|
||||
<el-button link type="success" @click="confirmBatchId(row.id)">
|
||||
{{ t('cashback.confirm_issue') }}
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="cancelBatchId(row.id)">
|
||||
{{ t('cashback.cancel_issue') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="historyPage"
|
||||
v-model:page-size="historyPageSize"
|
||||
:total="historyTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@current-change="onHistoryPageChange"
|
||||
@size-change="onHistorySizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
:title="t('cashback.detail_title')"
|
||||
width="920px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<div class="detail-meta">
|
||||
<span>{{ t('cashback.batch_no') }}:{{ detail.batch.batchNo }}</span>
|
||||
<span class="meta-sep">·</span>
|
||||
<span>{{ formatPeriodRange(detail.batch) }}</span>
|
||||
<el-tag :type="statusTagType(detail.batch.status)" size="small" class="detail-tag">
|
||||
{{ statusLabel(detail.batch.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="detail-summary">{{ t('cashback.detail_summary') }}</div>
|
||||
<el-row :gutter="12" class="preview-stats detail-stats">
|
||||
<el-col :span="8">
|
||||
<div class="pstat pstat-compact">
|
||||
<div class="pstat-value">{{ detail.batch.playerCount ?? 0 }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat pstat-compact">
|
||||
<div class="pstat-value pstat-green">{{ formatAmount(detail.totalAmount) }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat pstat-compact">
|
||||
<div class="pstat-value">{{ detail.totalBetCount ?? 0 }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.bet_count') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
:data="detailItems"
|
||||
stripe
|
||||
max-height="360"
|
||||
show-summary
|
||||
:summary-method="tableSummary"
|
||||
>
|
||||
<el-table-column type="index" :label="t('cashback.col.index')" width="56" align="center" />
|
||||
<el-table-column prop="username" :label="t('cashback.col.player')" min-width="110" />
|
||||
<el-table-column prop="agentUsername" :label="t('cashback.col.agent')" min-width="100">
|
||||
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="betCount" :label="t('cashback.col.bet_count')" width="88" align="right" />
|
||||
<el-table-column prop="effectiveStake" :label="t('cashback.col.effective_stake')" min-width="110" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.effectiveStake) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rate" :label="t('cashback.col.rate')" width="96" align="right">
|
||||
<template #default="{ row }">{{ formatRate(row.rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" :label="t('cashback.col.amount')" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-if="detail?.batch.status === 'PREVIEW'" class="detail-actions">
|
||||
<el-button type="success" :disabled="detailItems.length === 0" @click="confirmBatchId(detail.batch.id)">
|
||||
{{ t('cashback.confirm_issue') }}
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="cancelBatchId(detail.batch.id)">
|
||||
{{ t('cashback.cancel_issue') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -278,25 +621,44 @@ async function confirm() {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
.history-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preview-head,
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 15px;
|
||||
.preview-title,
|
||||
.table-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
.preview-meta,
|
||||
.detail-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.detail-tag {
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.detail-summary {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.meta-sep {
|
||||
margin: 0 6px;
|
||||
}
|
||||
@@ -305,16 +667,25 @@ async function confirm() {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pstat {
|
||||
padding: 16px;
|
||||
padding: 14px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pstat-compact {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.pstat-value {
|
||||
font-size: 26px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
@@ -330,13 +701,6 @@ async function confirm() {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -346,7 +710,17 @@ async function confirm() {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
.preview-actions,
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
import { clearStaffSession } from '../stores/auth';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
const router = useRouter();
|
||||
import {
|
||||
emptyPlayerCreateForm,
|
||||
emptyPlayerEditForm,
|
||||
@@ -58,15 +61,63 @@ const bettingLimits = ref({
|
||||
});
|
||||
const settingsSaving = ref(false);
|
||||
const limitsSaving = ref(false);
|
||||
const resetAllowed = ref(false);
|
||||
const resetLoading = ref(false);
|
||||
const resetConfirmPhrase = ref('');
|
||||
const settingsCollapseOpen = ref<string[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
loadAgentOptions();
|
||||
loadPlayerSettings();
|
||||
loadBettingLimits();
|
||||
loadResetDatabaseStatus();
|
||||
load();
|
||||
});
|
||||
|
||||
async function loadResetDatabaseStatus() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/system/reset-database');
|
||||
resetAllowed.value = !!data.data?.allowed;
|
||||
} catch {
|
||||
resetAllowed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDatabase() {
|
||||
if (resetConfirmPhrase.value !== 'RESET') {
|
||||
ElMessage.warning(t('user.reset_database_confirm_label'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(t('user.reset_database_hint'), t('user.reset_database'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('user.reset_database_btn'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
resetLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.post('/admin/system/reset-database', {
|
||||
confirmPhrase: 'RESET',
|
||||
});
|
||||
const accounts: string[] = data.data?.demoAccounts ?? [];
|
||||
ElMessage.success({
|
||||
message: `${t('user.reset_database_success')}\n${t('user.reset_database_accounts')}: ${accounts.join(' · ')}`,
|
||||
duration: 8000,
|
||||
});
|
||||
clearStaffSession();
|
||||
await router.push('/login');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
resetLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBettingLimits() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/settings/betting-limits');
|
||||
@@ -370,6 +421,40 @@ function statusLabel(s: string) {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="list-settings-block list-settings-block--danger">
|
||||
<p class="list-settings-title">{{ t('user.reset_database') }}</p>
|
||||
<p class="list-settings-hint">{{ t('user.reset_database_hint') }}</p>
|
||||
<el-alert
|
||||
v-if="!resetAllowed"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="reset-db-alert"
|
||||
:title="t('user.reset_database_disabled_prod')"
|
||||
/>
|
||||
<el-form inline size="small" class="settings-form reset-db-form">
|
||||
<el-form-item :label="t('user.reset_database_confirm_label')">
|
||||
<el-input
|
||||
v-model="resetConfirmPhrase"
|
||||
:placeholder="t('user.reset_database_confirm_ph')"
|
||||
style="width: 160px"
|
||||
:disabled="!resetAllowed"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:loading="resetLoading"
|
||||
:disabled="!resetAllowed || resetConfirmPhrase !== 'RESET'"
|
||||
@click="resetDatabase"
|
||||
>
|
||||
{{ t('user.reset_database_btn') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
@@ -793,6 +878,20 @@ function statusLabel(s: string) {
|
||||
.edit-stats {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.list-settings-block--danger {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed rgba(245, 108, 108, 0.35);
|
||||
}
|
||||
.list-settings-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reset-db-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user