feat(widget): Widget 配置、loader 与右下角悬浮入口
生成可嵌入站点的 loader 与悬浮按钮,支持展开收起和移动端;附独立宿主测试页。
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user