feat: 多AI角色支持、Bug修复、文档更新

This commit is contained in:
sunny-335
2026-06-15 12:05:46 +08:00
parent ef3cdf4bdc
commit 6f1cb4b037
27 changed files with 2400 additions and 755 deletions
+350 -100
View File
@@ -9,116 +9,297 @@
</template>
</VPageHeader>
<!-- Health Banner -->
<div v-if="health && healthVisible" class="mx-4 mt-2 flex items-center gap-2 rounded-lg px-4 py-2.5"
:class="{
'bg-green-50 border border-green-200 text-green-700': health.status === 'healthy',
'bg-yellow-50 border border-yellow-200 text-yellow-700': health.status === 'degraded',
'bg-red-50 border border-red-200 text-red-700': health.status === 'unhealthy',
}"
>
<svg v-if="health.status === 'healthy'" class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else-if="health.status === 'degraded'" class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<svg v-else class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm flex-1">
{{ health.status === 'healthy' ? 'AI Foundation 连接正常' : health.status === 'degraded' ? 'AI Foundation 部分功能不可用' : 'AI Foundation 不可用,请检查插件和模型配置' }}
</span>
<button class="shrink-0 hover:opacity-70 transition-opacity" @click="healthVisible = false">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="m-4">
<!-- Stats Cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<VCard :body-class="['!p-4']">
<div class="text-sm text-gray-500">总回复数</div>
<div class="mt-1 text-2xl font-bold">{{ stats?.total || 0 }}</div>
<!-- Top: AI Persona + Stats Overview -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<!-- AI Persona Card -->
<VCard :body-class="['!p-5']">
<div class="flex items-center gap-4">
<div class="relative">
<img
v-if="persona?.avatar"
:src="persona.avatar"
:alt="persona?.name"
class="h-14 w-14 rounded-full object-cover ring-2 ring-blue-100"
/>
<div
v-else
class="h-14 w-14 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-xl font-bold shadow-sm"
>
{{ persona?.name?.charAt(0) || '?' }}
</div>
<span class="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full bg-green-400 ring-2 ring-white"></span>
</div>
<div class="min-w-0 flex-1">
<div class="text-base font-semibold text-gray-900 truncate">{{ persona?.name || '加载中...' }}</div>
<div class="text-xs text-gray-400 mt-0.5">AI虚拟评论者 · 在线</div>
</div>
</div>
<div v-if="persona?.prompt" class="mt-3 bg-gray-50 rounded-md px-3 py-2">
<p class="text-xs text-gray-500 line-clamp-2 leading-relaxed">{{ persona.prompt }}</p>
</div>
<div class="mt-3 flex gap-2">
<VButton size="sm" type="secondary" @click="openSettings">修改配置</VButton>
<VButton size="sm" @click="$router.push({ name: 'CommentAiAutopilotLogs' })">查看日志</VButton>
</div>
</VCard>
<VCard :body-class="['!p-4']">
<div class="text-sm text-gray-500">通过数</div>
<div class="mt-1 text-2xl font-bold text-green-600">{{ stats?.passCount || 0 }}</div>
</VCard>
<VCard :body-class="['!p-4']">
<div class="text-sm text-gray-500">失败数</div>
<div class="mt-1 text-2xl font-bold text-red-600">{{ stats?.failCount || 0 }}</div>
</VCard>
<VCard :body-class="['!p-4']">
<div class="text-sm text-gray-500">待审核</div>
<div class="mt-1 text-2xl font-bold text-orange-500">{{ stats?.reviewingCount || 0 }}</div>
</VCard>
<VCard :body-class="['!p-4']">
<div class="text-sm text-gray-500">平均评分</div>
<div class="mt-1 text-2xl font-bold">{{ stats?.avgScore?.toFixed(1) || '0.0' }}</div>
<!-- Stats Overview -->
<VCard :body-class="['!p-5']" class="lg:col-span-2">
<h3 class="text-sm font-medium text-gray-500 mb-4">回复概览</h3>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">{{ stats?.total || 0 }}</div>
<div class="text-xs text-gray-400 mt-1">总回复</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-green-600">{{ stats?.passCount || 0 }}</div>
<div class="text-xs text-gray-400 mt-1">已通过</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-red-500">{{ stats?.failCount || 0 }}</div>
<div class="text-xs text-gray-400 mt-1">已失败</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-amber-500">{{ stats?.reviewingCount || 0 }}</div>
<div class="text-xs text-gray-400 mt-1">待审核</div>
</div>
</div>
<!-- Pass Rate Bar -->
<div class="mt-4">
<div class="flex items-center justify-between text-xs text-gray-400 mb-1">
<span>通过率</span>
<span>{{ passRate }}%</span>
</div>
<div class="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-green-400 to-green-500 rounded-full transition-all duration-500"
:style="{ width: passRate + '%' }"
></div>
</div>
</div>
</VCard>
</div>
<!-- Sentiment Distribution -->
<VCard class="mt-4" :body-class="['!p-4']">
<h3 class="text-base font-medium mb-3">情感分布</h3>
<div class="flex items-end gap-6">
<div class="flex flex-col items-center">
<div class="w-16 bg-green-100 rounded-t" :style="{ height: getSentimentBarHeight('POSITIVE') + 'px' }"></div>
<div class="mt-1 text-sm font-medium text-green-600">{{ stats?.sentimentDistribution?.POSITIVE || 0 }}</div>
<div class="text-xs text-gray-500">正面</div>
<!-- Middle: Sentiment + Trend -->
<div class="grid grid-cols-1 gap-4 mt-4 lg:grid-cols-2">
<!-- Sentiment Distribution -->
<VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-4">情感分布</h3>
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-green-500 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">正面</span>
<span class="font-medium text-green-600">{{ stats?.sentimentDistribution?.POSITIVE || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-green-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('POSITIVE') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-gray-400 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">中性</span>
<span class="font-medium text-gray-600">{{ stats?.sentimentDistribution?.NEUTRAL || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gray-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('NEUTRAL') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-red-500 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">负面</span>
<span class="font-medium text-red-500">{{ stats?.sentimentDistribution?.NEGATIVE || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-red-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('NEGATIVE') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-gray-300 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">未知</span>
<span class="font-medium text-gray-400">{{ stats?.sentimentDistribution?.UNKNOWN || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gray-300 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('UNKNOWN') + '%' }"
></div>
</div>
</div>
</div>
</div>
<div class="flex flex-col items-center">
<div class="w-16 bg-gray-200 rounded-t" :style="{ height: getSentimentBarHeight('NEUTRAL') + 'px' }"></div>
<div class="mt-1 text-sm font-medium text-gray-600">{{ stats?.sentimentDistribution?.NEUTRAL || 0 }}</div>
<div class="text-xs text-gray-500">中性</div>
</div>
<div class="flex flex-col items-center">
<div class="w-16 bg-red-100 rounded-t" :style="{ height: getSentimentBarHeight('NEGATIVE') + 'px' }"></div>
<div class="mt-1 text-sm font-medium text-red-600">{{ stats?.sentimentDistribution?.NEGATIVE || 0 }}</div>
<div class="text-xs text-gray-500">负面</div>
</div>
</div>
</VCard>
</VCard>
<!-- Daily Trend -->
<VCard class="mt-4" :body-class="['!p-4']">
<h3 class="text-base font-medium mb-3">近7日回复趋势</h3>
<div class="flex items-end gap-2 h-32">
<div
v-for="day in (stats?.dailyTrend || [])"
:key="day.date"
class="flex-1 flex flex-col items-center justify-end h-full"
>
<div class="text-xs text-gray-500 mb-1">{{ day.count }}</div>
<!-- Daily Trend -->
<VCard :body-class="['!p-5']">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-gray-500">近7日回复趋势</h3>
<div class="inline-flex rounded-md border border-gray-200 overflow-hidden">
<button
class="px-2.5 py-1 text-xs transition-colors"
:class="range === '7' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = '7'"
>
7
</button>
<button
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
:class="range === '30' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = '30'"
>
30
</button>
<button
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
:class="range === 'all' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = 'all'"
>
全部
</button>
</div>
</div>
<div v-if="stats?.dailyTrend?.length" class="flex items-end gap-3" style="height: 160px">
<div
class="w-full bg-blue-400 rounded-t transition-all"
:style="{ height: getTrendBarHeight(day.count) + 'px' }"
></div>
<div class="text-[10px] text-gray-400 mt-1">{{ formatTrendDate(day.date) }}</div>
</div>
</div>
</VCard>
<!-- AI Persona Section -->
<VCard class="mt-4" :body-class="['!p-4']">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium">AI角色信息</h3>
<p class="mt-1 text-sm text-gray-500">
AI虚拟评论者以独立身份参与评论讨论
</p>
</div>
<VButton @click="openSettings"> 修改配置 </VButton>
</div>
<div v-if="persona" class="mt-4 space-y-3">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-lg">
🤖
</div>
<div>
<div class="font-medium">{{ persona.name }}</div>
<div class="text-xs text-gray-400">AI虚拟评论者</div>
v-for="day in stats.dailyTrend"
:key="day.date"
class="flex-1 flex flex-col items-center justify-end h-full"
>
<div class="text-xs text-gray-500 mb-1 font-medium">{{ day.count }}</div>
<div
class="w-full rounded-t-md transition-all duration-500"
:class="day.count > 0 ? 'bg-gradient-to-t from-blue-500 to-blue-400' : 'bg-gray-100'"
:style="{ height: getTrendBarHeight(day.count) + 'px' }"
></div>
<div class="text-[10px] text-gray-400 mt-2 whitespace-nowrap">{{ formatTrendDate(day.date) }}</div>
</div>
</div>
<div v-if="persona.prompt" class="flex items-start gap-2">
<span class="text-sm text-gray-500 shrink-0 whitespace-nowrap">人格提示词</span>
<span class="text-sm text-gray-600 line-clamp-3">{{ persona.prompt }}</span>
<div v-else class="flex items-center justify-center text-sm text-gray-400" style="height: 160px">
暂无数据
</div>
</div>
<div v-else class="mt-4 text-sm text-gray-400">
加载中...
</div>
</VCard>
</VCard>
</div>
<!-- Quick Links -->
<div class="mt-4 flex gap-4">
<VButton @click="$router.push({ name: 'CommentAiAutopilotLogs' })">
查看AI回复日志
</VButton>
<!-- Bottom: Score + Quick Actions -->
<div class="grid grid-cols-1 gap-4 mt-4 sm:grid-cols-2">
<!-- Avg Score -->
<VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-3">平均审核评分</h3>
<div class="flex items-center gap-4">
<div class="text-4xl font-bold" :class="scoreColor">{{ stats?.avgScore?.toFixed(1) || '0.0' }}</div>
<div class="flex-1">
<div class="h-3 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="scoreBarColor"
:style="{ width: (stats?.avgScore || 0) * 10 + '%' }"
></div>
</div>
<div class="flex justify-between text-[10px] text-gray-300 mt-1">
<span>0</span>
<span>5</span>
<span>10</span>
</div>
</div>
</div>
</VCard>
<!-- Quick Actions -->
<VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-3">快捷操作</h3>
<div class="grid grid-cols-2 gap-2">
<button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="$router.push({ name: 'CommentAiAutopilotLogs' })"
>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
回复日志
</button>
<button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="openSettings"
>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
插件设置
</button>
<button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="$router.push({ name: 'CommentAiAutopilotSettings' })"
>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
AI角色
</button>
<button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="refreshData"
>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
刷新数据
</button>
</div>
</VCard>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { ref, computed, onMounted, watch } from "vue"
import { axiosInstance } from "@halo-dev/api-client"
import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
import { IconPlug } from "@halo-dev/components"
@@ -144,13 +325,39 @@ interface PersonaResponse {
avatar: string
}
interface HealthResponse {
status: string
}
const stats = ref<StatsResponse | null>(null)
const persona = ref<PersonaResponse | null>(null)
const range = ref("7")
const health = ref<HealthResponse | null>(null)
const healthVisible = ref(true)
const passRate = computed(() => {
if (!stats.value || stats.value.total === 0) return 0
return Math.round((stats.value.passCount / stats.value.total) * 100)
})
const scoreColor = computed(() => {
const score = stats.value?.avgScore || 0
if (score >= 7) return "text-green-600"
if (score >= 4) return "text-amber-500"
return "text-red-500"
})
const scoreBarColor = computed(() => {
const score = stats.value?.avgScore || 0
if (score >= 7) return "bg-gradient-to-r from-green-400 to-green-500"
if (score >= 4) return "bg-gradient-to-r from-amber-400 to-amber-500"
return "bg-gradient-to-r from-red-400 to-red-500"
})
const fetchStats = async () => {
try {
const { data } = await axiosInstance.get(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats",
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=${range.value}`,
)
stats.value = data
} catch (e) {
@@ -158,34 +365,72 @@ const fetchStats = async () => {
}
}
const computeGravatarHash = async (email: string): Promise<string> => {
const normalized = email.trim().toLowerCase()
const data = new TextEncoder().encode(normalized)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, "0")).join("")
}
const fetchPersona = async () => {
try {
const { data } = await axiosInstance.get(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/persona",
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas",
)
persona.value = data
const personas = Array.isArray(data) ? data : (data.items || [])
const defaultPersona = personas.find((p: any) => p.spec?.isDefault) || personas[0]
if (defaultPersona) {
persona.value = {
name: defaultPersona.spec?.displayName || '未命名',
prompt: defaultPersona.spec?.prompt || '',
avatar: '', // 需要单独计算
}
// 计算 Gravatar
const email = defaultPersona.spec?.email
if (email) {
const hash = await computeGravatarHash(email)
persona.value.avatar = `https://cn.cravatar.com/avatar/${hash}`
}
}
} catch (e) {
console.error("Failed to fetch persona", e)
}
}
const fetchHealth = async () => {
try {
const { data } = await axiosInstance.get(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/health",
)
health.value = data
} catch (e) {
console.error("Failed to fetch health", e)
}
}
const refreshData = () => {
fetchStats()
fetchPersona()
Toast.success("数据已刷新")
}
const openSettings = () => {
window.location.href = "/console/comment-ai-autopilot/settings"
}
const getSentimentBarHeight = (sentiment: string): number => {
const getSentimentPercent = (sentiment: string): number => {
const dist = stats.value?.sentimentDistribution
if (!dist) return 0
const max = Math.max(dist.POSITIVE || 0, dist.NEUTRAL || 0, dist.NEGATIVE || 0, 1)
const value = dist[sentiment] || 0
return Math.max((value / max) * 80, value > 0 ? 8 : 0)
const total = Object.values(dist).reduce((a, b) => a + b, 0)
if (total === 0) return 0
return Math.round(((dist[sentiment] || 0) / total) * 100)
}
const getTrendBarHeight = (count: number): number => {
const trend = stats.value?.dailyTrend
if (!trend || trend.length === 0) return 0
const max = Math.max(...trend.map(d => d.count), 1)
return Math.max((count / max) * 80, count > 0 ? 8 : 0)
return Math.max((count / max) * 100, count > 0 ? 8 : 4)
}
const formatTrendDate = (dateStr: string): string => {
@@ -194,8 +439,13 @@ const formatTrendDate = (dateStr: string): string => {
return parts.length >= 3 ? `${parts[1]}/${parts[2]}` : dateStr
}
watch(range, () => {
fetchStats()
})
onMounted(() => {
fetchStats()
fetchPersona()
fetchHealth()
})
</script>
+7 -1
View File
@@ -141,7 +141,7 @@
</div>
<!-- Row 2: AI reply content (truncated) -->
<div class="text-sm text-gray-800 leading-relaxed break-words line-clamp-3">
<div class="text-sm text-gray-800 leading-relaxed break-words line-clamp-3 mt-2">
{{ stripHtml(reply.spec.reply) || '(空)' }}
</div>
</div>
@@ -628,6 +628,12 @@ onMounted(fetchReplies)
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
File diff suppressed because it is too large Load Diff