chore: 并入 iotlcm-onelink-mock 历史到 onelink-mock/(原提交 f398595)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:02 +08:00
64 changed files with 10585 additions and 0 deletions
+548
View File
@@ -0,0 +1,548 @@
import { createApp, computed, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js'
import { ArrowLeft, BadgePlus, ClipboardList, Copy, CreditCard, Database, Download, Eye, FolderPlus, KeyRound, Plus, RefreshCw, Save, Search, Server, Trash2, X } from 'lucide-vue-next'
import { onBeforeUnmount } from 'vue'
import './style.css'
const appBase = normalizeBase(import.meta.env.BASE_URL)
const statusText = {
'1': '待激活',
'2': '已激活',
'4': '停机',
'6': '可测试',
'7': '库存',
'8': '预销户'
}
const detailLabels = {
msisdn: 'MSISDN', iccid: 'ICCID', imsi: 'IMSI', imei: 'IMEI', remark: '备注',
cardStatus: '卡状态', lastChangeDate: '最后变更时间', stopReason: '停机原因码',
frozenStatus: '管理冻结', frozenReason: '冻结原因', frozenOperTime: '冻结操作时间',
openDate: '开卡时间', activeDate: '首次激活时间', onOffStatus: '终端开关机',
sessionStatus: '会话状态', sessionCreateDate: '会话建立时间', ip: 'IPv4', ipv6Prefix: 'IPv6 前缀',
ipv6: 'IPv6 接口 ID', rat: '接入制式', cardBindStatus: '机卡绑定', regionLimitStatus: '区域限制',
areaRestrictType: '区域限制方式', restrictedArea: '限制区域', monthUsageKB: '当月流量 KB',
dailyBaseKB: '日基础流量 KB', monthlyBaseKB: '月基础流量 KB', lastUsageUpdateTime: '流量更新时间'
}
function detailLabel(key) { return detailLabels[key] || key }
function cardStatusLabel(value) { return statusText[value] || value || '-' }
function boolLabel(value) { return value === '1' || value === 1 || value === true ? '是' : '否' }
function apnDiagnosisStatusLabel(value) { return ({ '1': '未激活', '2': '激活', '4': '暂停' })[value] || value || '-' }
function apnServiceUsageLabel(value) { return value === '1' || !value ? '正常速率' : `限速档位 ${value}` }
const endpointGroups = [
{
key: 'mobile-core', label: '移动基础接口', items: [
['ec/query/sim-basic-info', '单卡基础信息'], ['ec/query/sim-session', '单卡会话信息'], ['ec/query/sim-imei', '查询 IMEI'],
['ec/query/sim-data-usage-inpool', '查询流量池用量'], ['ec/query/sim-data-usage', '查询流量用量'],
['ec/query/sim-data-usage-monthly/batch', '批量查询月流量'], ['ec/query/sim-data-usage-daily/batch', '批量查询日流量'],
['ec/query/apn-info', '查询 APN'], ['ec/change/sim-status', '变更 SIM 状态'], ['ec/operate/sim-apn-function', '操作 APN']
]
},
{
key: 'mobile-diagnosis', label: '移动智能诊断', items: [
['ec/query/sim-status', 'SIM 卡状态'], ['ec/query/sim-stop-reason', '停机原因'], ['ec/query/sim-frozen-state-info', '管理冻结状态'],
['ec/query/sim-apn-session-info', 'APN 在线会话'], ['ec/query/sim-activated-features-info', '通信功能状态'],
['ec/query/sim-operating-status', '开关机状态'], ['ec/query/sim-data-diagnosis', '达量限速档位'],
['ec/query/region-limit-status', '区域限制状态'], ['ec/query/card-bind-status', '机卡绑定状态'], ['ec/query/on-off-status', '设备开关机状态']
]
},
{
key: 'chan', label: 'Chan 统一接口', items: [
['sim/query/sim-basic-info', '单卡基础信息'], ['sim/query/sim-session', '单卡会话信息'], ['sim/query/sim-status', 'SIM 卡状态'],
['sim/query/sim-status-batch', '批量 SIM 卡状态'], ['sim/query/sim-card-bind-status', '机卡绑定状态'], ['sim/query/sim-device-status', '设备状态'],
['sim/query/sim-region-limit-status', '区域限制状态'], ['sim/query/sim-imei', '查询 IMEI'], ['sim/query/sim-datausage', '查询流量'],
['sim/query/sim-datausage-monthly', '查询月流量'], ['sim/query/sim-datausage-monthly-batch', '批量查询月流量'],
['sim/query/sim-datausage-daily', '查询日流量'], ['sim/query/sim-datausage-daily-batch', '批量查询日流量'],
['sim/query/sim-apn-info', '查询 APN'], ['sim/change/sim-status', '变更 SIM 状态'], ['sim/change/sim-apn', '变更 APN']
]
}
]
const endpointCatalog = endpointGroups.flatMap(group => group.items.map(([path, label]) => ({ path, label, group: group.key })))
const App = {
components: { ArrowLeft, BadgePlus, ClipboardList, Copy, CreditCard, Database, Download, Eye, FolderPlus, KeyRound, Plus, RefreshCw, Save, Search, Server, Trash2, X },
setup() {
const state = reactive({ appids: [], groups: [], cards: [], orders: [], settings: {} })
const message = ref('')
const appForm = reactive({ appid: '', password: '', name: '' })
const cardForm = reactive(emptyCard())
const selectedCategory = ref('mobile-core')
const selectedEndpoint = ref('ec/query/sim-basic-info')
const generateCount = ref(100)
const selectedGroupId = ref(0)
const groupForm = reactive({ name: '' })
const isGroupEditorOpen = ref(false)
const groupSaving = ref(false)
const selectedCards = reactive(new Set())
const cardKeyword = ref('')
const cardGroupFilter = ref('')
const cardExportStatusFilter = ref('')
const cardPage = reactive({ page: 1, pageSize: 20, total: 0 })
const orderPage = reactive({ page: 1, pageSize: 20, total: 0 })
const cardJumpPage = ref(1)
const orderJumpPage = ref(1)
const cardTotalPages = computed(() => Math.max(1, Math.ceil(cardPage.total / cardPage.pageSize)))
const orderTotalPages = computed(() => Math.max(1, Math.ceil(orderPage.total / orderPage.pageSize)))
const selectedCardDetail = ref(null)
const selectedOrderDetail = ref(null)
const isCardEditorOpen = ref(false)
const cardEditorMode = ref('create')
const cardSaving = ref(false)
const currentView = ref(viewFromPath())
const operationLogs = ref([])
const operationLogPage = reactive({ page: 1, pageSize: 20, total: 0 })
const operationLogKeyword = ref('')
const operationLogCategory = ref('')
const operationLogResult = ref('')
const selectedOperationLog = ref(null)
const operationLogTotalPages = computed(() => Math.max(1, Math.ceil(operationLogPage.total / operationLogPage.pageSize)))
const visibleEndpoints = computed(() => endpointCatalog.filter(item => item.group === selectedCategory.value))
const currentEndpoint = computed(() => endpointCatalog.find(item => item.path === selectedEndpoint.value) || endpointCatalog[0])
const isChanEndpoint = computed(() => selectedEndpoint.value.startsWith('sim/'))
const endpointMethod = computed(() => isChanEndpoint.value ? 'POST' : 'GET')
const baseUrl = computed(() => `${location.origin}${appBase}/v5/`)
const sampleCard = computed(() => state.cards[0] || { msisdn: '1440292201313' })
const selectedCount = computed(() => selectedCards.size)
const allCardsSelected = computed(() => state.cards.length > 0 && state.cards.every(card => selectedCards.has(card.msisdn)))
const firstTokenUrl = computed(() => {
const app = state.appids[0] || { appid: 'demo_appid', password: 'demo_password' }
return `${baseUrl.value}ec/get/token?appid=${app.appid}&password=${app.password}&transid=${transid(app.appid)}`
})
const endpointUrl = computed(() => {
if (isChanEndpoint.value) {
return `${location.origin}${appBase}/${selectedEndpoint.value}`
}
const token = state.appids[0]?.lastToken || '<token>'
const params = new URLSearchParams({ transid: transid(state.appids[0]?.appid || 'appid'), token })
if (selectedEndpoint.value.includes('/batch')) {
params.set('msisdns', state.cards.map(c => c.msisdn).slice(0, 3).join('_') || sampleCard.value.msisdn)
params.set('queryDate', selectedEndpoint.value.includes('monthly') ? monthText() : yesterday())
} else if (selectedEndpoint.value.includes('card-bind-status')) {
params.set('msisdn', sampleCard.value.msisdn)
params.set('testType', '0')
} else if (selectedEndpoint.value.includes('change/sim-status')) {
params.set('msisdn', sampleCard.value.msisdn)
params.set('operType', '0')
} else if (selectedEndpoint.value.includes('operate/sim-apn-function')) {
params.set('msisdn', sampleCard.value.msisdn)
params.set('apnName', 'CMIOT')
params.set('operType', '1')
} else {
params.set('msisdn', sampleCard.value.msisdn)
}
return `${baseUrl.value}${selectedEndpoint.value}?${params.toString()}`
})
const endpointBody = computed(() => {
if (!isChanEndpoint.value) return ''
const cards = state.cards.slice(0, 3)
const simKeys = (cards.length ? cards : [sampleCard.value]).map(card => ({
chanKey: 'channel_yd_mock',
iccid: card.iccid || '',
imsi: card.imsi || '',
msisdn: card.msisdn || ''
}))
const reqData = {}
if (selectedEndpoint.value.includes('monthly')) reqData.queryDate = monthText()
if (selectedEndpoint.value.includes('daily')) reqData.queryDate = yesterday()
if (selectedEndpoint.value === 'sim/change/sim-status') reqData.operType = '0'
if (selectedEndpoint.value === 'sim/change/sim-apn') Object.assign(reqData, { apnName: 'CMIOT', operType: '0' })
return JSON.stringify({ simKeys, reqData }, null, 2)
})
const endpointHeaders = computed(() => '')
function selectCategory() {
const first = visibleEndpoints.value[0]
if (first) selectedEndpoint.value = first.path
}
async function load() {
const [snapshot, groups, cards, orders, logs] = await Promise.all([request('/api/state'), request('/api/card-groups'), loadCards(), loadOrders(), loadOperationLogs(1, 1)])
Object.assign(state, snapshot, { groups, cards: cards.items, orders: orders.items })
Object.assign(cardPage, { page: cards.page, pageSize: cards.pageSize, total: cards.total })
Object.assign(orderPage, { page: orders.page, pageSize: orders.pageSize, total: orders.total })
cardJumpPage.value = cards.page
orderJumpPage.value = orders.page
operationLogPage.total = logs.total
for (const msisdn of Array.from(selectedCards)) {
if (!state.cards.some(card => card.msisdn === msisdn)) selectedCards.delete(msisdn)
}
}
function loadOperationLogs(page = operationLogPage.page, pageSize = operationLogPage.pageSize) {
const params = new URLSearchParams({ page, pageSize })
if (operationLogKeyword.value.trim()) params.set('keyword', operationLogKeyword.value.trim())
if (operationLogCategory.value) params.set('category', operationLogCategory.value)
if (operationLogResult.value) params.set('result', operationLogResult.value)
return request(`/api/operation-logs?${params.toString()}`)
}
async function refreshOperationLogs() {
const result = await loadOperationLogs()
operationLogs.value = result.items
Object.assign(operationLogPage, { page: result.page, pageSize: result.pageSize, total: result.total })
}
async function searchOperationLogs() {
operationLogPage.page = 1
await refreshOperationLogs()
}
async function clearOperationLogSearch() {
operationLogKeyword.value = ''
operationLogCategory.value = ''
operationLogResult.value = ''
await searchOperationLogs()
}
async function changeOperationLogPage(page) {
operationLogPage.page = page
await refreshOperationLogs()
}
async function changeOperationLogPageSize(pageSize) {
operationLogPage.pageSize = Number(pageSize)
operationLogPage.page = 1
await refreshOperationLogs()
}
async function navigate(view) {
const path = view === 'operation-logs' ? withBase('/operation-logs') : (appBase || '/')
history.pushState({}, '', path)
currentView.value = view
if (view === 'operation-logs') await refreshOperationLogs()
}
function handlePopState() {
currentView.value = viewFromPath()
if (currentView.value === 'operation-logs') refreshOperationLogs()
}
function scrollToOrders() {
document.getElementById('recent-orders')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function loadCards() {
const params = new URLSearchParams({ page: cardPage.page, pageSize: cardPage.pageSize })
appendCardFilters(params)
return request(`/api/cards?${params.toString()}`)
}
function appendCardFilters(params) {
if (cardKeyword.value.trim()) params.set('keyword', cardKeyword.value.trim())
if (cardGroupFilter.value !== '') params.set('groupId', cardGroupFilter.value)
if (cardExportStatusFilter.value) params.set('exportStatus', cardExportStatusFilter.value)
}
function loadOrders() { return request(`/api/orders?page=${orderPage.page}&pageSize=${orderPage.pageSize}`) }
async function searchCards() { cardPage.page = 1; await searchCardsPage() }
async function clearCardSearch() { cardKeyword.value = ''; cardGroupFilter.value = ''; cardExportStatusFilter.value = ''; await searchCards() }
async function filterCardsByGroup() { cardPage.page = 1; await searchCardsPage() }
async function changeCardPage(nextPage) { cardPage.page = nextPage; await searchCardsPage() }
async function changeCardPageSize(pageSize) { cardPage.pageSize = Number(pageSize); cardPage.page = 1; await searchCardsPage() }
async function searchCardsPage() {
const cards = await loadCards()
state.cards = cards.items
Object.assign(cardPage, { page: cards.page, pageSize: cards.pageSize, total: cards.total })
cardJumpPage.value = cards.page
}
async function changeOrderPage(nextPage) { orderPage.page = nextPage; await refreshOrders() }
async function changeOrderPageSize(pageSize) { orderPage.pageSize = Number(pageSize); orderPage.page = 1; await refreshOrders() }
async function refreshOrders() {
const orders = await loadOrders()
state.orders = orders.items
Object.assign(orderPage, { page: orders.page, pageSize: orders.pageSize, total: orders.total })
orderJumpPage.value = orders.page
}
async function goToCardPage() {
let p = Number(cardJumpPage.value)
if (!p || p < 1) p = 1
if (p > cardTotalPages.value) p = cardTotalPages.value
cardJumpPage.value = p
if (p !== cardPage.page) {
cardPage.page = p
await searchCardsPage()
}
}
async function goToOrderPage() {
let p = Number(orderJumpPage.value)
if (!p || p < 1) p = 1
if (p > orderTotalPages.value) p = orderTotalPages.value
orderJumpPage.value = p
if (p !== orderPage.page) {
orderPage.page = p
await refreshOrders()
}
}
function goCardFirst() { changeCardPage(1) }
function goCardLast() { changeCardPage(cardTotalPages.value) }
function goOrderFirst() { changeOrderPage(1) }
function goOrderLast() { changeOrderPage(orderTotalPages.value) }
async function generateCards() {
const result = await request('/api/generate', { method: 'POST', body: { count: generateCount.value, groupId: selectedGroupId.value || null } })
await load()
const group = state.groups.find(item => item.id === selectedGroupId.value)
toast(`已生成 ${result.generated} 张卡${group ? `,已放入“${group.name}”` : '(未分组)'},共 ${result.total} 张`)
}
function openGroupEditor() { groupForm.name = ''; isGroupEditorOpen.value = true }
async function createGroup() {
groupSaving.value = true
try {
const group = await request('/api/card-groups', { method: 'POST', body: groupForm })
state.groups.push(group)
selectedGroupId.value = group.id
isGroupEditorOpen.value = false
} finally { groupSaving.value = false }
}
async function exportCards() {
if (!selectedCards.size) {
toast('请选择要导出的卡')
return
}
const params = new URLSearchParams({ msisdns: Array.from(selectedCards).join(',') })
await downloadCardExport(params, selectedCards.size)
}
async function exportFilteredCards() {
if (!cardPage.total) {
toast('当前筛选条件下没有可导出的卡')
return
}
const exportCount = cardPage.total
const params = new URLSearchParams({ selection: 'filtered' })
appendCardFilters(params)
await downloadCardExport(params, exportCount)
}
async function downloadCardExport(params, exportCount) {
const response = await fetch(withBase(`/api/cards/export?${params.toString()}`))
if (!response.ok) throw new Error(await response.text())
const downloadURL = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = downloadURL
link.download = 'sim_card_export.xlsx'
link.click()
URL.revokeObjectURL(downloadURL)
await searchCardsPage()
toast(`已导出 ${exportCount} 张卡`)
}
function toggleCard(msisdn) {
if (selectedCards.has(msisdn)) selectedCards.delete(msisdn)
else selectedCards.add(msisdn)
}
function toggleAllCards() {
if (allCardsSelected.value) {
selectedCards.clear()
} else {
state.cards.forEach(card => selectedCards.add(card.msisdn))
}
}
async function saveApp() {
await request('/api/appids', { method: 'POST', body: appForm })
Object.assign(appForm, { appid: '', password: '', name: '' })
await load()
toast('AppID 已保存')
}
async function deleteApp(appid) {
await request(`/api/appids?appid=${encodeURIComponent(appid)}`, { method: 'DELETE' })
await load()
}
function openCardEditor(card = null) {
cardEditorMode.value = card ? 'edit' : 'create'
Object.assign(cardForm, JSON.parse(JSON.stringify(card || emptyCard())))
isCardEditorOpen.value = true
}
async function saveCard() {
cardSaving.value = true
try {
await request('/api/cards', { method: 'POST', body: cardForm })
isCardEditorOpen.value = false
Object.assign(cardForm, emptyCard())
await load()
toast(cardEditorMode.value === 'edit' ? '卡数据已更新' : '卡数据已保存')
} finally { cardSaving.value = false }
}
async function deleteCard(msisdn) {
await request(`/api/cards?msisdn=${encodeURIComponent(msisdn)}`, { method: 'DELETE' })
await load()
}
async function copyText(value) {
if (value === null || value === undefined) {
toast('复制内容为空')
return
}
const text = String(value)
if (!text) {
toast('复制内容为空')
return
}
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
toast('已复制到剪贴板')
return
}
} catch (_) {}
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'readonly')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
toast(ok ? '已复制到剪贴板' : '复制失败,请手动复制')
} catch (_) {
toast('复制失败,请手动复制')
}
}
async function request(path, options = {}) {
const init = { ...options, headers: { 'Content-Type': 'application/json' } }
if (options.body) init.body = JSON.stringify(options.body)
const res = await fetch(withBase(path), init)
if (!res.ok) throw new Error(await res.text())
return res.json()
}
function toast(text) {
message.value = text
setTimeout(() => { if (message.value === text) message.value = '' }, 2200)
}
onMounted(async () => {
window.addEventListener('popstate', handlePopState)
if (currentView.value === 'operation-logs') await refreshOperationLogs()
else await load()
})
onBeforeUnmount(() => window.removeEventListener('popstate', handlePopState))
return { state, message, appForm, cardForm, selectedCategory, selectedEndpoint, endpointGroups, visibleEndpoints, currentEndpoint, firstTokenUrl, endpointUrl, endpointBody, endpointHeaders, endpointMethod, isChanEndpoint, statusText, detailLabel, cardStatusLabel, boolLabel, apnDiagnosisStatusLabel, apnServiceUsageLabel, generateCount, selectedGroupId, groupForm, isGroupEditorOpen, groupSaving, selectedCards, selectedCount, allCardsSelected, cardKeyword, cardGroupFilter, cardExportStatusFilter, cardPage, orderPage, cardJumpPage, orderJumpPage, cardTotalPages, orderTotalPages, selectedCardDetail, selectedOrderDetail, isCardEditorOpen, cardEditorMode, cardSaving, currentView, operationLogs, operationLogPage, operationLogKeyword, operationLogCategory, operationLogResult, operationLogTotalPages, selectedOperationLog, load, selectCategory, searchCards, clearCardSearch, filterCardsByGroup, changeCardPage, changeCardPageSize, changeOrderPage, changeOrderPageSize, goToCardPage, goToOrderPage, goCardFirst, goCardLast, goOrderFirst, goOrderLast, generateCards, openGroupEditor, createGroup, exportCards, exportFilteredCards, toggleCard, toggleAllCards, saveApp, deleteApp, openCardEditor, saveCard, deleteCard, copyText, navigate, scrollToOrders, searchOperationLogs, clearOperationLogSearch, changeOperationLogPage, changeOperationLogPageSize }
},
template: `
<main class="shell">
<div v-if="currentView === 'dashboard'">
<header class="topbar">
<div><p class="eyebrow">OneLink V25.11.1 CT Mock</p><h1>挡板服务管理台</h1></div>
<div class="top-actions">
<input v-model.number="generateCount" class="count-input" type="number" min="1" />
<select v-model.number="selectedGroupId" class="group-select"><option :value="0">未分组</option><option v-for="group in state.groups" :key="group.id" :value="group.id">{{ group.name }}</option></select>
<button class="ghost icon-only" @click="openGroupEditor" title="新建分组"><FolderPlus :size="16" /></button>
<button @click="generateCards"><RefreshCw :size="16" />一键生成</button>
<button class="ghost" @click="exportCards"><Download :size="16" />导出选中 {{ selectedCount }}</button>
<button class="ghost" :disabled="!cardPage.total" @click="exportFilteredCards"><Download :size="16" />导出筛选结果 {{ cardPage.total }}</button>
</div>
</header>
<section class="metrics">
<div><KeyRound :size="19" /><strong>{{ state.appids.length }}</strong><span>AppID</span></div>
<div><CreditCard :size="19" /><strong>{{ cardPage.total }}</strong><span>SIM 卡</span></div>
<div><Database :size="19" /><strong>18432</strong><span>固定月 KB</span></div>
<div class="metric-link" role="link" tabindex="0" @click="scrollToOrders" @keyup.enter="scrollToOrders"><Database :size="19" /><strong>{{ orderPage.total }}</strong><span>办理订单</span></div>
<div class="metric-link" role="link" tabindex="0" @click="navigate('operation-logs')" @keyup.enter="navigate('operation-logs')"><ClipboardList :size="19" /><strong>{{ operationLogPage.total }}</strong><span>操作日志</span></div>
</section>
<section class="band">
<div class="section-head"><div><h2>AppID 配置</h2><p>支持多个 appid/password,Token 有效期 3600 秒。</p></div><KeyRound :size="22" /></div>
<div class="form-grid app-grid">
<input v-model="appForm.appid" placeholder="appid" />
<input v-model="appForm.password" placeholder="password" />
<input v-model="appForm.name" placeholder="名称" />
<button @click="saveApp"><Plus :size="16" />保存</button>
</div>
<div class="table">
<div class="tr th"><span>名称</span><span>appid</span><span>最近 token</span><span></span></div>
<div class="tr" v-for="app in state.appids" :key="app.appid">
<span>{{ app.name }}</span><span>{{ app.appid }}</span><span class="mono">{{ app.lastToken || '-' }}</span>
<button class="ghost" @click="deleteApp(app.appid)" title="删除"><Trash2 :size="15" /></button>
</div>
</div>
</section>
<section class="band">
<div class="section-head"><div><h2>卡与流量数据</h2><p>配置码号、状态、APN、在线状态和固定月流量。</p></div><div class="section-head-actions"><button @click="openCardEditor()"><Plus :size="16" />新增卡</button><CreditCard :size="22" /></div></div>
<div class="search-row"><input v-model="cardKeyword" @keyup.enter="searchCards" placeholder="输入 MSISDN / ICCID / IMSI" /><select v-model="cardGroupFilter" @change="filterCardsByGroup"><option value="">全部分组</option><option value="0">未分组</option><option v-for="group in state.groups" :key="group.id" :value="String(group.id)">{{ group.name }}</option></select><select v-model="cardExportStatusFilter" @change="filterCardsByGroup"><option value="">全部导出状态</option><option value="exported">已导出</option><option value="not_exported">未导出</option></select><button @click="searchCards"><Search :size="16" />查询</button><button class="ghost icon-only" @click="clearCardSearch" title="清空查询"><X :size="16" /></button></div>
<div class="table cards">
<div class="tr th"><span><input class="select-box" type="checkbox" :checked="allCardsSelected" @change="toggleAllCards" /></span><span>MSISDN</span><span>ICCID</span><span>IMSI</span><span>分组</span><span>状态</span><span>导出状态</span><span>在线</span><span>当月 KB</span><span></span></div>
<div class="tr" v-for="card in state.cards" :key="card.msisdn">
<span><input class="select-box" type="checkbox" :checked="selectedCards.has(card.msisdn)" @change="toggleCard(card.msisdn)" /></span>
<span class="mono">{{ card.msisdn }}</span><span class="mono">{{ card.iccid }}</span><span class="mono">{{ card.imsi }}</span><span>{{ card.groupName || '未分组' }}</span><span>{{ statusText[card.cardStatus] || card.cardStatus }}</span><span><em class="export-status" :class="card.exported ? 'exported' : 'not-exported'">{{ card.exported ? '已导出' : '未导出' }}</em></span><span>{{ card.sessionStatus === '01' ? '在线' : '离线' }}</span><span>{{ Math.round(card.monthUsageKB) }}</span>
<span class="row-actions"><button class="ghost icon-only" @click="selectedCardDetail = card" title="详情"><Eye :size="15" /></button><button class="ghost" @click="openCardEditor(card)">编辑</button><button class="ghost icon-only" @click="deleteCard(card.msisdn)" title="删除"><Trash2 :size="15" /></button></span>
</div>
</div>
<div class="pager"><span>共 {{ cardPage.total }} 张卡</span><label class="page-size"><span>每页</span><select :value="cardPage.pageSize" @change="changeCardPageSize($event.target.value)"><option v-for="size in [10,20,50,100]" :key="size" :value="size">{{ size }} 条</option></select></label><button class="ghost" :disabled="cardPage.page <= 1" @click="goCardFirst">首页</button><button class="ghost" :disabled="cardPage.page <= 1" @click="changeCardPage(cardPage.page - 1)">上一页</button><span class="page-jump">第 <input class="page-input" type="number" min="1" :max="cardTotalPages" v-model.number="cardJumpPage" @keyup.enter="goToCardPage" /> 页 / 共 {{ cardTotalPages }} 页</span><button class="ghost" :disabled="cardPage.page >= cardTotalPages" @click="changeCardPage(cardPage.page + 1)">下一页</button><button class="ghost" :disabled="cardPage.page >= cardTotalPages" @click="goCardLast">尾页</button><button class="ghost" @click="goToCardPage">跳转</button></div>
</section>
<section class="split single">
<div class="band">
<div class="section-head"><div><h2>接口调试</h2><p>支持移动 OneLink 与 Chan 统一接口。</p></div><Server :size="22" /></div>
<div class="endpoint-categories" role="tablist" aria-label="接口分类">
<button v-for="group in endpointGroups" :key="group.key" class="endpoint-category" :class="{ active: selectedCategory === group.key }" @click="selectedCategory = group.key; selectCategory()">{{ group.label }}<span>{{ group.items.length }}</span></button>
</div>
<label class="field" v-if="selectedCategory.startsWith('mobile-')"><span>Token 获取</span><div class="copy-field"><input class="mono" :value="firstTokenUrl" readonly /><button class="ghost icon-only" @click="copyText(firstTokenUrl)" title="复制 Token 地址"><Copy :size="16" /></button></div></label>
<label class="field"><span>业务接口({{ endpointMethod }})</span><select class="endpoint-select" v-model="selectedEndpoint"><option v-for="item in visibleEndpoints" :key="item.path" :value="item.path">{{ item.label }} · {{ item.path }}</option></select></label>
<div class="copy-field"><input class="mono" :value="endpointUrl" readonly /><button class="ghost icon-only" @click="copyText(endpointUrl)" title="复制业务接口地址"><Copy :size="16" /></button></div>
<label class="field endpoint-detail" v-if="endpointHeaders"><span>请求 Header</span><div class="copy-field"><textarea class="mono endpoint-body compact" :value="endpointHeaders" readonly></textarea><button class="ghost icon-only" @click="copyText(endpointHeaders)" title="复制请求 Header"><Copy :size="16" /></button></div></label>
<label class="field endpoint-detail" v-if="endpointBody"><span>JSON 请求体</span><div class="copy-field"><textarea class="mono endpoint-body" :value="endpointBody" readonly></textarea><button class="ghost icon-only" @click="copyText(endpointBody)" title="复制 JSON 请求体"><Copy :size="16" /></button></div></label>
</div>
</section>
<section class="band" id="recent-orders">
<div class="section-head"><div><h2>最近订单</h2><p>状态变更与 APN 开停会生成 orderNum。</p></div><BadgePlus :size="22" /></div>
<div class="empty" v-if="!state.orders.length">暂无订单</div>
<div class="table orders" v-else>
<div class="tr th"><span>订单号</span><span>类型</span><span>操作</span><span>MSISDN</span><span>状态</span><span>创建时间</span><span></span></div>
<div class="tr order-row" v-for="order in state.orders" :key="order.orderNum">
<span class="mono">{{ order.orderNum }}</span><span>{{ order.type }}</span><span>{{ order.operType || '-' }}</span><span class="mono">{{ order.msisdn }}</span><span>{{ order.status }}</span><span>{{ order.createdAt }}</span><button class="ghost icon-only" @click="selectedOrderDetail = order" title="详情"><Eye :size="15" /></button>
</div>
</div>
<div class="pager" v-if="state.orders.length"><span>共 {{ orderPage.total }} 条订单</span><label class="page-size"><span>每页</span><select :value="orderPage.pageSize" @change="changeOrderPageSize($event.target.value)"><option v-for="size in [10,20,50,100]" :key="size" :value="size">{{ size }} 条</option></select></label><button class="ghost" :disabled="orderPage.page <= 1" @click="goOrderFirst">首页</button><button class="ghost" :disabled="orderPage.page <= 1" @click="changeOrderPage(orderPage.page - 1)">上一页</button><span class="page-jump">第 <input class="page-input" type="number" min="1" :max="orderTotalPages" v-model.number="orderJumpPage" @keyup.enter="goToOrderPage" /> 页 / 共 {{ orderTotalPages }} 页</span><button class="ghost" :disabled="orderPage.page >= orderTotalPages" @click="changeOrderPage(orderPage.page + 1)">下一页</button><button class="ghost" :disabled="orderPage.page >= orderTotalPages" @click="goOrderLast">尾页</button><button class="ghost" @click="goToOrderPage">跳转</button></div>
</section>
<div class="modal-backdrop" v-if="selectedCardDetail" @click.self="selectedCardDetail = null"><section class="detail-modal card-detail-modal"><div class="detail-head"><div><h2>卡信息详情</h2><p class="mono">{{ selectedCardDetail.msisdn }}</p></div><button class="ghost icon-only" @click="selectedCardDetail = null" title="关闭"><X :size="18" /></button></div><div class="detail-section"><h3>基础信息</h3><div class="detail-grid"><div class="detail-item" v-for="key in ['msisdn','iccid','imsi','imei','remark','openDate','activeDate']" :key="key"><span>{{ detailLabel(key) }}</span><strong :class="['mono', { 'wrap-value': key === 'remark' }]">{{ selectedCardDetail[key] || '-' }}</strong></div></div></div><div class="detail-section"><h3>状态与连接</h3><div class="detail-grid"><div class="detail-item"><span>卡状态</span><strong>{{ cardStatusLabel(selectedCardDetail.cardStatus) }}</strong></div><div class="detail-item"><span>终端开关机</span><strong>{{ boolLabel(selectedCardDetail.onOffStatus) }}</strong></div><div class="detail-item"><span>会话状态</span><strong>{{ selectedCardDetail.sessionStatus === '01' ? '在线' : '离线' }}</strong></div><div class="detail-item"><span>机卡绑定</span><strong>{{ selectedCardDetail.cardBindStatus === '1' ? '未分离' : '已分离' }}</strong></div><div class="detail-item"><span>区域限制</span><strong>{{ selectedCardDetail.regionLimitStatus === '1' ? '已触发' : '未触发' }}</strong></div><div class="detail-item"><span>接入制式</span><strong>{{ selectedCardDetail.rat || '-' }}</strong></div><div class="detail-item"><span>IPv4</span><strong class="mono">{{ selectedCardDetail.ip || '-' }}</strong></div><div class="detail-item"><span>会话建立时间</span><strong class="mono">{{ selectedCardDetail.sessionCreateDate || selectedCardDetail.lastChangeDate || '-' }}</strong></div></div></div><div class="detail-section"><h3>停机与冻结诊断</h3><div class="detail-grid"><div class="detail-item"><span>停机原因码</span><strong class="mono">{{ selectedCardDetail.stopReason || '000000000000' }}</strong></div><div class="detail-item"><span>管理冻结</span><strong>{{ selectedCardDetail.frozenStatus === '1' ? '是' : '否' }}</strong></div><div class="detail-item"><span>冻结原因</span><strong>{{ selectedCardDetail.frozenReason || '-' }}</strong></div><div class="detail-item"><span>冻结操作时间</span><strong class="mono">{{ selectedCardDetail.frozenOperTime || '-' }}</strong></div></div></div><div class="detail-section"><h3>APN 与 PCC 智能诊断</h3><div class="diagnosis-list"><div class="diagnosis-row" v-for="apn in (selectedCardDetail.apns || [])" :key="apn.name"><div class="diagnosis-row-head"><strong>{{ apn.name }}</strong><span>{{ apnDiagnosisStatusLabel(apn.diagnosisStatus || (apn.status === '1' ? '2' : apn.status)) }}</span><span>{{ apnServiceUsageLabel(apn.serviceUsageState) }}</span></div><div class="diagnosis-meta">变更时间 <span class="mono">{{ apn.modifyTime || '-' }}</span><template v-if="apn.overUseDetails"> · 超停原因 <span>{{ apn.overUseDetails }}</span></template></div><div class="pcc-list"><div class="pcc-item" v-for="pcc in (apn.pccs || [])" :key="pcc.serviceCode"><span class="mono">{{ pcc.serviceCode }}</span><span>{{ pcc.name }}</span><span>策略 {{ pcc.policyStatus }}</span><span>{{ apnServiceUsageLabel(pcc.serviceUsageState) }}</span></div><div class="empty-inline" v-if="!(apn.pccs || []).length">暂无 PCC 策略</div></div></div><div class="empty-inline" v-if="!(selectedCardDetail.apns || []).length">暂无 APN 诊断数据</div></div></div></section></div>
<div class="modal-backdrop" v-if="selectedOrderDetail" @click.self="selectedOrderDetail = null"><section class="detail-modal compact"><div class="detail-head"><h2>订单完整信息</h2><button class="ghost icon-only" @click="selectedOrderDetail = null" title="关闭"><X :size="18" /></button></div><div class="detail-grid"><div class="detail-item" v-for="key in ['orderNum','msisdn','iccid','imsi','type','operType','apnName','status','createdAt']" :key="key"><span>{{ key }}</span><strong class="mono">{{ selectedOrderDetail[key] || '-' }}</strong></div></div></section></div>
<div class="modal-backdrop" v-if="isCardEditorOpen" @click.self="isCardEditorOpen = false"><section class="detail-modal card-editor-modal">
<div class="detail-head"><div><h2>{{ cardEditorMode === 'edit' ? '编辑卡' : '新增卡' }}</h2><p>保存后将刷新当前卡列表。</p></div><button class="ghost icon-only" @click="isCardEditorOpen = false" title="关闭"><X :size="18" /></button></div>
<div class="editor-section"><h3>基础信息</h3><div class="editor-grid"><label><span>MSISDN</span><input v-model="cardForm.msisdn" /></label><label><span>ICCID</span><input v-model="cardForm.iccid" /></label><label><span>IMSI</span><input v-model="cardForm.imsi" /></label><label><span>IMEI</span><input v-model="cardForm.imei" /></label><label class="wide"><span>备注</span><input v-model="cardForm.remark" /></label></div></div>
<div class="editor-section"><h3>状态与流量</h3><div class="editor-grid"><label><span>卡状态</span><select v-model="cardForm.cardStatus"><option value="1">待激活</option><option value="2">已激活</option><option value="4">停机</option><option value="6">可测试</option><option value="7">库存</option><option value="8">预销户</option></select></label><label><span>终端开关机</span><select v-model="cardForm.onOffStatus"><option value="0">关机</option><option value="1">开机</option></select></label><label><span>会话状态</span><select v-model="cardForm.sessionStatus"><option value="00">离线</option><option value="01">在线</option></select></label><label><span>机卡绑定</span><select v-model="cardForm.cardBindStatus"><option value="0">已分离</option><option value="1">未分离</option></select></label><label><span>区域限制</span><select v-model="cardForm.regionLimitStatus"><option value="0">未触发</option><option value="1">已触发</option></select></label><label><span>当月流量 KB</span><input v-model.number="cardForm.monthUsageKB" type="number" /></label><label><span>日基础流量 KB</span><input v-model.number="cardForm.dailyBaseKB" type="number" /></label><label><span>月基础流量 KB</span><input v-model.number="cardForm.monthlyBaseKB" type="number" /></label></div></div>
<div class="editor-section"><h3>诊断模拟</h3><div class="editor-grid"><label><span>停机原因码</span><input v-model="cardForm.stopReason" maxlength="12" /></label><label><span>管理冻结</span><select v-model="cardForm.frozenStatus"><option value="0">否</option><option value="1">是</option></select></label><label class="wide"><span>冻结原因</span><input v-model="cardForm.frozenReason" /></label><label><span>APN 诊断状态</span><select v-model="cardForm.apns[0].diagnosisStatus"><option value="1">未激活</option><option value="2">激活</option><option value="4">暂停</option></select></label><label><span>APN 限速档位</span><input v-model="cardForm.apns[0].serviceUsageState" /></label><label class="wide"><span>超停原因</span><input v-model="cardForm.apns[0].overUseDetails" placeholder="例如 16|17" /></label><label><span>PCC 编码</span><input v-model="cardForm.apns[0].pccs[0].serviceCode" /></label><label><span>PCC 名称</span><input v-model="cardForm.apns[0].pccs[0].name" /></label><label><span>PCC 策略状态</span><select v-model="cardForm.apns[0].pccs[0].policyStatus"><option value="2">激活</option><option value="4">暂停</option></select></label></div></div>
<div class="editor-actions"><button class="ghost" @click="isCardEditorOpen = false">取消</button><button :disabled="cardSaving" @click="saveCard"><Save :size="16" />{{ cardSaving ? '保存中...' : '保存卡' }}</button></div>
</section></div>
<div class="modal-backdrop" v-if="isGroupEditorOpen" @click.self="isGroupEditorOpen = false"><section class="detail-modal compact"><div class="detail-head"><h2>新建分组</h2><button class="ghost icon-only" @click="isGroupEditorOpen = false" title="关闭"><X :size="18" /></button></div><label class="field"><span>分组名称</span><input v-model="groupForm.name" @keyup.enter="createGroup" /></label><div class="editor-actions"><button class="ghost" @click="isGroupEditorOpen = false">取消</button><button :disabled="groupSaving || !groupForm.name.trim()" @click="createGroup"><Save :size="16" />{{ groupSaving ? '保存中...' : '创建分组' }}</button></div></section></div>
</div>
<div v-else>
<header class="page-header"><div><button class="ghost icon-only" @click="navigate('dashboard')" title="返回管理台"><ArrowLeft :size="18" /></button><div><p class="eyebrow">OneLink V25.11.1 CT Mock</p><h1>操作日志</h1></div></div><span>每日 00:00 自动清空</span></header>
<section class="band log-page">
<div class="section-head"><div><h2>今日操作</h2><p>记录管理台、OneLink 和 Chan 接口的操作结果。</p></div><ClipboardList :size="22" /></div>
<div class="log-search-row"><input v-model="operationLogKeyword" @keyup.enter="searchOperationLogs" placeholder="搜索动作、目标、接口或 IP" /><select v-model="operationLogCategory" @change="searchOperationLogs"><option value="">全部类别</option><option value="管理台">管理台</option><option value="OneLink">OneLink</option><option value="Chan">Chan</option></select><select v-model="operationLogResult" @change="searchOperationLogs"><option value="">全部结果</option><option value="success">成功</option><option value="failed">失败</option></select><button @click="searchOperationLogs"><Search :size="16" />查询</button><button class="ghost icon-only" @click="clearOperationLogSearch" title="清空查询"><X :size="16" /></button></div>
<div class="empty" v-if="!operationLogs.length">暂无操作日志</div>
<div class="table log-table" v-else>
<div class="tr th"><span>时间</span><span>类别</span><span>动作</span><span>API 接口</span><span>目标</span><span>结果</span><span>耗时</span><span>客户端 IP</span><span></span></div>
<div class="tr" v-for="log in operationLogs" :key="log.id"><span>{{ log.createdAt }}</span><span>{{ log.category }}</span><span>{{ log.action }}</span><span class="mono api-path">{{ log.method }} {{ log.path }}</span><span class="mono">{{ log.target || '-' }}</span><span><em class="result-tag" :class="log.result">{{ log.result === 'success' ? '成功' : '失败' }}</em></span><span>{{ log.durationMs }} ms</span><span class="mono">{{ log.clientIp || '-' }}</span><button class="ghost icon-only" @click="selectedOperationLog = log" title="详情"><Eye :size="15" /></button></div>
</div>
<div class="pager" v-if="operationLogPage.total"><span>共 {{ operationLogPage.total }} 条日志</span><label class="page-size"><span>每页</span><select :value="operationLogPage.pageSize" @change="changeOperationLogPageSize($event.target.value)"><option v-for="size in [10,20,50,100]" :key="size" :value="size">{{ size }} 条</option></select></label><button class="ghost" :disabled="operationLogPage.page <= 1" @click="changeOperationLogPage(operationLogPage.page - 1)">上一页</button><span>第 {{ operationLogPage.page }} / {{ operationLogTotalPages }} 页</span><button class="ghost" :disabled="operationLogPage.page >= operationLogTotalPages" @click="changeOperationLogPage(operationLogPage.page + 1)">下一页</button></div>
</section>
</div>
<div class="modal-backdrop" v-if="selectedOperationLog" @click.self="selectedOperationLog = null"><section class="detail-modal compact"><div class="detail-head"><h2>操作日志详情</h2><button class="ghost icon-only" @click="selectedOperationLog = null" title="关闭"><X :size="18" /></button></div><div class="detail-grid"><div class="detail-item" v-for="key in ['createdAt','category','action','target','method','path','params','result','statusCode','message','durationMs','clientIp']" :key="key"><span>{{ key }}</span><strong class="mono">{{ selectedOperationLog[key] ?? '-' }}</strong></div></div></section></div>
<div class="toast" v-if="message">{{ message }}</div>
</main>
`
}
function emptyCard() {
return {
msisdn: '', iccid: '', imsi: '', imei: '', remark: '',
stopReason: '000000000000', frozenStatus: '0', frozenReason: '', frozenOperTime: '', sessionCreateDate: '',
cardStatus: '2', onOffStatus: '1', sessionStatus: '01', cardBindStatus: '1', regionLimitStatus: '0',
monthUsageKB: 18432, dailyBaseKB: 614.4, monthlyBaseKB: 18432, growthPerMinuteKB: 0,
apns: [{ name: 'CMIOT', status: '1', diagnosisStatus: '2', serviceUsageState: '1', overUseDetails: '', usageBytes: 0, modifyTime: '', pccs: [{ serviceCode: 'default', name: 'APN 缺省流量', policyStatus: '2', serviceUsageState: '1', modifyTime: '' }] }]
}
}
function transid(appid) { return `${appid}${new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)}00000001` }
function yesterday() { return new Date(Date.now() - 86400000).toISOString().slice(0, 10).replaceAll('-', '') }
function monthText() { return new Date().toISOString().slice(0, 7).replace('-', '') }
function normalizeBase(base) {
const clean = (base || '/').replace(/\/+$/, '')
return clean === '' ? '' : clean
}
function withBase(path) {
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${appBase}${cleanPath}` || '/'
}
function viewFromPath() {
const path = location.pathname.replace(appBase, '') || '/'
return path === '/operation-logs' || path === '/operation-logs/' ? 'operation-logs' : 'dashboard'
}
createApp(App).mount('#app')
+308
View File
@@ -0,0 +1,308 @@
:root {
font-family: Inter, "Microsoft YaHei", system-ui, sans-serif;
color: #1d252b;
background: #f5f7f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input, select { font: inherit; }
.shell {
width: min(1280px, calc(100vw - 32px));
margin: 0 auto;
padding: 24px 0 44px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 18px;
}
.top-actions {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.count-input {
width: 110px;
}
.group-select {
width: 150px;
}
.eyebrow {
margin: 0 0 6px;
color: #57717f;
font-size: 13px;
font-weight: 700;
}
h1, h2, p { margin: 0; }
h1 { font-size: clamp(28px, 4vw, 44px); line-height: 1.08; color: #14242d; }
h2 { font-size: 20px; }
p { color: #63717a; }
.icon-btn, button {
border: 0;
border-radius: 8px;
min-height: 38px;
padding: 0 14px;
background: #116a6d;
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
cursor: pointer;
}
.icon-btn { width: 42px; padding: 0; }
.ghost {
min-height: 30px;
color: #116a6d;
background: #e5f0ef;
}
.metrics {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.metrics div {
min-height: 76px;
display: flex;
align-items: center;
gap: 10px;
background: white;
border: 1px solid #dce4e6;
border-radius: 8px;
padding: 16px;
}
.metric-link { cursor: pointer; }
.metric-link:hover, .metric-link:focus-visible { border-color: #116a6d; background: #f0f8f7; outline: none; }
.metrics strong { font-size: 26px; color: #0f4348; }
.metrics span { color: #68777f; }
.band {
background: white;
border: 1px solid #dce4e6;
border-radius: 8px;
padding: 18px;
margin-top: 14px;
}
.section-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 16px;
}
.section-head-actions { display: inline-flex; align-items: center; gap: 14px; }
.form-grid {
display: grid;
gap: 10px;
margin-bottom: 14px;
}
.search-row { display: grid; grid-template-columns: minmax(260px, 1fr) 150px 150px auto 42px; gap: 10px; margin-bottom: 14px; align-items: center; }
.search-row > button { white-space: nowrap; }
.app-grid { grid-template-columns: 1.4fr 1fr 1fr auto; }
.card-grid { grid-template-columns: repeat(4, minmax(0, 1fr)) auto; }
input, select {
width: 100%;
min-height: 38px;
border: 1px solid #ccd7da;
border-radius: 7px;
padding: 8px 10px;
color: #1d252b;
background: #fbfcfc;
}
.mono {
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 12px;
}
.table {
border: 1px solid #e0e7e9;
border-radius: 8px;
overflow: hidden;
}
.tr {
display: grid;
grid-template-columns: 1fr 1.4fr 2fr 56px;
gap: 10px;
align-items: center;
min-height: 42px;
padding: 8px 12px;
border-top: 1px solid #e8eeee;
}
.tr:first-child { border-top: 0; }
.th {
background: #f1f5f5;
color: #52646d;
font-weight: 700;
}
.cards .tr {
grid-template-columns: 42px 1fr 1.5fr 1.25fr 0.85fr 0.7fr 0.8fr 0.6fr 0.7fr 150px;
}
.cards { max-height: 492px; overflow-y: auto; }
.cards .tr.th { position: sticky; top: 0; z-index: 1; }
.orders { max-height: 492px; overflow-y: auto; }
.orders .tr.th { position: sticky; top: 0; z-index: 1; }
.row-actions { display: inline-flex; gap: 8px; justify-content: flex-end; }
.export-status { display: inline-block; padding: 2px 7px; border-radius: 4px; font-size: 12px; font-style: normal; }
.export-status.exported { color: #166534; background: #dcfce7; }
.export-status.not-exported { color: #9a3412; background: #ffedd5; }
.icon-only { width: 34px; padding: 0; }
.pager { display: flex; align-items: center; justify-content: flex-end; gap: 10px; margin-top: 10px; color: #63717a; font-size: 13px; }
.pager button:disabled { cursor: not-allowed; opacity: 0.45; }
.page-size { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.page-size select { width: auto; min-height: 30px; padding: 4px 28px 4px 8px; }
.page-jump { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.page-input { width: 56px; min-height: 30px; padding: 4px 8px; text-align: center; }
.page-input::-webkit-outer-spin-button,
.page-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.page-input[type=number] { -moz-appearance: textfield; }
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 20px; background: rgb(16 28 34 / 55%); }
.detail-modal { width: min(920px, 100%); max-height: min(760px, calc(100vh - 40px)); overflow: auto; padding: 20px; border-radius: 8px; background: white; box-shadow: 0 18px 50px rgb(0 0 0 / 20%); }
.detail-modal.compact { width: min(720px, 100%); }
.card-editor-modal { width: min(860px, 100%); }
.detail-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid #dce4e6; border-radius: 8px; background: #dce4e6; }
.detail-item { min-width: 0; display: grid; gap: 6px; padding: 12px; background: white; }
.detail-item span { color: #63717a; font-size: 12px; }
.detail-item strong { overflow-wrap: anywhere; font-weight: 600; }
.card-detail-modal { width: min(980px, 100%); }
.detail-section { margin-top: 16px; }
.detail-section:first-of-type { margin-top: 0; }
.detail-section h3 { margin: 0 0 9px; color: #31404a; font-size: 14px; }
.wrap-value { white-space: pre-wrap; }
.diagnosis-list { display: grid; gap: 10px; }
.diagnosis-row { padding: 12px; border: 1px solid #dce4e6; border-radius: 7px; background: #fbfcfc; }
.diagnosis-row-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.diagnosis-row-head span { padding: 3px 7px; border-radius: 4px; color: #116a6d; background: #e5f0ef; font-size: 12px; }
.diagnosis-meta { margin-top: 7px; color: #63717a; font-size: 12px; }
.pcc-list { display: grid; gap: 6px; margin-top: 10px; }
.pcc-item { display: grid; grid-template-columns: 1fr 1.5fr 0.8fr 1fr; gap: 8px; padding: 7px 9px; border-left: 3px solid #9fc7c6; color: #40515a; background: #f1f5f5; font-size: 12px; }
.empty-inline { color: #71818a; font-size: 12px; }
.editor-section { padding: 14px 0; border-top: 1px solid #e1e8e9; }
.editor-section:first-of-type { border-top: 0; }
.editor-section h3 { margin: 0 0 10px; font-size: 14px; color: #31404a; }
.editor-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.editor-grid label { display: grid; gap: 6px; min-width: 0; }
.editor-grid label span { color: #63717a; font-size: 12px; }
.editor-grid .wide { grid-column: span 2; }
.editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 16px; }
.editor-actions button:disabled { cursor: wait; opacity: 0.55; }
.order-row, .table .th:has(+ .order-row) { grid-template-columns: 1.5fr 1fr 0.6fr 1fr 0.7fr 1.2fr 42px; }
.select-box {
width: 18px;
min-height: 18px;
padding: 0;
}
.split {
display: grid;
grid-template-columns: 0.8fr 1.2fr;
gap: 14px;
}
.split.single {
grid-template-columns: 1fr;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
color: #31404a;
}
.toggle input {
width: 20px;
min-height: 20px;
}
.field {
display: grid;
gap: 6px;
color: #52646d;
margin-bottom: 12px;
}
.copy-field { display: grid; grid-template-columns: minmax(0, 1fr) 42px; gap: 8px; align-items: center; }
.copy-field input { min-width: 0; }
.endpoint-select { max-height: 220px; overflow-y: auto; }
.endpoint-body { width: 100%; min-height: 150px; resize: vertical; border: 1px solid #ccd7da; border-radius: 7px; padding: 10px; color: #1d252b; background: #fbfcfc; }
.endpoint-body.compact { min-height: 112px; }
.endpoint-detail { margin-top: 12px; }
.endpoint-categories { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.endpoint-category { min-height: 38px; padding: 8px 12px; color: #40515a; background: #f4f7f7; border: 1px solid #ccd7da; }
.endpoint-category span { min-width: 22px; height: 20px; display: inline-grid; place-items: center; margin-left: 7px; border-radius: 10px; color: #63717a; background: #e4e9ea; font-size: 12px; }
.endpoint-category.active { color: #fff; background: #116a6d; border-color: #116a6d; }
.endpoint-category.active span { color: #116a6d; background: #fff; }
.empty {
min-height: 64px;
display: grid;
place-items: center;
color: #71818a;
border: 1px dashed #cbd7da;
border-radius: 8px;
}
.toast {
position: fixed;
right: 24px;
bottom: 24px;
background: #14242d;
color: white;
border-radius: 8px;
padding: 12px 16px;
}
.page-header { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 18px; }
.page-header > div { display: flex; align-items: center; gap: 12px; }
.page-header > span { color: #63717a; font-size: 13px; }
.log-search-row { display: grid; grid-template-columns: minmax(260px, 1fr) 140px 140px auto 42px; gap: 10px; margin-bottom: 14px; align-items: center; }
.log-table { overflow-x: auto; }
.log-table .tr { min-width: 1240px; grid-template-columns: 1.15fr 0.65fr 1.25fr 1.7fr 1.2fr 0.65fr 0.6fr 1fr 42px; }
.api-path { overflow-wrap: anywhere; }
.result-tag { font-style: normal; font-size: 12px; color: #116a6d; background: #e5f0ef; padding: 4px 8px; border-radius: 6px; }
.result-tag.failed { color: #a23b32; background: #fae9e7; }
@media (max-width: 900px) {
.metrics, .split, .app-grid, .card-grid, .search-row, .log-search-row, .detail-grid, .editor-grid { grid-template-columns: 1fr; }
.editor-grid .wide { grid-column: auto; }
.topbar { align-items: flex-start; flex-direction: column; }
.top-actions { justify-content: flex-start; }
.tr, .cards .tr { grid-template-columns: 1fr; align-items: start; }
.th { display: none; }
.endpoint-categories { display: grid; grid-template-columns: 1fr 1fr; }
.pcc-item { grid-template-columns: 1fr 1fr; }
}