This commit is contained in:
2026-03-21 14:33:11 +08:00
14 changed files with 1746 additions and 89 deletions

View File

@@ -0,0 +1,582 @@
<template>
<div class="certification-reviews-page">
<div class="page-header">
<h1 class="page-title">企业审核</h1>
<p class="page-subtitle">审核用户提交的企业信息通过后可进入企业认证流程</p>
</div>
<div class="toolbar">
<el-select
v-model="filterStatus"
placeholder="认证状态"
clearable
style="width: 140px"
@change="loadList"
>
<el-option label="全部" value="" />
<el-option label="待审核" value="info_pending_review" />
<el-option label="已通过" value="info_submitted" />
<el-option label="已拒绝" value="info_rejected" />
</el-select>
<el-input
v-model="filterCompanyName"
placeholder="企业名称"
clearable
style="width: 180px"
@keyup.enter="loadList"
/>
<el-input
v-model="filterLegalPersonPhone"
placeholder="法人手机号"
clearable
style="width: 140px"
@keyup.enter="loadList"
/>
<el-input
v-model="filterLegalPersonName"
placeholder="法人姓名"
clearable
style="width: 120px"
@keyup.enter="loadList"
/>
<el-button type="primary" @click="loadList">查询</el-button>
</div>
<el-table
v-loading="loading"
:data="list"
stripe
style="width: 100%"
class="reviews-table"
>
<el-table-column prop="submit_at" label="提交时间" width="170">
<template #default="{ row }">
{{ formatDate(row.submit_at) }}
</template>
</el-table-column>
<el-table-column prop="user_id" label="用户ID" width="280" show-overflow-tooltip />
<el-table-column prop="company_name" label="企业名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="unified_social_code" label="统一社会信用代码" width="200" />
<el-table-column prop="legal_person_name" label="法人姓名" width="100" />
<el-table-column prop="certification_status" label="认证状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row)" size="small">
{{ certificationStatusDisplay(row?.certification_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row.id)">查看详情</el-button>
<template v-if="canShowApproveReject(row)">
<el-button link type="success" @click="handleApprove(row)">通过</el-button>
<el-button link type="danger" @click="handleReject(row)">拒绝</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next"
@size-change="loadList"
@current-change="loadList"
/>
</div>
<!-- 详情抽屉 -->
<el-drawer
v-model="drawerVisible"
title="企业信息详情"
size="560"
direction="rtl"
:close-on-click-modal="false"
>
<template #header>
<span class="drawer-title">企业信息详情</span>
<div class="drawer-actions">
<template v-if="detail && canShowApproveReject(detail)">
<el-button type="success" size="small" @click="approveFromDrawer">通过</el-button>
<el-button type="danger" size="small" @click="rejectFromDrawer">拒绝</el-button>
</template>
</div>
</template>
<div v-if="detail" class="detail-content">
<section class="detail-section">
<h4 class="detail-section-title">基本信息</h4>
<dl class="detail-dl">
<dt>企业名称</dt>
<dd>{{ detail.company_name }}</dd>
<dt>统一社会信用代码</dt>
<dd class="detail-mono">{{ detail.unified_social_code }}</dd>
<dt>法人姓名</dt>
<dd>{{ detail.legal_person_name }}</dd>
<dt>法人身份证号</dt>
<dd class="detail-mono">{{ detail.legal_person_id }}</dd>
<dt>法人手机号</dt>
<dd>{{ detail.legal_person_phone }}</dd>
<dt>企业地址</dt>
<dd class="detail-long">{{ detail.enterprise_address || '-' }}</dd>
<dt>提交时间</dt>
<dd>{{ formatDate(detail.submit_at) }}</dd>
<dt>认证状态</dt>
<dd>
<el-tag :type="statusTagType(detail)" size="small">
{{ certificationStatusDisplay(detail?.certification_status) }}
</el-tag>
</dd>
<template v-if="detail.failure_reason">
<dt>失败原因</dt>
<dd class="detail-long detail-error">{{ detail.failure_reason }}</dd>
</template>
</dl>
</section>
<section class="detail-section">
<h4 class="detail-section-title">授权代表</h4>
<dl class="detail-dl">
<dt>姓名</dt>
<dd>{{ (detail.authorized_rep_name ?? detail.authorizedRepName) || '-' }}</dd>
<dt>身份证号</dt>
<dd class="detail-mono">{{ (detail.authorized_rep_id ?? detail.authorizedRepId) || '-' }}</dd>
<dt>手机号</dt>
<dd>{{ (detail.authorized_rep_phone ?? detail.authorizedRepPhone) || '-' }}</dd>
</dl>
</section>
<section class="detail-section">
<h4 class="detail-section-title">应用场景说明</h4>
<div class="detail-long-block">{{ detail.api_usage || '无' }}</div>
</section>
<div class="image-section">
<h4>营业执照</h4>
<div v-if="detail.business_license_image_url" class="image-list">
<a :href="detail.business_license_image_url" target="_blank" rel="noopener" class="image-link">
<img v-if="isImageUrl(detail.business_license_image_url)" :src="detail.business_license_image_url" alt="营业执照" class="thumb" />
<span v-else>查看链接</span>
</a>
</div>
<p v-else class="text-gray-500 text-sm"></p>
</div>
<div class="image-section">
<h4>办公场地照片</h4>
<div v-if="officePlaceUrls.length" class="image-list">
<a v-for="(url, i) in officePlaceUrls" :key="i" :href="url" target="_blank" rel="noopener" class="image-link">
<img v-if="isImageUrl(url)" :src="url" :alt="`场地${i + 1}`" class="thumb" />
<span v-else>链接{{ i + 1 }}</span>
</a>
</div>
<p v-else class="text-gray-500 text-sm"></p>
</div>
<div class="image-section">
<h4>应用场景附件</h4>
<div v-if="scenarioUrls.length" class="image-list">
<a v-for="(url, i) in scenarioUrls" :key="i" :href="url" target="_blank" rel="noopener" class="image-link">
<img v-if="isImageUrl(url)" :src="url" :alt="`场景${i + 1}`" class="thumb" />
<span v-else>链接{{ i + 1 }}</span>
</a>
</div>
<p v-else class="text-gray-500 text-sm"></p>
</div>
<div class="image-section">
<h4>授权代表身份证</h4>
<div v-if="authorizedRepIdUrls.length" class="image-list">
<a v-for="(url, i) in authorizedRepIdUrls" :key="i" :href="url" target="_blank" rel="noopener" class="image-link">
<img v-if="isImageUrl(url)" :src="url" :alt="i === 0 ? '人像面' : '国徽面'" class="thumb" />
<span v-else>{{ i === 0 ? '人像面' : '国徽面' }}</span>
</a>
</div>
<p v-else class="text-gray-500 text-sm"></p>
</div>
</div>
</el-drawer>
<!-- 通过弹窗 -->
<el-dialog v-model="approveDialogVisible" title="审核通过" width="400px">
<el-form label-width="80">
<el-form-item label="审核备注">
<el-input v-model="approveRemark" type="textarea" :rows="3" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="approveDialogVisible = false">取消</el-button>
<el-button type="success" :loading="actionLoading" @click="confirmApprove">确认通过</el-button>
</template>
</el-dialog>
<!-- 拒绝弹窗 -->
<el-dialog v-model="rejectDialogVisible" title="审核拒绝" width="400px">
<el-form label-width="80">
<el-form-item label="拒绝原因" required>
<el-input v-model="rejectRemark" type="textarea" :rows="3" placeholder="必填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="actionLoading" :disabled="!rejectRemark.trim()" @click="confirmReject">确认拒绝</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { certificationApi } from '@/api/index.js'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const filterStatus = ref('')
const filterCompanyName = ref('')
const filterLegalPersonPhone = ref('')
const filterLegalPersonName = ref('')
const drawerVisible = ref(false)
const detail = ref(null)
const approveDialogVisible = ref(false)
const rejectDialogVisible = ref(false)
const approveRemark = ref('')
const rejectRemark = ref('')
const actionLoading = ref(false)
const pendingRecordId = ref('')
const pendingUserId = ref('')
function formatDate(val) {
if (!val) return '-'
try {
const d = new Date(val)
return Number.isNaN(d.getTime()) ? val : d.toLocaleString('zh-CN')
} catch {
return val
}
}
// 以状态机为准:认证状态展示与是否可操作(全流程口径)
const CERTIFICATION_STATUS_LABELS = {
pending: '待认证',
info_pending_review: '待审核',
info_submitted: '已通过',
info_rejected: '已拒绝',
enterprise_verified: '已企业认证',
contract_applied: '已申请合同',
contract_signed: '已签署合同',
contract_rejected: '合同拒签',
contract_expired: '合同超时',
completed: '已完成'
}
function certificationStatusDisplay(certificationStatus) {
if (!certificationStatus) return '-'
return CERTIFICATION_STATUS_LABELS[certificationStatus] || certificationStatus
}
function statusTagType(row) {
const status = row?.certification_status
const m = {
pending: 'info',
info_pending_review: 'warning',
info_submitted: 'success',
info_rejected: 'danger',
enterprise_verified: 'success',
contract_applied: 'info',
contract_signed: 'success',
contract_rejected: 'danger',
contract_expired: 'warning',
completed: 'success'
}
return m[status] || 'info'
}
function canShowApproveReject(row) {
if (!row) return false
return row.certification_status === 'info_pending_review'
}
// 判断是否可作为图片展示(含七牛云等无扩展名的 CDN URL
function isImageUrl(url) {
if (!url || typeof url !== 'string') return false
if (url.startsWith('blob:') || url.startsWith('data:')) return true
if (url.startsWith('https://file.tianyuanapi.com')) return true
return /\.(jpe?g|png|webp|gif)(\?|$)/i.test(url)
}
const officePlaceUrls = computed(() => {
if (!detail.value?.office_place_image_urls) return []
try {
const v = detail.value.office_place_image_urls
return typeof v === 'string' ? JSON.parse(v) : v
} catch {
return []
}
})
const scenarioUrls = computed(() => {
if (!detail.value?.scenario_attachment_urls) return []
try {
const v = detail.value.scenario_attachment_urls
return typeof v === 'string' ? JSON.parse(v) : v
} catch {
return []
}
})
const authorizedRepIdUrls = computed(() => {
if (!detail.value?.authorized_rep_id_image_urls) return []
try {
const v = detail.value.authorized_rep_id_image_urls
return typeof v === 'string' ? JSON.parse(v) : v
} catch {
return []
}
})
async function loadList() {
loading.value = true
try {
const res = await certificationApi.adminListSubmitRecords({
page: page.value,
page_size: pageSize.value,
certification_status: filterStatus.value || undefined,
company_name: filterCompanyName.value || undefined,
legal_person_phone: filterLegalPersonPhone.value || undefined,
legal_person_name: filterLegalPersonName.value || undefined
})
const data = res?.data
list.value = data?.items ?? []
total.value = data?.total ?? 0
} catch (e) {
ElMessage.error(e?.message || '加载列表失败')
} finally {
loading.value = false
}
}
async function openDetail(id) {
try {
const res = await certificationApi.adminGetSubmitRecord(id)
detail.value = res?.data ?? res
drawerVisible.value = true
} catch (e) {
ElMessage.error(e?.message || '加载详情失败')
}
}
function handleApprove(row) {
pendingRecordId.value = row.id
pendingUserId.value = row.user_id
approveRemark.value = ''
approveDialogVisible.value = true
}
function approveFromDrawer() {
if (!detail.value?.id) return
pendingRecordId.value = detail.value.id
pendingUserId.value = detail.value.user_id
approveRemark.value = ''
approveDialogVisible.value = true
}
async function confirmApprove() {
if (!pendingUserId.value) return
actionLoading.value = true
try {
await certificationApi.adminTransitionCertificationStatus({
user_id: pendingUserId.value,
target_status: 'info_submitted',
remark: approveRemark.value || ''
})
ElMessage.success('已通过')
approveDialogVisible.value = false
drawerVisible.value = false
detail.value = null
pendingRecordId.value = ''
pendingUserId.value = ''
loadList()
} catch (e) {
ElMessage.error(e?.message || '操作失败')
} finally {
actionLoading.value = false
}
}
function handleReject(row) {
pendingRecordId.value = row.id
pendingUserId.value = row.user_id
rejectRemark.value = ''
rejectDialogVisible.value = true
}
function rejectFromDrawer() {
if (!detail.value?.id) return
pendingRecordId.value = detail.value.id
pendingUserId.value = detail.value.user_id
rejectRemark.value = ''
rejectDialogVisible.value = true
}
async function confirmReject() {
if (!pendingUserId.value || !rejectRemark.value?.trim()) return
actionLoading.value = true
try {
await certificationApi.adminTransitionCertificationStatus({
user_id: pendingUserId.value,
target_status: 'info_rejected',
remark: rejectRemark.value.trim()
})
ElMessage.success('已拒绝')
rejectDialogVisible.value = false
drawerVisible.value = false
detail.value = null
pendingRecordId.value = ''
pendingUserId.value = ''
loadList()
} catch (e) {
ElMessage.error(e?.message || '操作失败')
} finally {
actionLoading.value = false
}
}
onMounted(() => {
loadList()
})
</script>
<style scoped>
.certification-reviews-page {
padding: 24px;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #1e293b;
margin: 0 0 4px;
}
.page-subtitle {
font-size: 14px;
color: #64748b;
margin: 0;
}
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.pagination-wrap {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.drawer-title {
margin-right: 12px;
}
.drawer-actions {
display: flex;
gap: 8px;
align-items: center;
}
.detail-content {
padding-right: 12px;
max-height: calc(100vh - 60px);
overflow-y: auto;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section-title {
font-size: 13px;
font-weight: 600;
color: #475569;
margin: 0 0 10px;
padding-bottom: 6px;
border-bottom: 1px solid #e2e8f0;
}
.detail-dl {
display: grid;
grid-template-columns: 110px 1fr;
gap: 8px 16px;
margin: 0;
font-size: 13px;
}
.detail-dl dt {
margin: 0;
color: #64748b;
font-weight: 500;
line-height: 1.5;
}
.detail-dl dd {
margin: 0;
color: #1e293b;
line-height: 1.5;
word-break: break-word;
}
.detail-mono {
font-family: ui-monospace, monospace;
font-size: 12px;
}
.detail-long {
white-space: pre-wrap;
word-break: break-word;
}
.detail-long-block {
font-size: 13px;
line-height: 1.6;
color: #334155;
white-space: pre-wrap;
word-break: break-word;
padding: 12px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
max-height: 160px;
overflow-y: auto;
}
.detail-error {
color: #dc2626;
}
.image-section {
margin-top: 20px;
}
.image-section h4 {
font-size: 13px;
font-weight: 600;
color: #475569;
margin: 0 0 8px;
padding-bottom: 4px;
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.image-link {
display: block;
width: 100px;
height: 100px;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
text-align: center;
line-height: 100px;
font-size: 12px;
color: #64748b;
}
.image-link .thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View File

@@ -1,5 +1,17 @@
<template>
<div class="list-page-card">
<div class="flex items-center justify-between mb-4">
<div class="text-sm text-gray-500">统计视图切换</div>
<el-segmented
v-model="viewMode"
:options="[
{ label: '统计仪表盘', value: 'dashboard' },
{ label: '请求流可视化', value: 'request-globe' }
]"
/>
</div>
<RequestFlowGlobe v-if="viewMode === 'request-globe'" />
<template v-else>
<!-- 加载状态 -->
<div v-if="loading" class="text-center py-8">
<el-icon size="32" class="animate-spin">
@@ -466,6 +478,7 @@
</div>
</div>
</template>
</div>
</template>
@@ -480,6 +493,7 @@ import {
adminGetUserCallRanking,
adminGetUserDomainStatistics
} from '@/api/statistics'
import RequestFlowGlobe from '@/pages/admin/statistics/components/RequestFlowGlobe.vue'
import DanmakuBar from '@/components/common/DanmakuBar.vue'
import { Check, Loading, Money, Refresh, TrendCharts, User } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
@@ -488,6 +502,7 @@ import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const viewMode = ref('dashboard')
// 响应式数据
const loading = ref(false)
const error = ref('')

View File

@@ -0,0 +1,337 @@
<template>
<div class="request-flow-page">
<div class="toolbar">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
class="toolbar-item"
/>
<el-input-number v-model="topN" :min="10" :max="1000" :step="10" class="toolbar-item" />
<el-button type="primary" :loading="loading" @click="loadData">刷新请求流</el-button>
<el-button v-if="selectedIP || selectedPath" @click="clearFilter">清空筛选</el-button>
</div>
<div class="content">
<div ref="chartRef" class="chart"></div>
<div class="side-list">
<h4>TOP 可疑来源</h4>
<div v-if="rows.length === 0" class="empty">暂无数据</div>
<div
v-for="(item, index) in rows.slice(0, 20)"
:key="`${item.ip}-${item.path}-${index}`"
class="row clickable"
@click="selectFlow(item)"
>
<div class="name">{{ item.from_name }} -> {{ item.path }}</div>
<div class="value">{{ item.value }}</div>
</div>
</div>
</div>
<div class="table-card">
<div class="table-title">
可疑IP明细
<span v-if="selectedIP || selectedPath" class="hint">
筛选{{ selectedIP || '-' }} / {{ selectedPath || '-' }}
</span>
</div>
<el-table :data="listRows" border size="small" height="360">
<el-table-column prop="ip" label="IP" width="150" />
<el-table-column prop="path" label="接口路径" min-width="240" />
<el-table-column prop="method" label="方法" width="90" />
<el-table-column prop="request_count" label="次数" width="90" />
<el-table-column prop="trigger_reason" label="触发原因" width="160" />
<el-table-column prop="created_at" label="时间" width="180" />
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="loadList"
@size-change="handlePageSizeChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import { adminGetSuspiciousIPGeoStream, adminGetSuspiciousIPList } from '@/api/statistics'
import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
const loading = ref(false)
const topN = ref(200)
const dateRange = ref([])
const rows = ref([])
const listRows = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const selectedIP = ref('')
const selectedPath = ref('')
const chartRef = ref(null)
let chart = null
const getDefaultRange = () => {
const now = new Date()
const start = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const format = (d) => {
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
const h = `${d.getHours()}`.padStart(2, '0')
const mi = `${d.getMinutes()}`.padStart(2, '0')
const s = `${d.getSeconds()}`.padStart(2, '0')
return `${y}-${m}-${day} ${h}:${mi}:${s}`
}
return [format(start), format(now)]
}
const renderChart = () => {
if (!chartRef.value) return
if (!chart) chart = echarts.init(chartRef.value)
const lineData = rows.value.map(item => ({
coords: [
[item.from_lng, item.from_lat],
[item.to_lng, item.to_lat]
],
value: item.value
}))
const pointData = rows.value.map(item => ({
name: item.from_name,
value: [item.from_lng, item.from_lat, item.value]
}))
chart.setOption({
backgroundColor: '#040b1b',
tooltip: {
trigger: 'item'
},
xAxis: {
type: 'value',
min: -180,
max: 180,
axisLabel: { color: '#6b7280' },
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } }
},
yAxis: {
type: 'value',
min: -90,
max: 90,
axisLabel: { color: '#6b7280' },
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } }
},
series: [
{
name: '请求流',
type: 'lines',
coordinateSystem: 'cartesian2d',
zlevel: 2,
effect: {
show: true,
period: 4,
symbol: 'arrow',
symbolSize: 6
},
lineStyle: {
width: 1,
color: '#4cc9f0',
curveness: 0.2
},
data: lineData
},
{
name: '来源点',
type: 'scatter',
coordinateSystem: 'cartesian2d',
symbolSize: (val) => Math.max(6, Math.min(18, (val[2] || 1) / 2)),
itemStyle: {
color: '#f72585'
},
data: pointData
}
]
})
chart.off('click')
chart.on('click', (params) => {
if (params.seriesName === '来源点' && params.dataIndex >= 0) {
const item = rows.value[params.dataIndex]
if (item) selectFlow(item)
}
})
}
const selectFlow = (item) => {
selectedIP.value = item.ip || ''
selectedPath.value = item.path || ''
page.value = 1
loadList()
}
const clearFilter = () => {
selectedIP.value = ''
selectedPath.value = ''
page.value = 1
loadList()
}
const loadList = async () => {
try {
if (!dateRange.value || dateRange.value.length !== 2) {
dateRange.value = getDefaultRange()
}
const res = await adminGetSuspiciousIPList({
page: page.value,
page_size: pageSize.value,
start_time: dateRange.value[0],
end_time: dateRange.value[1],
ip: selectedIP.value || undefined,
path: selectedPath.value || undefined
})
listRows.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (error) {
console.error('加载可疑IP明细失败:', error)
ElMessage.error('加载可疑IP明细失败')
}
}
const handlePageSizeChange = (size) => {
pageSize.value = size
page.value = 1
loadList()
}
const loadData = async () => {
loading.value = true
try {
if (!dateRange.value || dateRange.value.length !== 2) {
dateRange.value = getDefaultRange()
}
const res = await adminGetSuspiciousIPGeoStream({
start_time: dateRange.value[0],
end_time: dateRange.value[1],
top_n: topN.value
})
rows.value = res.data || []
await nextTick()
renderChart()
loadList()
} catch (error) {
console.error('加载请求流失败:', error)
ElMessage.error('加载请求流失败')
} finally {
loading.value = false
}
}
onMounted(() => {
dateRange.value = getDefaultRange()
loadData()
})
onUnmounted(() => {
if (chart) {
chart.dispose()
chart = null
}
})
</script>
<style scoped>
.request-flow-page {
width: 100%;
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.toolbar-item {
min-width: 220px;
}
.content {
display: grid;
grid-template-columns: 1fr 320px;
gap: 12px;
}
.chart {
height: 620px;
border-radius: 10px;
overflow: hidden;
}
.side-list {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 12px;
max-height: 620px;
overflow-y: auto;
}
.row {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
padding: 8px 0;
border-bottom: 1px solid #f3f4f6;
}
.clickable {
cursor: pointer;
}
.clickable:hover {
background: #f9fafb;
}
.name {
color: #374151;
word-break: break-all;
}
.value {
color: #111827;
font-weight: 600;
}
.empty {
color: #9ca3af;
}
.table-card {
margin-top: 12px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 12px;
}
.table-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.hint {
margin-left: 6px;
font-size: 12px;
color: #6b7280;
}
.pager {
margin-top: 10px;
display: flex;
justify-content: flex-end;
}
@media (max-width: 1200px) {
.content {
grid-template-columns: 1fr;
}
}
</style>