初始版本
This commit is contained in:
656
app/pages/admin/apps.vue
Normal file
656
app/pages/admin/apps.vue
Normal file
@@ -0,0 +1,656 @@
|
||||
<template>
|
||||
<div class="apps-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">📦 应用管理</h2>
|
||||
<p class="page-desc">管理平台所有应用,支持查看、编辑和状态管理</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button @click="loadApps" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="[16, 16]" class="mb-6">
|
||||
<a-col :xs="12" :md="6" v-for="stat in stats" :key="stat.key">
|
||||
<div
|
||||
class="stat-card"
|
||||
:class="[stat.color, { active: filterStatus === stat.key }]"
|
||||
@click="handleStatFilter(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 wrap>
|
||||
<a-select v-model:value="filterStatus" style="width: 130px" @change="handleSearch">
|
||||
<a-select-option value="">全部状态</a-select-option>
|
||||
<a-select-option :value="0">未开通</a-select-option>
|
||||
<a-select-option :value="1">运行中</a-select-option>
|
||||
<a-select-option :value="2">维护中</a-select-option>
|
||||
<a-select-option :value="3">已关闭</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="filterType" style="width: 140px" allow-clear placeholder="全部类型" @change="handleSearch">
|
||||
<a-select-option v-for="(name, key) in APP_TYPE_NAME" :key="key" :value="Number(key)">
|
||||
{{ name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model:value="filterTenantId"
|
||||
style="width: 180px"
|
||||
allow-clear
|
||||
placeholder="筛选租户"
|
||||
:loading="loadingTenants"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option v-for="t in tenantList" :key="t.tenantId" :value="t.tenantId">
|
||||
{{ t.tenantName }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-input-search
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索应用名称/标识"
|
||||
style="width: 210px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="apps"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="productId"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 应用信息 -->
|
||||
<template v-if="column.key === 'appInfo'">
|
||||
<div class="app-info-cell">
|
||||
<img v-if="record.icon" :src="record.icon" class="app-icon" />
|
||||
<div v-else class="app-icon-placeholder" :style="{ background: iconBgColor(record.productName) }">
|
||||
{{ (record.productName || 'A').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="app-info-text">
|
||||
<div class="app-name">{{ record.productName }}</div>
|
||||
<div class="app-code">{{ record.productCode }}</div>
|
||||
<div class="app-developer" v-if="record.developer || record.username">
|
||||
<UserOutlined style="font-size:11px;margin-right:3px" />{{ record.developer || record.username }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 类型 -->
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag color="blue">{{ APP_TYPE_NAME[record.appType ?? 10] || '未知' }}</a-tag>
|
||||
<a-tag v-if="record.official === 1" color="gold" style="margin-left:4px">官方</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 所属租户 -->
|
||||
<template v-if="column.key === 'tenantName'">
|
||||
<span class="tenant-name-cell" v-if="record.tenantId">
|
||||
{{ getTenantName(record.tenantId) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge :status="statusBadge(record.status)" :text="statusText(record.status)" />
|
||||
</template>
|
||||
|
||||
<!-- 发布状态 -->
|
||||
<template v-if="column.key === 'publishStatus'">
|
||||
<a-tag v-if="record.publishStatus" :color="pubStatusColor(record.publishStatus)">
|
||||
{{ pubStatusText(record.publishStatus) }}
|
||||
</a-tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 域名 -->
|
||||
<template v-if="column.key === 'domain'">
|
||||
<a v-if="record.domain" :href="'https://' + record.domain" target="_blank" class="domain-link">
|
||||
{{ record.domain }}
|
||||
</a>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 创建时间 -->
|
||||
<template v-if="column.key === 'createTime'">
|
||||
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">详情</a-button>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="link" size="small">更多 <DownOutlined /></a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => handleMoreAction(key as string, record)">
|
||||
<a-menu-item key="toggle-status">
|
||||
{{ record.status === 1 ? '🔒 暂停运行' : '▶️ 恢复运行' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="toggle-official">
|
||||
{{ record.official === 1 ? '取消官方' : '设为官方' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="toggle-market">
|
||||
{{ record.market === 1 ? '下架市场' : '上架市场' }}
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="delete" class="danger-item">🗑️ 删除应用</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showDetailModal"
|
||||
:title="`应用详情:${currentApp?.productName || ''}`"
|
||||
width="720px"
|
||||
:footer="null"
|
||||
>
|
||||
<template v-if="currentApp">
|
||||
<a-descriptions :column="2" bordered size="small">
|
||||
<a-descriptions-item label="应用名称">{{ currentApp.productName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="应用标识">{{ currentApp.productCode }}</a-descriptions-item>
|
||||
<a-descriptions-item label="开发者">{{ currentApp.developer || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="应用类型">
|
||||
<a-tag color="blue">{{ APP_TYPE_NAME[currentApp.appType ?? 10] || '未知' }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="运行状态">
|
||||
<a-badge :status="statusBadge(currentApp.status)" :text="statusText(currentApp.status)" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="发布状态">
|
||||
<a-tag v-if="currentApp.publishStatus" :color="pubStatusColor(currentApp.publishStatus)">
|
||||
{{ pubStatusText(currentApp.publishStatus) }}
|
||||
</a-tag>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="绑定域名" :span="2">
|
||||
<a v-if="currentApp.domain" :href="'https://' + currentApp.domain" target="_blank">{{ currentApp.domain }}</a>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="ICP备案">{{ currentApp.icpNo || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="安装次数">{{ currentApp.installs ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="评分">{{ currentApp.rating ? currentApp.rating + ' ⭐' : '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="到期时间">{{ currentApp.expirationTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间" :span="2">{{ currentApp.createTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="currentApp.description" label="应用简介" :span="2">
|
||||
{{ currentApp.description }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 应用入口信息 -->
|
||||
<div v-if="currentAppEntries.length > 0" class="entry-section">
|
||||
<h4 class="entry-title">🚀 应用入口</h4>
|
||||
<div class="entry-list">
|
||||
<div
|
||||
v-for="entry in currentAppEntries"
|
||||
:key="entry.type"
|
||||
class="entry-item"
|
||||
:class="{ disabled: !entry.available }"
|
||||
>
|
||||
<div class="entry-info">
|
||||
<component :is="entryIcon(entry.icon)" style="font-size: 18px; margin-right: 8px;" />
|
||||
<div>
|
||||
<div class="entry-label">{{ entry.label }}</div>
|
||||
<div class="entry-url" v-if="entry.url">{{ entry.type === 'scan-qr' ? '已配置小程序码' : entry.url }}</div>
|
||||
<div class="entry-url text-gray-400" v-else>未配置</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="entry.available"
|
||||
type="primary"
|
||||
size="small"
|
||||
ghost
|
||||
@click="handleEntryClick(entry)"
|
||||
>
|
||||
{{ entry.type === 'visit-site' ? '访问' : entry.type === 'download' ? '下载' : entry.type === 'scan-qr' ? '扫码' : '进入' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 扫码弹窗 -->
|
||||
<QrCodeModal
|
||||
v-model:open="showQrModal"
|
||||
:title="qrModalTitle"
|
||||
:tip="qrModalTip"
|
||||
:image-url="qrModalUrl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ReloadOutlined, UserOutlined, DownOutlined, GlobalOutlined, QrcodeOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { pageAppProduct, updateAppProduct, removeAppProduct } from '@/api/app/appProduct'
|
||||
import type { AppProduct } from '@/api/app/appProduct/model'
|
||||
import { APP_TYPE, APP_TYPE_NAME } from '@/api/app/appProduct/model'
|
||||
import { getAppEntries, executeEntry, getScanTip } from '@/utils/appEntry'
|
||||
import type { AppEntry } from '@/utils/appEntry'
|
||||
import QrCodeModal from '@/components/QrCodeModal.vue'
|
||||
import { listTenant } from '@/api/system/tenant/index'
|
||||
import type { Tenant } from '@/api/system/tenant/model'
|
||||
|
||||
definePageMeta({ layout: 'admin' })
|
||||
useHead({ title: '应用管理 - 平台管理' })
|
||||
|
||||
const loading = ref(false)
|
||||
const apps = ref<AppProduct[]>([])
|
||||
const filterStatus = ref<number | ''>('')
|
||||
const filterType = ref<number | ''>('')
|
||||
const filterTenantId = ref<number | ''>('')
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 租户列表
|
||||
const tenantList = ref<Tenant[]>([])
|
||||
const loadingTenants = ref(false)
|
||||
|
||||
async function loadTenantList() {
|
||||
loadingTenants.value = true
|
||||
try {
|
||||
const res = await listTenant()
|
||||
tenantList.value = res || []
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loadingTenants.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getTenantName(tenantId?: number) {
|
||||
if (!tenantId) return '-'
|
||||
const tenant = tenantList.value.find(t => t.tenantId === tenantId)
|
||||
return tenant?.tenantName || `租户${tenantId}`
|
||||
}
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
const stats = reactive([
|
||||
{ key: 1, icon: '✅', label: '运行中', value: 0, color: 'green' },
|
||||
{ key: 2, icon: '🔧', label: '维护中', value: 0, color: 'orange' },
|
||||
{ key: 3, icon: '⛔', label: '已关闭', value: 0, color: 'red' },
|
||||
{ key: '', icon: '📦', label: '全部应用', value: 0, color: 'blue' },
|
||||
])
|
||||
|
||||
const columns = [
|
||||
{ title: '应用信息', key: 'appInfo', width: 250 },
|
||||
{ title: '所属租户', key: 'tenantName', width: 150 },
|
||||
{ title: '类型', key: 'type', width: 120 },
|
||||
{ title: '运行状态', key: 'status', width: 110 },
|
||||
{ title: '发布状态', key: 'publishStatus', width: 110 },
|
||||
{ title: '绑定域名', key: 'domain', width: 180 },
|
||||
{ title: '创建时间', key: 'createTime', width: 110 },
|
||||
{ title: '操作', key: 'action', width: 160 },
|
||||
]
|
||||
|
||||
const showDetailModal = ref(false)
|
||||
const currentApp = ref<AppProduct | null>(null)
|
||||
|
||||
async function loadApps() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageAppProduct({
|
||||
current: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
status: filterStatus.value !== '' ? (filterStatus.value as number) : undefined,
|
||||
appType: filterType.value !== '' ? (filterType.value as number) : undefined,
|
||||
keywords: searchKeyword.value || undefined,
|
||||
tenantId: filterTenantId.value !== '' ? (filterTenantId.value as number) : undefined,
|
||||
})
|
||||
apps.value = res?.list || []
|
||||
pagination.total = res?.count || 0
|
||||
updateStats()
|
||||
} catch {
|
||||
message.error('加载应用列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStats() {
|
||||
try {
|
||||
const [runningRes, maintenanceRes, closedRes, allRes] = await Promise.allSettled([
|
||||
pageAppProduct({ current: 1, size: 1, status: 1 }),
|
||||
pageAppProduct({ current: 1, size: 1, status: 2 }),
|
||||
pageAppProduct({ current: 1, size: 1, status: 3 }),
|
||||
pageAppProduct({ current: 1, size: 1 }),
|
||||
])
|
||||
if (runningRes.status === 'fulfilled') stats[0].value = runningRes.value?.count || 0
|
||||
if (maintenanceRes.status === 'fulfilled') stats[1].value = maintenanceRes.value?.count || 0
|
||||
if (closedRes.status === 'fulfilled') stats[2].value = closedRes.value?.count || 0
|
||||
if (allRes.status === 'fulfilled') stats[3].value = allRes.value?.count || 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleStatFilter(key: number | '') {
|
||||
filterStatus.value = key
|
||||
pagination.current = 1
|
||||
loadApps()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadApps()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadApps()
|
||||
}
|
||||
|
||||
function handleView(record: AppProduct) {
|
||||
currentApp.value = record
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
async function handleMoreAction(key: string, record: AppProduct) {
|
||||
if (key === 'toggle-status') {
|
||||
const newStatus = record.status === 1 ? 3 : 1
|
||||
try {
|
||||
await updateAppProduct({ productId: record.productId, status: newStatus })
|
||||
message.success('状态已更新')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '操作失败') }
|
||||
}
|
||||
if (key === 'toggle-official') {
|
||||
try {
|
||||
await updateAppProduct({ productId: record.productId, official: !record.official })
|
||||
message.success(record.official ? '已取消官方标记' : '已设为官方应用')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '操作失败') }
|
||||
}
|
||||
if (key === 'toggle-market') {
|
||||
try {
|
||||
await updateAppProduct({ productId: record.productId, market: !record.market })
|
||||
message.success(record.market ? '已从市场下架' : '已上架至应用市场')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '操作失败') }
|
||||
}
|
||||
if (key === 'delete') {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除应用「${record.productName}」吗?此操作不可恢复!`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await removeAppProduct(record.productId)
|
||||
message.success('应用已删除')
|
||||
loadApps()
|
||||
} catch (e: any) { message.error(e?.message || '删除失败') }
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(status?: number) {
|
||||
const map: Record<number, string> = { 0: '未开通', 1: '运行中', 2: '维护中', 3: '已关闭', 4: '已欠费', 5: '违规停止' }
|
||||
return map[status ?? -1] || '未知'
|
||||
}
|
||||
|
||||
function statusBadge(status?: number): 'success' | 'warning' | 'error' | 'default' {
|
||||
if (status === 1) return 'success'
|
||||
if (status === 2) return 'warning'
|
||||
if (status === 3 || status === 5) return 'error'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function pubStatusColor(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
developing: 'default',
|
||||
pending_review: 'orange',
|
||||
published: 'success',
|
||||
rejected: 'error',
|
||||
deprecated: 'default',
|
||||
}
|
||||
return map[status || ''] || 'default'
|
||||
}
|
||||
|
||||
function pubStatusText(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
developing: '开发中',
|
||||
pending_review: '待审核',
|
||||
published: '已上架',
|
||||
rejected: '已拒绝',
|
||||
deprecated: '已下架',
|
||||
}
|
||||
return map[status || ''] || '-'
|
||||
}
|
||||
|
||||
const PALETTE = ['#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f', '#e9c46a', '#457b9d']
|
||||
function iconBgColor(name?: string) {
|
||||
if (!name) return PALETTE[0]
|
||||
let h = 0
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffffffff
|
||||
return PALETTE[Math.abs(h) % PALETTE.length]
|
||||
}
|
||||
|
||||
const showQrModal = ref(false)
|
||||
const qrModalTitle = ref('')
|
||||
const qrModalTip = ref('')
|
||||
const qrModalUrl = ref('')
|
||||
|
||||
const currentAppEntries = computed(() => {
|
||||
if (!currentApp.value) return []
|
||||
return getAppEntries(currentApp.value)
|
||||
})
|
||||
|
||||
/** 入口图标组件映射 */
|
||||
function entryIcon(iconName: string) {
|
||||
const map: Record<string, any> = {
|
||||
GlobalOutlined,
|
||||
QrcodeOutlined,
|
||||
DownloadOutlined,
|
||||
SettingOutlined,
|
||||
}
|
||||
return map[iconName] || GlobalOutlined
|
||||
}
|
||||
|
||||
function handleEntryClick(entry: AppEntry) {
|
||||
const needQr = executeEntry(entry)
|
||||
if (needQr && entry.type === 'scan-qr' && currentApp.value) {
|
||||
qrModalTitle.value = currentApp.value.productName || '小程序码'
|
||||
qrModalTip.value = getScanTip(currentApp.value.appType || 20)
|
||||
qrModalUrl.value = entry.url || ''
|
||||
showQrModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTenantList()
|
||||
loadApps()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.apps-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;
|
||||
}
|
||||
|
||||
.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 { 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;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
|
||||
|
||||
.app-info-cell { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.app-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 8px; object-fit: cover; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-icon-placeholder {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; font-weight: 600; color: #fff; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-info-text { flex: 1; min-width: 0; }
|
||||
.app-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
|
||||
.app-code { font-size: 12px; color: rgba(0,0,0,0.45); }
|
||||
.app-developer { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 2px; }
|
||||
|
||||
.tenant-name-cell {
|
||||
font-size: 13px;
|
||||
color: rgba(0,0,0,0.75);
|
||||
}
|
||||
|
||||
.domain-link { font-size: 13px; color: #4f46e5; text-decoration: none; }
|
||||
.domain-link:hover { text-decoration: underline; }
|
||||
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-gray { color: rgba(0,0,0,0.45); }
|
||||
.text-gray-400 { color: #9ca3af; }
|
||||
|
||||
.danger-item { color: #ff4d4f !important; }
|
||||
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
|
||||
.entry-section {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0,0,0,0.85);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.entry-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.entry-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.entry-item.disabled { opacity: 0.5; }
|
||||
|
||||
.entry-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(0,0,0,0.85);
|
||||
}
|
||||
|
||||
.entry-url {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.45);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 400px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user