初始化2
This commit is contained in:
675
app/pages/developer/apikeys.vue
Normal file
675
app/pages/developer/apikeys.vue
Normal file
@@ -0,0 +1,675 @@
|
||||
<template>
|
||||
<div class="dev-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">🔑 API Key 管理</h2>
|
||||
<p class="page-desc">创建和管理你的 API Key,用于调用平台 REST API 和 SDK。</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="showCreateModal = true">
|
||||
+ 创建 API Key
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="page-body">
|
||||
<!-- 使用提示 -->
|
||||
<a-alert
|
||||
class="mb-5"
|
||||
show-icon
|
||||
type="info"
|
||||
message="安全提示"
|
||||
description="API Key 具有完整的账号访问权限,请勿在前端代码中明文使用,建议通过服务端调用 API。创建后请妥善保管,丢失后需重新生成。"
|
||||
/>
|
||||
|
||||
<!-- API Key 列表 -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">我的 API Key</span>
|
||||
<a-tag color="blue">{{ keyList.length }} 个</a-tag>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="keyList.length === 0 && !loading" class="empty-state">
|
||||
<div class="empty-icon">🔑</div>
|
||||
<div class="empty-title">还没有 API Key</div>
|
||||
<div class="empty-desc">创建第一个 API Key,开始调用平台接口</div>
|
||||
<a-button type="primary" class="mt-4" @click="showCreateModal = true">创建 API Key</a-button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="keyList.length > 0" class="key-list">
|
||||
<div v-for="key in keyList" :key="key.id" class="key-item">
|
||||
<div class="key-item-main">
|
||||
<div class="key-name-row">
|
||||
<span class="key-name">{{ key.name }}</span>
|
||||
<a-tag :color="key.status === 'active' ? 'green' : 'default'">
|
||||
{{ key.status === 'active' ? '正常' : '已禁用' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="key-value-row">
|
||||
<code class="key-value">{{ key.visible ? key.value : maskKey(key.value) }}</code>
|
||||
<a-tooltip title="显示/隐藏">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
class="key-action-btn"
|
||||
@click="toggleVisible(key)"
|
||||
>{{ key.visible ? '🙈' : '👁️' }}</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="复制">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
class="key-action-btn"
|
||||
@click="copyKey(key.value)"
|
||||
>📋</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="key-meta">
|
||||
<span>📅 创建于 {{ key.createdAt }}</span>
|
||||
<span>⏰ 最近使用:{{ key.lastUsed }}</span>
|
||||
<span v-if="key.expireAt">🕐 到期:{{ key.expireAt }}</span>
|
||||
<span v-else>♾️ 永不过期</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="key-item-actions">
|
||||
<a-button
|
||||
size="small"
|
||||
:type="key.status === 'active' ? 'default' : 'primary'"
|
||||
@click="toggleStatus(key)"
|
||||
>
|
||||
{{ key.status === 'active' ? '禁用' : '启用' }}
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确认删除该 API Key?此操作不可撤销。"
|
||||
ok-text="删除"
|
||||
ok-type="danger"
|
||||
cancel-text="取消"
|
||||
@confirm="deleteKey(key.id)"
|
||||
>
|
||||
<a-button size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- API 接入示例 -->
|
||||
<div class="panel mt-4">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">📘 接入示例</span>
|
||||
<a-radio-group v-model:value="activeTab" size="small" button-style="solid">
|
||||
<a-radio-button v-for="tab in codeTabs" :key="tab.key" :value="tab.key">{{ tab.label }}</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div class="code-example">
|
||||
<div class="code-toolbar">
|
||||
<span class="code-lang">{{ currentTab?.lang }}</span>
|
||||
<a-button type="text" size="small" @click="copyCode">📋 复制代码</a-button>
|
||||
</div>
|
||||
<pre class="code-pre"><code>{{ currentTab?.code }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 速率限制说明 -->
|
||||
<div class="panel mt-4">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">⚡ 速率限制</span>
|
||||
</div>
|
||||
<div class="rate-grid">
|
||||
<div v-for="rate in rateLimits" :key="rate.plan" class="rate-card" :class="rate.highlight ? 'highlight' : ''">
|
||||
<div class="rate-plan">{{ rate.plan }}</div>
|
||||
<div class="rate-value">{{ rate.rps }} <span class="rate-unit">次/秒</span></div>
|
||||
<div class="rate-daily">日限 {{ rate.daily }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建 API Key 弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showCreateModal"
|
||||
title="创建 API Key"
|
||||
ok-text="创建"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="creating"
|
||||
@ok="handleCreate"
|
||||
>
|
||||
<a-form layout="vertical" class="mt-2">
|
||||
<a-form-item label="名称" required>
|
||||
<a-input
|
||||
v-model:value="createForm.name"
|
||||
placeholder="例如:生产环境、测试 Key..."
|
||||
:maxlength="50"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="有效期">
|
||||
<a-radio-group v-model:value="createForm.expire">
|
||||
<a-radio value="">永不过期</a-radio>
|
||||
<a-radio value="30d">30 天</a-radio>
|
||||
<a-radio value="90d">90 天</a-radio>
|
||||
<a-radio value="1y">1 年</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="权限范围">
|
||||
<a-checkbox-group v-model:value="createForm.scopes">
|
||||
<a-checkbox value="read">读取(GET)</a-checkbox>
|
||||
<a-checkbox value="write">写入(POST/PUT)</a-checkbox>
|
||||
<a-checkbox value="delete">删除(DELETE)</a-checkbox>
|
||||
<a-checkbox value="ai">AI 接口</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea
|
||||
v-model:value="createForm.remark"
|
||||
:rows="2"
|
||||
placeholder="可选,用途说明"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
pageAppApiKey,
|
||||
createAppApiKey,
|
||||
updateAppApiKeyStatus,
|
||||
removeAppApiKey,
|
||||
getApiRateLimits
|
||||
} from '@/api/app/apikey'
|
||||
|
||||
definePageMeta({ layout: 'developer' })
|
||||
useHead({ title: 'API Key 管理 - 开发者中心' })
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const activeTab = ref('ts')
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
expire: '',
|
||||
scopes: ['read', 'write'],
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// API Key 列表
|
||||
const keyList = ref<Array<{
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
status: 'active' | 'disabled' | 'expired'
|
||||
createdAt: string
|
||||
lastUsed: string
|
||||
expireAt: string
|
||||
visible: boolean
|
||||
usageCount?: number
|
||||
scopes?: string[]
|
||||
remark?: string
|
||||
}>>([])
|
||||
|
||||
// 速率限制信息
|
||||
const rateLimits = ref<Array<{
|
||||
plan: string
|
||||
rps: string
|
||||
daily: string
|
||||
dailyLimit?: number
|
||||
usedToday?: number
|
||||
remainingToday?: number
|
||||
highlight: boolean
|
||||
}>>([])
|
||||
|
||||
// 加载API Key列表
|
||||
async function loadApiKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await pageAppApiKey({ page: 1, limit: 100 })
|
||||
console.log('API Key 返回数据:', result)
|
||||
keyList.value = (result.list || []).map((key) => ({
|
||||
id: String(key.id),
|
||||
name: key.name,
|
||||
value: key.apiKey || key.keyPrefix,
|
||||
status: key.status === 0 ? 'active' : 'disabled',
|
||||
createdAt: formatDate(key.createTime),
|
||||
lastUsed: key.lastUsedAt ? formatRelativeTime(key.lastUsedAt) : '从未使用',
|
||||
expireAt: key.expireTime ? formatDate(key.expireTime) : '',
|
||||
visible: false,
|
||||
usageCount: key.usageCount,
|
||||
scopes: key.scopes ? (typeof key.scopes === 'string' ? JSON.parse(key.scopes) : key.scopes) : [],
|
||||
remark: key.remark
|
||||
}))
|
||||
} catch (error: any) {
|
||||
console.error('加载API Key列表失败:', error)
|
||||
message.error(error.message || '加载API Key列表失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载速率限制信息
|
||||
async function loadRateLimits() {
|
||||
try {
|
||||
const rateData = await getApiRateLimits()
|
||||
|
||||
// 更新默认的速率限制显示
|
||||
rateLimits.value = [
|
||||
{ plan: '免费版', rps: '5', daily: '1,000 次', highlight: rateData.plan === '免费版' },
|
||||
{ plan: '基础版', rps: '20', daily: '10,000 次', highlight: rateData.plan === '基础版' },
|
||||
{ plan: '专业版', rps: '100', daily: '100,000 次', highlight: rateData.plan === '专业版' },
|
||||
{ plan: '企业版', rps: '自定义', daily: '不限', highlight: rateData.plan === '企业版' },
|
||||
]
|
||||
|
||||
// 如果API返回了实际数据,更新当前套餐
|
||||
const currentPlanIndex = rateLimits.value.findIndex(r => r.plan.includes(rateData.plan))
|
||||
if (currentPlanIndex !== -1) {
|
||||
rateLimits.value[currentPlanIndex] = {
|
||||
...rateLimits.value[currentPlanIndex],
|
||||
dailyLimit: rateData.dailyLimit,
|
||||
usedToday: rateData.usedToday,
|
||||
remainingToday: rateData.remainingToday,
|
||||
daily: `${(rateData.usedToday || 0).toLocaleString()} / ${(rateData.dailyLimit || 0).toLocaleString()} 次`,
|
||||
highlight: true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载速率限制失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function maskKey(val: string) {
|
||||
if (!val) return ''
|
||||
return val.slice(0, 8) + '••••••••••••••••' + val.slice(-4)
|
||||
}
|
||||
|
||||
function toggleVisible(key: any) {
|
||||
key.visible = !key.visible
|
||||
}
|
||||
|
||||
async function copyKey(val: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(val)
|
||||
message.success('已复制到剪贴板')
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换API Key状态
|
||||
async function toggleStatus(key: any) {
|
||||
try {
|
||||
const newStatus = key.status === 'active' ? 1 : 0 // 后端: 0=正常, 1=禁用
|
||||
await updateAppApiKeyStatus(Number(key.id), newStatus)
|
||||
key.status = newStatus === 0 ? 'active' : 'disabled'
|
||||
message.success(key.status === 'active' ? '已启用' : '已禁用')
|
||||
} catch (error: any) {
|
||||
console.error('更新API Key状态失败:', error)
|
||||
message.error(error.message || '操作失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除API Key
|
||||
async function deleteKey(id: string) {
|
||||
try {
|
||||
await removeAppApiKey(Number(id))
|
||||
await loadApiKeys()
|
||||
message.success('API Key 已删除')
|
||||
} catch (error: any) {
|
||||
console.error('删除API Key失败:', error)
|
||||
message.error(error.message || '删除失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 创建API Key
|
||||
async function handleCreate() {
|
||||
if (!createForm.name.trim()) {
|
||||
message.error('请填写 API Key 名称')
|
||||
return
|
||||
}
|
||||
|
||||
// 将过期选项转换为过期时间
|
||||
let expireTime: string | undefined
|
||||
if (createForm.expire) {
|
||||
const now = new Date()
|
||||
switch (createForm.expire) {
|
||||
case '30d': now.setDate(now.getDate() + 30); break
|
||||
case '90d': now.setDate(now.getDate() + 90); break
|
||||
case '1y': now.setFullYear(now.getFullYear() + 1); break
|
||||
}
|
||||
expireTime = now.toISOString()
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const result = await createAppApiKey({
|
||||
name: createForm.name.trim(),
|
||||
expireTime,
|
||||
scopes: JSON.stringify(createForm.scopes),
|
||||
remark: createForm.remark.trim() || undefined
|
||||
})
|
||||
|
||||
message.success('API Key 创建成功')
|
||||
showCreateModal.value = false
|
||||
|
||||
// 重置表单
|
||||
Object.assign(createForm, { name: '', expire: '', scopes: ['read', 'write'], remark: '' })
|
||||
|
||||
// 重新加载列表
|
||||
await loadApiKeys()
|
||||
|
||||
// 显示新创建的Key提示
|
||||
message.info(`新Key前缀: ${result.keyPrefix || 'sk-'}...,请在列表中查看完整密钥`)
|
||||
} catch (error: any) {
|
||||
console.error('创建API Key失败:', error)
|
||||
message.error(error.message || '创建失败,请稍后重试')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数:格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 工具函数:格式化相对时间
|
||||
function formatRelativeTime(dateStr: string) {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffMins < 1) return '刚刚'
|
||||
if (diffMins < 60) return `${diffMins}分钟前`
|
||||
if (diffHours < 24) return `${diffHours}小时前`
|
||||
if (diffDays < 30) return `${diffDays}天前`
|
||||
return formatDate(dateStr)
|
||||
}
|
||||
|
||||
const codeTabs = [
|
||||
{
|
||||
key: 'ts',
|
||||
label: 'TypeScript',
|
||||
lang: 'TypeScript',
|
||||
code: `import { WebsopyClient } from '@websopy/sdk'
|
||||
|
||||
const client = new WebsopyClient({
|
||||
apiKey: 'sk-xxxxxxxxxxxxxxxx' // 替换为你的 API Key
|
||||
})
|
||||
|
||||
const reply = await client.agent.chat({
|
||||
message: '你好,帮我查询今日数据'
|
||||
})
|
||||
|
||||
console.log(reply.content)`,
|
||||
},
|
||||
{
|
||||
key: 'python',
|
||||
label: 'Python',
|
||||
lang: 'Python',
|
||||
code: `from websopy import WebsopyClient
|
||||
|
||||
client = WebsopyClient(api_key="sk-xxxxxxxxxxxxxxxx")
|
||||
|
||||
reply = client.agent.chat(
|
||||
message="你好,帮我查询今日数据"
|
||||
)
|
||||
|
||||
print(reply.content)`,
|
||||
},
|
||||
{
|
||||
key: 'curl',
|
||||
label: 'cURL',
|
||||
lang: 'Shell',
|
||||
code: `curl -X POST https://api.websopy.com/v1/agent/chat \\
|
||||
-H "Authorization: Bearer sk-xxxxxxxxxxxxxxxx" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"message": "你好,帮我查询今日数据"}'`,
|
||||
},
|
||||
]
|
||||
|
||||
const currentTab = computed(() => codeTabs.find(t => t.key === activeTab.value))
|
||||
|
||||
async function copyCode() {
|
||||
const code = currentTab.value?.code || ''
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
message.success('代码已复制')
|
||||
} catch {
|
||||
message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时初始化
|
||||
onMounted(() => {
|
||||
loadApiKeys()
|
||||
loadRateLimits()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
padding: 20px 24px 28px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-title { font-size: 16px; font-weight: 600; color: rgba(0, 0, 0, 0.7); }
|
||||
.empty-desc { font-size: 13px; color: rgba(0, 0, 0, 0.4); margin-top: 6px; }
|
||||
|
||||
/* Key 列表 */
|
||||
.key-list {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.key-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.key-item:hover { background: #fafafa; }
|
||||
.key-item:last-child { border-bottom: none; }
|
||||
.key-item-main { flex: 1; min-width: 0; }
|
||||
|
||||
.key-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.key-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.key-value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.key-value {
|
||||
font-family: 'Fira Code', 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: #f5f5f5;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.key-action-btn {
|
||||
padding: 0 5px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.key-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.key-item-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 代码示例 */
|
||||
.code-example {
|
||||
background: #0f0f23;
|
||||
}
|
||||
|
||||
.code-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.code-lang {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.code-toolbar :deep(.ant-btn) {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.code-toolbar :deep(.ant-btn:hover) {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.code-pre {
|
||||
padding: 16px 20px;
|
||||
margin: 0;
|
||||
font-family: 'Fira Code', 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #e2e8f0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 速率限制 */
|
||||
.rate-grid {
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rate-card {
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.rate-card.highlight {
|
||||
background: linear-gradient(135deg, #f0f0ff 0%, #f5f3ff 100%);
|
||||
border-color: #c4b5fd;
|
||||
}
|
||||
|
||||
.rate-plan {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rate-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.rate-unit {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.rate-daily {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user