modify: 授权
This commit is contained in:
Vendored
+4
@@ -18,6 +18,8 @@ declare module 'vue' {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElFooter: typeof import('element-plus/es')['ElFooter']
|
||||
@@ -59,6 +61,8 @@ declare global {
|
||||
const ElButton: typeof import('element-plus/es')['ElButton']
|
||||
const ElCard: typeof import('element-plus/es')['ElCard']
|
||||
const ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
const ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
const ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
const ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
const ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
const ElFooter: typeof import('element-plus/es')['ElFooter']
|
||||
|
||||
+5
-1
@@ -27,6 +27,10 @@
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>Worker 管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/license">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>授权</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/about">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>关于</span>
|
||||
@@ -46,7 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Cpu, HomeFilled, InfoFilled, Monitor, Setting } from '@element-plus/icons-vue'
|
||||
import { Cpu, HomeFilled, InfoFilled, Key, Monitor, Setting } from '@element-plus/icons-vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 机器码授权 API(管理端点 /license/*)
|
||||
*
|
||||
* 授权状态与机器码属设备标识信息,导入 license 又会改变整个网关的插件加载行为,
|
||||
* 故与 /admin/workers 同级:均要求超级管理员的 Bearer token。
|
||||
*/
|
||||
// 去掉尾斜杠,避免与以 '/' 开头的 path 拼接成 '//host...'(协议相对 URL 导致 ERR_NAME_NOT_RESOLVED)
|
||||
const API_BASE: string = ((import.meta.env.VITE_API_BASE_URL as string) || '').replace(/\/+$/, '')
|
||||
|
||||
/** 指纹比对结论 */
|
||||
export interface LicenseMatch {
|
||||
ok: boolean
|
||||
matched: number
|
||||
total: number
|
||||
reason: string
|
||||
matched_sources: string[]
|
||||
mismatched_sources: string[]
|
||||
/** 可信来源不足、判定纳入了 weak 来源(安全性下降) */
|
||||
degraded: boolean
|
||||
}
|
||||
|
||||
/** license 载荷 */
|
||||
export interface LicensePayload {
|
||||
v: number
|
||||
lic_id: string
|
||||
customer: string
|
||||
machine_code: string
|
||||
sources: Record<string, string>
|
||||
plugins: string[]
|
||||
not_before: string | null
|
||||
expires_at: string | null
|
||||
issued_at: string | null
|
||||
note: string
|
||||
}
|
||||
|
||||
/** 整份 license 的校验结论 */
|
||||
export interface LicenseStatus {
|
||||
ok: boolean
|
||||
/** 原因码:ok / expired / machine_mismatch / signature_invalid / license_missing … */
|
||||
code: string
|
||||
message: string
|
||||
license_path: string
|
||||
machine_code: string
|
||||
public_key_fingerprint: string
|
||||
sources: Record<string, string>
|
||||
warnings: string[]
|
||||
match: LicenseMatch | null
|
||||
payload: LicensePayload | null
|
||||
licensed_plugins: string[]
|
||||
}
|
||||
|
||||
/** 单个硬件来源的采集明细 */
|
||||
export interface SourceDetail {
|
||||
name: string
|
||||
label: string
|
||||
/** weak 来源不参与判定阈值(同镜像批量刷机时可能全机相同) */
|
||||
weak: boolean
|
||||
present: boolean
|
||||
value: string
|
||||
raw: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
/** 插件视角的授权全景 */
|
||||
export interface PluginOverview {
|
||||
discovered: string[]
|
||||
licensed: string[]
|
||||
denied: Record<string, { code: string; message: string }>
|
||||
loaded: string[]
|
||||
denied_from_last_scan: Record<string, string>
|
||||
}
|
||||
|
||||
export interface LicenseInfo {
|
||||
enforce: boolean
|
||||
enforce_forced_by_packaging: boolean
|
||||
license_path: string
|
||||
min_match: number
|
||||
status: LicenseStatus
|
||||
sources_detail: SourceDetail[]
|
||||
warnings: string[]
|
||||
/** 申请码:把本机指纹打包成一段可抄写文本,发给厂商签发 */
|
||||
request_code: string
|
||||
plugins: PluginOverview
|
||||
}
|
||||
|
||||
export interface LicenseUploadResult {
|
||||
success: boolean
|
||||
license_path: string
|
||||
status: LicenseStatus
|
||||
licensed_plugins: string[]
|
||||
restart_required: boolean
|
||||
pending_plugins: string[]
|
||||
restart_hint: string
|
||||
}
|
||||
|
||||
async function request<T>(path: string, token: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
let detail = `HTTP ${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
if (body?.detail) {
|
||||
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail)
|
||||
}
|
||||
} catch {
|
||||
/* ignore non-JSON error body */
|
||||
}
|
||||
throw new Error(detail)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
/** 读取授权状态、机器码、申请码与各来源明细 */
|
||||
export function getLicenseInfo(token: string) {
|
||||
return request<LicenseInfo>('/api/v1/license/info', token)
|
||||
}
|
||||
|
||||
/** 重新读取磁盘上的授权文件(手工替换文件后无需重启即可刷新状态) */
|
||||
export function reloadLicense(token: string) {
|
||||
return request<{ success: boolean; status: LicenseStatus }>('/api/v1/license/reload', token, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入 license 文件。
|
||||
*
|
||||
* 单独实现而不复用 request():multipart 必须让浏览器自行生成 boundary,
|
||||
* 手写 Content-Type 会把 boundary 弄丢导致后端解析失败。
|
||||
*/
|
||||
export async function uploadLicense(token: string, file: File): Promise<LicenseUploadResult> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await fetch(`${API_BASE}/api/v1/license/file`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let detail = `HTTP ${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
if (body?.detail) {
|
||||
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail)
|
||||
}
|
||||
} catch {
|
||||
/* ignore non-JSON error body */
|
||||
}
|
||||
throw new Error(detail)
|
||||
}
|
||||
return res.json() as Promise<LicenseUploadResult>
|
||||
}
|
||||
@@ -22,6 +22,12 @@ const router = createRouter({
|
||||
// 懒加载 Worker 管理页面(实例盘点 + 单/批量重启)
|
||||
component: () => import('../views/WorkerView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/license',
|
||||
name: 'license',
|
||||
// 懒加载机器码授权页(机器码/申请码展示 + 授权状态 + 导入授权文件)
|
||||
component: () => import('../views/LicenseView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'config',
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
<template>
|
||||
<div class="license-container">
|
||||
<el-card class="toolbar-card" shadow="hover">
|
||||
<template v-if="!token">
|
||||
<el-form inline>
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="username"
|
||||
placeholder="用户名"
|
||||
style="width: 200px"
|
||||
@keyup.enter="doLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="密码"
|
||||
style="width: 220px"
|
||||
@keyup.enter="doLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="logging" style="margin-left: 8px" @click="doLogin">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="机器码与授权状态属设备标识信息,仅超级管理员可查看与导入授权。"
|
||||
show-icon
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="login-bar">
|
||||
<el-tag effect="plain" type="primary">{{ currentUser?.username || '已登录' }}</el-tag>
|
||||
<el-tag v-if="currentUser?.is_superuser" type="success" size="small">超级管理员</el-tag>
|
||||
<el-tag v-else type="danger" size="small">非超管(操作将被拒绝)</el-tag>
|
||||
<el-button type="primary" :loading="loading" style="margin-left: 12px" @click="fetchInfo">
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button type="info" plain @click="logout">退出</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-alert
|
||||
v-if="token && info"
|
||||
:type="statusType"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="statusTitle"
|
||||
:description="statusDescription"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<template v-if="token && info">
|
||||
<el-card class="machine-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">本机机器码</div>
|
||||
</template>
|
||||
<div class="machine-code">{{ info.status.machine_code || '(未采集到硬件指纹)' }}</div>
|
||||
<div class="hint">
|
||||
把机器码(或下方申请码)发给厂商即可申请授权。申请码包含各硬件来源指纹,
|
||||
厂商据此签发的授权可容忍更换单块网卡或单颗 eMMC;只报机器码则只能签出精确匹配的授权。
|
||||
</div>
|
||||
<div class="copy-bar">
|
||||
<el-button size="small" :disabled="!info.status.machine_code" @click="copy(info.status.machine_code)">
|
||||
复制机器码
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" :disabled="!info.request_code" @click="copy(info.request_code)">
|
||||
复制申请码
|
||||
</el-button>
|
||||
<el-input
|
||||
v-model="info.request_code"
|
||||
readonly
|
||||
size="small"
|
||||
class="request-code"
|
||||
placeholder="(申请码生成失败)"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="detail-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">授权信息</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="授权文件">
|
||||
{{ info.license_path }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="校验方式">
|
||||
{{ info.enforce ? '强制校验' : '未启用(开发/测试)' }}
|
||||
<el-tag v-if="info.enforce_forced_by_packaging" type="warning" size="small" style="margin-left: 6px">
|
||||
打包产物强制
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="授权编号">
|
||||
{{ info.status.payload?.lic_id || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="客户">
|
||||
{{ info.status.payload?.customer || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="签发时间">
|
||||
{{ info.status.payload?.issued_at || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="有效期">
|
||||
{{ info.status.payload?.not_before || '立即生效' }} →
|
||||
{{ info.status.payload?.expires_at || '永久' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="指纹比对">
|
||||
{{ info.status.match?.reason || '—' }}
|
||||
<el-tag v-if="info.status.match?.degraded" type="warning" size="small" style="margin-left: 6px">
|
||||
已降级
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="厂商公钥指纹">
|
||||
<code>{{ info.status.public_key_fingerprint || '(未配置公钥)' }}</code>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ info.status.payload?.note || '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-alert
|
||||
v-for="(w, i) in info.warnings"
|
||||
:key="i"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
:title="w"
|
||||
show-icon
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-card class="detail-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
插件授权
|
||||
<span class="count-tip">
|
||||
盘扫 {{ info.plugins.discovered.length }} 个 / 已授权 {{ info.plugins.licensed.length }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="pluginRows" size="small" stripe>
|
||||
<el-table-column prop="type" label="插件类型" min-width="180" />
|
||||
<el-table-column label="授权" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.authorized ? 'success' : 'danger'" size="small">
|
||||
{{ row.authorized ? '已授权' : '未授权' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已加载" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.loaded ? 'primary' : 'info'" size="small">
|
||||
{{ row.loaded ? '已加载' : '未加载' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="说明" min-width="280" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="detail-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
硬件指纹来源
|
||||
<span class="count-tip">带 * 的为 weak 来源,不参与判定阈值</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="info.sources_detail" size="small" stripe>
|
||||
<el-table-column prop="name" label="来源" width="140" />
|
||||
<el-table-column prop="label" label="说明" min-width="200" />
|
||||
<el-table-column label="采集" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.present ? 'success' : 'info'" size="small">
|
||||
{{ row.present ? '成功' : '缺失' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="值" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<code>{{ row.value || '—' }}{{ row.weak && row.present ? ' *' : '' }}</code>
|
||||
<span v-if="row.detail" class="source-detail">{{ row.detail }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="hint">
|
||||
可信来源越多,授权的容错越好。若这里只有 1 个来源或大量"缺失",
|
||||
说明该硬件上的序列号不可读 —— 建议先核实再签发(换一块网卡就会失授权)。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="detail-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">导入授权文件</div>
|
||||
</template>
|
||||
<div class="upload-bar">
|
||||
<input ref="fileInput" type="file" accept=".lic,application/json" class="file-input" @change="onFilePicked" />
|
||||
<el-button :loading="uploading" @click="fileInput?.click()">
|
||||
{{ pickedFile ? `重新选择(当前:${pickedFile.name})` : '选择 license 文件' }}
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="uploading" :disabled="!pickedFile" @click="doUpload">
|
||||
导入并校验
|
||||
</el-button>
|
||||
<el-button :loading="loading" @click="doReload">从磁盘重读</el-button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
导入前会完整校验签名、机器码与有效期,**校验不通过不会覆盖**现有授权文件。
|
||||
新授权的插件需重启网关后才会加载。
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAdminAuth } from '@/composables/useAdminAuth'
|
||||
import {
|
||||
getLicenseInfo,
|
||||
reloadLicense,
|
||||
uploadLicense,
|
||||
type LicenseInfo,
|
||||
} from '@/api/license'
|
||||
|
||||
const { token, currentUser, username, password, logging, doLogin, logout } = useAdminAuth()
|
||||
|
||||
const info = ref<LicenseInfo | null>(null)
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const pickedFile = ref<File | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const statusType = computed(() => {
|
||||
if (!info.value) return 'info'
|
||||
if (!info.value.enforce) return 'info'
|
||||
return info.value.status.ok ? 'success' : 'error'
|
||||
})
|
||||
|
||||
const statusTitle = computed(() => {
|
||||
const i = info.value
|
||||
if (!i) return ''
|
||||
if (!i.enforce) return '授权校验未启用(开发/测试模式)'
|
||||
return i.status.ok ? '授权有效' : `授权无效:${i.status.message}`
|
||||
})
|
||||
|
||||
const statusDescription = computed(() => {
|
||||
const i = info.value
|
||||
if (!i) return ''
|
||||
if (!i.enforce) {
|
||||
return `当前未执行拒绝;真实校验结果:${i.status.message}`
|
||||
}
|
||||
if (i.status.ok) {
|
||||
const plugins = i.status.licensed_plugins
|
||||
return `已授权插件:${plugins.length ? plugins.join('、') : '无'}`
|
||||
}
|
||||
const denied = Object.keys(i.plugins.denied)
|
||||
return denied.length
|
||||
? `以下插件不会被加载:${denied.join('、')}`
|
||||
: '导入有效授权文件后重启网关即可加载插件。'
|
||||
})
|
||||
|
||||
interface PluginRow {
|
||||
type: string
|
||||
authorized: boolean
|
||||
loaded: boolean
|
||||
reason: string
|
||||
}
|
||||
|
||||
const pluginRows = computed<PluginRow[]>(() => {
|
||||
const i = info.value
|
||||
if (!i) return []
|
||||
const loaded = new Set(i.plugins.loaded)
|
||||
return i.plugins.discovered.map((type) => {
|
||||
const denied = i.plugins.denied[type]
|
||||
const authorized = i.plugins.licensed.includes(type)
|
||||
return {
|
||||
type,
|
||||
authorized,
|
||||
loaded: loaded.has(type),
|
||||
reason: denied
|
||||
? denied.message
|
||||
: authorized
|
||||
? '在白名单内'
|
||||
: '未在授权白名单内',
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function fetchInfo() {
|
||||
if (!token.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
info.value = await getLicenseInfo(token.value)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(`读取授权状态失败:${e.message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(text: string) {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
} catch {
|
||||
// 非 HTTPS / 无剪贴板权限时回退到选中提示,避免静默失败
|
||||
ElMessage.warning('复制失败,请手动选中复制')
|
||||
}
|
||||
}
|
||||
|
||||
function onFilePicked(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement
|
||||
pickedFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
async function doUpload() {
|
||||
if (!pickedFile.value) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const res = await uploadLicense(token.value, pickedFile.value)
|
||||
ElMessage.success(`导入成功:${res.status.message}`)
|
||||
if (res.restart_required) {
|
||||
ElMessage.warning(res.restart_hint)
|
||||
}
|
||||
pickedFile.value = null
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
await fetchInfo()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(`导入失败:${e.message}`)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doReload() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await reloadLicense(token.value)
|
||||
ElMessage.success(`已重读授权:${res.status.message}`)
|
||||
await fetchInfo()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(`重读失败:${e.message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (token.value) fetchInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.license-container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.toolbar-card,
|
||||
.machine-card,
|
||||
.detail-card {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--cm-border);
|
||||
background: var(--cm-bg-soft);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--cm-text);
|
||||
}
|
||||
.login-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.count-tip {
|
||||
margin-left: 12px;
|
||||
color: var(--cm-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.machine-code {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 26px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--cm-text);
|
||||
padding: 6px 0 10px;
|
||||
user-select: all;
|
||||
}
|
||||
.hint {
|
||||
color: var(--cm-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.copy-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.request-code {
|
||||
max-width: 460px;
|
||||
}
|
||||
.source-detail {
|
||||
margin-left: 8px;
|
||||
color: var(--cm-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.upload-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user