mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat: productize webchat channel config
This commit is contained in:
parent
1c2acfd2e9
commit
a219f92410
@ -1,6 +1,8 @@
|
||||
package vip.mate.channel.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -8,7 +10,10 @@ import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.repository.ChannelMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 渠道业务服务
|
||||
@ -24,6 +29,8 @@ import java.util.List;
|
||||
public class ChannelService {
|
||||
|
||||
private final ChannelMapper channelMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
/**
|
||||
* 获取所有渠道列表
|
||||
@ -97,6 +104,9 @@ public class ChannelService {
|
||||
if (channel.getEnabled() == null) {
|
||||
channel.setEnabled(false);
|
||||
}
|
||||
if ("webchat".equals(channel.getChannelType())) {
|
||||
channel.setConfigJson(enrichWebChatConfig(channel.getConfigJson(), null));
|
||||
}
|
||||
channelMapper.insert(channel);
|
||||
log.info("Created channel: {} (type={})", channel.getName(), channel.getChannelType());
|
||||
return channel;
|
||||
@ -107,6 +117,9 @@ public class ChannelService {
|
||||
*/
|
||||
public ChannelEntity updateChannel(ChannelEntity channel) {
|
||||
ChannelEntity existing = getChannel(channel.getId());
|
||||
if ("webchat".equals(channel.getChannelType())) {
|
||||
channel.setConfigJson(enrichWebChatConfig(channel.getConfigJson(), existing.getConfigJson()));
|
||||
}
|
||||
channelMapper.updateById(channel);
|
||||
log.info("Updated channel: {}", existing.getName());
|
||||
return channel;
|
||||
@ -131,4 +144,63 @@ public class ChannelService {
|
||||
log.info("Channel {} {}", channel.getName(), enabled ? "enabled" : "disabled");
|
||||
return channel;
|
||||
}
|
||||
|
||||
private String enrichWebChatConfig(String incomingConfigJson, String existingConfigJson) {
|
||||
Map<String, Object> incoming = parseConfig(incomingConfigJson);
|
||||
Map<String, Object> existing = parseConfig(existingConfigJson);
|
||||
|
||||
String existingApiKey = asNonBlankString(existing.get("api_key"));
|
||||
incoming.put("api_key", existingApiKey != null ? existingApiKey : generateWebChatApiKey());
|
||||
|
||||
if (!incoming.containsKey("title") && existing.containsKey("title")) {
|
||||
incoming.put("title", existing.get("title"));
|
||||
}
|
||||
if (!incoming.containsKey("placeholder") && existing.containsKey("placeholder")) {
|
||||
incoming.put("placeholder", existing.get("placeholder"));
|
||||
}
|
||||
if (!incoming.containsKey("primary_color") && existing.containsKey("primary_color")) {
|
||||
incoming.put("primary_color", existing.get("primary_color"));
|
||||
}
|
||||
if (!incoming.containsKey("welcome_message") && existing.containsKey("welcome_message")) {
|
||||
incoming.put("welcome_message", existing.get("welcome_message"));
|
||||
}
|
||||
if (!incoming.containsKey("allowed_origins") && existing.containsKey("allowed_origins")) {
|
||||
incoming.put("allowed_origins", existing.get("allowed_origins"));
|
||||
}
|
||||
|
||||
try {
|
||||
return objectMapper.writeValueAsString(incoming);
|
||||
} catch (Exception e) {
|
||||
throw new MateClawException("WebChat 渠道配置序列化失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseConfig(String configJson) {
|
||||
if (configJson == null || configJson.isBlank()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(configJson, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse channel configJson: {}", e.getMessage());
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private String asNonBlankString(Object value) {
|
||||
if (value == null) return null;
|
||||
String text = String.valueOf(value).trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
private String generateWebChatApiKey() {
|
||||
byte[] random = new byte[18];
|
||||
SECURE_RANDOM.nextBytes(random);
|
||||
StringBuilder sb = new StringBuilder("mc_webchat_");
|
||||
for (byte b : random) {
|
||||
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
sb.append(Character.forDigit(b & 0xF, 16));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package vip.mate.channel.webchat;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -44,6 +46,7 @@ public class WebChatController {
|
||||
private final AgentService agentService;
|
||||
private final ConversationService conversationService;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||
|
||||
@ -156,9 +159,14 @@ public class WebChatController {
|
||||
if (channel == null) {
|
||||
return R.fail(401, "Invalid API Key");
|
||||
}
|
||||
JsonNode config = parseConfig(channel.getConfigJson());
|
||||
return R.ok(Map.of(
|
||||
"channelName", channel.getName(),
|
||||
"agentId", channel.getAgentId() != null ? channel.getAgentId() : 0
|
||||
"agentId", channel.getAgentId() != null ? channel.getAgentId() : 0,
|
||||
"title", textOrDefault(config, "title", channel.getName()),
|
||||
"placeholder", textOrDefault(config, "placeholder", "Type a message..."),
|
||||
"primaryColor", textOrDefault(config, "primary_color", "#409eff"),
|
||||
"welcomeMessage", textOrDefault(config, "welcome_message", "")
|
||||
));
|
||||
}
|
||||
|
||||
@ -174,14 +182,40 @@ public class WebChatController {
|
||||
List<ChannelEntity> channels = channelService.listChannelsByType("webchat");
|
||||
for (ChannelEntity channel : channels) {
|
||||
if (!Boolean.TRUE.equals(channel.getEnabled())) continue;
|
||||
String configJson = channel.getConfigJson();
|
||||
if (configJson != null && configJson.contains(apiKey)) {
|
||||
JsonNode config = parseConfig(channel.getConfigJson());
|
||||
String configuredApiKey = textOrDefault(config, "api_key", null);
|
||||
if (configuredApiKey != null && apiKey.equals(configuredApiKey)) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private JsonNode parseConfig(String configJson) {
|
||||
if (configJson == null || configJson.isBlank()) {
|
||||
return objectMapper.createObjectNode();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(configJson);
|
||||
} catch (Exception e) {
|
||||
log.warn("[WebChat] Failed to parse configJson: {}", e.getMessage());
|
||||
return objectMapper.createObjectNode();
|
||||
}
|
||||
}
|
||||
|
||||
private String textOrDefault(JsonNode node, String fieldName, String defaultValue) {
|
||||
if (node != null) {
|
||||
JsonNode value = node.get(fieldName);
|
||||
if (value != null && !value.isNull()) {
|
||||
String text = value.asText();
|
||||
if (!text.isBlank()) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private void sendErrorAndComplete(SseEmitter emitter, String message) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("error").data(Map.of("message", message)));
|
||||
|
||||
5
mateclaw-ui/public/icons/channels/webchat.svg
Normal file
5
mateclaw-ui/public/icons/channels/webchat.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect x="6" y="10" width="52" height="38" rx="12" fill="#EEF5FF" stroke="#409EFF" stroke-width="3"/>
|
||||
<path d="M18 24h28M18 32h20" stroke="#409EFF" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M24 48l-8 8v-8" fill="#EEF5FF" stroke="#409EFF" stroke-width="3" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 375 B |
@ -1289,6 +1289,7 @@ export default {
|
||||
weixin: 'WeChat',
|
||||
qq: 'QQ',
|
||||
slack: 'Slack',
|
||||
webchat: 'WebChat Embed',
|
||||
webhook: 'Webhook',
|
||||
},
|
||||
tabs: {
|
||||
@ -1326,6 +1327,9 @@ export default {
|
||||
authFailed: 'Authorization failed',
|
||||
},
|
||||
webHint: 'Web channel uses built-in SSE communication, no additional configuration needed.',
|
||||
webchatHint: 'WebChat embeds the MateClaw chat widget into external websites. Configure an API key, title, and primary color, then load the WebChat SDK on your site.',
|
||||
webchatApiKeyGenerated: 'The platform will generate the API key after save. Reopen this channel to copy it.',
|
||||
webchatApiKeyReadOnly: 'This API key is generated and managed by the platform. It can be copied, but not edited manually.',
|
||||
webhookHint: 'Webhook channel configuration should be edited in the "Raw JSON" tab below.',
|
||||
jsonHint: 'Edit the complete JSON configuration directly. Switching to "Form" tab will sync automatically.',
|
||||
advanced: 'Advanced',
|
||||
|
||||
@ -1299,6 +1299,7 @@ export default {
|
||||
weixin: 'WeChat (微信)',
|
||||
qq: 'QQ',
|
||||
slack: 'Slack',
|
||||
webchat: 'WebChat 嵌入',
|
||||
webhook: 'Webhook',
|
||||
},
|
||||
tabs: {
|
||||
@ -1336,6 +1337,9 @@ export default {
|
||||
authFailed: '授权失败',
|
||||
},
|
||||
webHint: 'Web 渠道使用内置 SSE 通信,无需额外配置。',
|
||||
webchatHint: 'WebChat 用于把 MateClaw 聊天挂件嵌入外部网站。请配置 API Key、标题和主题色,然后在网站中引入 WebChat SDK。',
|
||||
webchatApiKeyGenerated: '保存后平台会自动生成 API Key。创建完成后返回此页面即可复制。',
|
||||
webchatApiKeyReadOnly: '该 API Key 由平台自动生成并托管,你只能复制,不能手动修改。',
|
||||
webhookHint: 'Webhook 渠道配置请在下方「原始 JSON」标签页中编辑。',
|
||||
jsonHint: '直接编辑渠道的完整 JSON 配置。切换到「表单配置」标签页时会自动同步。',
|
||||
advanced: '高级配置',
|
||||
|
||||
@ -304,6 +304,7 @@ export interface ChannelFieldDef {
|
||||
placeholder: string
|
||||
required?: boolean
|
||||
sensitive?: boolean
|
||||
readOnly?: boolean
|
||||
tooltip?: string
|
||||
type: 'text' | 'password' | 'select' | 'switch' | 'number'
|
||||
options?: { label: string; value: string }[]
|
||||
@ -371,6 +372,14 @@ export const CHANNEL_FIELD_DEFS: Record<string, ChannelFieldDef[]> = {
|
||||
{ key: 'app_token', label: 'App Token', placeholder: 'xapp-xxxxxxxxxxxx', required: true, sensitive: true, type: 'password', tooltip: 'Slack App-Level Token(需要 connections:write scope,在 Slack App → Basic Information → App-Level Tokens 生成)' },
|
||||
{ key: 'signing_secret', label: 'Signing Secret', placeholder: 'xxxxxxxxxxxxxxxx', sensitive: true, type: 'password', tooltip: 'Slack App Signing Secret(用于 Webhook 模式验证请求签名,Socket Mode 可选)' },
|
||||
],
|
||||
webchat: [
|
||||
{ key: 'api_key', label: 'API Key', placeholder: '保存后由平台自动生成', required: true, sensitive: true, readOnly: true, type: 'password', tooltip: '由平台自动生成的嵌入式 WebChat 渠道密钥,创建后可复制使用' },
|
||||
{ key: 'title', label: '标题', placeholder: 'MateClaw', type: 'text', defaultValue: 'MateClaw', tooltip: '聊天面板顶部显示的标题' },
|
||||
{ key: 'placeholder', label: '输入框占位文案', placeholder: 'Type a message...', type: 'text', defaultValue: 'Type a message...', tooltip: '输入框默认提示文案' },
|
||||
{ key: 'primary_color', label: '主题色', placeholder: '#409eff', type: 'text', defaultValue: '#409eff', tooltip: '聊天气泡与头部使用的主色,建议使用十六进制颜色值' },
|
||||
{ key: 'welcome_message', label: '欢迎语', placeholder: '你好,我可以帮你处理什么?', type: 'text', tooltip: '前端 SDK 初始化后可读取并展示的欢迎语(当前主要供配置接口返回)' },
|
||||
{ key: 'allowed_origins', label: '允许嵌入域名', placeholder: 'https://example.com, https://app.example.com', type: 'text', tooltip: '预留给嵌入来源白名单校验的域名列表,多个域名用逗号分隔' },
|
||||
],
|
||||
}
|
||||
|
||||
// ==================== 流控制 ====================
|
||||
|
||||
@ -107,6 +107,7 @@
|
||||
<option value="weixin">{{ t('channels.types.weixin') }}</option>
|
||||
<option value="qq">{{ t('channels.types.qq') }}</option>
|
||||
<option value="slack">{{ t('channels.types.slack') }}</option>
|
||||
<option value="webchat">{{ t('channels.types.webchat') }}</option>
|
||||
<option value="webhook">{{ t('channels.types.webhook') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -228,9 +229,11 @@
|
||||
:type="visibleFields[field.key] ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
:placeholder="field.placeholder"
|
||||
:readonly="field.readOnly"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
v-if="!field.readOnly"
|
||||
type="button" class="eye-btn"
|
||||
@click="visibleFields[field.key] = !visibleFields[field.key]"
|
||||
:title="visibleFields[field.key] ? t('common.hide') : t('common.show')"
|
||||
@ -244,6 +247,17 @@
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="channelConfig[field.key]"
|
||||
type="button" class="copy-inline-btn"
|
||||
@click="copyText(String(channelConfig[field.key]))"
|
||||
:title="t('common.copy')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
@ -273,6 +287,7 @@
|
||||
type="number"
|
||||
class="form-input"
|
||||
:placeholder="field.placeholder"
|
||||
:readonly="field.readOnly"
|
||||
/>
|
||||
|
||||
<!-- 普通文本 -->
|
||||
@ -282,7 +297,12 @@
|
||||
type="text"
|
||||
class="form-input"
|
||||
:placeholder="field.placeholder"
|
||||
:readonly="field.readOnly"
|
||||
/>
|
||||
|
||||
<span v-if="field.readOnly && field.key === 'api_key'" class="form-hint">
|
||||
{{ editingChannel ? t('channels.webchatApiKeyReadOnly') : t('channels.webchatApiKeyGenerated') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -314,6 +334,11 @@
|
||||
<p class="empty-text">{{ t('channels.webHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- WebChat 类型 -->
|
||||
<div v-else-if="form.channelType === 'webchat'" class="empty-config">
|
||||
<p class="empty-text">{{ t('channels.webchatHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Webhook 类型 -->
|
||||
<div v-else-if="form.channelType === 'webhook'" class="empty-config">
|
||||
<p class="empty-text">{{ t('channels.webhookHint') }}</p>
|
||||
@ -847,6 +872,15 @@ async function copyWebhookUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success(t('common.copied'))
|
||||
} catch {
|
||||
ElMessage.warning(t('channels.webhook.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(async () => {
|
||||
@ -1154,7 +1188,7 @@ async function toggleChannel(channel: Channel) {
|
||||
|
||||
// ==================== 渠道图标 ====================
|
||||
|
||||
const CHANNEL_ICON_TYPES = ['web', 'dingtalk', 'feishu', 'wecom', 'weixin', 'telegram', 'discord', 'qq', 'slack', 'webhook']
|
||||
const CHANNEL_ICON_TYPES = ['web', 'dingtalk', 'feishu', 'wecom', 'weixin', 'telegram', 'discord', 'qq', 'slack', 'webchat', 'webhook']
|
||||
function getChannelIconPath(type: string) {
|
||||
const name = CHANNEL_ICON_TYPES.includes(type) ? type : 'default'
|
||||
return `/icons/channels/${name}.svg`
|
||||
@ -1295,6 +1329,9 @@ function getChannelIconPath(type: string) {
|
||||
.password-wrap .form-input { padding-right: 36px; }
|
||||
.eye-btn { position: absolute; right: 8px; background: none; border: none; cursor: pointer; color: var(--mc-text-tertiary); padding: 2px; display: flex; align-items: center; }
|
||||
.eye-btn:hover { color: var(--mc-text-primary); }
|
||||
.copy-inline-btn { position: absolute; right: 8px; background: none; border: none; cursor: pointer; color: var(--mc-text-tertiary); padding: 2px; display: flex; align-items: center; }
|
||||
.copy-inline-btn:hover { color: var(--mc-text-primary); }
|
||||
.form-hint { font-size: 12px; color: var(--mc-text-tertiary); line-height: 1.5; }
|
||||
|
||||
/* 开关 */
|
||||
.switch-wrap { display: flex; align-items: center; gap: 8px; height: 36px; }
|
||||
|
||||
@ -3,12 +3,14 @@
|
||||
<div class="provider-header">
|
||||
<div>
|
||||
<div class="provider-title-row">
|
||||
<img
|
||||
:src="getProviderIcon(provider.id)"
|
||||
:alt="provider.name"
|
||||
class="provider-icon"
|
||||
@error="onIconError"
|
||||
/>
|
||||
<span class="provider-icon-shell">
|
||||
<img
|
||||
:src="getProviderIcon(provider.id)"
|
||||
:alt="provider.name"
|
||||
class="provider-icon"
|
||||
@error="onIconError"
|
||||
/>
|
||||
</span>
|
||||
<h3 class="provider-name">{{ provider.name }}</h3>
|
||||
<span class="provider-badge" :class="provider.isCustom ? 'custom' : 'builtin'">
|
||||
{{ provider.isCustom ? t('settings.model.custom') : t('settings.model.builtin') }}
|
||||
@ -113,7 +115,41 @@ const { t } = useI18n()
|
||||
<style scoped>
|
||||
.provider-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.provider-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.provider-icon { width: 28px; height: 28px; border-radius: 6px; object-fit: contain; flex-shrink: 0; }
|
||||
.provider-icon-shell {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(123, 88, 67, 0.18);
|
||||
background: linear-gradient(180deg, #ffffff, #f5ede6);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
0 6px 16px rgba(25, 14, 8, 0.14);
|
||||
}
|
||||
|
||||
.provider-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 1px 1px rgba(44, 24, 10, 0.12));
|
||||
}
|
||||
|
||||
:global(html.dark) .provider-icon-shell {
|
||||
border-color: rgba(255, 248, 241, 0.28);
|
||||
background: linear-gradient(180deg, #fffdfb, #f3e8dc);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.96),
|
||||
0 8px 22px rgba(0, 0, 0, 0.26);
|
||||
}
|
||||
|
||||
:global(html.dark) .provider-icon {
|
||||
filter: drop-shadow(0 1px 1px rgba(44, 24, 10, 0.18));
|
||||
}
|
||||
.provider-name { margin: 0; font-size: 18px; color: var(--mc-text-primary); }
|
||||
.provider-id { margin: 6px 0 0; font-size: 13px; color: var(--mc-primary); }
|
||||
.provider-badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 600; }
|
||||
|
||||
@ -3,12 +3,14 @@
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header">
|
||||
<h2>
|
||||
<img
|
||||
:src="getProviderIcon(provider.id)"
|
||||
:alt="provider.name"
|
||||
class="modal-provider-icon"
|
||||
@error="onIconError"
|
||||
/>
|
||||
<span class="modal-provider-icon-shell">
|
||||
<img
|
||||
:src="getProviderIcon(provider.id)"
|
||||
:alt="provider.name"
|
||||
class="modal-provider-icon"
|
||||
@error="onIconError"
|
||||
/>
|
||||
</span>
|
||||
{{ t('settings.model.manageTitle') }} · {{ provider.name }}
|
||||
</h2>
|
||||
<div class="modal-header-actions">
|
||||
@ -176,7 +178,42 @@ defineEmits<{
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.modal-header h2 { color: var(--mc-text-primary); margin: 0; font-size: 18px; display: flex; align-items: center; }
|
||||
.modal-header-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.modal-provider-icon { width: 22px; height: 22px; border-radius: 4px; object-fit: contain; vertical-align: middle; margin-right: 6px; }
|
||||
.modal-provider-icon-shell {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
padding: 7px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(123, 88, 67, 0.18);
|
||||
background: linear-gradient(180deg, #ffffff, #f5ede6);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
0 6px 16px rgba(25, 14, 8, 0.14);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-provider-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
filter: drop-shadow(0 1px 1px rgba(44, 24, 10, 0.12));
|
||||
}
|
||||
|
||||
:global(html.dark) .modal-provider-icon-shell {
|
||||
border-color: rgba(255, 248, 241, 0.28);
|
||||
background: linear-gradient(180deg, #fffdfb, #f3e8dc);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.96),
|
||||
0 8px 22px rgba(0, 0, 0, 0.26);
|
||||
}
|
||||
|
||||
:global(html.dark) .modal-provider-icon {
|
||||
filter: drop-shadow(0 1px 1px rgba(44, 24, 10, 0.18));
|
||||
}
|
||||
.modal-close { background: none; border: none; font-size: 24px; line-height: 1; cursor: pointer; color: var(--mc-text-secondary); }
|
||||
.modal-close:hover { color: var(--mc-text-primary); }
|
||||
.modal-body { padding: 20px; overflow-y: auto; flex: 1; min-height: 0; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user