feat: add custom chat context menu
This commit is contained in:
+56
-6
@@ -31,6 +31,7 @@ const activePage = ref<'dashboard' | 'shop' | 'lottery' | 'bans' | 'rules'>('das
|
||||
const activeTab = reactive({ shop: 'items', lottery: 'events' });
|
||||
const toast = ref('');
|
||||
const confirmState = reactive({ open: false, title: '', message: '', confirmText: '确认', danger: false, resolver: null as null | ((value: boolean) => void) });
|
||||
const chatContextMenu = reactive({ open: false, x: 0, y: 0, item: null as ChatItem | null });
|
||||
const authReady = ref(false);
|
||||
const authenticated = ref(false);
|
||||
const loginForm = reactive({ username: '', password: '' });
|
||||
@@ -415,6 +416,42 @@ async function sendMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function openChatContextMenu(event: MouseEvent, item: ChatItem) {
|
||||
chatContextMenu.item = item;
|
||||
chatContextMenu.open = true;
|
||||
const menuWidth = 168;
|
||||
const menuHeight = item.deleted ? 92 : 136;
|
||||
chatContextMenu.x = Math.min(event.clientX, window.innerWidth - menuWidth - 8);
|
||||
chatContextMenu.y = Math.min(event.clientY, window.innerHeight - menuHeight - 8);
|
||||
}
|
||||
function closeChatContextMenu() {
|
||||
chatContextMenu.open = false;
|
||||
chatContextMenu.item = null;
|
||||
}
|
||||
async function contextAddToRules() {
|
||||
const item = chatContextMenu.item;
|
||||
closeChatContextMenu();
|
||||
if (item) await addChatMessageToRules(item);
|
||||
}
|
||||
async function contextDeleteMessage(ban = false) {
|
||||
const item = chatContextMenu.item;
|
||||
closeChatContextMenu();
|
||||
if (item) await deleteChatMessage(item, ban);
|
||||
}
|
||||
async function contextCopyText() {
|
||||
const item = chatContextMenu.item;
|
||||
const text = String(item?.text || '').trim();
|
||||
closeChatContextMenu();
|
||||
if (!text) return showToast('当前消息没有可复制的文本');
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast('消息文本已复制');
|
||||
} catch {
|
||||
showToast('复制失败,浏览器未允许剪贴板权限');
|
||||
}
|
||||
}
|
||||
|
||||
async function addChatMessageToRules(item: ChatItem) {
|
||||
const text = String(item.text || '').trim();
|
||||
if (!text) return showToast('当前消息没有可写入的文本');
|
||||
@@ -512,10 +549,14 @@ async function bootstrapData() {
|
||||
if (!statTimer) statTimer = window.setInterval(loadStats, 30000);
|
||||
}
|
||||
onMounted(async () => {
|
||||
window.addEventListener('click', closeChatContextMenu);
|
||||
window.addEventListener('scroll', closeChatContextMenu, true);
|
||||
await checkAuth();
|
||||
if (authenticated.value) await bootstrapData();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', closeChatContextMenu);
|
||||
window.removeEventListener('scroll', closeChatContextMenu, true);
|
||||
if (es) es.close();
|
||||
if (statTimer) window.clearInterval(statTimer);
|
||||
});
|
||||
@@ -576,7 +617,7 @@ onBeforeUnmount(() => {
|
||||
<SCard id="chat" class="panel chat-panel" title="聊天记录" :description="`${chatCount} 条`">
|
||||
<div class="scroll-area chat-scroll">
|
||||
<div v-if="!chatLogs.length" class="empty">等待消息...</div>
|
||||
<article v-for="(item, i) in chatLogs" :key="item.id || i" :class="[chatItemClass(item), { deleting: item._deleting || item._ruling }]">
|
||||
<article v-for="(item, i) in chatLogs" :key="item.id || i" :class="[chatItemClass(item), { deleting: item._deleting || item._ruling }]" @contextmenu.prevent.stop="openChatContextMenu($event, item)">
|
||||
<div class="chat-head">
|
||||
<div class="chat-user-line">
|
||||
<STag size="xs" :color="tagColor(item)" variant="soft">{{ chatStatusText(item) }}</STag>
|
||||
@@ -593,12 +634,8 @@ onBeforeUnmount(() => {
|
||||
<STag v-if="item.spam" class="reason-tag" size="xs" color="destructive" variant="soft" :title="item.reason || item.spam_reason">{{ item.reason || item.spam_reason }}</STag>
|
||||
<span v-else />
|
||||
<div class="chat-actions">
|
||||
<SButton size="xs" variant="link" color="primary" :disabled="item._deleting || item._ruling" @click="addChatMessageToRules(item)">{{ item._ruling ? '入库中...' : '入库' }}</SButton>
|
||||
<STag v-if="item.deleted" size="xs" color="secondary" variant="soft">已删 · {{ formatTime(item.deleted_at) }}</STag>
|
||||
<template v-else>
|
||||
<SButton size="xs" variant="link" color="destructive" :disabled="item._deleting || item._ruling" @click="deleteChatMessage(item, false)">{{ item._deleting ? '删除中...' : '删除' }}</SButton>
|
||||
<SButton v-if="item.user_id" size="xs" variant="link" color="warning" :disabled="item._deleting || item._ruling" @click="deleteChatMessage(item, true)">{{ item._deleting ? '处理中...' : '删封' }}</SButton>
|
||||
</template>
|
||||
<STag v-else size="xs" color="secondary" variant="soft">右键操作</STag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="item._deleting || item._ruling" class="chat-loading-mask">
|
||||
@@ -627,6 +664,19 @@ onBeforeUnmount(() => {
|
||||
</SCard>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="chatContextMenu.open"
|
||||
class="chat-context-menu"
|
||||
:style="{ left: `${chatContextMenu.x}px`, top: `${chatContextMenu.y}px` }"
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<button @click="contextAddToRules">📥 写入拦截库</button>
|
||||
<button @click="contextCopyText">📋 复制文本</button>
|
||||
<button v-if="chatContextMenu.item && !chatContextMenu.item.deleted" class="danger" @click="contextDeleteMessage(false)">🗑️ 删除消息</button>
|
||||
<button v-if="chatContextMenu.item && !chatContextMenu.item.deleted && chatContextMenu.item.user_id" class="warning" @click="contextDeleteMessage(true)">⛔ 删除并封禁</button>
|
||||
</div>
|
||||
|
||||
<section v-show="activePage === 'shop'" class="tab-page manager-layout">
|
||||
<aside class="editor-panel"><div class="section-title"><span>商品编辑</span><small>{{ shopForm.item_key ? '编辑中' : '新建' }}</small></div><label>商品编号<SInput v-model="shopForm.item_key" placeholder="如 1 / vip_card" /></label><label>商品名称<SInput v-model="shopForm.name" placeholder="补签卡" /></label><label>积分价格<SInput v-model="shopForm.cost" placeholder="50" /></label><label>说明<SInput v-model="shopForm.desc" placeholder="展示在 /shop 内" /></label><label class="switch-line"><input v-model="shopForm.enabled" type="checkbox" /> 上架销售</label><div class="editor-actions"><SButton size="xs" color="primary" :loading="shopSaving" :disabled="shopSaving" @click="saveShopItem">保存商品</SButton><SButton size="xs" variant="outline" @click="Object.assign(shopForm, { item_key: '', name: '', cost: 0, desc: '', enabled: true })">新建</SButton></div></aside>
|
||||
<section class="data-panel"><div class="tabbar"><button :class="{active: activeTab.shop === 'items'}" @click="activeTab.shop='items'">商品列表</button><button :class="{active: activeTab.shop === 'tx'}" @click="activeTab.shop='tx'; loadShopTransactions()">交易记录</button></div><div v-if="activeTab.shop === 'items'" class="data-table"><div class="table-head"><span>编号/商品</span><span>价格</span><span>状态</span><span>操作</span></div><div v-for="item in shopItems" :key="item.item_key" class="table-row"><span><b>{{ item.item_key }}</b><em>{{ item.name }}</em><small>{{ item.desc || '无说明' }}</small></span><span>{{ item.cost }} 分</span><span><STag size="xs" :color="item.enabled ? 'success' : 'secondary'" variant="soft">{{ item.enabled ? '上架' : '下架' }}</STag></span><span class="row-actions"><SButton size="xs" variant="outline" @click="editShopItem(item)">编辑</SButton><SButton size="xs" variant="soft" color="destructive" :loading="item._loading" :disabled="item._loading" @click="deleteShopItem(item)">删除</SButton></span></div></div><div v-else class="data-table tx-table"><div class="table-head"><span>用户</span><span>商品</span><span>积分</span><span>时间</span></div><div v-for="tx in shopTransactions" :key="tx.id" class="table-row"><span>@{{ tx.username || tx.user_id }}</span><span>{{ tx.item_name }}</span><span>{{ tx.cost }} 分</span><span>{{ formatTime(tx.created_at) }}</span></div></div></section>
|
||||
|
||||
@@ -319,3 +319,13 @@ body { background: #f3f6fb; }
|
||||
@media (max-width: 980px) {
|
||||
.rule-test-grid, .rule-write-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
|
||||
/* Custom chat context menu */
|
||||
.chat-context-menu { position: fixed; z-index: 120; width: 168px; padding: 6px; border: 1px solid rgba(148,163,184,.35); border-radius: 12px; background: rgba(255,255,255,.98); box-shadow: 0 18px 45px rgba(15,23,42,.18); backdrop-filter: blur(14px); display: grid; gap: 3px; }
|
||||
.chat-context-menu button { width: 100%; border: 0; background: transparent; border-radius: 9px; padding: 8px 10px; text-align: left; font-size: 13px; color: #0f172a; cursor: pointer; }
|
||||
.chat-context-menu button:hover { background: #eff6ff; color: #1d4ed8; }
|
||||
.chat-context-menu button.danger:hover { background: #fef2f2; color: #dc2626; }
|
||||
.chat-context-menu button.warning:hover { background: #fff7ed; color: #ea580c; }
|
||||
.chat-item { user-select: text; }
|
||||
.chat-item:hover { outline: 1px solid rgba(59,130,246,.18); }
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icon.svg" />
|
||||
<title>TG Spam Guard</title>
|
||||
<script type="module" crossorigin src="/static/assets/index-h7vFW1gU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-DfsWb80Q.css">
|
||||
<script type="module" crossorigin src="/static/assets/index-Orp-c1A_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-BAKZPs4k.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user