Files
tiantian-system/app/pages/admin/git-review.vue
2026-04-08 17:10:58 +08:00

806 lines
26 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="review-page">
<!-- 页面头部 -->
<div class="page-header">
<div>
<h2 class="page-title">🔧 Git 审核管理</h2>
<p class="page-desc">审核开发者的 Git 账号绑定与仓库权限申请</p>
</div>
<a-space>
<a-button @click="loadAll" :loading="loadingGit || loadingPerm">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- Tab 切换 -->
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
<!-- Git 账号审核 -->
<a-tab-pane key="git-account" tab="Git 账号审核">
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="8" :md="6" v-for="stat in gitStats" :key="stat.key">
<div
class="stat-card"
:class="[stat.color, { active: gitFilter.status === stat.key }]"
@click="handleGitStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<!-- 筛选栏 -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 Git 账号绑定列表</span>
<a-space>
<a-input-search
v-model:value="gitFilter.keyword"
placeholder="搜索用户名/邮箱"
style="width: 200px"
@search="loadGitAccounts"
allow-clear
/>
</a-space>
</div>
<a-table
:columns="gitColumns"
:data-source="gitAccounts"
:loading="loadingGit"
:pagination="gitPagination"
row-key="id"
@change="handleGitTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userInfo'">
<div class="user-info-cell">
<a-avatar :size="36" class="user-avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<div class="user-info-text">
<div class="user-name">{{ record.username }}</div>
<div class="user-email" v-if="record.email">{{ record.email }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'remark'">
<span class="text-gray">{{ record.remark || '-' }}</span>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="gitStatusColor(record.status)">
{{ gitStatusText(record.status) }}
</a-tag>
</template>
<template v-if="column.key === 'time'">
<span class="text-sm text-gray">{{ formatTime(record.updateTime || record.createTime) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewGitDetail(record)">详情</a-button>
<template v-if="record.status === 'pending'">
<a-popconfirm title="确认通过此 Git 账号绑定?" @confirm="handleApproveGit(record)">
<a-button type="primary" size="small">通过</a-button>
</a-popconfirm>
<a-button danger size="small" @click="handleRejectGit(record)">拒绝</a-button>
</template>
</a-space>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
<!-- 仓库权限审核 -->
<a-tab-pane key="permission-request" tab="仓库权限审核">
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="8" :md="6" v-for="stat in permStats" :key="stat.key">
<div
class="stat-card"
:class="[stat.color, { active: permFilter.status === stat.key }]"
@click="handlePermStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<!-- 筛选栏 -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 仓库权限申请列表</span>
<a-space>
<a-input-search
v-model:value="permFilter.keyword"
placeholder="搜索用户名/仓库名"
style="width: 200px"
@search="loadPermRequests"
allow-clear
/>
</a-space>
</div>
<a-table
:columns="permColumns"
:data-source="permRequests"
:loading="loadingPerm"
:pagination="permPagination"
row-key="id"
@change="handlePermTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'applicant'">
<div class="user-info-cell">
<a-avatar :size="32" class="user-avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<span class="user-name">{{ record.gitUsername }}</span>
</div>
</template>
<template v-if="column.key === 'repo'">
<a-tag color="blue">{{ record.repo }}</a-tag>
</template>
<template v-if="column.key === 'reason'">
<a-tooltip :title="record.reason">
<span class="reason-text">{{ record.reason }}</span>
</a-tooltip>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="permStatusColor(record.status)">
{{ permStatusText(record.status) }}
</a-tag>
</template>
<template v-if="column.key === 'time'">
<span class="text-sm text-gray">{{ formatTime(record.createdAt || record.reviewedAt) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewPermDetail(record)">详情</a-button>
<template v-if="record.status === 'pending'">
<a-popconfirm title="确认通过此仓库权限申请?" @confirm="handleApprovePerm(record)">
<a-button type="primary" size="small">通过</a-button>
</a-popconfirm>
<a-button danger size="small" @click="handleRejectPerm(record)">拒绝</a-button>
</template>
</a-space>
</template>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
<!-- Git 账号详情弹窗 -->
<a-modal
v-model:open="showGitDetailModal"
:title="`Git 账号详情:${currentGit?.username || ''}`"
width="560px"
:footer="null"
>
<template v-if="currentGit">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="Gitea 用户名">{{ currentGit.username }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ currentGit.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="gitStatusColor(currentGit.status)">{{ gitStatusText(currentGit.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="用户ID">{{ currentGit.userId }}</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ formatTime(currentGit.createTime) }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ formatTime(currentGit.updateTime) }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ currentGit.remark || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="currentGit.verificationNote" label="审核备注" :span="2">
<a-alert :type="currentGit.status === 'rejected' ? 'error' : 'success'" :message="currentGit.verificationNote" show-icon />
</a-descriptions-item>
</a-descriptions>
<div v-if="currentGit.status === 'pending'" class="detail-actions">
<a-popconfirm title="确认通过此 Git 账号绑定?" @confirm="handleApproveGit(currentGit)">
<a-button type="primary" :loading="approvingGit"> 审核通过</a-button>
</a-popconfirm>
<a-button danger @click="handleRejectGit(currentGit)"> 拒绝绑定</a-button>
</div>
</template>
</a-modal>
<!-- 权限申请详情弹窗 -->
<a-modal
v-model:open="showPermDetailModal"
:title="`权限申请详情:${currentPerm?.repoName || ''}`"
width="560px"
:footer="null"
>
<template v-if="currentPerm">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="申请人">{{ currentPerm.gitUsername }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="permStatusColor(currentPerm.status)">{{ permStatusText(currentPerm.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="仓库" :span="2">
<a-tag color="blue">{{ currentPerm.repo }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="申请理由" :span="2">
<div class="detail-reason">{{ currentPerm.reason }}</div>
</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ formatTime(currentPerm.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="审核时间">{{ formatTime(currentPerm.reviewedAt) || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="currentPerm.reviewerName" label="审核人">{{ currentPerm.reviewerName }}</a-descriptions-item>
<a-descriptions-item v-if="currentPerm.rejectReason" label="拒绝原因" :span="2">
<a-alert type="error" :message="currentPerm.rejectReason" show-icon />
</a-descriptions-item>
</a-descriptions>
<div v-if="currentPerm.status === 'pending'" class="detail-actions">
<a-popconfirm title="确认通过此仓库权限申请?" @confirm="handleApprovePerm(currentPerm)">
<a-button type="primary" :loading="approvingPerm"> 审核通过</a-button>
</a-popconfirm>
<a-button danger @click="handleRejectPerm(currentPerm)"> 拒绝申请</a-button>
</div>
</template>
</a-modal>
<!-- 拒绝原因弹窗 -->
<a-modal
v-model:open="showRejectModal"
:title="rejectModalTitle"
:confirm-loading="rejecting"
@ok="confirmReject"
@cancel="showRejectModal = false"
>
<a-form layout="vertical">
<a-form-item label="拒绝原因" required>
<a-textarea
v-model:value="rejectReasonInput"
:rows="4"
:placeholder="rejectPlaceholder"
:maxlength="500"
show-count
/>
</a-form-item>
<div class="reject-tips">
<p>💡 常见拒绝原因</p>
<a-space wrap>
<a-tag
v-for="tip in rejectTips"
:key="tip"
class="reject-tip-tag"
@click="rejectReasonInput = tip"
>{{ tip }}</a-tag>
</a-space>
</div>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined, UserOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import {
pageGitAccounts,
approveGitAccount,
rejectGitAccount,
pagePermissionRequestsAdmin,
approvePermissionRequest,
rejectPermissionRequest,
type GitAccountItem,
type PermissionRequestItem,
} from '@/api/developer'
definePageMeta({ layout: 'admin' })
useHead({ title: 'Git 审核管理 - 平台管理' })
// ==================== 通用 ====================
const activeTab = ref('git-account')
function formatTime(time?: string) {
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
}
// ==================== 拒绝弹窗 ====================
const showRejectModal = ref(false)
const rejectReasonInput = ref('')
const rejecting = ref(false)
const rejectType = ref<'git' | 'perm'>('git')
const rejectTargetId = ref<number>(0)
const rejectTargetRecord = ref<GitAccountItem | PermissionRequestItem | null>(null)
const rejectModalTitle = computed(() => rejectType.value === 'git' ? '拒绝 Git 账号绑定' : '拒绝仓库权限申请')
const rejectPlaceholder = computed(() => rejectType.value === 'git'
? '请填写拒绝原因,以便开发者了解问题并修改'
: '请填写拒绝原因,以便开发者了解问题')
const rejectTips = [
'用户名与 Gitea 平台不一致,请核实后重新提交',
'提交信息不完整,请补充后重新提交',
'该账号存在异常,请联系管理员核实',
'仓库暂时不对外开放权限申请',
'申请理由不充分,请详细说明使用场景',
]
function handleTabChange() {
// tab 切换时无需额外操作,数据已加载
}
// ==================== Git 账号审核 ====================
const loadingGit = ref(false)
const gitAccounts = ref<GitAccountItem[]>([])
const gitFilter = reactive({ status: '', keyword: '' })
const gitPagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
const gitStats = reactive([
{ key: 'pending', icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 'verified', icon: '✅', label: '已通过', value: 0, color: 'green' },
{ key: 'rejected', icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: '', icon: '📦', label: '全部', value: 0, color: 'blue' },
])
const gitColumns = [
{ title: '用户信息', key: 'userInfo', width: 220 },
{ title: '备注', key: 'remark', ellipsis: true },
{ title: '状态', key: 'status', width: 100 },
{ title: '更新时间', key: 'time', width: 160 },
{ title: '操作', key: 'action', width: 200 },
]
// 详情弹窗
const showGitDetailModal = ref(false)
const currentGit = ref<GitAccountItem | null>(null)
const approvingGit = ref(false)
async function loadGitAccounts() {
loadingGit.value = true
try {
const res = await pageGitAccounts({
page: gitPagination.current,
size: gitPagination.pageSize,
status: gitFilter.status || undefined,
keyword: gitFilter.keyword || undefined,
})
const listData = (res as any)?.data?.data
gitAccounts.value = listData?.records || []
gitPagination.total = listData?.total || 0
updateGitStats()
} catch {
message.error('加载 Git 账号列表失败')
} finally {
loadingGit.value = false
}
}
async function updateGitStats() {
try {
const [pendingRes, verifiedRes, rejectedRes, allRes] = await Promise.allSettled([
pageGitAccounts({ page: 1, size: 1, status: 'pending' }),
pageGitAccounts({ page: 1, size: 1, status: 'verified' }),
pageGitAccounts({ page: 1, size: 1, status: 'rejected' }),
pageGitAccounts({ page: 1, size: 1 }),
])
const extract = (r: any) => (r.status === 'fulfilled' ? (r.value as any)?.data?.data?.total || 0 : 0)
gitStats[0].value = extract(pendingRes)
gitStats[1].value = extract(verifiedRes)
gitStats[2].value = extract(rejectedRes)
gitStats[3].value = extract(allRes)
} catch { /* ignore */ }
}
function handleGitStatFilter(key: string) {
gitFilter.status = key
gitPagination.current = 1
loadGitAccounts()
}
function handleGitTableChange(pag: any) {
gitPagination.current = pag.current
gitPagination.pageSize = pag.pageSize
loadGitAccounts()
}
function handleViewGitDetail(record: GitAccountItem) {
currentGit.value = record
showGitDetailModal.value = true
}
async function handleApproveGit(record: GitAccountItem) {
approvingGit.value = true
try {
const res = await approveGitAccount(record.id) as any
if (res?.data?.code === 200 || res?.data?.code === 0) {
message.success(`Git 账号「${record.username}」已通过审核`)
showGitDetailModal.value = false
loadGitAccounts()
} else {
message.error(res?.data?.message || '操作失败')
}
} catch (e: any) {
message.error(e?.data?.message || e?.message || '操作失败')
} finally {
approvingGit.value = false
}
}
function handleRejectGit(record: GitAccountItem) {
rejectType.value = 'git'
rejectTargetId.value = record.id
rejectTargetRecord.value = record
rejectReasonInput.value = ''
showRejectModal.value = true
}
// ==================== 权限申请审核 ====================
const loadingPerm = ref(false)
const permRequests = ref<PermissionRequestItem[]>([])
const permFilter = reactive({ status: '', keyword: '' })
const permPagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
const permStats = reactive([
{ key: 'pending', icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 'approved', icon: '✅', label: '已通过', value: 0, color: 'green' },
{ key: 'rejected', icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: '', icon: '📦', label: '全部', value: 0, color: 'blue' },
])
const permColumns = [
{ title: '申请人', key: 'applicant', width: 160 },
{ title: '仓库', key: 'repo', width: 200 },
{ title: '申请理由', key: 'reason', ellipsis: true },
{ title: '状态', key: 'status', width: 100 },
{ title: '申请时间', key: 'time', width: 160 },
{ title: '操作', key: 'action', width: 200 },
]
// 详情弹窗
const showPermDetailModal = ref(false)
const currentPerm = ref<PermissionRequestItem | null>(null)
const approvingPerm = ref(false)
async function loadPermRequests() {
loadingPerm.value = true
try {
const res = await pagePermissionRequestsAdmin({
page: permPagination.current,
size: permPagination.pageSize,
status: permFilter.status || undefined,
keyword: permFilter.keyword || undefined,
})
const listData = (res as any)?.data?.data
permRequests.value = listData?.records || []
permPagination.total = listData?.total || 0
updatePermStats()
} catch {
message.error('加载权限申请列表失败')
} finally {
loadingPerm.value = false
}
}
async function updatePermStats() {
try {
const [pendingRes, approvedRes, rejectedRes, allRes] = await Promise.allSettled([
pagePermissionRequestsAdmin({ page: 1, size: 1, status: 'pending' }),
pagePermissionRequestsAdmin({ page: 1, size: 1, status: 'approved' }),
pagePermissionRequestsAdmin({ page: 1, size: 1, status: 'rejected' }),
pagePermissionRequestsAdmin({ page: 1, size: 1 }),
])
const extract = (r: any) => (r.status === 'fulfilled' ? (r.value as any)?.data?.data?.total || 0 : 0)
permStats[0].value = extract(pendingRes)
permStats[1].value = extract(approvedRes)
permStats[2].value = extract(rejectedRes)
permStats[3].value = extract(allRes)
} catch { /* ignore */ }
}
function handlePermStatFilter(key: string) {
permFilter.status = key
permPagination.current = 1
loadPermRequests()
}
function handlePermTableChange(pag: any) {
permPagination.current = pag.current
permPagination.pageSize = pag.pageSize
loadPermRequests()
}
function handleViewPermDetail(record: PermissionRequestItem) {
currentPerm.value = record
showPermDetailModal.value = true
}
async function handleApprovePerm(record: PermissionRequestItem) {
approvingPerm.value = true
try {
const res = await approvePermissionRequest(record.id) as any
if (res?.data?.code === 200 || res?.data?.code === 0) {
message.success(`仓库权限「${record.repo}」已通过审核`)
showPermDetailModal.value = false
loadPermRequests()
} else {
message.error(res?.data?.message || '操作失败')
}
} catch (e: any) {
message.error(e?.data?.message || e?.message || '操作失败')
} finally {
approvingPerm.value = false
}
}
function handleRejectPerm(record: PermissionRequestItem) {
rejectType.value = 'perm'
rejectTargetId.value = record.id
rejectTargetRecord.value = record
rejectReasonInput.value = ''
showRejectModal.value = true
}
// ==================== 确认拒绝 ====================
async function confirmReject() {
if (!rejectReasonInput.value.trim()) {
message.warning('请填写拒绝原因')
return
}
rejecting.value = true
try {
if (rejectType.value === 'git') {
const res = await rejectGitAccount(rejectTargetId.value, rejectReasonInput.value) as any
if (res?.data?.code === 200 || res?.data?.code === 0) {
message.success('已拒绝 Git 账号绑定申请')
showGitDetailModal.value = false
showRejectModal.value = false
loadGitAccounts()
} else {
message.error(res?.data?.message || '操作失败')
}
} else {
const res = await rejectPermissionRequest(rejectTargetId.value, rejectReasonInput.value) as any
if (res?.data?.code === 200 || res?.data?.code === 0) {
message.success('已拒绝仓库权限申请')
showPermDetailModal.value = false
showRejectModal.value = false
loadPermRequests()
} else {
message.error(res?.data?.message || '操作失败')
}
}
} catch (e: any) {
message.error(e?.data?.message || e?.message || '操作失败')
} finally {
rejecting.value = false
}
}
// ==================== 状态映射 ====================
function gitStatusText(status?: string) {
const map: Record<string, string> = { pending: '待审核', verified: '已通过', rejected: '已拒绝' }
return map[status || ''] || status || '-'
}
function gitStatusColor(status?: string) {
const map: Record<string, string> = { pending: 'orange', verified: 'success', rejected: 'error' }
return map[status || ''] || 'default'
}
function permStatusText(status?: string) {
const map: Record<string, string> = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
return map[status || ''] || status || '-'
}
function permStatusColor(status?: string) {
const map: Record<string, string> = { pending: 'orange', approved: 'success', rejected: 'error' }
return map[status || ''] || 'default'
}
// ==================== 加载全部 ====================
function loadAll() {
loadGitAccounts()
loadPermRequests()
}
onMounted(() => {
loadAll()
})
</script>
<style scoped>
.review-page {
min-height: 100%;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
line-height: 1.4;
}
.page-desc {
font-size: 13px;
color: #9ca3af;
margin: 2px 0 0;
}
/* 统计卡片 */
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-card.active { border-color: currentColor; box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-card.active.blue { border-color: #3b82f6; }
.stat-card.active.orange { border-color: #f97316; }
.stat-card.active.green { border-color: #22c55e; }
.stat-card.active.red { border-color: #ef4444; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value {
font-size: 22px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
line-height: 1.2;
}
.stat-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
/* 面板 */
.panel {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
/* 用户信息格 */
.user-info-cell {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
flex-shrink: 0;
background: #f0f0f0;
}
.user-info-text { flex: 1; min-width: 0; }
.user-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
}
.user-email {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 2px;
}
.reason-text {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 详情弹窗 */
.detail-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.detail-reason {
max-height: 150px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.6;
}
/* 拒绝原因提示 */
.reject-tips {
margin-top: 12px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
}
.reject-tips p {
font-size: 12px;
color: rgba(0,0,0,0.45);
margin: 0 0 8px;
}
.reject-tip-tag {
cursor: pointer;
transition: all 0.15s;
}
.reject-tip-tag:hover {
color: #4f46e5;
border-color: #4f46e5;
}
.mb-6 { margin-bottom: 24px; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
</style>