feat(widget): Widget 配置、loader 与右下角悬浮入口

生成可嵌入站点的 loader 与悬浮按钮,支持展开收起和移动端;附独立宿主测试页。
This commit is contained in:
WangShen007 committed 2026-09-25 20:50:00 +08:00
1 parent 556b650c7a
commit 0cc6299d1c
57 files changed
+32086

No files matched your search

@@ -0,0 +1,208 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref } from 'vue'
import { ClipboardIcon, CheckIcon } from '@heroicons/vue/24/outline'
import Modal from '@/components/common/Modal.vue'
import { toast } from 'vue-sonner'
const props = defineProps<{
apiKey: string
}>()
const emit = defineEmits<{
close: []
}>()
const copied = ref(false)
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(props.apiKey)
copied.value = true
toast.success('已复制!', {
description: 'API 密钥已复制到剪贴板',
duration: 2000
})
setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
toast.error('复制失败', {
description: '请手动复制',
duration: 2000
})
}
}
</script>
<template>
<Modal @close="emit('close')">
<template #title>API 密钥已创建</template>
<template #content>
<div class="api-key-modal">
<div class="warning-banner">
<strong>⚠️ 请立即保存此密钥!</strong>
<p>这是唯一一次显示该 API 密钥,之后将无法再次查看。</p>
</div>
<div class="key-container">
<label>API 密钥:</label>
<div class="key-display">
<code class="api-key">{{ apiKey }}</code>
<button
class="copy-button"
@click="copyToClipboard"
:title="copied ? '已复制!' : '复制到剪贴板'"
>
<CheckIcon v-if="copied" class="icon success" />
<ClipboardIcon v-else class="icon" />
</button>
</div>
</div>
<div class="instructions">
<h4>后续步骤:</h4>
<ol>
<li>复制该 API 密钥并妥善保管</li>
<li>用它来验证组件 Token 生成请求</li>
<li>将其放入 Authorization 请求头:<code>Bearer {{ apiKey.substring(0, 20) }}...</code></li>
</ol>
</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="emit('close')">
我已保存密钥
</button>
</div>
</div>
</template>
</Modal>
</template>
<style scoped>
.api-key-modal {
padding: var(--space-md);
max-width: 600px;
}
.warning-banner {
background: var(--warning-bg);
border: 1px solid var(--warning-color);
border-radius: var(--radius-md);
padding: var(--space-md);
margin-bottom: var(--space-lg);
}
.warning-banner strong {
display: block;
color: var(--warning-color);
margin-bottom: var(--space-xs);
}
.warning-banner p {
margin: 0;
font-size: var(--text-sm);
}
.key-container {
margin-bottom: var(--space-lg);
}
.key-container label {
display: block;
font-weight: 500;
margin-bottom: var(--space-sm);
}
.key-display {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--code-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
.api-key {
flex: 1;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: var(--text-sm);
word-break: break-all;
user-select: all;
}
.copy-button {
padding: var(--space-xs);
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition-normal);
}
.copy-button:hover {
background: var(--hover-bg);
}
.icon {
width: 20px;
height: 20px;
color: var(--text-color);
}
.icon.success {
color: var(--success-color);
}
.instructions {
background: var(--info-bg);
border: 1px solid var(--info-color);
border-radius: var(--radius-md);
padding: var(--space-md);
margin-bottom: var(--space-lg);
}
.instructions h4 {
margin-top: 0;
margin-bottom: var(--space-sm);
color: var(--info-color);
}
.instructions ol {
margin: 0;
padding-left: var(--space-lg);
}
.instructions li {
margin-bottom: var(--space-xs);
font-size: var(--text-sm);
}
.instructions code {
background: var(--code-bg);
padding: 2px 4px;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
}
.modal-actions {
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,204 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref } from 'vue'
import type { WidgetApp, WidgetAppCreate } from '@/types/widget-app'
const props = defineProps<{
app?: WidgetApp | null
}>()
// Always emit WidgetAppCreate since the form requires name
// The parent component uses this for both create and update operations
const emit = defineEmits<{
submit: [data: WidgetAppCreate]
cancel: []
}>()
const name = ref(props.app?.name || '')
const description = ref(props.app?.description || '')
const error = ref('')
const handleSubmit = () => {
error.value = ''
if (!name.value.trim()) {
error.value = '请输入名称'
return
}
if (name.value.length > 100) {
error.value = '名称不能超过 100 个字符'
return
}
if (description.value && description.value.length > 500) {
error.value = '描述不能超过 500 个字符'
return
}
emit('submit', {
name: name.value.trim(),
description: description.value.trim() || undefined
})
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="widget-app-form">
<div class="form-group">
<label for="name">名称 <span class="required">*</span></label>
<input
id="name"
v-model="name"
type="text"
placeholder="例如:市场官网"
maxlength="100"
required
/>
<span class="hint">便于识别的网站组件名称</span>
</div>
<div class="form-group">
<label for="description">描述</label>
<textarea
id="description"
v-model="description"
placeholder="该组件将部署在哪里?"
maxlength="500"
rows="3"
/>
<span class="hint">可选:说明该组件的用途</span>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
取消
</button>
<button type="submit" class="btn btn-primary">
{{ app ? '更新组件' : '创建组件' }}
</button>
</div>
</form>
</template>
<style scoped>
.widget-app-form {
padding: 0;
}
.form-group {
margin-bottom: var(--space-lg);
}
.form-group label {
display: block;
margin-bottom: 9px;
font-family: var(--font-display);
font-weight: 600;
font-size: 15px;
color: var(--text);
}
.required {
color: var(--c-coral);
}
.form-group input,
.form-group textarea {
width: 100%;
box-sizing: border-box;
padding: 13px 15px;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: var(--radius-input);
color: var(--text);
font-family: var(--font-sans);
font-size: 14.5px;
}
.form-group textarea {
resize: vertical;
line-height: 1.5;
}
.form-group input::placeholder,
.form-group textarea::placeholder {
color: var(--faint);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-ink);
box-shadow: var(--ring-focus);
}
.hint {
display: block;
margin-top: 8px;
font-size: 13px;
color: var(--muted);
}
.error-message {
padding: var(--space-sm) var(--space-md);
background: var(--error-bg);
color: var(--error-color);
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: var(--space-lg);
}
.btn {
padding: 12px 22px;
border-radius: var(--radius-btn);
font-family: var(--font-sans);
font-size: 14.5px;
font-weight: 600;
cursor: pointer;
transition: background-color var(--transition-fast), filter var(--transition-fast);
}
.btn-primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
}
.btn-primary:hover {
filter: brightness(1.05);
}
.btn-secondary {
background: var(--o05);
border: 1px solid var(--o14);
color: var(--text);
}
.btn-secondary:hover {
background: var(--o10);
}
</style>
@@ -0,0 +1,859 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { useWidgetApps } from '@/composables/useWidgetApps'
import { PlusIcon } from '@heroicons/vue/24/outline'
import Modal from '@/components/common/Modal.vue'
import WidgetAppForm from './WidgetAppForm.vue'
import ApiKeyModal from './ApiKeyModal.vue'
import { permissionChecks } from '@/utils/permissions'
import { useSubscriptionStorage } from '@/utils/storage'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
const subscriptionStorage = useSubscriptionStorage()
const { hasEnterpriseModule } = useEnterpriseFeatures()
const {
apps,
loading,
error,
showCreateModal,
showEditModal,
showDeleteModal,
showApiKeyModal,
selectedApp,
newApiKey,
showInactive,
hasAnyApps,
hasInactiveApps,
fetchApps,
handleCreateApp,
handleEditApp,
handleUpdateApp,
handleDeleteApp,
confirmDeleteApp,
handleRegenerateKey,
closeApiKeyModal
} = useWidgetApps()
const canManage = computed(() => permissionChecks.canManageOrganization())
// Subscription and widget apps feature checking
const currentSubscription = computed(() => subscriptionStorage.getCurrentSubscription())
const isSubscriptionActive = computed(() => subscriptionStorage.isSubscriptionActive())
// Check if widget apps is locked (only if enterprise module exists)
const isWidgetAppsLocked = computed(() => {
// Only lock if enterprise module exists
if (!hasEnterpriseModule) {
return false
}
if (!currentSubscription.value || !isSubscriptionActive.value) {
return true
}
// Check if api_access feature exists in subscription (required for widget apps)
const hasApiAccessFeature = subscriptionStorage.hasFeature('api_access')
if (!hasApiAccessFeature) {
return true // Lock widget apps if feature doesn't exist
}
return false
})
// Upgrade modal functions
const handleUpgrade = () => {
// Only redirect to subscription page if enterprise module exists
if (hasEnterpriseModule) {
window.location.href = '/settings/subscription'
}
}
onMounted(async () => {
if (canManage.value && !isWidgetAppsLocked.value) {
await fetchApps()
}
})
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<template>
<div v-if="!canManage" class="no-permission">
<p>你没有管理网站组件的权限。</p>
</div>
<div v-else class="widget-app-list">
<!-- Widget Apps Locked Overlay -->
<div v-if="isWidgetAppsLocked" class="widget-apps-locked-overlay">
<div class="locked-content">
<div class="locked-header">
<div class="locked-icon-wrapper">
<div class="locked-icon-bg">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="locked-icon">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
</div>
</div>
<h2>网站组件</h2>
<div class="locked-badge">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="badge-icon">
<path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 00-5.25 5.25v3a3 3 0 00-3 3v6.75a3 3 0 003 3h10.5a3 3 0 003-3v-6.75a3 3 0 00-3-3v-3c0-2.9-2.35-5.25-5.25-5.25zm3.75 8.25v-3a3.75 3.75 0 10-7.5 0v3h7.5z" clip-rule="evenodd" />
</svg>
<span>付费功能</span>
</div>
</div>
<p class="locked-description">
解锁网站组件,即可创建安全的 API 密钥,用于在多个平台与应用中验证聊天组件。
</p>
<div class="locked-features">
<div class="feature-item">
<div class="feature-icon-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<div class="feature-content">
<span class="feature-title">安全 API 密钥</span>
<span class="feature-desc">为每个集成生成独立密钥</span>
</div>
</div>
<div class="feature-item">
<div class="feature-icon-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<div class="feature-content">
<span class="feature-title">访问控制</span>
<span class="feature-desc">随时管理与吊销访问权限</span>
</div>
</div>
<div class="feature-item">
<div class="feature-icon-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="feature-icon">
<path d="M21 12a9 9 0 0 1-9 9m9-9a9 9 0 0 0-9-9m9 9H3m9 9a9 9 0 0 1-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/>
</svg>
</div>
<div class="feature-content">
<span class="feature-title">多平台</span>
<span class="feature-desc">在多个站点部署组件</span>
</div>
</div>
</div>
<button class="upgrade-btn" @click="handleUpgrade">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="upgrade-icon">
<path fill-rule="evenodd" d="M12.516 2.17a.75.75 0 00-1.032 0 11.209 11.209 0 01-7.877 3.08.75.75 0 00-.722.515A12.74 12.74 0 002.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 00.374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 00-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08zm3.094 8.016a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clip-rule="evenodd" />
</svg>
升级到 Pro
</button>
</div>
</div>
<!-- Normal content when not locked -->
<template v-else>
<!-- Header -->
<header class="page-header">
<div class="page-header-text">
<h1>网站组件</h1>
<p class="subtitle">
生成安全的 API 密钥,用于验证你的聊天组件。
<span v-if="hasAnyApps" class="subtitle-count">{{ apps.length }} 个组件</span>
</p>
</div>
<button v-if="hasAnyApps" class="btn btn-primary" @click="showCreateModal = true">
<PlusIcon class="icon" />
创建组件
</button>
</header>
<!-- Include inactive toggle (only show when there are inactive apps) -->
<div v-if="hasInactiveApps" class="filters">
<label class="checkbox-label">
<input
type="checkbox"
v-model="showInactive"
/>
显示已停用组件
</label>
</div>
<!-- Loading state -->
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>正在加载网站组件…</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-state">
<p>{{ error }}</p>
<button class="btn btn-secondary" @click="fetchApps">重试</button>
</div>
<!-- Empty state (only when no apps at all) -->
<div v-else-if="!hasAnyApps" class="empty-state">
<div class="empty-content">
<div class="empty-icon-bg">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" class="empty-icon">
<rect x="4" y="4" width="7" height="7" rx="2"/>
<rect x="13" y="4" width="7" height="7" rx="2"/>
<rect x="4" y="13" width="7" height="7" rx="2"/>
<rect x="13" y="13" width="7" height="7" rx="2"/>
</svg>
</div>
<h2>暂无网站组件</h2>
<p class="empty-description">
网站组件可用于生成安全的 API 密钥,以验证你的聊天组件。
创建第一个组件即可开始。
</p>
<button class="btn btn-primary create-btn" @click="showCreateModal = true">
<PlusIcon class="icon" />
创建第一个组件
</button>
</div>
</div>
<!-- Table -->
<div v-else-if="apps.length > 0" class="table-container">
<div class="table-head">
<span class="col-name">名称</span>
<span class="col-desc">描述</span>
<span class="col-created">创建时间</span>
<span class="col-updated">更新时间</span>
<span class="col-actions">操作</span>
</div>
<div
v-for="app in apps"
:key="app.id"
:class="['table-row', { inactive: !app.is_active }]"
>
<div class="cell-name">
<div class="app-name">{{ app.name }}</div>
<span :class="['status-badge', app.is_active ? 'active' : 'inactive']">
<span class="status-dot"></span>
{{ app.is_active ? '启用中' : '已停用' }}
</span>
</div>
<div class="cell-desc">{{ app.description || '—' }}</div>
<div class="cell-date">{{ formatDate(app.created_at) }}</div>
<div class="cell-date">{{ formatDate(app.updated_at) }}</div>
<div class="cell-actions">
<button
class="action-btn"
title="编辑"
@click="handleEditApp(app)"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
</button>
<button
class="action-btn"
title="重新生成密钥"
@click="handleRegenerateKey(app)"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
</button>
<button
class="action-btn action-btn-danger"
title="吊销"
@click="handleDeleteApp(app)"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M6 6l1 14a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-14"/></svg>
</button>
</div>
</div>
</div>
<!-- All apps filtered state (all inactive and showInactive is false) -->
<div v-else-if="hasAnyApps && apps.length === 0" class="filtered-state">
<p>所有网站组件当前均已停用。</p>
<button class="btn btn-secondary" @click="showInactive = true">
显示已停用组件
</button>
</div>
<!-- Create Modal -->
<Modal v-if="showCreateModal" @close="showCreateModal = false">
<template #title>创建网站组件</template>
<template #content>
<WidgetAppForm
@submit="handleCreateApp"
@cancel="showCreateModal = false"
/>
</template>
</Modal>
<!-- Edit Modal -->
<Modal v-if="showEditModal && selectedApp" @close="showEditModal = false">
<template #title>编辑网站组件</template>
<template #content>
<WidgetAppForm
:app="selectedApp"
@submit="handleUpdateApp"
@cancel="showEditModal = false"
/>
</template>
</Modal>
<!-- Delete Confirmation Modal -->
<Modal v-if="showDeleteModal && selectedApp" @close="showDeleteModal = false">
<template #title>删除网站组件</template>
<template #content>
<div class="confirm-delete">
<p>确定要删除 <strong>{{ selectedApp.name }}</strong> 吗?</p>
<p class="warning">此操作将立即使该 API 密钥失效。</p>
<div class="modal-actions">
<button class="btn btn-danger" @click="confirmDeleteApp">
删除
</button>
<button class="btn btn-secondary" @click="showDeleteModal = false">
取消
</button>
</div>
</div>
</template>
</Modal>
<!-- API Key Display Modal -->
<ApiKeyModal
v-if="showApiKeyModal && newApiKey"
:apiKey="newApiKey"
@close="closeApiKeyModal"
/>
</template>
</div>
</template>
<style scoped>
.widget-app-list {
width: 100%;
max-width: 1100px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 22px;
}
.page-header h1 {
font-family: var(--font-display);
font-weight: 700;
font-size: 30px;
letter-spacing: -0.02em;
margin: 0 0 6px;
color: var(--text);
}
.subtitle {
color: var(--muted);
font-size: 15px;
margin: 0;
}
.subtitle-count {
color: var(--muted2);
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
}
.page-header .btn-primary {
padding: 12px 20px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: var(--radius-btn);
font-size: 14.5px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: filter var(--transition-fast, 0.15s);
}
.page-header .btn-primary:hover {
filter: brightness(1.05);
}
.page-header .btn-primary .icon {
width: 17px;
height: 17px;
}
.filters {
margin-bottom: var(--space-lg);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
}
.table-container {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 18px;
padding: 18px 0 6px;
}
.table-head,
.table-row {
display: grid;
grid-template-columns: minmax(140px, 1.3fr) minmax(160px, 1.6fr) 130px 130px 130px;
gap: 14px;
align-items: center;
}
.table-head {
padding: 0 22px 14px;
}
.table-head span {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.05em;
color: var(--faint);
}
.table-head .col-actions {
text-align: right;
}
.table-row {
padding: 15px 22px;
border-top: 1px solid var(--o06);
}
.table-row.inactive {
opacity: 0.6;
}
.cell-name {
min-width: 0;
}
.app-name {
font-size: 14px;
font-weight: 600;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-desc {
font-size: 13px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-date {
font-size: 12.5px;
color: var(--muted);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 4px;
padding: 2px 8px;
border-radius: var(--radius-pill);
font-size: 10.5px;
font-weight: 500;
}
.status-dot {
width: 5px;
height: 5px;
border-radius: 50%;
}
.status-badge.active {
background: var(--teal-bg);
border: 1px solid var(--teal-border);
color: var(--c-online);
}
.status-badge.active .status-dot {
background: var(--c-teal);
}
.status-badge.inactive {
background: var(--o06);
border: 1px solid var(--o10);
color: var(--muted);
}
.status-badge.inactive .status-dot {
background: var(--muted);
}
.cell-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.action-btn {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--o05);
border: 1px solid var(--o12);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast, 0.15s), color var(--transition-fast, 0.15s);
}
.action-btn:hover {
background: var(--o10);
color: var(--text2);
}
.action-btn-danger {
background: transparent;
border: 1px solid var(--coral-border);
color: var(--c-coral);
}
.action-btn-danger:hover {
background: var(--coral-bg);
color: var(--c-coral);
}
.confirm-delete {
padding: var(--space-md);
}
.confirm-delete .warning {
color: var(--error-color);
margin-top: var(--space-sm);
font-size: var(--text-sm);
}
.modal-actions {
display: flex;
gap: var(--space-md);
margin-top: var(--space-lg);
}
.icon {
width: 20px;
height: 20px;
}
.loading-state,
.error-state,
.no-permission,
.filtered-state {
text-align: center;
padding: var(--space-2xl);
}
.filtered-state {
background: var(--background-soft);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
}
.filtered-state p {
color: var(--text-muted);
margin-bottom: var(--space-md);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--space-md);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Empty State Styles */
.empty-state {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 20px;
padding: 48px 32px;
text-align: center;
}
.empty-content {
max-width: 440px;
margin: 0 auto;
}
.empty-icon-bg {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
margin: 0 auto 18px;
border-radius: 16px;
background: var(--accent-bg-12);
border: 1px solid var(--accent-border);
}
.empty-icon {
width: 28px;
height: 28px;
color: var(--accent-ink);
}
.empty-content h2 {
font-family: var(--font-display);
font-weight: 700;
font-size: 22px;
color: var(--text);
margin: 0 0 8px;
}
.empty-description {
font-size: 14.5px;
color: var(--muted);
line-height: 1.55;
margin: 0 auto 26px;
}
.create-btn {
padding: 13px 24px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: var(--radius-btn);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: filter var(--transition-fast, 0.15s);
}
.create-btn:hover {
filter: brightness(1.05);
}
.create-btn .icon {
width: 17px;
height: 17px;
}
/* Widget Apps Locked Overlay Styles */
.widget-apps-locked-overlay {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
background: var(--background-soft);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
position: relative;
overflow: hidden;
}
.widget-apps-locked-overlay::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(201, 242, 78, 0.04) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(59, 130, 246, 0.05) 0%, transparent 50%);
pointer-events: none;
}
.widget-apps-locked-overlay .locked-content {
text-align: center;
max-width: 500px;
padding: var(--space-xl);
position: relative;
z-index: 1;
}
.widget-apps-locked-overlay .locked-header {
margin-bottom: var(--space-lg);
}
.widget-apps-locked-overlay .locked-icon-wrapper {
margin-bottom: var(--space-md);
}
.widget-apps-locked-overlay .locked-icon-bg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: linear-gradient(135deg, var(--accent-solid), var(--primary-dark));
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.widget-apps-locked-overlay .locked-icon {
width: 40px;
height: 40px;
color: var(--on-accent-solid);
}
.widget-apps-locked-overlay .locked-header h2 {
font-size: var(--text-2xl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-sm);
}
.widget-apps-locked-overlay .locked-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background: var(--warning-bg);
color: var(--warning-color);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
}
.widget-apps-locked-overlay .badge-icon {
width: 14px;
height: 14px;
}
.widget-apps-locked-overlay .locked-description {
font-size: var(--text-base);
color: var(--text-muted);
line-height: 1.6;
margin-bottom: var(--space-xl);
}
.widget-apps-locked-overlay .locked-features {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.widget-apps-locked-overlay .feature-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--background-color);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
text-align: left;
}
.widget-apps-locked-overlay .feature-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: var(--info-bg);
border-radius: var(--radius-md);
flex-shrink: 0;
}
.widget-apps-locked-overlay .feature-icon {
width: 22px;
height: 22px;
color: var(--info-color);
}
.widget-apps-locked-overlay .feature-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.widget-apps-locked-overlay .feature-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
}
.widget-apps-locked-overlay .feature-desc {
font-size: var(--text-sm);
color: var(--text-muted);
}
.widget-apps-locked-overlay .upgrade-btn {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-xl);
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: var(--radius-md);
font-size: var(--text-base);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-normal);
box-shadow: var(--shadow-md);
}
.widget-apps-locked-overlay .upgrade-btn:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.widget-apps-locked-overlay .upgrade-icon {
width: 20px;
height: 20px;
}
</style>