Files
zsglpt/app-frontend/src/pages/AccountsPage.vue

916 lines
26 KiB
Vue
Raw 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.
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
addAccount,
batchStartAccounts,
batchStopAccounts,
clearAccounts,
deleteAccount as apiDeleteAccount,
fetchAccounts,
startAccount,
stopAccount,
takeScreenshot,
updateAccount,
updateAccountRemark,
} from '../api/accounts'
import { fetchRunStats } from '../api/stats'
import { useSocket } from '../composables/useSocket'
import { useUserStore } from '../stores/user'
const userStore = useUserStore()
const socket = useSocket()
const loading = ref(false)
const statsLoading = ref(false)
const stats = reactive({
today_completed: 0,
today_failed: 0,
current_running: 0,
today_items: 0,
today_attachments: 0,
})
const accountsById = reactive({})
const selectedIds = ref([])
const browseTypeById = reactive({})
const batchBrowseType = ref('应读')
const SCREENSHOT_TOGGLE_STORAGE_KEY = 'zsglpt:accounts:enable_screenshot'
function loadEnableScreenshot() {
try {
const raw = window.localStorage.getItem(SCREENSHOT_TOGGLE_STORAGE_KEY)
if (raw === '0' || raw === 'false') return false
if (raw === '1' || raw === 'true') return true
} catch {
// ignore
}
return true
}
const batchEnableScreenshot = ref(loadEnableScreenshot())
watch(batchEnableScreenshot, (value) => {
try {
window.localStorage.setItem(SCREENSHOT_TOGGLE_STORAGE_KEY, value ? '1' : '0')
} catch {
// ignore
}
})
const addOpen = ref(false)
const editOpen = ref(false)
const upgradeOpen = ref(false)
const addForm = reactive({
username: '',
password: '',
remark: '',
})
const editForm = reactive({
id: '',
username: '',
password: '',
remark: '',
originalRemark: '',
})
const browseTypeOptions = [
{ label: '应读', value: '应读' },
{ label: '注册前未读', value: '注册前未读' },
]
const accounts = computed(() =>
Object.values(accountsById).sort((a, b) => String(a.username || '').localeCompare(String(b.username || ''), 'zh-CN')),
)
const accountCount = computed(() => accounts.value.length)
const accountLimit = computed(() => (userStore.isVip ? 999 : 3))
const selectedCount = computed(() => selectedIds.value.length)
const allSelected = computed(() => accountCount.value > 0 && selectedCount.value === accountCount.value)
const showUpgradeBanner = computed(() => !userStore.isVip)
function normalizeAccountPayload(acc) {
const base = accountsById[acc.id] || {}
accountsById[acc.id] = { ...base, ...acc }
}
function replaceAccounts(list) {
const nextList = Array.isArray(list) ? list : []
const nextIds = new Set(nextList.map((acc) => String(acc?.id || '')))
for (const existingId of Object.keys(accountsById)) {
if (!nextIds.has(existingId)) delete accountsById[existingId]
}
for (const acc of nextList) normalizeAccountPayload(acc)
}
function ensureBrowseTypeDefaults() {
for (const acc of accounts.value) {
if (!browseTypeById[acc.id]) browseTypeById[acc.id] = '应读'
}
}
watch(accounts, ensureBrowseTypeDefaults, { immediate: true })
function toggleSelectAll(value) {
if (value) selectedIds.value = accounts.value.map((a) => a.id)
else selectedIds.value = []
}
function requireVip(featureName) {
if (userStore.isVip) return true
ElMessage.warning(`${featureName}是VIP专属功能`)
upgradeOpen.value = true
return false
}
function toPercent(acc) {
const total = Number(acc.total_items || 0)
const done = Number(acc.progress_items || 0)
if (!total) return 0
return Math.max(0, Math.min(100, Math.round((done / total) * 100)))
}
function statusTagType(status = '') {
const text = String(status)
if (text.includes('已完成') || text.includes('完成')) return 'success'
if (text.includes('失败') || text.includes('错误') || text.includes('异常') || text.includes('登录失败')) return 'danger'
if (text.includes('排队') || text.includes('运行') || text.includes('截图')) return 'warning'
return 'info'
}
function showRuntimeProgress(acc) {
if (!acc?.is_running) return false
const statusText = String(acc.status || '')
const detailText = String(acc.detail_status || '')
if (!statusText || statusText === '未开始') return false
// 仅在“运行中”展示进度;排队/等待等非执行阶段不展示
if (!statusText.includes('运行')) return false
// 浏览完成后(包含等待截图/截图中等阶段)不再展示进度条与内容/附件
if (statusText.includes('截图') || statusText.includes('等待截图')) return false
if (detailText.includes('截图') || detailText.includes('等待截图')) return false
if (detailText.includes('浏览完成') || detailText.includes('任务完成')) return false
if (statusText.includes('已完成')) return false
return true
}
async function refreshStats(options = {}) {
const silent = Boolean(options?.silent)
if (!silent) statsLoading.value = true
try {
const data = await fetchRunStats()
stats.today_completed = Number(data?.today_completed || 0)
stats.today_failed = Number(data?.today_failed || 0)
stats.current_running = Number(data?.current_running || 0)
stats.today_items = Number(data?.today_items || 0)
stats.today_attachments = Number(data?.today_attachments || 0)
} catch (e) {
if (e?.response?.status === 401) window.location.href = '/login'
} finally {
if (!silent) statsLoading.value = false
}
}
async function refreshAccounts() {
loading.value = true
try {
const list = await fetchAccounts({ refresh: true })
replaceAccounts(list)
} catch (e) {
if (e?.response?.status === 401) window.location.href = '/login'
} finally {
loading.value = false
}
}
async function onStart(acc) {
try {
await startAccount(acc.id, { browse_type: browseTypeById[acc.id] || '应读', enable_screenshot: batchEnableScreenshot.value })
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '启动失败')
}
}
async function onStop(acc) {
try {
await stopAccount(acc.id)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '停止失败')
}
}
async function onScreenshot(acc) {
try {
await takeScreenshot(acc.id, { browse_type: browseTypeById[acc.id] || '应读' })
ElMessage.success('已提交截图')
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '截图失败')
}
}
async function onDelete(acc) {
try {
await ElMessageBox.confirm(`确定要删除账号「${acc.username}」吗?`, '删除账号', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await apiDeleteAccount(acc.id)
if (res?.success) {
delete accountsById[acc.id]
selectedIds.value = selectedIds.value.filter((id) => id !== acc.id)
ElMessage.success('已删除')
await refreshStats()
} else {
ElMessage.error(res?.error || '删除失败')
}
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '删除失败')
}
}
function openAdd() {
addForm.username = ''
addForm.password = ''
addForm.remark = ''
addOpen.value = true
}
async function submitAdd() {
const username = addForm.username.trim()
if (!username || !addForm.password.trim()) {
ElMessage.error('用户名和密码不能为空')
return
}
try {
await addAccount({
username,
password: addForm.password,
remember: true,
remark: addForm.remark.trim(),
})
ElMessage.success('添加成功')
addOpen.value = false
await refreshStats()
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '添加失败')
}
}
function openEdit(acc) {
editForm.id = acc.id
editForm.username = acc.username
editForm.password = ''
editForm.remark = String(acc.remark || '')
editForm.originalRemark = String(acc.remark || '')
editOpen.value = true
}
async function submitEdit() {
if (!editForm.id) return
const newPassword = editForm.password.trim()
const remarkText = editForm.remark.trim()
if (!newPassword && remarkText === editForm.originalRemark) {
ElMessage.info('没有修改')
editOpen.value = false
return
}
try {
if (newPassword) {
const res = await updateAccount(editForm.id, { password: newPassword, remember: true })
if (res?.account) normalizeAccountPayload(res.account)
}
if (remarkText !== editForm.originalRemark) {
await updateAccountRemark(editForm.id, { remark: remarkText })
normalizeAccountPayload({ id: editForm.id, remark: remarkText })
}
ElMessage.success('已更新')
editOpen.value = false
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '更新失败')
}
}
async function batchStart() {
if (!requireVip('批量操作')) return
if (selectedIds.value.length === 0) {
ElMessage.warning('请先选择账号')
return
}
try {
const res = await batchStartAccounts({
account_ids: selectedIds.value,
browse_type: batchBrowseType.value,
enable_screenshot: batchEnableScreenshot.value,
})
ElMessage.success(`已启动 ${res?.started_count || 0} 个账号`)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
async function batchStop() {
if (!requireVip('批量操作')) return
if (selectedIds.value.length === 0) {
ElMessage.warning('请先选择账号')
return
}
try {
const res = await batchStopAccounts({ account_ids: selectedIds.value })
ElMessage.success(`已停止 ${res?.stopped_count || 0} 个账号`)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
async function startAll() {
if (!requireVip('全部启动')) return
if (accounts.value.length === 0) {
ElMessage.warning('没有账号')
return
}
try {
await ElMessageBox.confirm('确定要启动全部账号吗?', '全部启动', {
confirmButtonText: '启动',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await batchStartAccounts({
account_ids: accounts.value.map((a) => a.id),
browse_type: batchBrowseType.value,
enable_screenshot: batchEnableScreenshot.value,
})
ElMessage.success(`已启动 ${res?.started_count || 0} 个账号`)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
async function stopAll() {
if (!requireVip('全部停止')) return
if (accounts.value.length === 0) {
ElMessage.warning('没有账号')
return
}
try {
await ElMessageBox.confirm('确定要停止全部账号吗?', '全部停止', {
confirmButtonText: '停止',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await batchStopAccounts({ account_ids: accounts.value.map((a) => a.id) })
ElMessage.success(`已停止 ${res?.stopped_count || 0} 个账号`)
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
async function clearAll() {
if (accounts.value.length === 0) {
ElMessage.warning('没有账号')
return
}
try {
await ElMessageBox.confirm('确定要清空所有账号吗?此操作不可恢复!', '清空账号', {
confirmButtonText: '继续',
cancelButtonText: '取消',
type: 'warning',
})
await ElMessageBox.confirm('再次确认:真的要删除所有账号吗?', '二次确认', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await clearAccounts()
if (res?.success) {
replaceAccounts([])
selectedIds.value = []
ElMessage.success('已清空所有账号')
await refreshStats()
return
}
ElMessage.error(res?.error || '操作失败')
} catch (e) {
const data = e?.response?.data
ElMessage.error(data?.error || '操作失败')
}
}
function bindSocket() {
const onAccountsList = (list) => {
replaceAccounts(list)
}
const onAccountUpdate = (acc) => {
normalizeAccountPayload(acc)
}
const onTaskProgress = (payload) => {
if (!payload?.account_id) return
normalizeAccountPayload({
id: payload.account_id,
detail_status: payload.stage || '',
total_items: payload.total_items,
progress_items: payload.browsed_items,
total_attachments: payload.total_attachments,
progress_attachments: payload.viewed_attachments,
elapsed_seconds: payload.elapsed_seconds,
elapsed_display: payload.elapsed_display,
})
}
socket.on('accounts_list', onAccountsList)
socket.on('account_update', onAccountUpdate)
socket.on('task_progress', onTaskProgress)
if (!socket.connected) socket.connect()
return () => {
socket.off('accounts_list', onAccountsList)
socket.off('account_update', onAccountUpdate)
socket.off('task_progress', onTaskProgress)
}
}
let unbindSocket = null
let statsTimer = null
const shouldPollStats = computed(() => {
// 仅在“真正执行中”才轮询(排队中不轮询,避免空转导致页面闪烁)
return accounts.value.some((acc) => {
if (!acc?.is_running) return false
const statusText = String(acc.status || '')
if (statusText.includes('排队')) return false
return true
})
})
function stopStatsPolling() {
if (!statsTimer) return
window.clearInterval(statsTimer)
statsTimer = null
}
function startStatsPolling() {
if (statsTimer) return
statsTimer = window.setInterval(() => refreshStats({ silent: true }), 10_000)
}
function syncStatsPolling(prevRunning = null) {
const running = shouldPollStats.value
// 任务从“运行中 -> 空闲”时,补拉一次统计以确保最终数据正确,再停止轮询
if (prevRunning === true && running === false) refreshStats({ silent: true }).catch(() => {})
if (running) startStatsPolling()
else stopStatsPolling()
}
watch(shouldPollStats, (running, prevRunning) => {
syncStatsPolling(prevRunning)
})
onMounted(async () => {
if (!userStore.vipInfo) {
userStore.refreshVipInfo().catch(() => {
window.location.href = '/login'
})
}
unbindSocket = bindSocket()
await refreshAccounts()
await refreshStats()
syncStatsPolling()
})
onBeforeUnmount(() => {
if (unbindSocket) unbindSocket()
stopStatsPolling()
})
</script>
<template>
<div class="page">
<el-row :gutter="12" class="stats-row">
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">今日完成</div>
<div class="stat-value">{{ stats.today_completed }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">今日失败</div>
<div class="stat-value">{{ stats.today_failed }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">运行中</div>
<div class="stat-value">{{ stats.current_running }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">浏览内容</div>
<div class="stat-value">{{ stats.today_items }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">查看附件</div>
<div class="stat-value">{{ stats.today_attachments }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-label app-muted">账号数</div>
<div class="stat-value">
{{ accountCount }}<span class="stat-suffix app-muted">/ {{ userStore.isVip ? '∞' : accountLimit }}</span>
</div>
</el-card>
</el-col>
</el-row>
<el-alert
v-if="showUpgradeBanner"
type="info"
show-icon
:closable="false"
class="upgrade-banner"
title="升级 VIP解锁更多功能无限账号 · 优先排队 · 定时任务 · 批量操作"
>
<template #default>
<div class="upgrade-actions">
<el-button type="primary" plain @click="upgradeOpen = true">了解VIP特权</el-button>
</div>
</template>
</el-alert>
<el-card shadow="never" class="panel" :body-style="{ padding: '14px' }">
<div class="panel-head">
<div class="panel-title">账号管理</div>
<div class="panel-actions">
<el-button :loading="loading" @click="refreshAccounts">刷新</el-button>
<el-button type="primary" @click="openAdd">添加账号</el-button>
</div>
</div>
<div class="toolbar">
<div class="toolbar-left">
<el-checkbox :model-value="allSelected" @change="toggleSelectAll">全选</el-checkbox>
<span class="app-muted">已选 {{ selectedCount }} </span>
</div>
<div class="toolbar-middle">
<el-select v-model="batchBrowseType" size="small" style="width: 120px">
<el-option v-for="opt in browseTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-switch v-model="batchEnableScreenshot" inline-prompt active-text="截图" inactive-text="不截图" />
</div>
<div class="toolbar-right">
<el-button type="primary" @click="batchStart">批量启动</el-button>
<el-button @click="batchStop">批量停止</el-button>
<el-button type="success" plain @click="startAll">全部启动</el-button>
<el-button type="danger" plain @click="stopAll">全部停止</el-button>
<el-button type="danger" text @click="clearAll">清空</el-button>
</div>
</div>
<el-skeleton v-if="loading" :rows="5" animated />
<template v-else>
<el-empty v-if="accounts.length === 0" description="暂无账号,点击右上角添加" />
<div v-else class="grid">
<el-card v-for="acc in accounts" :key="acc.id" shadow="never" class="account-card" :body-style="{ padding: '14px' }">
<div class="card-top">
<el-checkbox-group v-model="selectedIds" class="card-check">
<el-checkbox :value="acc.id" />
</el-checkbox-group>
<div class="card-main">
<div class="card-title">
<span class="card-name">{{ acc.username }}</span>
<el-tag size="small" :type="statusTagType(acc.status)" effect="light">{{ acc.status }}</el-tag>
</div>
<div class="card-sub app-muted">
{{ acc.remark || '—' }}
<span v-if="showRuntimeProgress(acc) && acc.detail_status"> · {{ acc.detail_status }}</span>
<span v-if="showRuntimeProgress(acc) && acc.elapsed_display"> · {{ acc.elapsed_display }}</span>
<span v-if="String(acc.status || '').includes('排队') && acc.queue_ahead != null">
· 前面 {{ acc.queue_ahead }} · 运行中 {{ acc.queue_running_total ?? 0 }}
</span>
<span v-else-if="showRuntimeProgress(acc) && (acc.queue_pending_total != null || acc.queue_running_total != null)">
· 排队 {{ acc.queue_pending_total ?? 0 }} · 运行中 {{ acc.queue_running_total ?? 0 }}
</span>
</div>
</div>
</div>
<div v-if="showRuntimeProgress(acc)" class="progress">
<el-progress :percentage="toPercent(acc)" :stroke-width="10" :show-text="false" />
<div class="progress-meta app-muted">
<span>内容 {{ acc.progress_items || 0 }}</span>
</div>
</div>
<div class="card-controls">
<el-select v-model="browseTypeById[acc.id]" size="small" style="width: 130px">
<el-option v-for="opt in browseTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<div class="card-buttons">
<el-button size="small" type="primary" :disabled="acc.is_running" @click="onStart(acc)">启动</el-button>
<el-button size="small" :disabled="!acc.is_running" @click="onStop(acc)">停止</el-button>
<el-button size="small" :disabled="acc.is_running" @click="onScreenshot(acc)">截图</el-button>
<el-button size="small" :disabled="acc.is_running" @click="openEdit(acc)">编辑</el-button>
<el-button size="small" type="danger" text @click="onDelete(acc)">删除</el-button>
</div>
</div>
</el-card>
</div>
</template>
</el-card>
<el-dialog v-model="addOpen" title="添加账号" width="min(560px, 92vw)">
<el-form label-position="top">
<el-form-item label="账号">
<el-input v-model="addForm.username" placeholder="请输入账号" autocomplete="off" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="addForm.password" type="password" show-password placeholder="请输入密码" autocomplete="off" />
</el-form-item>
<el-form-item label="备注可选最多200字">
<el-input v-model="addForm.remark" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="例如:部门/用途" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addOpen = false">取消</el-button>
<el-button type="primary" @click="submitAdd">添加</el-button>
</template>
</el-dialog>
<el-dialog v-model="editOpen" title="编辑账号" width="min(560px, 92vw)">
<el-form label-position="top">
<el-form-item label="账号">
<el-input v-model="editForm.username" disabled />
</el-form-item>
<el-form-item label="新密码(可选)">
<el-input
v-model="editForm.password"
type="password"
show-password
placeholder="留空表示不修改密码"
autocomplete="off"
/>
</el-form-item>
<el-form-item label="备注可选最多200字">
<el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="例如:部门/用途" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editOpen = false">取消</el-button>
<el-button type="primary" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="upgradeOpen" title="VIP 特权" width="min(560px, 92vw)">
<el-alert
type="info"
:closable="false"
title="升级 VIP 后可解锁:无限账号、优先排队、定时任务、批量操作。"
show-icon
/>
<div class="vip-body">
<div class="vip-tip app-muted">升级方式请通过反馈联系管理员开通与后台一致</div>
</div>
<template #footer>
<el-button type="primary" @click="upgradeOpen = false">我知道了</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-card,
.panel {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.stat-label {
font-size: 12px;
}
.stat-value {
margin-top: 6px;
font-size: 22px;
font-weight: 900;
letter-spacing: 0.2px;
}
.stat-suffix {
margin-left: 6px;
font-size: 12px;
font-weight: 600;
}
.upgrade-banner {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.upgrade-actions {
margin-top: 10px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.panel-title {
font-size: 16px;
font-weight: 900;
}
.panel-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 10px;
border: 1px dashed rgba(17, 24, 39, 0.14);
border-radius: 12px;
background: rgba(246, 247, 251, 0.6);
}
.toolbar-left,
.toolbar-middle,
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-right {
margin-left: auto;
justify-content: flex-end;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.account-card {
border-radius: 14px;
border: 1px solid var(--app-border);
}
.card-top {
display: flex;
gap: 10px;
}
.card-check {
padding-top: 2px;
}
.card-main {
min-width: 0;
flex: 1;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.card-name {
font-size: 14px;
font-weight: 900;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-sub {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
word-break: break-word;
}
.progress {
margin-top: 12px;
}
.progress-meta {
margin-top: 6px;
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 12px;
}
.card-controls {
margin-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.card-buttons {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.vip-body {
padding: 12px 0 0;
}
.vip-tip {
margin-top: 10px;
font-size: 13px;
line-height: 1.6;
}
@media (max-width: 480px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>