feat: 截屏功能优化

This commit is contained in:
digua
2025-12-14 02:27:07 +08:00
parent 9d63845526
commit fdbf47b74f
11 changed files with 148 additions and 91 deletions
@@ -67,9 +67,11 @@ export function getMentionAnalysis(sessionId: string, filter?: TimeFilter): any
let whereClause = clause
if (whereClause.includes('WHERE')) {
whereClause += " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'"
whereClause +=
" AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'"
} else {
whereClause = " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'"
whereClause =
" WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'"
}
const messages = db
@@ -151,7 +153,7 @@ export function getMentionAnalysis(sessionId: string, filter?: TimeFilter): any
}
topMentioned.sort((a, b) => b.count - a.count)
// 6. 检测单向关注(舔狗检测)
// 6. 检测单向关注
// 条件:A @ B 的比例 >= 80%(即 B @ A / A @ B < 20%
const oneWay: any[] = []
const processedPairs = new Set<string>()
@@ -500,4 +502,3 @@ export function getLaughAnalysis(sessionId: string, filter?: TimeFilter, keyword
groupLaughRate: Math.round((totalLaughs / totalMessages) * 10000) / 100,
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ const subTabs = [
{
id: 'campus',
label: '阵营9宫格',
desc: '和朋友们聊天的时候产生的一个有趣的想法,群里偶尔会很认真的讨论某个话题,大家都聊的很认真,那么是不是可以让AI分析聊天记录,然后针对这个话题,让AI用 守序善良/绝对中立/守序邪恶/混乱邪恶这样的九宫格把群友划分到对应的格子里面',
desc: '和朋友们聊天的时候产生的一个有趣的想法,群里偶尔会很认真的讨论某个话题,那么是不是可以让AI分析聊天记录,然后针对这个话题,让AI用 守序善良/绝对中立/守序邪恶/混乱邪恶 这样的九宫格把群友划分到对应的格子里面',
icon: 'i-heroicons-squares-2x2',
},
]
+29 -14
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import CaptureButton from '@/components/common/CaptureButton.vue'
const props = withDefaults(
defineProps<{
@@ -23,6 +24,10 @@ const props = withDefaults(
// 控制弹窗
const isOpen = ref(false)
// 截屏相关 ref
const cardRef = ref<HTMLElement | null>(null)
const modalBodyRef = ref<HTMLElement | null>(null)
// Top N 数据
const topNData = computed(() => props.items.slice(0, props.topN))
@@ -34,30 +39,40 @@ const formattedCount = computed(() => props.countTemplate.replace('{count}', Str
</script>
<template>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div ref="cardRef" class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-3 dark:border-gray-800">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
<h3 class="font-semibold text-gray-900 whitespace-nowrap dark:text-white">{{ title }}</h3>
<p v-if="description" class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ description }}</p>
</div>
<div class="flex items-center gap-2">
<div class="no-capture flex items-center gap-2">
<!-- 自定义头部右侧内容 -->
<slot name="headerRight" />
<!-- 卡片截屏按钮 -->
<CaptureButton tooltip="截取列表" size="xs" type="element" :target-element="cardRef" />
<!-- 完整列表弹窗 -->
<UModal v-model:open="isOpen" :ui="{ content: 'md:w-full max-w-4xl' }">
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" variant="ghost">查看完整排行</UButton>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
<span class="text-sm text-gray-500">{{ formattedCount }}</span>
</div>
</template>
<template #body>
<div class="max-h-[60vh] divide-y divide-gray-100 overflow-y-auto dark:divide-gray-800">
<div v-for="(item, index) in items" :key="index" class="px-5 py-3">
<slot name="item" :item="item" :index="index" />
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" variant="ghost">完整排行</UButton>
<template #content>
<div ref="modalBodyRef" class="section-content flex flex-col">
<!-- Header -->
<div
class="flex w-full items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700"
>
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold text-gray-900 whitespace-nowrap dark:text-white">{{ title }}</h3>
<span class="text-sm text-gray-500">{{ formattedCount }}</span>
</div>
<CaptureButton tooltip="截取完整列表" size="xs" type="element" :target-element="modalBodyRef" />
</div>
<!-- Body -->
<div class="max-h-[60vh] divide-y divide-gray-100 overflow-y-auto dark:divide-gray-800">
<div v-for="(item, index) in items" :key="index" class="px-5 py-3">
<slot name="item" :item="item" :index="index" />
</div>
</div>
</div>
</template>
+34 -17
View File
@@ -2,6 +2,7 @@
import { computed, ref } from 'vue'
import RankList from './RankList.vue'
import type { RankItem } from './RankList.vue'
import CaptureButton from '@/components/common/CaptureButton.vue'
interface Props {
/** 完整的排行数据 */
@@ -24,6 +25,10 @@ const props = withDefaults(defineProps<Props>(), {
// 控制弹窗
const isOpen = ref(false)
// 截屏相关 ref
const cardRef = ref<HTMLElement | null>(null)
const modalBodyRef = ref<HTMLElement | null>(null)
// Top N 数据
const topNData = computed(() => {
return props.members.slice(0, props.topN)
@@ -36,28 +41,40 @@ const showViewAll = computed(() => {
</script>
<template>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div ref="cardRef" class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-3 dark:border-gray-800">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
<h3 class="font-semibold text-gray-900 whitespace-nowrap dark:text-white">{{ title }}</h3>
<p v-if="description" class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ description }}</p>
</div>
<!-- 完整排行榜 Dialog -->
<UModal v-model:open="isOpen" :ui="{ content: 'md:w-full max-w-3xl' }">
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" variant="ghost">查看完整排行</UButton>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ title }}</h3>
<span class="text-sm text-gray-500"> {{ members.length }} 位成员</span>
</div>
</template>
<template #body>
<div class="max-h-[60vh] overflow-y-auto">
<RankList :members="members" :unit="unit" />
</div>
</template>
</UModal>
<div class="no-capture flex items-center gap-1">
<!-- 卡片截屏按钮 -->
<CaptureButton tooltip="截取当前卡片" size="xs" type="element" :target-element="cardRef" />
<!-- 完整排行榜 Dialog -->
<UModal v-model:open="isOpen" :ui="{ content: 'md:w-full max-w-3xl' }">
<UButton v-if="showViewAll" icon="i-heroicons-list-bullet" variant="ghost">完整排行</UButton>
<template #content>
<div ref="modalBodyRef" class="section-content flex flex-col">
<!-- Header -->
<div
class="flex w-full items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700"
>
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold text-gray-900 whitespace-nowrap dark:text-white">{{ title }}</h3>
<span class="text-sm text-gray-500"> {{ members.length }} 位成员</span>
</div>
<CaptureButton tooltip="截取完整排行" size="xs" type="element" :target-element="modalBodyRef" />
</div>
<!-- Body -->
<div class="max-h-[60vh] overflow-y-auto">
<RankList :members="members" :unit="unit" />
</div>
</div>
</template>
</UModal>
</div>
</div>
<RankList :members="topNData" :unit="unit" />
+1 -1
View File
@@ -64,7 +64,7 @@ async function handleCapture(event: Event) {
:id="buttonId"
icon="i-heroicons-camera"
variant="ghost"
color="neutral"
color="primary"
:size="size"
:loading="isCapturing"
@click="handleCapture"
+1 -1
View File
@@ -25,7 +25,7 @@ function closeModal() {
</script>
<template>
<UModal v-model:open="isOpen" :ui="{ content: 'max-w-5xl' }">
<UModal v-model:open="isOpen" :ui="{ content: 'max-w-5xl z-100' }">
<template #content>
<div class="flex flex-col">
<!-- Header -->
+1 -1
View File
@@ -139,7 +139,7 @@ export function useAIChat(
messages.value.push({
id: generateId('error'),
role: 'assistant',
content: '⚠️ 请先配置 AI 服务。点击左下角「设置」按钮前往「AI模型Tab」进行配置。',
content: '⚠️ 请先配置 AI 服务。点击左下角「设置」按钮前往「模型配置Tab」进行配置。',
timestamp: Date.now(),
})
return
+66 -43
View File
@@ -156,18 +156,25 @@ export function useScreenCapture() {
watermark.textContent = '聊天分析实验室 · chatlab.fun'
element.appendChild(watermark)
// 保存原始 display 状态并隐藏指定元素(使用 display: none 完全移除占位
const hiddenElements: { el: HTMLElement; originalDisplay: string }[] = []
// 隐藏指定元素(使用临时 class 而不是 inline style,避免恢复问题
const hiddenElements: HTMLElement[] = []
const HIDDEN_CLASS = '__capture-hidden__'
// 注入隐藏样式(如果不存在)
let styleTag = document.getElementById('__capture-style__')
if (!styleTag) {
styleTag = document.createElement('style')
styleTag.id = '__capture-style__'
styleTag.textContent = `.${HIDDEN_CLASS} { display: none !important; }`
document.head.appendChild(styleTag)
}
// 隐藏带有 .no-capture class 的元素(通用排除规则)
const noCaptureElements = element.querySelectorAll('.no-capture')
noCaptureElements.forEach((el) => {
const htmlEl = el as HTMLElement
hiddenElements.push({
el: htmlEl,
originalDisplay: htmlEl.style.display,
})
htmlEl.style.display = 'none'
hiddenElements.push(htmlEl)
htmlEl.classList.add(HIDDEN_CLASS)
})
// 隐藏用户指定的选择器元素
@@ -176,26 +183,55 @@ export function useScreenCapture() {
const elements = document.querySelectorAll(selector)
elements.forEach((el) => {
const htmlEl = el as HTMLElement
hiddenElements.push({
el: htmlEl,
originalDisplay: htmlEl.style.display,
})
htmlEl.style.display = 'none'
hiddenElements.push(htmlEl)
htmlEl.classList.add(HIDDEN_CLASS)
})
}
}
// 如果需要捕获完整内容,临时移除 overflow 限制
const fullContent = options?.fullContent !== false
const overflowElements: { el: HTMLElement; originalOverflow: string; originalHeight: string }[] = []
const overflowElements: { el: HTMLElement; originalOverflow: string; originalHeight: string; originalMaxHeight: string }[] = []
if (fullContent) {
// 找到所有有 overflow 限制的祖先元素和目标元素本身
let node: HTMLElement | null = element
while (node) {
// 处理目标元素及其所有子元素的 overflow 和 max-height 限制
const elementsWithOverflow = [element, ...Array.from(element.querySelectorAll('*'))] as HTMLElement[]
for (const node of elementsWithOverflow) {
const style = window.getComputedStyle(node)
const overflow = style.overflow
const overflowY = style.overflowY
const maxHeight = style.maxHeight
if (
overflow === 'hidden' ||
overflow === 'auto' ||
overflow === 'scroll' ||
overflowY === 'hidden' ||
overflowY === 'auto' ||
overflowY === 'scroll' ||
(maxHeight !== 'none' && maxHeight !== '0px')
) {
overflowElements.push({
el: node,
originalOverflow: node.style.overflow,
originalHeight: node.style.height,
originalMaxHeight: node.style.maxHeight,
})
node.style.overflow = 'visible'
node.style.maxHeight = 'none'
// 如果有固定高度,也需要临时移除
if (style.height !== 'auto' && node.scrollHeight > node.clientHeight) {
node.style.height = 'auto'
}
}
}
// 也处理祖先元素
let parent: HTMLElement | null = element.parentElement
while (parent) {
const style = window.getComputedStyle(parent)
const overflow = style.overflow
const overflowY = style.overflowY
if (
overflow === 'hidden' ||
overflow === 'auto' ||
@@ -205,17 +241,14 @@ export function useScreenCapture() {
overflowY === 'scroll'
) {
overflowElements.push({
el: node,
originalOverflow: node.style.overflow,
originalHeight: node.style.height,
el: parent,
originalOverflow: parent.style.overflow,
originalHeight: parent.style.height,
originalMaxHeight: parent.style.maxHeight,
})
node.style.overflow = 'visible'
// 如果有固定高度,也需要临时移除
if (style.height !== 'auto' && node.scrollHeight > node.clientHeight) {
node.style.height = 'auto'
}
parent.style.overflow = 'visible'
}
node = node.parentElement
parent = parent.parentElement
}
}
@@ -232,16 +265,14 @@ export function useScreenCapture() {
canvas.style.border = 'none'
})
// 修复 Markdown 标题元素在 @zumer/snapdom 中的黑色边框和额外高度问题
// 修复 Markdown 标题元素在 @zumer/snapdom 中的黑色边框问题
// 注意:不修改 background,以保留渐变文字等效果
const headingElements: {
el: HTMLElement
originalStyles: {
border: string
outline: string
boxShadow: string
background: string
margin: string
padding: string
}
}[] = []
const headings = element.querySelectorAll('h1, h2, h3, h4, h5, h6')
@@ -253,18 +284,12 @@ export function useScreenCapture() {
border: htmlEl.style.border,
outline: htmlEl.style.outline,
boxShadow: htmlEl.style.boxShadow,
background: htmlEl.style.background,
margin: htmlEl.style.margin,
padding: htmlEl.style.padding,
},
})
// 清除所有可能导致 snapdom 渲染问题的样式
// 清除边框相关样式,保留 background/margin/padding
htmlEl.style.border = 'none'
htmlEl.style.outline = 'none'
htmlEl.style.boxShadow = 'none'
htmlEl.style.background = 'transparent'
htmlEl.style.margin = '0.5em 0'
htmlEl.style.padding = '0'
})
// 修复 Markdown 列表元素在 @zumer/snapdom 中的渲染问题
@@ -408,9 +433,6 @@ export function useScreenCapture() {
el.style.border = originalStyles.border
el.style.outline = originalStyles.outline
el.style.boxShadow = originalStyles.boxShadow
el.style.background = originalStyles.background
el.style.margin = originalStyles.margin
el.style.padding = originalStyles.padding
}
// 恢复列表元素样式并移除手动添加的前缀(@zumer/snapdom bug workaround
for (const { el, originalStyles, addedPrefixes } of listElements) {
@@ -426,13 +448,14 @@ export function useScreenCapture() {
}
}
// 恢复 overflow 设置
for (const { el, originalOverflow, originalHeight } of overflowElements) {
for (const { el, originalOverflow, originalHeight, originalMaxHeight } of overflowElements) {
el.style.overflow = originalOverflow
el.style.height = originalHeight
el.style.maxHeight = originalMaxHeight
}
// 恢复隐藏元素的 display
for (const { el, originalDisplay } of hiddenElements) {
el.style.display = originalDisplay
// 恢复隐藏元素
for (const el of hiddenElements) {
el.classList.remove('__capture-hidden__')
}
isCapturing.value = false
}
@@ -29,7 +29,7 @@ const props = defineProps<{
// 计算赛季标题
const seasonTitle = computed(() => {
if (props.selectedYear && props.selectedYear > 0) {
return `${props.selectedYear} 赛季`
return `${props.selectedYear} 赛季群榜单`
}
// 全部时间:显示年份范围
if (props.availableYears && props.availableYears.length > 0) {
@@ -37,11 +37,11 @@ const seasonTitle = computed(() => {
const minYear = sorted[0]
const maxYear = sorted[sorted.length - 1]
if (minYear === maxYear) {
return `${minYear} 赛季`
return `${minYear} 赛季群榜单`
}
return `${minYear}-${maxYear} 赛季`
return `${minYear}-${maxYear} 赛季群榜单`
}
return '全部赛季'
return '全部赛季群榜单'
})
// 锚点导航配置
@@ -82,7 +82,7 @@ const memberRankData = computed<RankItem[]>(() => {
>
🏆 {{ seasonTitle }}
</h1>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">各榜单前三名请 @群主 领取奖 🎁</p>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">各榜单前三名请 @群主 领取奖 🎁</p>
</div>
<!-- 龙王排名 -->
@@ -97,8 +97,10 @@ watch(
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 单向关注舔狗检测 -->
<!-- 单向关注 -->
<!-- 有严重BUG很不准先隐藏 -->
<SectionCard
class="hidden"
v-if="mentionAnalysis.oneWay.length > 0"
title="🐕 单向关注检测"
:description="`发现 ${mentionAnalysis.oneWay.length} 对单向关注关系(一方 @ 另一方占比 ≥80%)`"
@@ -276,4 +278,3 @@ watch(
</UModal>
</div>
</template>
+2 -2
View File
@@ -560,7 +560,7 @@ export interface MentionPair {
}
/**
* 单向关注(舔狗检测)
* 单向关注
*/
export interface OneWayMention {
fromMemberId: number
@@ -606,7 +606,7 @@ export interface MentionAnalysis {
topMentioners: MentionRankItem[]
/** 被 @ 最多的人排行 */
topMentioned: MentionRankItem[]
/** 单向关注列表(舔狗检测) */
/** 单向关注列表 */
oneWay: OneWayMention[]
/** 双向奔赴列表(CP检测) */
twoWay: TwoWayMention[]