mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui): unify workspace and platform console surfaces
This commit is contained in:
parent
5124842526
commit
c4edcbd95c
@ -61,9 +61,7 @@
|
||||
<div v-if="pendingApproval?.status === 'pending_approval'" class="approval-bar">
|
||||
<div class="approval-bar__info">
|
||||
<span class="approval-bar__icon">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
</span>
|
||||
<span class="approval-bar__label">{{ t('chat.approvalAllow') }}</span>
|
||||
<span class="approval-bar__tool">{{ pendingApproval.toolName }}</span>
|
||||
@ -75,9 +73,7 @@
|
||||
class="approval-bar__btn approval-bar__btn--deny"
|
||||
@click="emit('deny', pendingApproval.pendingId)"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<el-icon><CloseBold /></el-icon>
|
||||
{{ t('chat.deny') }}
|
||||
</button>
|
||||
<button
|
||||
@ -85,9 +81,7 @@
|
||||
class="approval-bar__btn approval-bar__btn--approve"
|
||||
@click="emit('approve', pendingApproval.pendingId)"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
<el-icon><Select /></el-icon>
|
||||
{{ t('chat.approve') }}
|
||||
</button>
|
||||
</div>
|
||||
@ -98,9 +92,7 @@
|
||||
<!-- 排队消息指示器 -->
|
||||
<div v-if="queuedMessage && queuedMessage.status !== 'cancelled'" class="queued-indicator">
|
||||
<div class="queued-indicator__info">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<el-icon><Timer /></el-icon>
|
||||
<span class="queued-indicator__text">
|
||||
{{ queuedMessage.status === 'sending' ? t('chat.queuedSending') : t('chat.queuedWillSend') }}
|
||||
<span v-if="queueSize > 1" class="queued-indicator__count">({{ queueSize }})</span>
|
||||
@ -141,9 +133,7 @@
|
||||
:disabled="disabled || loading || uploading"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21.44 11.05l-8.49 8.49a6 6 0 0 1-8.49-8.49l9.2-9.19a4 4 0 1 1 5.66 5.66l-9.19 9.2a2 2 0 1 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
</button>
|
||||
|
||||
<!-- Talk Mode 按钮 -->
|
||||
@ -155,11 +145,7 @@
|
||||
@click="emit('talk')"
|
||||
:title="t('talk.title')"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</svg>
|
||||
<el-icon><Microphone /></el-icon>
|
||||
</button>
|
||||
|
||||
<!-- 发送/停止/中断按钮 -->
|
||||
@ -171,19 +157,11 @@
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<!-- 有输入时始终显示发送图标(运行中发送 = interrupt) -->
|
||||
<svg v-if="canSend" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
<el-icon v-if="canSend"><Promotion /></el-icon>
|
||||
<!-- 运行中无输入:停止图标 -->
|
||||
<svg v-else-if="loading" width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
</svg>
|
||||
<el-icon v-else-if="loading"><CloseBold /></el-icon>
|
||||
<!-- 空闲无输入 -->
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
<el-icon v-else><Promotion /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -212,6 +190,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CloseBold, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue'
|
||||
import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@ -418,8 +397,9 @@ defineExpose({
|
||||
|
||||
<style scoped>
|
||||
.chat-input-wrapper {
|
||||
padding: 18px 24px 22px;
|
||||
padding: 10px 14px 12px;
|
||||
background: var(--mc-bg-elevated, #f8fafc);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input-wrapper.is-focused {
|
||||
@ -431,7 +411,7 @@ defineExpose({
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.attachment-chip {
|
||||
@ -519,8 +499,8 @@ defineExpose({
|
||||
align-items: flex-end;
|
||||
background: var(--mc-input-bg, #ffffff);
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
padding: 12px 12px 12px 16px;
|
||||
border-radius: 16px;
|
||||
padding: 8px 10px 8px 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
@ -538,7 +518,7 @@ defineExpose({
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--mc-input-text, #1e293b);
|
||||
min-height: 44px;
|
||||
min-height: 38px;
|
||||
max-height: 160px;
|
||||
font-family: inherit;
|
||||
}
|
||||
@ -555,13 +535,13 @@ defineExpose({
|
||||
/* 操作按钮 */
|
||||
.input-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
@ -615,7 +595,7 @@ defineExpose({
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
margin-top: 6px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
@ -645,10 +625,10 @@ defineExpose({
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--mc-input-bg, #ffffff);
|
||||
border-radius: 18px;
|
||||
padding: 10px 10px 10px 16px;
|
||||
border-radius: 16px;
|
||||
padding: 8px 8px 8px 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(217, 119, 87, 0.3);
|
||||
min-height: 56px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.approval-bar__info {
|
||||
@ -741,8 +721,8 @@ defineExpose({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 16px;
|
||||
margin-bottom: 4px;
|
||||
padding: 5px 12px;
|
||||
margin-bottom: 3px;
|
||||
border-radius: 10px;
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
border: 1px solid rgba(59, 130, 246, 0.15);
|
||||
@ -756,6 +736,32 @@ defineExpose({
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-input-wrapper {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
gap: 8px;
|
||||
padding: 7px 8px 7px 10px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.chat-textarea {
|
||||
min-height: 34px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.attachment-chip__label span:first-child {
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.queued-indicator__text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@ -22,18 +22,12 @@
|
||||
<div v-if="showThinkingPanel" class="thinking-section">
|
||||
<button class="thinking-toggle" type="button" @click="toggleThinking">
|
||||
<span class="thinking-toggle__indicator" :class="{ active: isGenerating && !hasContent }">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 18h6"/>
|
||||
<path d="M10 22h4"/>
|
||||
<path d="M12 2a7 7 0 0 0-4 12.75c.63.44 1 1.16 1 1.93V17h6v-.32c0-.77.37-1.49 1-1.93A7 7 0 0 0 12 2z"/>
|
||||
</svg>
|
||||
<el-icon><Opportunity /></el-icon>
|
||||
</span>
|
||||
<span class="thinking-toggle__label">{{ $t('chat.thinking') }}</span>
|
||||
<span class="thinking-toggle__duration" v-if="thinkingDuration">{{ thinkingDuration }}</span>
|
||||
<span class="thinking-toggle__arrow" :class="{ expanded: localThinkingExpanded }">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -51,16 +45,12 @@
|
||||
<div v-if="showExecutionPanel" class="execution-section">
|
||||
<button class="execution-toggle" type="button" @click="executionExpanded = !executionExpanded">
|
||||
<span class="execution-toggle__indicator" :class="{ active: isGenerating }">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
<el-icon><Tools /></el-icon>
|
||||
</span>
|
||||
<span class="execution-toggle__label">{{ executionPhaseLabel }}</span>
|
||||
<span class="execution-toggle__count" v-if="toolCallsMeta.length">{{ toolCallsMeta.length }} calls</span>
|
||||
<span class="execution-toggle__arrow" :class="{ expanded: executionExpanded }">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -92,18 +82,10 @@
|
||||
:class="{ 'tool-call--running': tc.status === 'running', 'tool-call--awaiting': tc.status === 'awaiting_approval', 'tool-call--error': tc.status === 'completed' && tc.success === false }"
|
||||
>
|
||||
<span class="tool-call__status">
|
||||
<svg v-if="tc.status === 'running'" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin">
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56"/>
|
||||
</svg>
|
||||
<svg v-else-if="tc.status === 'awaiting_approval'" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2">
|
||||
<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>
|
||||
</svg>
|
||||
<svg v-else-if="tc.success !== false" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<el-icon v-if="tc.status === 'running'" class="spin"><Loading /></el-icon>
|
||||
<el-icon v-else-if="tc.status === 'awaiting_approval'" style="color: #f59e0b;"><WarningFilled /></el-icon>
|
||||
<el-icon v-else-if="tc.success !== false" style="color: #10b981;"><Select /></el-icon>
|
||||
<el-icon v-else style="color: #ef4444;"><CloseBold /></el-icon>
|
||||
</span>
|
||||
<span class="tool-call__name">{{ tc.name }}</span>
|
||||
<span class="tool-call__args" v-if="tc.arguments">{{ truncateArgs(tc.arguments) }}</span>
|
||||
@ -123,9 +105,7 @@
|
||||
<!-- 工具审批面板 -->
|
||||
<div v-if="pendingApproval" class="approval-section" :class="approvalSeverityClass">
|
||||
<div class="approval-header">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<span class="approval-title">{{ $t('chat.approvalRequired') || 'Approval Required' }}</span>
|
||||
<span v-if="pendingApproval.maxSeverity" class="approval-severity-badge" :class="'severity-' + pendingApproval.maxSeverity?.toLowerCase()">
|
||||
{{ pendingApproval.maxSeverity }}
|
||||
@ -149,9 +129,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingApproval.status === 'pending_approval'" class="approval-waiting">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="approval-waiting__spin">
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56"/>
|
||||
</svg>
|
||||
<el-icon class="approval-waiting__spin"><Loading /></el-icon>
|
||||
<span>{{ $t('chat.approvalWaiting') }}</span>
|
||||
</div>
|
||||
<div v-else-if="pendingApproval.status === 'approved'" class="approval-resolved approval-resolved--approved">
|
||||
@ -179,20 +157,14 @@
|
||||
|
||||
<!-- 停止指示器 -->
|
||||
<div v-if="status === 'stopped' || status === 'interrupted'" class="stopped-indicator">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
</svg>
|
||||
<el-icon><CloseBold /></el-icon>
|
||||
<span>{{ status === 'interrupted' ? ($t('chat.interrupted') || '已中断') : $t('chat.stopped') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误卡片 -->
|
||||
<div v-if="status === 'failed'" class="error-card">
|
||||
<div class="error-card__header">
|
||||
<svg class="error-card__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
<el-icon class="error-card__icon"><WarningFilled /></el-icon>
|
||||
<span class="error-card__title">{{ errorTitle }}</span>
|
||||
</div>
|
||||
<p class="error-card__description">{{ errorDescription }}</p>
|
||||
@ -200,10 +172,7 @@
|
||||
<div class="error-card__footer">
|
||||
<span v-if="errorCode" class="error-card__code">{{ errorCode }}</span>
|
||||
<button v-if="errorRetryable" class="error-card__retry" type="button" @click="$emit('regenerate')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
{{ $t('chat.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
@ -244,10 +213,7 @@
|
||||
type="button"
|
||||
@click="downloadFile(attachment)"
|
||||
>
|
||||
<svg class="message-attachment__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
<el-icon class="message-attachment__icon"><Document /></el-icon>
|
||||
<span class="message-attachment__name">{{ attachment.name }}</span>
|
||||
<span class="message-attachment__meta">{{ formatFileSize(attachment.size) }}</span>
|
||||
</button>
|
||||
@ -270,13 +236,8 @@
|
||||
:title="copyState === 'copied' ? $t('chat.copied') : $t('chat.copy')"
|
||||
@click="copyMessage"
|
||||
>
|
||||
<svg v-if="copyState !== 'copied'" 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"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
<el-icon v-if="copyState !== 'copied'"><CopyDocument /></el-icon>
|
||||
<el-icon v-else><Select /></el-icon>
|
||||
</button>
|
||||
<!-- 朗读 TTS(仅 assistant) -->
|
||||
<button
|
||||
@ -288,18 +249,9 @@
|
||||
:disabled="ttsState === 'loading'"
|
||||
@click="handleTts"
|
||||
>
|
||||
<svg v-if="ttsState === 'loading'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="tts-loading-icon">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
<svg v-else-if="ttsState === 'playing'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||
</svg>
|
||||
<el-icon v-if="ttsState === 'loading'" class="tts-loading-icon"><Loading /></el-icon>
|
||||
<el-icon v-else-if="ttsState === 'playing'"><VideoPause /></el-icon>
|
||||
<el-icon v-else><Microphone /></el-icon>
|
||||
</button>
|
||||
<!-- 重新生成(仅 assistant) -->
|
||||
<button
|
||||
@ -309,10 +261,7 @@
|
||||
:title="$t('chat.regenerate')"
|
||||
@click="$emit('regenerate')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
</button>
|
||||
<!-- 时间戳(inline) -->
|
||||
<span class="action-time">{{ formattedTime }}</span>
|
||||
@ -324,6 +273,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
ArrowDown,
|
||||
CloseBold,
|
||||
CopyDocument,
|
||||
Document,
|
||||
Loading,
|
||||
Microphone,
|
||||
Opportunity,
|
||||
RefreshRight,
|
||||
Select,
|
||||
Tools,
|
||||
VideoPause,
|
||||
WarningFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
|
||||
import { http } from '@/api'
|
||||
|
||||
@ -22,12 +22,14 @@
|
||||
class="suggestion-card"
|
||||
@click="$emit('suggestion-click', s)"
|
||||
>
|
||||
<span class="suggestion-card__icon">{{ ['💬', '✍️', '💻', '📊'][i % 4] }}</span>
|
||||
<span class="suggestion-card__icon">
|
||||
<el-icon v-if="i % 4 === 0"><ChatDotRound /></el-icon>
|
||||
<el-icon v-else-if="i % 4 === 1"><EditPen /></el-icon>
|
||||
<el-icon v-else-if="i % 4 === 2"><Monitor /></el-icon>
|
||||
<el-icon v-else><DataLine /></el-icon>
|
||||
</span>
|
||||
<span class="suggestion-card__text">{{ s }}</span>
|
||||
<svg class="suggestion-card__arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
<el-icon class="suggestion-card__arrow"><Right /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -65,6 +67,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, nextTick } from 'vue'
|
||||
import { ChatDotRound, DataLine, EditPen, Monitor, Right } from '@element-plus/icons-vue'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import { useStickToBottom } from '@/composables/chat/useStickToBottom'
|
||||
import type { Message } from '@/types'
|
||||
@ -149,8 +152,9 @@ watch(
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 28px 28px 20px;
|
||||
padding: 18px 20px 12px;
|
||||
scroll-behavior: smooth;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.message-list::-webkit-scrollbar {
|
||||
@ -165,7 +169,7 @@ watch(
|
||||
.message-list-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
gap: 14px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@ -176,12 +180,12 @@ watch(
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.welcome-screen {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
padding: 24px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@ -231,7 +235,7 @@ watch(
|
||||
.welcome-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--mc-text-secondary, #64748b);
|
||||
margin: 0 0 36px;
|
||||
margin: 0 0 24px;
|
||||
max-width: 360px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@ -341,19 +345,19 @@ watch(
|
||||
/* ===== 移动端适配 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.message-list {
|
||||
padding: 16px 12px 12px;
|
||||
padding: 12px 10px 8px;
|
||||
}
|
||||
|
||||
.message-list-content {
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 300px;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.welcome-screen {
|
||||
padding: 24px 12px;
|
||||
padding: 16px 10px;
|
||||
}
|
||||
|
||||
.welcome-logo__icon {
|
||||
|
||||
@ -5,9 +5,7 @@
|
||||
<div class="talk-header">
|
||||
<span class="talk-title">{{ t('talk.title') }}</span>
|
||||
<button class="talk-close" @click="$emit('close')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<el-icon><CloseBold /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -17,22 +15,10 @@
|
||||
<div class="talk-pulse-ring"></div>
|
||||
<div class="talk-pulse-ring talk-pulse-ring--2"></div>
|
||||
<div class="talk-pulse-core">
|
||||
<svg v-if="state === 'idle'" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</svg>
|
||||
<svg v-else-if="state === 'listening'" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="pulse-anim">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/>
|
||||
</svg>
|
||||
<svg v-else-if="state === 'processing'" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin">
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56"/>
|
||||
</svg>
|
||||
<svg v-else width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
||||
</svg>
|
||||
<el-icon v-if="state === 'idle'"><Microphone /></el-icon>
|
||||
<el-icon v-else-if="state === 'listening'" class="pulse-anim"><Microphone /></el-icon>
|
||||
<el-icon v-else-if="state === 'processing'" class="spin"><Loading /></el-icon>
|
||||
<el-icon v-else><Service /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="talk-state-label">{{ stateLabel }}</div>
|
||||
@ -68,6 +54,7 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CloseBold, Loading, Microphone, Service } from '@element-plus/icons-vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@ -223,6 +223,8 @@ export default {
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
kicker: 'Configuration',
|
||||
layoutDesc: 'Tune models, system behavior, and media capabilities without letting the product collapse into a pile of controls.',
|
||||
sections: {
|
||||
model: 'Model Management',
|
||||
system: 'System',
|
||||
@ -542,6 +544,7 @@ export default {
|
||||
},
|
||||
},
|
||||
agentContext: {
|
||||
kicker: 'Context Studio',
|
||||
title: 'Agent Context',
|
||||
desc: 'Manage prompt files and memory for your agents',
|
||||
selectAgent: 'Select Agent',
|
||||
@ -645,6 +648,8 @@ export default {
|
||||
},
|
||||
security: {
|
||||
title: 'Security',
|
||||
kicker: 'Governance',
|
||||
layoutDesc: 'Boundaries, approvals, audit, and workspace governance have to speak the same language or the system will not feel trustworthy.',
|
||||
sections: {
|
||||
toolGuard: 'Tool Guard',
|
||||
fileGuard: 'File Guard',
|
||||
@ -873,9 +878,12 @@ export default {
|
||||
},
|
||||
},
|
||||
tokenUsage: {
|
||||
kicker: 'Runtime Cost',
|
||||
title: 'Token Usage',
|
||||
desc: 'View token consumption and model invocation statistics',
|
||||
refresh: 'Refresh',
|
||||
startDate: 'Start',
|
||||
endDate: 'End',
|
||||
promptTokens: 'Prompt Tokens',
|
||||
completionTokens: 'Completion Tokens',
|
||||
assistantMessages: 'Assistant Messages',
|
||||
@ -889,6 +897,7 @@ export default {
|
||||
noData: 'No token usage data',
|
||||
},
|
||||
mcp: {
|
||||
kicker: 'Capability Bridge',
|
||||
title: 'MCP Server Management',
|
||||
desc: 'Manage MCP (Model Context Protocol) server connections',
|
||||
addServer: 'Add Server',
|
||||
@ -1033,6 +1042,7 @@ export default {
|
||||
},
|
||||
},
|
||||
datasources: {
|
||||
kicker: 'Data Fabric',
|
||||
title: 'Datasources',
|
||||
desc: 'Manage external database connections for natural language queries',
|
||||
addButton: 'Add Datasource',
|
||||
@ -1092,12 +1102,14 @@ export default {
|
||||
},
|
||||
},
|
||||
wiki: {
|
||||
kicker: 'Knowledge Engine',
|
||||
desc: 'AI-powered structured knowledge base that digests raw materials into Wiki pages',
|
||||
createKB: 'New Knowledge Base',
|
||||
knowledgeBases: 'Knowledge Bases',
|
||||
noKB: 'No knowledge bases yet',
|
||||
selectKB: 'Select a knowledge base',
|
||||
selectPage: 'Select a page from the sidebar',
|
||||
pageKicker: 'Knowledge Page',
|
||||
kbName: 'Name',
|
||||
kbNamePlaceholder: 'Enter knowledge base name',
|
||||
kbDescription: 'Description',
|
||||
@ -1115,15 +1127,30 @@ export default {
|
||||
materialContent: 'Content',
|
||||
pasteContent: 'Paste or type content here...',
|
||||
backlinks: 'Backlinks',
|
||||
generatedByAi: 'AI generated',
|
||||
editedManually: 'Manually edited',
|
||||
configTitle: 'Wiki Processing Rules',
|
||||
configDesc: 'Configure the rules AI follows when digesting raw materials',
|
||||
configPlaceholder: 'Edit wiki processing rules here...',
|
||||
pageCount: '{count} pages',
|
||||
sourceCount: '{count} sources',
|
||||
dirPlaceholder: 'Enter local directory path, e.g. /Users/me/docs',
|
||||
scan: 'Scan',
|
||||
scanning: 'Scanning...',
|
||||
scanResult: 'Scanned {scanned} files, added {added}, skipped {skipped}',
|
||||
saving: 'Saving...',
|
||||
status: {
|
||||
active: 'ACTIVE',
|
||||
processing: 'PROCESSING',
|
||||
error: 'ERROR',
|
||||
pending: 'PENDING',
|
||||
completed: 'COMPLETED',
|
||||
partial: 'PARTIAL',
|
||||
failed: 'FAILED',
|
||||
},
|
||||
},
|
||||
cronJobs: {
|
||||
kicker: 'Automation',
|
||||
title: 'Cron Jobs',
|
||||
desc: 'Schedule agents to run messages or goals on a timer',
|
||||
createJob: 'New Job',
|
||||
@ -1178,7 +1205,15 @@ export default {
|
||||
},
|
||||
},
|
||||
login: {
|
||||
kicker: 'AI Operating System',
|
||||
subtitle: 'Your intelligent AI assistant',
|
||||
heroTitle: 'Move beyond answers. Put AI inside a system that can keep working.',
|
||||
heroDesc: 'MateClaw is not about stuffing more capability into a chat box. It is about pulling context, knowledge, execution, and memory into one reliable operating surface.',
|
||||
pointContext: 'Keep context continuous',
|
||||
pointKnowledge: 'Organize knowledge into use',
|
||||
pointExecution: 'Turn capability into action',
|
||||
cardTitle: 'Sign in to the working surface',
|
||||
cardDesc: 'Enter the system where models, knowledge, workspace, and runtime state move together instead of living in separate tools.',
|
||||
fields: {
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
|
||||
@ -213,6 +213,8 @@ export default {
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
kicker: '配置中心',
|
||||
layoutDesc: '调整模型、系统行为与媒体能力,让整个产品在扩张时仍然保持一致。',
|
||||
sections: {
|
||||
model: '模型管理',
|
||||
system: '系统设置',
|
||||
@ -542,6 +544,7 @@ export default {
|
||||
},
|
||||
},
|
||||
agentContext: {
|
||||
kicker: '上下文工作面',
|
||||
title: 'Agent 上下文',
|
||||
desc: '管理 Agent 的提示文件和记忆',
|
||||
selectAgent: '选择 Agent',
|
||||
@ -645,6 +648,8 @@ export default {
|
||||
},
|
||||
security: {
|
||||
title: '安全管理',
|
||||
kicker: '治理中心',
|
||||
layoutDesc: '边界、审批、审计和工作区治理必须在这里说同一种语言,否则系统不值得被信任。',
|
||||
sections: {
|
||||
toolGuard: '工具防护',
|
||||
fileGuard: '文件防护',
|
||||
@ -873,9 +878,12 @@ export default {
|
||||
},
|
||||
},
|
||||
tokenUsage: {
|
||||
kicker: '运行成本',
|
||||
title: 'Token 统计',
|
||||
desc: '查看 Token 使用量和模型调用统计',
|
||||
refresh: '刷新',
|
||||
startDate: '开始日期',
|
||||
endDate: '结束日期',
|
||||
promptTokens: 'Prompt Tokens',
|
||||
completionTokens: 'Completion Tokens',
|
||||
assistantMessages: 'Assistant Messages',
|
||||
@ -889,6 +897,7 @@ export default {
|
||||
noData: '暂无 Token 使用数据',
|
||||
},
|
||||
mcp: {
|
||||
kicker: '能力桥接',
|
||||
title: 'MCP 服务管理',
|
||||
desc: '管理 MCP (Model Context Protocol) 服务器连接',
|
||||
addServer: '添加服务',
|
||||
@ -1033,6 +1042,7 @@ export default {
|
||||
},
|
||||
},
|
||||
datasources: {
|
||||
kicker: '数据接入',
|
||||
title: '数据源管理',
|
||||
desc: '管理外部数据库连接,用于自然语言查数',
|
||||
addButton: '添加数据源',
|
||||
@ -1102,12 +1112,14 @@ export default {
|
||||
diagnose: '诊断问题',
|
||||
},
|
||||
wiki: {
|
||||
kicker: '知识引擎',
|
||||
desc: 'AI 驱动的结构化知识库,自动消化原始材料为 Wiki 页面',
|
||||
createKB: '新建知识库',
|
||||
knowledgeBases: '知识库',
|
||||
noKB: '暂无知识库',
|
||||
selectKB: '请选择一个知识库',
|
||||
selectPage: '从左侧选择一个页面查看',
|
||||
pageKicker: '知识页面',
|
||||
kbName: '名称',
|
||||
kbNamePlaceholder: '输入知识库名称',
|
||||
kbDescription: '描述',
|
||||
@ -1125,15 +1137,30 @@ export default {
|
||||
materialContent: '内容',
|
||||
pasteContent: '粘贴或输入文本内容...',
|
||||
backlinks: '反向链接',
|
||||
generatedByAi: 'AI 生成',
|
||||
editedManually: '人工编辑',
|
||||
configTitle: '知识库处理规则',
|
||||
configDesc: '配置 AI 消化原始材料时遵循的规则',
|
||||
configPlaceholder: '在此编辑 Wiki 处理规则...',
|
||||
pageCount: '{count} 页',
|
||||
sourceCount: '{count} 份材料',
|
||||
dirPlaceholder: '输入本地目录路径,如 /Users/me/docs',
|
||||
scan: '扫描',
|
||||
scanning: '扫描中...',
|
||||
scanResult: '已扫描 {scanned} 个文件,新增 {added} 个,跳过 {skipped} 个',
|
||||
saving: '保存中...',
|
||||
status: {
|
||||
active: '启用',
|
||||
processing: '处理中',
|
||||
error: '错误',
|
||||
pending: '待处理',
|
||||
completed: '已完成',
|
||||
partial: '部分完成',
|
||||
failed: '失败',
|
||||
},
|
||||
},
|
||||
cronJobs: {
|
||||
kicker: '自动执行',
|
||||
title: '定时任务',
|
||||
desc: '定时触发 Agent 执行消息或目标任务',
|
||||
createJob: '新建任务',
|
||||
@ -1188,7 +1215,15 @@ export default {
|
||||
},
|
||||
},
|
||||
login: {
|
||||
kicker: 'AI Operating System',
|
||||
subtitle: '你的智能 AI 助手',
|
||||
heroTitle: '让 AI 不止会回答,而是真正进入持续工作的系统。',
|
||||
heroDesc: 'MateClaw 的重点不是把更多能力堆进聊天框,而是把上下文、知识、执行和记忆收拢成一个可靠的工作面。',
|
||||
pointContext: '上下文保持连续',
|
||||
pointKnowledge: '知识被组织起来',
|
||||
pointExecution: '能力真正能执行',
|
||||
cardTitle: '登录并进入工作面',
|
||||
cardDesc: '从这里进入你的 AI 工作系统。模型、知识、工作区和运行状态会在同一个界面里协同。 ',
|
||||
fields: {
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<div class="workspace-container">
|
||||
<div class="mc-page-shell agent-context-shell">
|
||||
<div class="mc-page-frame agent-context-frame">
|
||||
<div class="mc-page-inner agent-context-container">
|
||||
<!-- 顶部栏 -->
|
||||
<div class="workspace-header">
|
||||
<div>
|
||||
<div class="workspace-header-copy">
|
||||
<div class="mc-page-kicker">{{ t('agentContext.kicker') }}</div>
|
||||
<h1 class="page-title">{{ t('agentContext.title') }}</h1>
|
||||
<p class="page-desc">{{ t('agentContext.desc') }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-actions mc-surface-card">
|
||||
<select v-model="selectedAgentId" class="agent-select" @change="onAgentChange">
|
||||
<option value="" disabled>{{ t('agentContext.selectAgent') }}</option>
|
||||
<option v-for="agent in agents" :key="agent.id" :value="agent.id">
|
||||
@ -197,6 +200,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -437,12 +442,32 @@ function formatTime(time?: string): string {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workspace-container { height: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--mc-bg); }
|
||||
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; padding: 20px 24px 12px; flex-shrink: 0; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
|
||||
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.agent-select { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); font-size: 14px; outline: none; min-width: 200px; cursor: pointer; }
|
||||
.agent-context-shell {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-context-frame {
|
||||
height: min(calc(100vh - 28px), 100%);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-context-container {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 14px; flex-shrink: 0; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.workspace-header-copy { min-width: 0; }
|
||||
.page-title { font-size: clamp(28px, 3vw, 40px); font-weight: 800; color: var(--mc-text-primary); letter-spacing: -0.04em; margin: 0 0 8px; }
|
||||
.page-desc { font-size: 15px; color: var(--mc-text-secondary); margin: 0; line-height: 1.7; max-width: 720px; }
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; padding: 12px; border-radius: 18px; flex-shrink: 0; }
|
||||
.agent-select { padding: 10px 14px; border: 1px solid var(--mc-border); border-radius: 12px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); font-size: 14px; outline: none; min-width: 240px; cursor: pointer; }
|
||||
.agent-select:focus { border-color: var(--mc-primary); }
|
||||
|
||||
/* 空状态 */
|
||||
@ -451,15 +476,15 @@ function formatTime(time?: string): string {
|
||||
.empty-state h3 { font-size: 16px; color: var(--mc-text-secondary); margin: 0; }
|
||||
|
||||
/* 主体 */
|
||||
.workspace-body { display: flex; flex: 1; overflow: hidden; padding: 0 24px 24px; gap: 16px; }
|
||||
.workspace-body { display: flex; flex: 1; overflow: hidden; padding-top: 18px; gap: 16px; min-height: 0; }
|
||||
|
||||
/* 左侧面板 */
|
||||
.file-list-panel { width: 300px; min-width: 260px; display: flex; flex-direction: column; }
|
||||
.panel-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 12px; display: flex; flex-direction: column; flex: 1; overflow: hidden; }
|
||||
.panel-card { background: linear-gradient(180deg, var(--mc-bg-elevated), var(--mc-bg-muted)); border: 1px solid var(--mc-border); border-radius: 22px; display: flex; flex-direction: column; flex: 1; overflow: hidden; box-shadow: var(--mc-shadow-soft); }
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0; }
|
||||
.section-title { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
|
||||
.panel-actions { display: flex; gap: 4px; }
|
||||
.icon-btn { width: 28px; height: 28px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
|
||||
.icon-btn { width: 30px; height: 30px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
|
||||
.icon-btn:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
|
||||
.info-text { font-size: 12px; color: var(--mc-text-tertiary); padding: 6px 16px 0; margin: 0; line-height: 1.4; }
|
||||
.divider { height: 1px; background: var(--mc-border-light); margin: 10px 16px; }
|
||||
@ -521,9 +546,9 @@ function formatTime(time?: string): string {
|
||||
.editor-content.mode-split .editor-textarea { flex: 1; min-width: 0; }
|
||||
.editor-content.mode-split .markdown-preview { flex: 1; min-width: 0; }
|
||||
|
||||
.editor-textarea { flex: 1; width: 100%; resize: none; border: 1px solid var(--mc-border); border-radius: 8px; padding: 12px; font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: var(--mc-text-primary); background: var(--mc-bg-sunken); outline: none; tab-size: 2; }
|
||||
.editor-textarea { flex: 1; width: 100%; resize: none; border: 1px solid var(--mc-border); border-radius: 14px; padding: 14px; font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: var(--mc-text-primary); background: var(--mc-bg-sunken); outline: none; tab-size: 2; }
|
||||
.editor-textarea:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1); }
|
||||
.markdown-preview { flex: 1; overflow-y: auto; padding: 16px; border: 1px solid var(--mc-border); border-radius: 8px; background: var(--mc-bg-sunken); font-size: 14px; line-height: 1.7; color: var(--mc-text-primary); }
|
||||
.markdown-preview { flex: 1; overflow-y: auto; padding: 16px; border: 1px solid var(--mc-border); border-radius: 14px; background: var(--mc-bg-sunken); font-size: 14px; line-height: 1.7; color: var(--mc-text-primary); }
|
||||
.markdown-preview::-webkit-scrollbar { width: 4px; }
|
||||
.markdown-preview::-webkit-scrollbar-thumb { background: var(--mc-border); border-radius: 2px; }
|
||||
|
||||
@ -549,4 +574,28 @@ function formatTime(time?: string): string {
|
||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
||||
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.agent-context-frame {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 28px);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.workspace-header,
|
||||
.workspace-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.file-list-panel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -10,12 +10,12 @@
|
||||
<!-- 会话侧边栏 -->
|
||||
<div class="conversation-panel" :class="{ 'mobile-open': convPanelOpen }">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">{{ $t('chat.conversations') }}</h2>
|
||||
<div class="panel-header-copy">
|
||||
<div class="panel-kicker">{{ $t('nav.chat') }}</div>
|
||||
<h2 class="panel-title">{{ $t('chat.conversations') }}</h2>
|
||||
</div>
|
||||
<button class="new-chat-btn" @click="newConversation" :title="$t('chat.newChat')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -50,10 +50,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="conv-delete" @click.stop="deleteConversation(conv.conversationId)" :title="$t('common.delete')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||
</svg>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@ -77,11 +74,7 @@
|
||||
<Transition name="fade">
|
||||
<div v-if="isDragging" class="drop-overlay">
|
||||
<div class="drop-overlay__content">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<el-icon><UploadFilled /></el-icon>
|
||||
<span>{{ $t('chat.dropToUpload') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -90,14 +83,15 @@
|
||||
<div class="chat-header">
|
||||
<div class="chat-header-left">
|
||||
<button v-if="isMobile" class="conv-toggle-btn" @click="convPanelOpen = !convPanelOpen" :title="$t('chat.conversations')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
</button>
|
||||
<div class="agent-badge" v-if="currentAgent" :title="currentAgent.name">
|
||||
<span class="agent-badge-icon">{{ currentAgent.icon || '🤖' }}</span>
|
||||
<span class="agent-badge-name">{{ currentAgent.name }}</span>
|
||||
<span class="agent-badge-type">{{ currentAgent.agentType === 'react' ? 'ReAct' : 'Plan-Execute' }}</span>
|
||||
<div class="chat-stage-copy" v-if="currentAgent">
|
||||
<div class="chat-stage-kicker">{{ $t('nav.chat') }}</div>
|
||||
<div class="agent-badge" :title="currentAgent.name">
|
||||
<span class="agent-badge-icon">{{ currentAgent.icon || '🤖' }}</span>
|
||||
<span class="agent-badge-name">{{ currentAgent.name }}</span>
|
||||
<span class="agent-badge-type">{{ currentAgent.agentType === 'react' ? 'ReAct' : 'Plan-Execute' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
|
||||
</div>
|
||||
@ -114,16 +108,10 @@
|
||||
</option>
|
||||
</select>
|
||||
<button v-else class="header-btn" @click="goToModelSettings" :title="$t('chat.configModel')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l-.06.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01A1.65 1.65 0 0 0 10.5 3.1V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<el-icon><Setting /></el-icon>
|
||||
</button>
|
||||
<button class="header-btn" @click="clearMessages" :title="$t('chat.clearMessages')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||
</svg>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -210,6 +198,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ChatDotRound, Delete, Plus, Setting, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { conversationApi, agentApi, modelApi, chatApi } from '@/api/index'
|
||||
import { channelIconUrl } from '@/utils/channelSource'
|
||||
import { useChat } from '@/composables/chat/useChat'
|
||||
@ -1137,10 +1126,15 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
<style scoped>
|
||||
.chat-console-shell {
|
||||
background: transparent;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-console-frame {
|
||||
height: calc(100vh - 28px);
|
||||
height: min(calc(100vh - 28px), 100%);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-layout {
|
||||
@ -1151,8 +1145,8 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
}
|
||||
|
||||
.conversation-panel {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
width: 248px;
|
||||
min-width: 248px;
|
||||
background: linear-gradient(180deg, var(--mc-panel-top), var(--mc-panel-bottom));
|
||||
border-right: 1px solid var(--mc-border-light);
|
||||
display: flex;
|
||||
@ -1164,15 +1158,29 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
padding: 14px 14px 12px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
|
||||
.panel-header-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-kicker {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mc-accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text-primary);
|
||||
margin: 0;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
@ -1196,15 +1204,15 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
}
|
||||
|
||||
.agent-selector {
|
||||
padding: 10px 12px;
|
||||
padding: 10px 12px 12px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
|
||||
.agent-select {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 6px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-primary);
|
||||
background: var(--mc-bg-sunken);
|
||||
@ -1224,26 +1232,27 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
}
|
||||
|
||||
.conv-group-title {
|
||||
padding: 8px 10px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 10px 10px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 12px;
|
||||
padding: 10px 11px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.conv-item:hover {
|
||||
background: var(--mc-bg-sunken);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.conv-item.active {
|
||||
@ -1331,6 +1340,7 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--mc-chat-header-bg), var(--mc-chat-bg));
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 拖拽上传遮罩 */
|
||||
@ -1373,26 +1383,51 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(180deg, var(--mc-panel-raised), var(--mc-surface-overlay));
|
||||
border-bottom: 1px solid var(--mc-border);
|
||||
min-height: 52px;
|
||||
backdrop-filter: blur(12px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-stage-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-stage-kicker {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mc-accent);
|
||||
}
|
||||
|
||||
.agent-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
padding: 5px 10px;
|
||||
background: var(--mc-primary-bg);
|
||||
border-radius: 20px;
|
||||
border-radius: 999px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.agent-badge-icon {
|
||||
@ -1401,8 +1436,11 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
|
||||
.agent-badge-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: var(--mc-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-badge-type {
|
||||
@ -1420,18 +1458,18 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
|
||||
.model-select {
|
||||
min-width: 260px;
|
||||
height: 32px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 8px;
|
||||
background: var(--mc-bg-elevated);
|
||||
border-radius: 12px;
|
||||
background: var(--mc-panel-raised);
|
||||
color: var(--mc-text-primary);
|
||||
font-size: 13px;
|
||||
padding: 0 10px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--mc-border);
|
||||
background: var(--mc-panel-raised);
|
||||
border-radius: 10px;
|
||||
@ -1500,7 +1538,7 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
/* ===== 移动端适配 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.chat-console-frame {
|
||||
height: auto;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 28px);
|
||||
}
|
||||
|
||||
@ -1510,8 +1548,8 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
width: 272px;
|
||||
min-width: 272px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
box-shadow: none;
|
||||
@ -1551,21 +1589,18 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 10px 12px;
|
||||
padding: 9px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.agent-badge {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.chat-stage-kicker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-badge-name,
|
||||
.agent-badge-type {
|
||||
display: none;
|
||||
|
||||
@ -1,99 +1,152 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('cronJobs.title') }}</h1>
|
||||
<p class="page-desc">{{ t('cronJobs.desc') }}</p>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div class="page-lead">
|
||||
<div class="page-kicker">{{ t('cronJobs.kicker') }}</div>
|
||||
<h1 class="page-title">{{ t('cronJobs.title') }}</h1>
|
||||
<p class="page-desc">{{ t('cronJobs.desc') }}</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('cronJobs.createJob') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="page-stage">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('cronJobs.columns.name') }}</th>
|
||||
<th>{{ t('cronJobs.columns.cron') }}</th>
|
||||
<th>{{ t('tokenUsage.date') }}</th>
|
||||
<th>{{ t('cronJobs.columns.enabled') }}</th>
|
||||
<th>{{ t('cronJobs.columns.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in store.jobs" :key="job.id" class="data-row">
|
||||
<td>
|
||||
<div class="job-main">
|
||||
<div class="job-name" :title="job.name">{{ job.name }}</div>
|
||||
<div class="job-meta-row">
|
||||
<span class="agent-badge" :title="job.agentName || 'Unknown'">{{ job.agentName || 'Unknown' }}</span>
|
||||
<span class="type-badge" :class="'type-' + job.taskType">
|
||||
{{ t('cronJobs.taskTypes.' + job.taskType) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code class="cron-code" :title="cronToHumanReadable(job.cronExpression, job.timezone)">
|
||||
{{ job.cronExpression }}
|
||||
</code>
|
||||
<div class="cron-readable">{{ cronToHumanReadable(job.cronExpression, job.timezone) }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="runtime-stack">
|
||||
<span v-if="job.nextRunTime" class="time-text" :title="`${t('cronJobs.columns.nextRun')}: ${formatTime(job.nextRunTime)}`">{{ formatTime(job.nextRunTime) }}</span>
|
||||
<span v-else class="time-empty">-</span>
|
||||
<span v-if="job.lastRunTime" class="time-subtext" :title="`${t('cronJobs.columns.lastRun')}: ${formatTime(job.lastRunTime)}`">{{ t('cronJobs.columns.lastRun') }}: {{ formatTime(job.lastRunTime) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" :checked="job.enabled" @change="handleToggle(job)" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="row-btn" :title="t('common.view')" @click="openDetailModal(job)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M2.05 12a9.94 9.94 0 0 1 19.9 0 9.94 9.94 0 0 1-19.9 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="row-btn" :title="t('cronJobs.actions.runNow')" @click="handleRunNow(job)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="row-btn" :title="t('cronJobs.actions.edit')" @click="openEditModal(job)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="row-btn danger" :title="t('cronJobs.actions.delete')" @click="handleDelete(job)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="store.jobs.length === 0">
|
||||
<td colspan="5" class="empty-row">
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">⏱</span>
|
||||
<p>{{ t('cronJobs.noJobs') }}</p>
|
||||
<button class="btn-primary btn-sm" @click="openCreateModal">{{ t('cronJobs.createFirst') }}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('cronJobs.createJob') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('cronJobs.columns.name') }}</th>
|
||||
<th>{{ t('cronJobs.columns.agent') }}</th>
|
||||
<th>{{ t('cronJobs.columns.taskType') }}</th>
|
||||
<th>{{ t('cronJobs.columns.cron') }}</th>
|
||||
<th>{{ t('cronJobs.columns.nextRun') }}</th>
|
||||
<th>{{ t('cronJobs.columns.lastRun') }}</th>
|
||||
<th>{{ t('cronJobs.columns.enabled') }}</th>
|
||||
<th>{{ t('cronJobs.columns.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in store.jobs" :key="job.id" class="data-row">
|
||||
<td>
|
||||
<div class="job-name">{{ job.name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="agent-badge">{{ job.agentName || 'Unknown' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="type-badge" :class="'type-' + job.taskType">
|
||||
{{ t('cronJobs.taskTypes.' + job.taskType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<code class="cron-code" :title="cronToHumanReadable(job.cronExpression, job.timezone)">
|
||||
{{ job.cronExpression }}
|
||||
</code>
|
||||
<div class="cron-readable">{{ cronToHumanReadable(job.cronExpression, job.timezone) }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="job.nextRunTime" class="time-text">{{ formatTime(job.nextRunTime) }}</span>
|
||||
<span v-else class="time-empty">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="job.lastRunTime" class="time-text">{{ formatTime(job.lastRunTime) }}</span>
|
||||
<span v-else class="time-empty">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" :checked="job.enabled" @change="handleToggle(job)" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="row-btn" :title="t('cronJobs.actions.runNow')" @click="handleRunNow(job)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="row-btn" :title="t('cronJobs.actions.edit')" @click="openEditModal(job)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="row-btn danger" :title="t('cronJobs.actions.delete')" @click="handleDelete(job)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="store.jobs.length === 0">
|
||||
<td colspan="8" class="empty-row">
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">⏱</span>
|
||||
<p>{{ t('cronJobs.noJobs') }}</p>
|
||||
<button class="btn-primary btn-sm" @click="openCreateModal">{{ t('cronJobs.createFirst') }}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="detailJob" class="modal-overlay" @click.self="closeDetailModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ detailJob.name }}</h2>
|
||||
<button class="modal-close" @click="closeDetailModal">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('cronJobs.columns.agent') }}</div>
|
||||
<div class="detail-value">{{ detailJob.agentName || 'Unknown' }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('cronJobs.columns.taskType') }}</div>
|
||||
<div class="detail-value">{{ t('cronJobs.taskTypes.' + detailJob.taskType) }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('cronJobs.columns.cron') }}</div>
|
||||
<div class="detail-value mono">{{ detailJob.cronExpression }}</div>
|
||||
<div class="detail-subvalue">{{ cronToHumanReadable(detailJob.cronExpression, detailJob.timezone) }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('cronJobs.fields.timezone') }}</div>
|
||||
<div class="detail-value">{{ detailJob.timezone || '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('cronJobs.columns.nextRun') }}</div>
|
||||
<div class="detail-value">{{ detailJob.nextRunTime ? formatTime(detailJob.nextRunTime) : '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('cronJobs.columns.lastRun') }}</div>
|
||||
<div class="detail-value">{{ detailJob.lastRunTime ? formatTime(detailJob.lastRunTime) : '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-item detail-item-full" v-if="detailJob.taskType === 'text'">
|
||||
<div class="detail-label">{{ t('cronJobs.fields.triggerMessage') }}</div>
|
||||
<div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-item detail-item-full" v-else>
|
||||
<div class="detail-label">{{ t('cronJobs.fields.requestBody') }}</div>
|
||||
<div class="detail-value detail-block">{{ detailJob.requestBody || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
@ -108,13 +161,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- 名称 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('cronJobs.fields.name') }} *</label>
|
||||
<input v-model="form.name" class="form-input" :placeholder="t('cronJobs.fields.namePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<!-- 关联 Agent -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('cronJobs.fields.agent') }} *</label>
|
||||
<select v-model="form.agentId" class="form-input">
|
||||
@ -123,7 +174,6 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 任务类型 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('cronJobs.fields.taskType') }}</label>
|
||||
<div class="radio-group">
|
||||
@ -138,7 +188,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 触发消息 / 执行目标 -->
|
||||
<div v-if="form.taskType === 'text'" class="form-group">
|
||||
<label class="form-label">{{ t('cronJobs.fields.triggerMessage') }} *</label>
|
||||
<textarea v-model="form.triggerMessage" class="form-textarea" rows="3"
|
||||
@ -150,7 +199,6 @@
|
||||
:placeholder="t('cronJobs.fields.requestBodyPlaceholder')"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Cron 频率 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('cronJobs.fields.cronFrequency') }}</label>
|
||||
<div class="radio-group">
|
||||
@ -161,7 +209,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间选择器(daily/weekly) -->
|
||||
<div v-if="cronType === 'daily' || cronType === 'weekly'" class="form-row">
|
||||
<div v-if="cronType === 'weekly'" class="form-group">
|
||||
<label class="form-label">{{ t('cronJobs.fields.cronDays') }}</label>
|
||||
@ -181,14 +228,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义表达式 -->
|
||||
<div v-if="cronType === 'custom'" class="form-group">
|
||||
<label class="form-label">{{ t('cronJobs.fields.cronExpression') }}</label>
|
||||
<input v-model="form.cronExpression" class="form-input mono"
|
||||
:placeholder="t('cronJobs.fields.cronExpressionPlaceholder')" />
|
||||
</div>
|
||||
|
||||
<!-- 时区 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('cronJobs.fields.timezone') }}</label>
|
||||
<select v-model="form.timezone" class="form-input">
|
||||
@ -196,7 +241,6 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 启用 -->
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<label class="toggle-switch">
|
||||
@ -231,6 +275,7 @@ const agents = computed(() => agentStore.agents)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref<CronJob | null>(null)
|
||||
const detailJob = ref<CronJob | null>(null)
|
||||
|
||||
const cronTypeOptions = ['hourly', 'daily', 'weekly', 'custom'] as const
|
||||
const cronType = ref<string>('daily')
|
||||
@ -270,7 +315,6 @@ onMounted(() => {
|
||||
agentStore.fetchAgents()
|
||||
})
|
||||
|
||||
// 根据 cronType/cronTime/selectedDays 自动生成 cronExpression
|
||||
watch([cronType, cronTime, selectedDays], () => {
|
||||
if (cronType.value === 'custom') return
|
||||
const [h, m] = cronTime.value.split(':').map(Number)
|
||||
@ -317,6 +361,14 @@ function closeModal() {
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
function openDetailModal(job: CronJob) {
|
||||
detailJob.value = job
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
detailJob.value = null
|
||||
}
|
||||
|
||||
async function saveJob() {
|
||||
try {
|
||||
if (editing.value) {
|
||||
@ -327,7 +379,6 @@ async function saveJob() {
|
||||
ElMessage.success(t('cronJobs.messages.createSuccess'))
|
||||
}
|
||||
closeModal()
|
||||
// store.createJob / updateJob 已在本地数组更新,无需再全量刷新
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || e)
|
||||
}
|
||||
@ -363,22 +414,18 @@ async function handleRunNow(job: CronJob) {
|
||||
try {
|
||||
await store.runNow(job.id)
|
||||
ElMessage.success(t('cronJobs.messages.runTriggered', { id: job.id }))
|
||||
// Agent 执行完成后刷新列表,让 lastRunTime 显示最新值
|
||||
setTimeout(() => store.fetchJobs(), 3000)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Cron 工具函数 ====================
|
||||
|
||||
interface CronFormParts {
|
||||
type: string
|
||||
time: string
|
||||
days: number[]
|
||||
}
|
||||
|
||||
/** dow 是否为纯整数逗号列表,如 "1"、"1,3,5",不含范围/步长 */
|
||||
function isSimpleIntList(s: string): boolean {
|
||||
return s.split(',').every((v) => /^\d+$/.test(v.trim()))
|
||||
}
|
||||
@ -389,33 +436,25 @@ function parseCronToForm(expr: string): CronFormParts {
|
||||
|
||||
const [min, hour, dom, mon, dow] = parts
|
||||
|
||||
// 每小时
|
||||
if (min === '0' && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
|
||||
return { type: 'hourly', time: '00:00', days: [] }
|
||||
}
|
||||
|
||||
// min / hour 必须是纯整数,否则直接降级 custom(如 */5 * * * *)
|
||||
if (!/^\d+$/.test(min) || !/^\d+$/.test(hour)) {
|
||||
return { type: 'custom', time: '09:00', days: [] }
|
||||
}
|
||||
|
||||
const timeStr = pad(+hour) + ':' + pad(+min)
|
||||
|
||||
// 每天(dom/mon/dow 均为 *)
|
||||
if (dom === '*' && mon === '*' && dow === '*') {
|
||||
return { type: 'daily', time: timeStr, days: [] }
|
||||
}
|
||||
|
||||
// 每周 —— 严格要求:
|
||||
// 1. dom='*' mon='*'
|
||||
// 2. dow 是纯数字逗号列表(不含范围 1-5、步长 1-7/2 等)
|
||||
// 否则降级为 custom,防止 watch 把表达式改坏
|
||||
if (dom === '*' && mon === '*' && dow !== '*' && isSimpleIntList(dow)) {
|
||||
const days = dow.split(',').map(Number)
|
||||
return { type: 'weekly', time: timeStr, days }
|
||||
}
|
||||
|
||||
// 其余所有情况(包含 1-5、*/2、dom/mon 非 * 等)一律 custom
|
||||
return { type: 'custom', time: timeStr, days: [] }
|
||||
}
|
||||
|
||||
@ -441,7 +480,6 @@ function cronToHumanReadable(expr: string, timezone: string): string {
|
||||
if (dom === '*' && mon === '*' && dow !== '*' && !isNaN(+min) && !isNaN(+hour)) {
|
||||
const dayNames = dow.split(',').map((d) => {
|
||||
const n = +d
|
||||
// 0 和 7 均表示 Sunday,映射到 dayKeys[6];1=Mon→0 … 6=Sat→5
|
||||
const idx = n === 0 ? 6 : n - 1
|
||||
return idx >= 0 && idx < dayKeys.length ? t('cronJobs.days.' + dayKeys[idx]) : d
|
||||
})
|
||||
@ -463,38 +501,158 @@ function formatTime(datetime: string | undefined): string {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
|
||||
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
|
||||
.page-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
.page-shell {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
.page-lead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--mc-primary) 18%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mc-primary-bg) 72%, var(--mc-bg-elevated) 28%);
|
||||
color: var(--mc-primary-hover);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: clamp(28px, 4vw, 40px);
|
||||
line-height: 0.95;
|
||||
font-weight: 800;
|
||||
color: var(--mc-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
max-width: 620px;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
color: var(--mc-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
||||
.btn-primary.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
|
||||
/* 表格 */
|
||||
.table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 12px; overflow: hidden; }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th { padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.05em; background: var(--mc-bg-sunken); border-bottom: 1px solid var(--mc-border); white-space: nowrap; }
|
||||
.page-stage {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
width: 100%;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 96%, white 4%) 0%, var(--mc-bg-elevated) 100%);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 24px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
box-shadow: 0 20px 48px rgba(128, 84, 60, 0.08);
|
||||
}
|
||||
|
||||
.data-table { width: 100%; table-layout: fixed; border-collapse: collapse; }
|
||||
.data-table th { position: sticky; top: 0; z-index: 1; padding: 12px 16px; text-align: left; font-size: 11px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.08em; background: color-mix(in srgb, var(--mc-bg-sunken) 86%, white 14%); border-bottom: 1px solid var(--mc-border); white-space: nowrap; }
|
||||
.data-row { border-bottom: 1px solid var(--mc-border-light); transition: background 0.1s; }
|
||||
.data-row:hover { background: var(--mc-bg-sunken); }
|
||||
.data-row:last-child { border-bottom: none; }
|
||||
.data-table td { padding: 14px 16px; font-size: 14px; color: var(--mc-text-primary); }
|
||||
.data-table td { padding: 16px; font-size: 14px; color: var(--mc-text-primary); vertical-align: top; }
|
||||
|
||||
.job-name { font-weight: 500; color: var(--mc-text-primary); }
|
||||
.agent-badge { padding: 2px 8px; border-radius: 6px; font-size: 12px; background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
|
||||
.type-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
||||
.job-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.job-name {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text-primary);
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.job-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 150px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.type-badge { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
|
||||
.type-text { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||
.type-agent { background: var(--mc-success-bg, var(--mc-primary-bg)); color: var(--mc-success, var(--mc-primary-hover)); }
|
||||
.cron-code { background: var(--mc-bg-sunken); padding: 2px 8px; border-radius: 4px; font-size: 12px; color: var(--mc-text-primary); font-family: monospace; }
|
||||
.cron-readable { font-size: 11px; color: var(--mc-text-tertiary); margin-top: 2px; }
|
||||
.time-text { font-size: 13px; color: var(--mc-text-secondary); }
|
||||
.cron-code { display: inline-flex; background: var(--mc-bg-sunken); padding: 4px 8px; border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); font-family: monospace; }
|
||||
.cron-readable { font-size: 12px; line-height: 1.45; color: var(--mc-text-tertiary); margin-top: 6px; }
|
||||
.runtime-stack { display: flex; flex-direction: column; gap: 6px; }
|
||||
.time-text {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
.time-subtext {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.time-empty { color: var(--mc-text-tertiary); }
|
||||
|
||||
/* Toggle */
|
||||
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
|
||||
@ -502,19 +660,16 @@ function formatTime(datetime: string | undefined): string {
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
|
||||
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
|
||||
|
||||
/* 操作按钮 */
|
||||
.row-actions { display: flex; gap: 4px; }
|
||||
.row-btn { width: 28px; height: 28px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
|
||||
.row-actions { display: flex; gap: 6px; }
|
||||
.row-btn { width: 30px; height: 30px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
|
||||
.row-btn:hover { background: var(--mc-bg-sunken); color: var(--mc-primary); }
|
||||
.row-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
|
||||
|
||||
/* 空状态 */
|
||||
.empty-row { padding: 40px !important; }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; color: var(--mc-text-tertiary); }
|
||||
.empty-icon { font-size: 32px; }
|
||||
.empty-state p { font-size: 14px; margin: 0; }
|
||||
|
||||
/* 弹窗 */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
|
||||
.modal { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; width: 100%; max-width: 520px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
|
||||
.modal.modal-lg { max-width: 600px; }
|
||||
@ -523,9 +678,45 @@ function formatTime(datetime: string | undefined): string {
|
||||
.modal-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 6px; }
|
||||
.modal-close:hover { background: var(--mc-bg-sunken); }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.detail-item-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--mc-text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
.detail-subvalue {
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.detail-block {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 10px;
|
||||
background: var(--mc-bg-sunken);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--mc-border-light); }
|
||||
|
||||
/* 表单 */
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); }
|
||||
.form-input { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); width: 100%; }
|
||||
@ -536,20 +727,38 @@ function formatTime(datetime: string | undefined): string {
|
||||
.form-row { display: flex; gap: 16px; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
|
||||
/* Radio 组 */
|
||||
.radio-group { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.radio-option { display: flex; align-items: center; gap: 4px; padding: 6px 14px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; }
|
||||
.radio-option:hover { border-color: var(--mc-primary); }
|
||||
.radio-option.active { border-color: var(--mc-primary); background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||
.radio-option input { display: none; }
|
||||
|
||||
/* 星期选择 */
|
||||
.day-picker { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.day-chip { display: flex; align-items: center; justify-content: center; padding: 4px 10px; border: 1px solid var(--mc-border); border-radius: 6px; font-size: 12px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; user-select: none; }
|
||||
.day-chip:hover { border-color: var(--mc-primary); }
|
||||
.day-chip.active { border-color: var(--mc-primary); background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||
.day-chip input { display: none; }
|
||||
|
||||
/* Toggle label */
|
||||
.toggle-label { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--mc-text-primary); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-stage {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="mc-page-shell dashboard-shell">
|
||||
<div class="mc-page-frame">
|
||||
<div class="mc-page-inner">
|
||||
<div class="mc-page-frame dashboard-frame">
|
||||
<div class="mc-page-inner dashboard-inner">
|
||||
<div class="mc-page-header">
|
||||
<div>
|
||||
<div class="mc-page-kicker">Operations Pulse</div>
|
||||
@ -15,96 +15,106 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card mc-surface-card">
|
||||
<div class="stat-icon">💬</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ todayStats.conversations }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.conversations') }}</div>
|
||||
<div class="dashboard-body">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card mc-surface-card">
|
||||
<div class="stat-icon">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ todayStats.conversations }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.conversations') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card mc-surface-card">
|
||||
<div class="stat-icon">
|
||||
<el-icon><Document /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ todayStats.messages }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.messages') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card mc-surface-card">
|
||||
<div class="stat-icon">
|
||||
<el-icon><DataLine /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ formatTokens(todayStats.totalTokens) }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.tokens') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card mc-surface-card">
|
||||
<div class="stat-icon">
|
||||
<el-icon><Tools /></el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ todayStats.toolCalls }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.toolCalls') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card mc-surface-card">
|
||||
<div class="stat-icon">📝</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ todayStats.messages }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.messages') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card mc-surface-card">
|
||||
<div class="stat-icon">🎯</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ formatTokens(todayStats.totalTokens) }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.tokens') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card mc-surface-card">
|
||||
<div class="stat-icon">🔧</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ todayStats.toolCalls }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.toolCalls') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison-section">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">{{ t('dashboard.periodComparison') }}</h2>
|
||||
<p class="section-subtitle">A sharper view of how your system behaves across short, medium, and monthly horizons.</p>
|
||||
</div>
|
||||
<div class="comparison-grid">
|
||||
<div class="comparison-card mc-surface-card" v-for="(period, key) in overview" :key="key">
|
||||
<h3 class="comparison-title">{{ t('dashboard.periods.' + key) }}</h3>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.conversations') }}</span>
|
||||
<span class="comparison-value">{{ period.conversations }}</span>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.messages') }}</span>
|
||||
<span class="comparison-value">{{ period.messages }}</span>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.tokens') }}</span>
|
||||
<span class="comparison-value">{{ formatTokens(period.totalTokens) }}</span>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.toolCalls') }}</span>
|
||||
<span class="comparison-value">{{ period.toolCalls }}</span>
|
||||
<div class="comparison-section">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">{{ t('dashboard.periodComparison') }}</h2>
|
||||
<p class="section-subtitle">A sharper view of how your system behaves across short, medium, and monthly horizons.</p>
|
||||
</div>
|
||||
<div class="comparison-grid">
|
||||
<div class="comparison-card mc-surface-card" v-for="(period, key) in overview" :key="key">
|
||||
<h3 class="comparison-title">{{ t('dashboard.periods.' + key) }}</h3>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.conversations') }}</span>
|
||||
<span class="comparison-value">{{ period.conversations }}</span>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.messages') }}</span>
|
||||
<span class="comparison-value">{{ period.messages }}</span>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.tokens') }}</span>
|
||||
<span class="comparison-value">{{ formatTokens(period.totalTokens) }}</span>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.toolCalls') }}</span>
|
||||
<span class="comparison-value">{{ period.toolCalls }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="runs-section">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">{{ t('dashboard.recentRuns') }}</h2>
|
||||
<p class="section-subtitle">Execution should feel legible. If it runs, you should see its rhythm, cost, and outcome instantly.</p>
|
||||
</div>
|
||||
<div class="runs-table-wrapper mc-surface-card">
|
||||
<table v-if="recentRuns.length" class="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('dashboard.runColumns.time') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.job') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.status') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.trigger') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.duration') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.tokens') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="run in recentRuns" :key="run.id">
|
||||
<td class="cell-time">{{ formatTime(run.startedAt) }}</td>
|
||||
<td class="cell-job">#{{ run.cronJobId }}</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="'status-' + run.status">{{ run.status }}</span>
|
||||
</td>
|
||||
<td class="cell-trigger">{{ run.triggerType }}</td>
|
||||
<td class="cell-duration">{{ calcDuration(run) }}</td>
|
||||
<td class="cell-tokens">{{ run.tokenUsage || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state">{{ t('dashboard.noRuns') }}</div>
|
||||
<div class="runs-section">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">{{ t('dashboard.recentRuns') }}</h2>
|
||||
<p class="section-subtitle">Execution should feel legible. If it runs, you should see its rhythm, cost, and outcome instantly.</p>
|
||||
</div>
|
||||
<div class="runs-table-wrapper mc-surface-card">
|
||||
<table v-if="recentRuns.length" class="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('dashboard.runColumns.time') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.job') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.status') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.trigger') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.duration') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.tokens') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="run in recentRuns" :key="run.id">
|
||||
<td class="cell-time">{{ formatTime(run.startedAt) }}</td>
|
||||
<td class="cell-job">#{{ run.cronJobId }}</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="'status-' + run.status">{{ run.status }}</span>
|
||||
</td>
|
||||
<td class="cell-trigger">{{ run.triggerType }}</td>
|
||||
<td class="cell-duration">{{ calcDuration(run) }}</td>
|
||||
<td class="cell-tokens">{{ run.tokenUsage || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state">{{ t('dashboard.noRuns') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -115,6 +125,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue'
|
||||
import { dashboardApi } from '@/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -168,11 +179,34 @@ function calcDuration(run: any): string {
|
||||
<style scoped>
|
||||
.dashboard-shell {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-frame {
|
||||
height: min(calc(100vh - 28px), 100%);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dashboard-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.hero-note {
|
||||
min-width: 220px;
|
||||
padding: 18px 20px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.hero-note__label {
|
||||
@ -199,7 +233,7 @@ function calcDuration(run: any): string {
|
||||
}
|
||||
|
||||
.section-head {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@ -216,10 +250,10 @@ function calcDuration(run: any): string {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; margin-bottom: 36px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||
.stat-card {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 22px;
|
||||
padding: 18px;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 52px;
|
||||
@ -229,16 +263,17 @@ function calcDuration(run: any): string {
|
||||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, rgba(217, 109, 70, 0.12), rgba(24, 74, 69, 0.08));
|
||||
font-size: 25px;
|
||||
font-size: 24px;
|
||||
color: var(--mc-primary);
|
||||
}
|
||||
.stat-body { display: flex; flex-direction: column; }
|
||||
.stat-value { font-size: 30px; font-weight: 800; color: var(--mc-text-primary); line-height: 1; letter-spacing: -0.05em; }
|
||||
.stat-label { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 6px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
|
||||
.comparison-section { margin-bottom: 32px; }
|
||||
.comparison-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||
.comparison-section { margin-bottom: 22px; }
|
||||
.comparison-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.comparison-card {
|
||||
padding: 18px 18px 12px;
|
||||
padding: 16px 16px 10px;
|
||||
}
|
||||
.comparison-title { font-size: 12px; font-weight: 700; color: var(--mc-accent); margin: 0 0 12px; text-transform: uppercase; letter-spacing: 0.09em; }
|
||||
.comparison-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--mc-border-light); }
|
||||
@ -247,15 +282,18 @@ function calcDuration(run: any): string {
|
||||
.comparison-value { font-size: 14px; font-weight: 700; color: var(--mc-text-primary); }
|
||||
|
||||
/* Runs Section */
|
||||
.runs-section { margin-bottom: 32px; }
|
||||
.runs-table-wrapper { overflow: hidden; }
|
||||
.runs-section { min-height: 0; }
|
||||
.runs-table-wrapper {
|
||||
overflow: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
.runs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.runs-table th {
|
||||
padding: 10px 14px; text-align: left; font-weight: 600; font-size: 12px;
|
||||
padding: 9px 14px; text-align: left; font-weight: 600; font-size: 12px;
|
||||
color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;
|
||||
background: var(--mc-bg-muted); border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.runs-table td { padding: 10px 14px; border-bottom: 1px solid var(--mc-border-light); color: var(--mc-text-primary); }
|
||||
.runs-table td { padding: 9px 14px; border-bottom: 1px solid var(--mc-border-light); color: var(--mc-text-primary); }
|
||||
.runs-table tr:last-child td { border-bottom: none; }
|
||||
.runs-table tbody tr:hover { background: rgba(217, 109, 70, 0.04); }
|
||||
|
||||
@ -273,6 +311,16 @@ function calcDuration(run: any): string {
|
||||
.empty-state { padding: 48px; text-align: center; color: var(--mc-text-tertiary); font-size: 14px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-frame {
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 28px);
|
||||
}
|
||||
|
||||
.dashboard-body {
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.hero-note {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
@ -1,25 +1,25 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('datasources.title') }}</h1>
|
||||
<p class="page-desc">{{ t('datasources.desc') }}</p>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div class="page-lead">
|
||||
<div class="page-kicker">{{ t('datasources.kicker') }}</div>
|
||||
<h1 class="page-title">{{ t('datasources.title') }}</h1>
|
||||
<p class="page-desc">{{ t('datasources.desc') }}</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
<el-icon><Plus /></el-icon>
|
||||
{{ t('datasources.addButton') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('datasources.addButton') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 数据源列表 -->
|
||||
<div class="tools-table-wrap">
|
||||
<table class="tools-table">
|
||||
<div class="page-stage">
|
||||
<!-- 数据源列表 -->
|
||||
<div class="tools-table-wrap">
|
||||
<table class="tools-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('datasources.columns.name') }}</th>
|
||||
<th>{{ t('datasources.columns.type') }}</th>
|
||||
<th>{{ t('datasources.columns.connection') }}</th>
|
||||
<th>{{ t('datasources.columns.status') }}</th>
|
||||
<th>{{ t('datasources.columns.actions') }}</th>
|
||||
@ -27,69 +27,58 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ds in datasources" :key="ds.id" class="tool-row">
|
||||
<!-- 名称 + 描述 -->
|
||||
<td>
|
||||
<div class="tool-info">
|
||||
<div class="tool-icon-wrap" :class="{ 'icon-ok': ds.lastTestOk === true, 'icon-fail': ds.lastTestOk === false }">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||
</svg>
|
||||
<el-icon><Coin /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tool-name">{{ ds.name }}</div>
|
||||
<div class="tool-desc" v-if="ds.description">{{ ds.description }}</div>
|
||||
<div class="tool-name" :title="ds.name">{{ ds.name }}</div>
|
||||
<div class="tool-type-inline">
|
||||
<span class="type-badge" :class="'type-' + ds.dbType">{{ dbTypeLabel(ds.dbType) }}</span>
|
||||
</div>
|
||||
<div class="tool-desc" v-if="ds.description" :title="ds.description">{{ ds.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- 类型 -->
|
||||
<td>
|
||||
<span class="type-badge" :class="'type-' + ds.dbType">{{ dbTypeLabel(ds.dbType) }}</span>
|
||||
</td>
|
||||
<!-- 连接信息 -->
|
||||
<td>
|
||||
<div class="conn-info">
|
||||
<code class="conn-host">{{ ds.host }}:{{ ds.port }}</code>
|
||||
<span class="conn-db">{{ ds.databaseName }}<template v-if="ds.schemaName"> / {{ ds.schemaName }}</template></span>
|
||||
<code class="conn-host" :title="`${ds.host}:${ds.port}`">{{ ds.host }}:{{ ds.port }}</code>
|
||||
<span class="conn-db" :title="`${ds.databaseName}${ds.schemaName ? ` / ${ds.schemaName}` : ''}`">{{ ds.databaseName }}<template v-if="ds.schemaName"> / {{ ds.schemaName }}</template></span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- 状态 -->
|
||||
<td>
|
||||
<div class="status-cell">
|
||||
<span class="status-dot" :class="statusClass(ds)"></span>
|
||||
<span class="status-label">{{ ds.lastTestOk === true ? t('datasources.messages.testSuccess') : ds.lastTestOk === false ? t('datasources.messages.testFailed') : 'Pending' }}</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" :checked="ds.enabled" @change="toggleDs(ds)" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<!-- 操作 -->
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="row-btn" @click="openDetailModal(ds)" :title="t('common.view')">
|
||||
<el-icon><View /></el-icon>
|
||||
</button>
|
||||
<button class="row-btn test-btn" @click="testConnection(ds)" :disabled="testing === ds.id" :title="t('datasources.testButton')">
|
||||
<svg v-if="testing !== ds.id" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
<el-icon v-if="testing !== ds.id"><Select /></el-icon>
|
||||
<span v-else class="spinner"></span>
|
||||
</button>
|
||||
<button class="row-btn" @click="openEditModal(ds)" :title="t('common.edit')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
<el-icon><EditPen /></el-icon>
|
||||
</button>
|
||||
<button class="row-btn danger" @click="deleteDs(ds.id)" :title="t('common.delete')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||
</svg>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="datasources.length === 0">
|
||||
<td colspan="5" class="empty-row">
|
||||
<td colspan="4" class="empty-row">
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">🗄</span>
|
||||
<el-icon class="empty-icon"><Coin /></el-icon>
|
||||
<p>{{ t('datasources.empty') }}</p>
|
||||
<button class="btn-primary" style="margin-top: 8px" @click="openCreateModal">
|
||||
{{ t('datasources.addButton') }}
|
||||
@ -98,22 +87,62 @@
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailDs" class="modal-overlay" @click.self="closeDetailModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ detailDs.name }}</h2>
|
||||
<button class="modal-close" @click="closeDetailModal">
|
||||
<el-icon><CloseBold /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('datasources.columns.type') }}</div>
|
||||
<div class="detail-value">{{ dbTypeLabel(detailDs.dbType) }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('datasources.columns.status') }}</div>
|
||||
<div class="detail-value">{{ detailDs.enabled ? 'Enabled' : 'Disabled' }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('datasources.fields.host') }}</div>
|
||||
<div class="detail-value mono">{{ detailDs.host }}:{{ detailDs.port }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('datasources.fields.databaseName') }}</div>
|
||||
<div class="detail-value">{{ detailDs.databaseName }}</div>
|
||||
<div v-if="detailDs.schemaName" class="detail-subvalue">{{ detailDs.schemaName }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('datasources.fields.username') }}</div>
|
||||
<div class="detail-value">{{ detailDs.username || '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('datasources.fields.extraParams') }}</div>
|
||||
<div class="detail-value detail-block">{{ detailDs.extraParams || '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-item detail-item-full" v-if="detailDs.description">
|
||||
<div class="detail-label">{{ t('datasources.fields.description') }}</div>
|
||||
<div class="detail-value detail-block">{{ detailDs.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ editingDs ? t('datasources.modal.editTitle') : t('datasources.modal.newTitle') }}</h2>
|
||||
<button class="modal-close" @click="closeModal">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<el-icon><CloseBold /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- 基本信息 -->
|
||||
<div class="form-section-title">{{ t('datasources.sections.basic') }}</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
@ -135,7 +164,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接信息 -->
|
||||
<div class="form-section-title">{{ t('datasources.sections.connection') }}</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group" style="flex: 2">
|
||||
@ -156,7 +184,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 认证信息 -->
|
||||
<div class="form-section-title">{{ t('datasources.sections.auth') }}</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
@ -168,17 +195,16 @@
|
||||
<div class="password-wrap">
|
||||
<input v-model="form.password" :type="showPassword ? 'text' : 'password'" class="form-input" autocomplete="new-password" :placeholder="t('datasources.placeholders.password')" />
|
||||
<button class="password-toggle" @click="showPassword = !showPassword" type="button">
|
||||
<svg v-if="!showPassword" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
<el-icon v-if="!showPassword"><View /></el-icon>
|
||||
<el-icon v-else><Hide /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高级配置 -->
|
||||
<div class="form-section-title advanced-toggle" @click="showAdvanced = !showAdvanced">
|
||||
{{ t('datasources.sections.advanced') }}
|
||||
<svg :class="{ rotated: showAdvanced }" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<el-icon :class="{ rotated: showAdvanced }"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<div class="form-grid" v-if="showAdvanced">
|
||||
<div class="form-group full-width">
|
||||
@ -188,10 +214,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接测试结果 -->
|
||||
<div v-if="modalTestResult !== null" class="test-result" :class="modalTestResult ? 'test-ok' : 'test-fail'">
|
||||
<svg v-if="modalTestResult" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||
<el-icon v-if="modalTestResult"><CircleCheckFilled /></el-icon>
|
||||
<el-icon v-else><CircleCloseFilled /></el-icon>
|
||||
{{ modalTestResult ? t('datasources.messages.testSuccess') : t('datasources.messages.testFailed') }}
|
||||
</div>
|
||||
</div>
|
||||
@ -213,6 +238,19 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowDown,
|
||||
CircleCheckFilled,
|
||||
CircleCloseFilled,
|
||||
CloseBold,
|
||||
Coin,
|
||||
Delete,
|
||||
EditPen,
|
||||
Hide,
|
||||
Plus,
|
||||
Select,
|
||||
View,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { datasourceApi } from '@/api/index'
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -237,6 +275,7 @@ interface Datasource {
|
||||
const datasources = ref<Datasource[]>([])
|
||||
const showModal = ref(false)
|
||||
const editingDs = ref<Datasource | null>(null)
|
||||
const detailDs = ref<Datasource | null>(null)
|
||||
const testing = ref<number | string | null>(null)
|
||||
const showPassword = ref(false)
|
||||
const showAdvanced = ref(false)
|
||||
@ -307,6 +346,14 @@ function closeModal() {
|
||||
modalTestResult.value = null
|
||||
}
|
||||
|
||||
function openDetailModal(ds: Datasource) {
|
||||
detailDs.value = ds
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
detailDs.value = null
|
||||
}
|
||||
|
||||
async function saveDs() {
|
||||
try {
|
||||
let saved: any
|
||||
@ -317,11 +364,8 @@ async function saveDs() {
|
||||
}
|
||||
closeModal()
|
||||
await loadDatasources()
|
||||
// 保存成功后自动触发测试连接
|
||||
const id = saved?.data?.id
|
||||
if (id) {
|
||||
autoTestAfterSave(id)
|
||||
}
|
||||
if (id) autoTestAfterSave(id)
|
||||
} catch (e: any) { ElMessage.error(e?.message || t('datasources.messages.saveFailed')) }
|
||||
}
|
||||
|
||||
@ -332,13 +376,12 @@ async function autoTestAfterSave(id: number | string) {
|
||||
const ok = res.data?.success
|
||||
ElMessage({ type: ok ? 'success' : 'warning', message: ok ? t('datasources.messages.testSuccess') : t('datasources.messages.testFailed') })
|
||||
await loadDatasources()
|
||||
} catch { /* ignore */ }
|
||||
finally { testing.value = null }
|
||||
} catch {
|
||||
} finally { testing.value = null }
|
||||
}
|
||||
|
||||
async function testInModal() {
|
||||
if (!editingDs.value) {
|
||||
// 新建模式:先保存再测试
|
||||
try {
|
||||
const saved: any = await datasourceApi.create(form.value)
|
||||
editingDs.value = saved.data
|
||||
@ -349,7 +392,6 @@ async function testInModal() {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 编辑模式:先保存更新
|
||||
try {
|
||||
await datasourceApi.update(editingDs.value.id, form.value)
|
||||
await loadDatasources()
|
||||
@ -399,10 +441,40 @@ async function testConnection(ds: Datasource) {
|
||||
|
||||
<style scoped>
|
||||
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
|
||||
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
.page-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, color-mix(in srgb, var(--mc-primary-bg) 34%, transparent) 0, transparent 36%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 78%, white 22%) 0%, var(--mc-bg) 100%);
|
||||
}
|
||||
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||
.page-lead { display: flex; flex-direction: column; gap: 8px; }
|
||||
.page-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--mc-primary) 18%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mc-primary-bg) 72%, var(--mc-bg-elevated) 28%);
|
||||
color: var(--mc-primary-hover);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.page-title { font-size: clamp(28px, 4vw, 40px); line-height: 0.95; font-weight: 800; color: var(--mc-text-primary); margin: 0; }
|
||||
.page-desc { max-width: 620px; font-size: 15px; line-height: 1.55; color: var(--mc-text-secondary); margin: 0; }
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
||||
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||
@ -411,42 +483,47 @@ async function testConnection(ds: Datasource) {
|
||||
.btn-test:hover { background: var(--mc-primary-bg); }
|
||||
.btn-test:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Table */
|
||||
.tools-table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 12px; overflow: hidden; }
|
||||
.tools-table { width: 100%; border-collapse: collapse; }
|
||||
.tools-table th { padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.05em; background: var(--mc-bg-sunken); border-bottom: 1px solid var(--mc-border); }
|
||||
.page-stage {
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 96%, white 4%) 0%, var(--mc-bg-elevated) 100%);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 18px 48px rgba(152, 93, 63, 0.06);
|
||||
}
|
||||
|
||||
.tools-table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 14px; overflow-x: auto; overflow-y: hidden; }
|
||||
.tools-table { width: 100%; table-layout: fixed; border-collapse: collapse; }
|
||||
.tools-table th { position: sticky; top: 0; z-index: 1; padding: 12px 16px; text-align: left; font-size: 11px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.08em; background: color-mix(in srgb, var(--mc-bg-sunken) 86%, white 14%); border-bottom: 1px solid var(--mc-border); }
|
||||
.tool-row { border-bottom: 1px solid var(--mc-border-light); transition: background 0.1s; }
|
||||
.tool-row:hover { background: var(--mc-bg-sunken); }
|
||||
.tool-row:last-child { border-bottom: none; }
|
||||
.tools-table td { padding: 14px 16px; font-size: 14px; color: var(--mc-text-primary); }
|
||||
.tools-table td { padding: 16px; font-size: 14px; color: var(--mc-text-primary); vertical-align: top; }
|
||||
.tool-info { display: flex; align-items: center; gap: 10px; }
|
||||
.tool-icon-wrap { width: 32px; height: 32px; background: var(--mc-bg-sunken); border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--mc-text-secondary); }
|
||||
.tool-icon-wrap.icon-ok { background: #e8f5e9; color: #2e7d32; }
|
||||
.tool-icon-wrap.icon-fail { background: #fce4ec; color: #c62828; }
|
||||
.tool-name { font-weight: 500; color: var(--mc-text-primary); }
|
||||
.tool-name { max-width: 180px; font-weight: 700; color: var(--mc-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tool-type-inline { margin-top: 6px; }
|
||||
.tool-desc { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 1px; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Connection info */
|
||||
.conn-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.conn-host { background: var(--mc-bg-sunken); padding: 2px 8px; border-radius: 4px; font-size: 12px; color: var(--mc-text-primary); display: inline-block; }
|
||||
.conn-db { font-size: 12px; color: var(--mc-text-tertiary); }
|
||||
.conn-host { max-width: 180px; background: var(--mc-bg-sunken); padding: 2px 8px; border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.conn-db { max-width: 200px; font-size: 12px; color: var(--mc-text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Type badge */
|
||||
.type-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
||||
.type-mysql { background: #e8f4fd; color: #1a73e8; }
|
||||
.type-postgresql { background: #e8f0fe; color: #336791; }
|
||||
.type-clickhouse { background: #fff8e1; color: #e6a817; }
|
||||
.type-mariadb { background: #fce4ec; color: #c0392b; }
|
||||
|
||||
/* Status */
|
||||
.status-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.status-label { font-size: 12px; color: var(--mc-text-secondary); white-space: nowrap; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-ok { background: #4caf50; box-shadow: 0 0 4px rgba(76,175,80,0.4); }
|
||||
.dot-fail { background: #f44336; box-shadow: 0 0 4px rgba(244,67,54,0.4); }
|
||||
.dot-unknown { background: var(--mc-border); }
|
||||
.dot-disabled { background: var(--mc-border); opacity: 0.5; }
|
||||
|
||||
/* Toggle */
|
||||
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
|
||||
@ -454,25 +531,21 @@ async function testConnection(ds: Datasource) {
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
|
||||
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
|
||||
|
||||
/* Actions */
|
||||
.row-actions { display: flex; gap: 4px; }
|
||||
.row-btn { width: 28px; height: 28px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
|
||||
.row-actions { display: flex; gap: 6px; }
|
||||
.row-btn { width: 30px; height: 30px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
|
||||
.row-btn:hover { background: var(--mc-bg-sunken); }
|
||||
.row-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.row-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
|
||||
.row-btn.test-btn:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
|
||||
|
||||
/* Spinner */
|
||||
.spinner { width: 12px; height: 12px; border: 2px solid var(--mc-border); border-top-color: var(--mc-primary); border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Empty */
|
||||
.empty-row { padding: 40px !important; }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--mc-text-tertiary); }
|
||||
.empty-icon { font-size: 32px; }
|
||||
.empty-state p { font-size: 14px; margin: 0; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
|
||||
.modal { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; width: 100%; max-width: 580px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--mc-border-light); }
|
||||
@ -480,15 +553,20 @@ async function testConnection(ds: Datasource) {
|
||||
.modal-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 6px; }
|
||||
.modal-close:hover { background: var(--mc-bg-sunken); }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.detail-item { display: flex; flex-direction: column; gap: 6px; }
|
||||
.detail-item-full { grid-column: 1 / -1; }
|
||||
.detail-label { font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--mc-text-tertiary); }
|
||||
.detail-value { font-size: 14px; line-height: 1.5; color: var(--mc-text-primary); word-break: break-word; }
|
||||
.detail-subvalue { font-size: 12px; color: var(--mc-text-tertiary); }
|
||||
.detail-block { padding: 12px 14px; border: 1px solid var(--mc-border); border-radius: 10px; background: var(--mc-bg-sunken); white-space: pre-wrap; }
|
||||
|
||||
/* Form sections */
|
||||
.form-section-title { font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin: 20px 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.form-section-title:first-child { margin-top: 0; }
|
||||
.advanced-toggle { cursor: pointer; display: flex; align-items: center; gap: 4px; user-select: none; }
|
||||
.advanced-toggle svg { transition: transform 0.2s; }
|
||||
.advanced-toggle svg.rotated { transform: rotate(180deg); }
|
||||
|
||||
/* Form */
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group.full-width { grid-column: 1 / -1; }
|
||||
@ -497,17 +575,20 @@ async function testConnection(ds: Datasource) {
|
||||
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
|
||||
.form-hint { font-size: 11px; color: var(--mc-text-tertiary); margin-top: 2px; }
|
||||
|
||||
/* Password field */
|
||||
.password-wrap { position: relative; }
|
||||
.password-wrap .form-input { padding-right: 36px; }
|
||||
.password-toggle { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); padding: 4px; display: flex; align-items: center; justify-content: center; }
|
||||
.password-toggle:hover { color: var(--mc-text-secondary); }
|
||||
|
||||
/* Test result in modal */
|
||||
.test-result { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; margin-top: 16px; }
|
||||
.test-ok { background: #e8f5e9; color: #2e7d32; }
|
||||
.test-fail { background: #fce4ec; color: #c62828; }
|
||||
|
||||
/* Footer */
|
||||
.modal-footer { display: flex; align-items: center; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--mc-border-light); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-header { flex-direction: column; align-items: stretch; }
|
||||
.btn-primary { width: 100%; justify-content: center; }
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,79 +1,96 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<!-- Logo -->
|
||||
<div class="login-logo">
|
||||
<img src="/logo/mateclaw_logo_s.png" alt="MateClaw" class="logo-image" />
|
||||
<h1 class="logo-title">Mate<span class="logo-title-highlight">Claw</span></h1>
|
||||
<p class="logo-subtitle">{{ t('login.subtitle') }}</p>
|
||||
<div class="login-frame">
|
||||
<div class="login-hero">
|
||||
<div class="login-hero__kicker">{{ t('login.kicker') }}</div>
|
||||
<div class="login-logo">
|
||||
<img src="/logo/mateclaw_logo_s.png" alt="MateClaw" class="logo-image" />
|
||||
<h1 class="logo-title">Mate<span class="logo-title-highlight">Claw</span></h1>
|
||||
<p class="logo-subtitle">{{ t('login.subtitle') }}</p>
|
||||
</div>
|
||||
<h2 class="login-hero__title">{{ t('login.heroTitle') }}</h2>
|
||||
<p class="login-hero__desc">{{ t('login.heroDesc') }}</p>
|
||||
<div class="login-hero__points">
|
||||
<div class="hero-point">{{ t('login.pointContext') }}</div>
|
||||
<div class="hero-point">{{ t('login.pointKnowledge') }}</div>
|
||||
<div class="hero-point">{{ t('login.pointExecution') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<form class="login-form" @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('login.fields.username') }}</label>
|
||||
<div class="input-wrap">
|
||||
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
class="form-input"
|
||||
:placeholder="t('login.placeholders.username')"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<div class="login-card__intro">
|
||||
<div class="login-card__kicker">{{ t('login.signIn') }}</div>
|
||||
<h3 class="login-card__title">{{ t('login.cardTitle') }}</h3>
|
||||
<p class="login-card__desc">{{ t('login.cardDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('login.fields.password') }}</label>
|
||||
<div class="input-wrap">
|
||||
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="form.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
:placeholder="t('login.placeholders.password')"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button type="button" class="eye-btn" @click="showPassword = !showPassword">
|
||||
<svg v-if="!showPassword" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<!-- 登录表单 -->
|
||||
<form class="login-form" @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('login.fields.username') }}</label>
|
||||
<div class="input-wrap">
|
||||
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
class="form-input"
|
||||
:placeholder="t('login.placeholders.username')"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="error-msg">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('login.fields.password') }}</label>
|
||||
<div class="input-wrap">
|
||||
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="form.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
:placeholder="t('login.placeholders.password')"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button type="button" class="eye-btn" @click="showPassword = !showPassword">
|
||||
<svg v-if="!showPassword" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="login-btn" :disabled="loading">
|
||||
<span v-if="!loading">{{ t('login.signIn') }}</span>
|
||||
<span v-else class="loading-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
<div v-if="errorMsg" class="error-msg">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<p class="login-hint" v-html="t('login.hint')"></p>
|
||||
<button type="submit" class="login-btn" :disabled="loading">
|
||||
<span v-if="!loading">{{ t('login.signIn') }}</span>
|
||||
<span v-else class="loading-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login-hint" v-html="t('login.hint')"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 背景装饰 -->
|
||||
@ -126,6 +143,7 @@ async function handleLogin() {
|
||||
background: linear-gradient(135deg, var(--mc-primary-bg) 0%, #FAF5F0 50%, #F5EDE5 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
:root.dark .login-page,
|
||||
@ -133,22 +151,112 @@ html.dark .login-page {
|
||||
background: linear-gradient(135deg, var(--mc-bg) 0%, #1E1814 50%, #1A1210 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 20px 60px rgba(217, 119, 87, 0.12);
|
||||
.login-frame {
|
||||
width: min(1120px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-hero,
|
||||
.login-card {
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 20px 60px rgba(217, 119, 87, 0.12);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.login-hero__kicker,
|
||||
.login-card__kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--mc-bg-muted);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
color: var(--mc-accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-hero__title {
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
line-height: 0.98;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.05em;
|
||||
color: var(--mc-text-primary);
|
||||
margin: 18px 0 12px;
|
||||
}
|
||||
|
||||
.login-hero__desc {
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
color: var(--mc-text-secondary);
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-hero__points {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.hero-point {
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--mc-border-light);
|
||||
background: linear-gradient(180deg, var(--mc-bg-muted), var(--mc-bg-elevated));
|
||||
color: var(--mc-text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 36px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-card__intro {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-card__title {
|
||||
margin: 14px 0 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
|
||||
.login-card__desc {
|
||||
margin: 0;
|
||||
color: var(--mc-text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
@ -161,10 +269,11 @@ html.dark .login-page {
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
font-size: 42px;
|
||||
font-weight: 800;
|
||||
color: var(--mc-text-primary);
|
||||
margin: 0 0 6px;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.logo-title-highlight {
|
||||
@ -172,7 +281,7 @@ html.dark .login-page {
|
||||
}
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
color: var(--mc-text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
@ -313,7 +422,7 @@ html.dark .login-page {
|
||||
|
||||
/* 提示 */
|
||||
.login-hint {
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
margin: 20px 0 0;
|
||||
@ -376,4 +485,27 @@ html.dark .bg-circle-2 {
|
||||
html.dark .bg-circle-3 {
|
||||
background: radial-gradient(circle, rgba(123, 63, 30, 0.12), transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.login-frame {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
min-height: auto;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.login-hero__title {
|
||||
font-size: 34px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,34 +1,35 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('mcp.title') }}</h1>
|
||||
<p class="page-desc">{{ t('mcp.desc') }}</p>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div class="page-lead">
|
||||
<div class="page-kicker">{{ t('mcp.kicker') }}</div>
|
||||
<h1 class="page-title">{{ t('mcp.title') }}</h1>
|
||||
<p class="page-desc">{{ t('mcp.desc') }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-secondary" @click="refreshAll" :disabled="refreshing">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
{{ t('mcp.refreshAll') }}
|
||||
</button>
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('mcp.addServer') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-secondary" @click="refreshAll" :disabled="refreshing">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
{{ t('mcp.refreshAll') }}
|
||||
</button>
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('mcp.addServer') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server 列表 -->
|
||||
<div class="tools-table-wrap">
|
||||
<table class="tools-table">
|
||||
<div class="page-stage">
|
||||
<div class="tools-table-wrap">
|
||||
<table class="tools-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('mcp.columns.name') }}</th>
|
||||
<th>{{ t('mcp.columns.transport') }}</th>
|
||||
<th>{{ t('mcp.columns.lastStatus') }}</th>
|
||||
<th>{{ t('mcp.columns.toolCount') }}</th>
|
||||
<th>{{ t('mcp.columns.enabled') }}</th>
|
||||
@ -48,20 +49,23 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tool-name">{{ server.name }}</div>
|
||||
<div class="tool-desc">{{ server.description || '-' }}</div>
|
||||
<div class="tool-name" :title="server.name">{{ server.name }}</div>
|
||||
<div class="tool-type-inline">
|
||||
<span class="type-badge" :class="'type-' + server.transport">
|
||||
{{ t('mcp.transport.' + server.transport) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tool-desc" :title="server.description || '-'">{{ server.description || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="type-badge" :class="'type-' + server.transport">
|
||||
{{ t('mcp.transport.' + server.transport) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="'status-' + server.lastStatus">
|
||||
{{ t('mcp.status.' + (server.lastStatus || 'disconnected')) }}
|
||||
</span>
|
||||
<div class="status-stack">
|
||||
<span class="status-badge" :class="'status-' + server.lastStatus" :title="t('mcp.status.' + (server.lastStatus || 'disconnected'))">
|
||||
{{ t('mcp.status.' + (server.lastStatus || 'disconnected')) }}
|
||||
</span>
|
||||
<span v-if="server.lastConnectedTime" class="status-time" :title="server.lastConnectedTime">{{ server.lastConnectedTime }}</span>
|
||||
</div>
|
||||
<div v-if="server.lastError" class="status-error" :title="server.lastError">
|
||||
{{ truncate(server.lastError, 40) }}
|
||||
</div>
|
||||
@ -77,6 +81,11 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="row-btn" @click="openDetailModal(server)" :title="t('common.view')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M2.05 12a9.94 9.94 0 0 1 19.9 0 9.94 9.94 0 0 1-19.9 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="row-btn" @click="testConnection(server)" :disabled="testingId === server.id" :title="t('mcp.actions.test')">
|
||||
<svg v-if="testingId !== server.id" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
@ -105,7 +114,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="servers.length === 0">
|
||||
<td colspan="6" class="empty-row">
|
||||
<td colspan="5" class="empty-row">
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--mc-text-tertiary)">
|
||||
@ -121,10 +130,67 @@
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailServer" class="modal-overlay" @click.self="closeDetailModal">
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header">
|
||||
<h2>{{ detailServer.name }}</h2>
|
||||
<button class="modal-close" @click="closeDetailModal">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('mcp.columns.transport') }}</div>
|
||||
<div class="detail-value">{{ t('mcp.transport.' + detailServer.transport) }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('mcp.columns.lastStatus') }}</div>
|
||||
<div class="detail-value">{{ t('mcp.status.' + (detailServer.lastStatus || 'disconnected')) }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('mcp.columns.toolCount') }}</div>
|
||||
<div class="detail-value">{{ detailServer.toolCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('mcp.fields.enabled') }}</div>
|
||||
<div class="detail-value">{{ detailServer.enabled ? 'Enabled' : 'Disabled' }}</div>
|
||||
</div>
|
||||
<div class="detail-item detail-item-full" v-if="detailServer.transport === 'stdio'">
|
||||
<div class="detail-label">{{ t('mcp.fields.command') }}</div>
|
||||
<div class="detail-value detail-block mono">{{ detailServer.command || '-' }}</div>
|
||||
<div class="detail-subvalue" v-if="detailServer.argsJson">{{ detailServer.argsJson }}</div>
|
||||
</div>
|
||||
<div class="detail-item detail-item-full" v-else>
|
||||
<div class="detail-label">{{ t('mcp.fields.url') }}</div>
|
||||
<div class="detail-value detail-block mono">{{ detailServer.url || '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('mcp.fields.connectTimeout') }}</div>
|
||||
<div class="detail-value">{{ detailServer.connectTimeoutSeconds || 30 }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">{{ t('mcp.fields.readTimeout') }}</div>
|
||||
<div class="detail-value">{{ detailServer.readTimeoutSeconds || 30 }}</div>
|
||||
</div>
|
||||
<div class="detail-item detail-item-full" v-if="detailServer.lastError">
|
||||
<div class="detail-label">Error</div>
|
||||
<div class="detail-value detail-block">{{ detailServer.lastError }}</div>
|
||||
</div>
|
||||
<div class="detail-item detail-item-full" v-if="detailServer.description">
|
||||
<div class="detail-label">{{ t('mcp.fields.description') }}</div>
|
||||
<div class="detail-value detail-block">{{ detailServer.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Result Toast -->
|
||||
<transition name="toast">
|
||||
<div v-if="testResult" class="test-toast" :class="testResult.success ? 'toast-success' : 'toast-error'">
|
||||
<div class="toast-title">{{ testResult.success ? t('mcp.testResult.success') : t('mcp.testResult.failed') }}</div>
|
||||
@ -136,7 +202,6 @@
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header">
|
||||
@ -167,7 +232,6 @@
|
||||
<input v-model="form.description" class="form-input" :placeholder="t('mcp.placeholders.description')" />
|
||||
</div>
|
||||
|
||||
<!-- stdio fields -->
|
||||
<template v-if="form.transport === 'stdio'">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('mcp.fields.command') }} *</label>
|
||||
@ -187,7 +251,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- http/sse fields -->
|
||||
<template v-else>
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">{{ t('mcp.fields.url') }} *</label>
|
||||
@ -266,6 +329,7 @@ interface TestResultData {
|
||||
const servers = ref<McpServer[]>([])
|
||||
const showModal = ref(false)
|
||||
const editing = ref<McpServer | null>(null)
|
||||
const detailServer = ref<McpServer | null>(null)
|
||||
const refreshing = ref(false)
|
||||
const testingId = ref<number | null>(null)
|
||||
const testResult = ref<TestResultData | null>(null)
|
||||
@ -295,14 +359,12 @@ const canSave = computed(() => {
|
||||
|
||||
onMounted(loadServers)
|
||||
|
||||
/** 防止并发 loadServers 导致旧数据覆盖新数据 */
|
||||
let loadGeneration = 0
|
||||
|
||||
async function loadServers() {
|
||||
const gen = ++loadGeneration
|
||||
try {
|
||||
const res: any = await mcpApi.list()
|
||||
// 只有最后一次发起的请求才写入 servers
|
||||
if (gen === loadGeneration) {
|
||||
servers.value = res.data || []
|
||||
}
|
||||
@ -343,6 +405,14 @@ function closeModal() {
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
function openDetailModal(server: McpServer) {
|
||||
detailServer.value = server
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
detailServer.value = null
|
||||
}
|
||||
|
||||
async function saveServer() {
|
||||
try {
|
||||
if (editing.value) {
|
||||
@ -392,7 +462,6 @@ async function testConnection(server: McpServer) {
|
||||
try {
|
||||
const res: any = await mcpApi.test(server.id)
|
||||
testResult.value = res.data
|
||||
// Auto-hide after 4s
|
||||
setTimeout(() => { testResult.value = null }, 4000)
|
||||
} catch (e: any) {
|
||||
testResult.value = { success: false, message: e?.message || 'Unknown error', toolCount: 0, latencyMs: 0, discoveredTools: [] }
|
||||
@ -421,30 +490,68 @@ function truncate(str: string, len: number) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
|
||||
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
.page-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, color-mix(in srgb, var(--mc-primary-bg) 34%, transparent) 0, transparent 36%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 78%, white 22%) 0%, var(--mc-bg) 100%);
|
||||
}
|
||||
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||
.page-lead { display: flex; flex-direction: column; gap: 8px; }
|
||||
.page-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--mc-primary) 18%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mc-primary-bg) 72%, var(--mc-bg-elevated) 28%);
|
||||
color: var(--mc-primary-hover);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.page-title { font-size: clamp(28px, 4vw, 40px); line-height: 0.95; font-weight: 800; color: var(--mc-text-primary); margin: 0; }
|
||||
.page-desc { max-width: 620px; font-size: 15px; line-height: 1.55; color: var(--mc-text-secondary); margin: 0; }
|
||||
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
||||
.btn-secondary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||
.btn-secondary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; white-space: nowrap; }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.tools-table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 12px; overflow: hidden; }
|
||||
.tools-table { width: 100%; border-collapse: collapse; }
|
||||
.tools-table th { padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.05em; background: var(--mc-bg-sunken); border-bottom: 1px solid var(--mc-border); }
|
||||
.page-stage {
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 96%, white 4%) 0%, var(--mc-bg-elevated) 100%);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 18px 48px rgba(152, 93, 63, 0.06);
|
||||
}
|
||||
|
||||
.tools-table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 14px; overflow-x: auto; overflow-y: hidden; }
|
||||
.tools-table { width: 100%; table-layout: fixed; border-collapse: collapse; }
|
||||
.tools-table th { position: sticky; top: 0; z-index: 1; padding: 12px 16px; text-align: left; font-size: 11px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.08em; background: color-mix(in srgb, var(--mc-bg-sunken) 86%, white 14%); border-bottom: 1px solid var(--mc-border); }
|
||||
.tool-row { border-bottom: 1px solid var(--mc-border-light); transition: background 0.1s; }
|
||||
.tool-row:hover { background: var(--mc-bg-sunken); }
|
||||
.tool-row:last-child { border-bottom: none; }
|
||||
.tools-table td { padding: 14px 16px; font-size: 14px; color: var(--mc-text-primary); }
|
||||
.tools-table td { padding: 16px; font-size: 14px; color: var(--mc-text-primary); vertical-align: top; }
|
||||
.tool-info { display: flex; align-items: center; gap: 10px; }
|
||||
.tool-icon-wrap { width: 32px; height: 32px; background: var(--mc-bg-sunken); border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--mc-text-secondary); }
|
||||
.status-icon-connected { color: var(--mc-primary); background: var(--mc-primary-bg); }
|
||||
.status-icon-error { color: var(--mc-danger); background: var(--mc-danger-bg); }
|
||||
.tool-name { font-weight: 500; color: var(--mc-text-primary); }
|
||||
.tool-desc { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 1px; }
|
||||
.tool-name { max-width: 180px; font-weight: 700; color: var(--mc-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tool-type-inline { margin-top: 6px; }
|
||||
.tool-desc { max-width: 240px; font-size: 12px; color: var(--mc-text-tertiary); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.type-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
||||
.type-stdio { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||
.type-sse { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||
@ -452,6 +559,8 @@ function truncate(str: string, len: number) {
|
||||
.status-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
||||
.status-connected { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||
.status-disconnected { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
||||
.status-stack { display: flex; flex-direction: column; gap: 6px; }
|
||||
.status-time { font-size: 12px; color: var(--mc-text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status-error { font-size: 11px; color: var(--mc-danger); margin-top: 2px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tool-count { font-weight: 600; color: var(--mc-text-primary); }
|
||||
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
|
||||
@ -460,8 +569,8 @@ function truncate(str: string, len: number) {
|
||||
.toggle-slider::before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: 0.2s; }
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
|
||||
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
|
||||
.row-actions { display: flex; gap: 4px; }
|
||||
.row-btn { width: 28px; height: 28px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
|
||||
.row-actions { display: flex; gap: 6px; }
|
||||
.row-btn { width: 30px; height: 30px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
|
||||
.row-btn:hover { background: var(--mc-bg-sunken); }
|
||||
.row-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.row-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
|
||||
@ -470,7 +579,6 @@ function truncate(str: string, len: number) {
|
||||
.empty-sub { font-size: 13px; margin: 0; }
|
||||
.empty-state p { font-size: 14px; margin: 0; }
|
||||
|
||||
/* Toast */
|
||||
.test-toast { position: fixed; bottom: 24px; right: 24px; padding: 14px 20px; border-radius: 10px; z-index: 2000; box-shadow: 0 4px 20px rgba(0,0,0,0.15); }
|
||||
.toast-success { background: var(--mc-primary); color: white; }
|
||||
.toast-error { background: var(--mc-danger); color: white; }
|
||||
@ -479,7 +587,6 @@ function truncate(str: string, len: number) {
|
||||
.toast-enter-active, .toast-leave-active { transition: all 0.3s ease; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(20px); }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
|
||||
.modal { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; width: 100%; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
|
||||
.modal-wide { max-width: 600px; }
|
||||
@ -488,6 +595,13 @@ function truncate(str: string, len: number) {
|
||||
.modal-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 6px; }
|
||||
.modal-close:hover { background: var(--mc-bg-sunken); }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.detail-item { display: flex; flex-direction: column; gap: 6px; }
|
||||
.detail-item-full { grid-column: 1 / -1; }
|
||||
.detail-label { font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--mc-text-tertiary); }
|
||||
.detail-value { font-size: 14px; line-height: 1.5; color: var(--mc-text-primary); word-break: break-word; }
|
||||
.detail-subvalue { font-size: 12px; color: var(--mc-text-tertiary); white-space: pre-wrap; }
|
||||
.detail-block { padding: 12px 14px; border: 1px solid var(--mc-border); border-radius: 10px; background: var(--mc-bg-sunken); white-space: pre-wrap; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group.full-width { grid-column: 1 / -1; }
|
||||
@ -500,7 +614,12 @@ function truncate(str: string, len: number) {
|
||||
.toggle-inline input { width: 16px; height: 16px; accent-color: var(--mc-primary); }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--mc-border-light); }
|
||||
|
||||
/* Spin animation */
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-header { flex-direction: column; align-items: stretch; }
|
||||
.btn-primary, .btn-secondary { width: 100%; justify-content: center; }
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="mc-page-shell security-shell">
|
||||
<div class="mc-page-frame">
|
||||
<div class="mc-page-inner settings-layout">
|
||||
<div class="mc-page-frame security-frame">
|
||||
<div class="mc-page-inner security-layout">
|
||||
<div class="settings-nav mc-surface-card">
|
||||
<div class="settings-nav__intro">
|
||||
<div class="mc-page-kicker">Governance</div>
|
||||
<div class="mc-page-kicker">{{ t('security.kicker') }}</div>
|
||||
<h2 class="nav-title">{{ t('security.title') }}</h2>
|
||||
<p class="nav-desc">This is where the product becomes trustworthy: boundaries, approvals, audit, and operational truth.</p>
|
||||
<p class="nav-desc">{{ t('security.layoutDesc') }}</p>
|
||||
</div>
|
||||
<router-link
|
||||
v-for="section in sections"
|
||||
@ -21,7 +21,9 @@
|
||||
</div>
|
||||
|
||||
<div class="settings-content mc-surface-card">
|
||||
<router-view />
|
||||
<div class="settings-content__inner">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -83,24 +85,36 @@ function isActive(path: string) {
|
||||
<style scoped>
|
||||
.security-shell {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
.security-frame {
|
||||
height: min(calc(100vh - 28px), 100%);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.security-layout {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 120px);
|
||||
gap: 20px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
width: 270px;
|
||||
min-width: 270px;
|
||||
width: 286px;
|
||||
min-width: 286px;
|
||||
padding: 18px 14px;
|
||||
overflow-y: auto;
|
||||
align-self: flex-start;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.settings-nav__intro {
|
||||
padding: 6px 8px 16px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
@ -141,8 +155,45 @@ function isActive(path: string) {
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.settings-content__inner {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
min-height: 720px;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.security-frame {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 28px);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.security-layout {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
align-self: auto;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.settings-content__inner {
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="mc-page-shell settings-shell">
|
||||
<div class="mc-page-frame">
|
||||
<div class="mc-page-frame settings-frame">
|
||||
<div class="mc-page-inner settings-layout">
|
||||
<div class="settings-nav mc-surface-card">
|
||||
<div class="settings-nav__intro">
|
||||
<div class="mc-page-kicker">System</div>
|
||||
<div class="mc-page-kicker">{{ t('settings.kicker') }}</div>
|
||||
<h2 class="nav-title">{{ t('settings.title') }}</h2>
|
||||
<p class="nav-desc">Tune the machine, shape the context, and keep the product coherent as it grows.</p>
|
||||
<p class="nav-desc">{{ t('settings.layoutDesc') }}</p>
|
||||
</div>
|
||||
<template v-for="section in sections" :key="section.id">
|
||||
<div v-if="section.isDivider" class="nav-divider">{{ section.label }}</div>
|
||||
@ -23,7 +23,9 @@
|
||||
</div>
|
||||
|
||||
<div class="settings-content mc-surface-card">
|
||||
<router-view />
|
||||
<div class="settings-content__inner">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -129,13 +131,40 @@ function isActive(path: string) {
|
||||
<style scoped>
|
||||
.settings-shell {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-frame {
|
||||
height: min(calc(100vh - 28px), 100%);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
width: 286px;
|
||||
min-width: 286px;
|
||||
padding: 18px 14px;
|
||||
overflow-y: auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.settings-nav__intro {
|
||||
padding: 6px 8px 16px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-layout { display: flex; min-height: calc(100vh - 120px); gap: 20px; }
|
||||
.settings-nav { width: 270px; min-width: 270px; padding: 18px 14px; align-self: flex-start; }
|
||||
.settings-nav__intro { padding: 6px 8px 16px; }
|
||||
.nav-title { font-size: 28px; font-weight: 800; color: var(--mc-text-primary); letter-spacing: -0.04em; margin: 0 0 6px; }
|
||||
.nav-desc { color: var(--mc-text-secondary); font-size: 13px; line-height: 1.65; }
|
||||
.nav-desc { color: var(--mc-text-secondary); font-size: 13px; line-height: 1.65; margin: 0; }
|
||||
.nav-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; border: none; background: none; border-radius: 14px; font-size: 14px; font-weight: 500; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; text-align: left; text-decoration: none; }
|
||||
.nav-item:hover { background: var(--mc-bg-muted); color: var(--mc-text-primary); }
|
||||
.nav-item.active { background: var(--mc-primary-bg); color: var(--mc-primary); font-weight: 600; box-shadow: inset 0 0 0 1px rgba(217, 109, 70, 0.08); }
|
||||
@ -143,11 +172,32 @@ function isActive(path: string) {
|
||||
.nav-icon { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.nav-icon :deep(svg) { width: 18px; height: 18px; display: block; }
|
||||
.nav-divider { font-size: 11px; font-weight: 700; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.1em; padding: 18px 8px 8px; margin-top: 4px; }
|
||||
.settings-content { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px; min-height: 720px; }
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.settings-content__inner {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.settings-layout { flex-direction: column; }
|
||||
.settings-nav { width: 100%; min-width: 100%; }
|
||||
.settings-content { min-height: 0; }
|
||||
.settings-frame {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 28px);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.settings-layout { flex-direction: column; height: auto; }
|
||||
.settings-nav { width: 100%; min-width: 100%; max-height: none; align-self: auto; }
|
||||
.settings-content { min-height: 0; overflow: visible; }
|
||||
.settings-content__inner { overflow: visible; padding-right: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,46 +1,49 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('skills.title') }}</h1>
|
||||
<p class="page-desc">{{ t('skills.desc') }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-secondary" @click="handleRefreshRuntime" :disabled="refreshing">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
{{ refreshing ? t('skills.refreshing') : t('skills.refreshRuntime') }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="showImportDialog = true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
{{ t('skills.importSkill') }}
|
||||
</button>
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('skills.newSkill') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mc-page-shell">
|
||||
<div class="mc-page-frame">
|
||||
<div class="mc-page-inner skills-page">
|
||||
<div class="mc-page-header">
|
||||
<div>
|
||||
<div class="mc-page-kicker">Capabilities</div>
|
||||
<h1 class="mc-page-title">{{ t('skills.title') }}</h1>
|
||||
<p class="mc-page-desc">{{ t('skills.desc') }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-secondary" @click="handleRefreshRuntime" :disabled="refreshing">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
{{ refreshing ? t('skills.refreshing') : t('skills.refreshRuntime') }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="showImportDialog = true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
{{ t('skills.importSkill') }}
|
||||
</button>
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('skills.newSkill') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类 Tab -->
|
||||
<div class="category-tabs">
|
||||
<button v-for="tab in categoryTabs" :key="tab.value" class="cat-tab"
|
||||
:class="{ active: activeCategory === tab.value }" @click="activeCategory = tab.value">
|
||||
<span class="cat-icon">{{ tab.icon }}</span>
|
||||
{{ tab.label }}
|
||||
<span class="cat-count">{{ getCategoryCount(tab.value) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 分类 Tab -->
|
||||
<div class="category-tabs mc-surface-card">
|
||||
<button v-for="tab in categoryTabs" :key="tab.value" class="cat-tab"
|
||||
:class="{ active: activeCategory === tab.value }" @click="activeCategory = tab.value">
|
||||
<span class="cat-icon">{{ tab.icon }}</span>
|
||||
{{ tab.label }}
|
||||
<span class="cat-count">{{ getCategoryCount(tab.value) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 技能列表 -->
|
||||
<div class="skill-grid" v-if="filteredSkills.length > 0">
|
||||
<div v-for="skill in filteredSkills" :key="skill.id" class="skill-card"
|
||||
<!-- 技能列表 -->
|
||||
<div class="skill-grid" v-if="filteredSkills.length > 0">
|
||||
<div v-for="skill in filteredSkills" :key="skill.id" class="skill-card mc-surface-card"
|
||||
:class="{ disabled: !skill.enabled }">
|
||||
<div class="skill-header">
|
||||
<div class="skill-icon-wrap" :class="getSkillIconBg(skill.skillType)">
|
||||
@ -110,13 +113,15 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-icon">🛠️</div>
|
||||
<h3>{{ t('skills.empty') }}</h3>
|
||||
<p>{{ t('skills.emptyDesc') }}</p>
|
||||
<div v-else class="empty-state mc-surface-card">
|
||||
<div class="empty-icon">🛠️</div>
|
||||
<h3>{{ t('skills.empty') }}</h3>
|
||||
<p>{{ t('skills.emptyDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Hub Dialog -->
|
||||
@ -504,21 +509,18 @@ function getSkillTypeLabel(type: string) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
|
||||
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.15s; }
|
||||
.skills-page { gap: 18px; }
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; box-shadow: var(--mc-shadow-soft); }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
||||
.btn-secondary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||
.btn-secondary { display: flex; align-items: center; gap: 6px; padding: 9px 14px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 12px; font-size: 14px; cursor: pointer; }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* 分类 Tab */
|
||||
.category-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.cat-tab { display: flex; align-items: center; gap: 6px; padding: 8px 16px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 8px; font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; }
|
||||
.category-tabs { display: flex; gap: 8px; flex-wrap: wrap; padding: 14px; }
|
||||
.cat-tab { display: flex; align-items: center; gap: 6px; padding: 9px 16px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 999px; font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; font-weight: 600; }
|
||||
.cat-tab:hover { background: var(--mc-bg-sunken); }
|
||||
.cat-tab.active { background: var(--mc-primary-bg); border-color: var(--mc-primary); color: var(--mc-primary); font-weight: 500; }
|
||||
.cat-icon { font-size: 14px; }
|
||||
@ -526,19 +528,19 @@ function getSkillTypeLabel(type: string) {
|
||||
.cat-tab.active .cat-count { background: rgba(217, 119, 87, 0.2); color: var(--mc-primary); }
|
||||
|
||||
/* 技能网格 */
|
||||
.skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
.skill-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 12px; padding: 16px; transition: all 0.15s; display: flex; flex-direction: column; }
|
||||
.skill-card:hover { border-color: var(--mc-primary-light); box-shadow: 0 4px 12px rgba(217, 119, 87, 0.08); }
|
||||
.skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
|
||||
.skill-card { padding: 18px; transition: all 0.15s; display: flex; flex-direction: column; min-height: 280px; }
|
||||
.skill-card:hover { border-color: var(--mc-primary-light); box-shadow: var(--mc-shadow-medium); transform: translateY(-2px); }
|
||||
.skill-card.disabled { opacity: 0.6; }
|
||||
.skill-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 10px; }
|
||||
.skill-icon-wrap { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.skill-icon-wrap { width: 44px; height: 44px; border-radius: 14px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.bg-blue { background: var(--mc-primary-bg); }
|
||||
.bg-purple { background: var(--mc-primary-bg); }
|
||||
.bg-green { background: var(--mc-primary-bg); }
|
||||
.bg-gray { background: var(--mc-bg-sunken); }
|
||||
.skill-icon { font-size: 20px; }
|
||||
.skill-meta { flex: 1; overflow: hidden; }
|
||||
.skill-name { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin: 0 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.skill-name { font-size: 16px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.skill-meta-row { display: flex; align-items: center; gap: 6px; }
|
||||
.skill-type-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
|
||||
.badge-blue { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||
@ -581,7 +583,7 @@ function getSkillTypeLabel(type: string) {
|
||||
.skill-footer { display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--mc-border-light); padding-top: 12px; }
|
||||
.skill-author { font-size: 12px; color: var(--mc-text-tertiary); }
|
||||
.skill-actions { display: flex; gap: 6px; }
|
||||
.skill-btn { display: flex; align-items: center; gap: 4px; padding: 5px 10px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 6px; font-size: 12px; color: var(--mc-text-primary); cursor: pointer; transition: all 0.15s; }
|
||||
.skill-btn { display: flex; align-items: center; gap: 4px; padding: 7px 11px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 10px; font-size: 12px; color: var(--mc-text-primary); cursor: pointer; transition: all 0.15s; font-weight: 600; }
|
||||
.skill-btn:hover { background: var(--mc-bg-sunken); }
|
||||
.skill-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
|
||||
|
||||
@ -610,4 +612,10 @@ function getSkillTypeLabel(type: string) {
|
||||
.form-textarea { resize: vertical; font-family: inherit; }
|
||||
.form-textarea.code { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 13px; background: var(--mc-bg-sunken); }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--mc-border-light); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,123 +1,121 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('tokenUsage.title') }}</h1>
|
||||
<p class="page-desc">{{ t('tokenUsage.desc') }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="date-range">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="–"
|
||||
start-placeholder="Start"
|
||||
end-placeholder="End"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
:clearable="false"
|
||||
size="default"
|
||||
/>
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div class="page-lead">
|
||||
<div class="page-kicker">{{ t('tokenUsage.kicker') }}</div>
|
||||
<h1 class="page-title">{{ t('tokenUsage.title') }}</h1>
|
||||
<p class="page-desc">{{ t('tokenUsage.desc') }}</p>
|
||||
</div>
|
||||
<button class="action-btn" :disabled="loading" @click="fetchData">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
{{ t('tokenUsage.refresh') }}
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<div class="date-range">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="–"
|
||||
:start-placeholder="t('tokenUsage.startDate')"
|
||||
:end-placeholder="t('tokenUsage.endDate')"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
:clearable="false"
|
||||
size="default"
|
||||
/>
|
||||
</div>
|
||||
<button class="action-btn" :disabled="loading" @click="fetchData">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
{{ t('tokenUsage.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-body">
|
||||
<div v-if="loading && !data" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="empty-state">
|
||||
<span class="empty-icon">⚠️</span>
|
||||
<p>{{ error }}</p>
|
||||
<button class="action-btn" @click="fetchData">{{ t('tokenUsage.refresh') }}</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<div v-if="data.totalMessages > 0" class="summary-cards">
|
||||
<div class="summary-card">
|
||||
<div class="card-kicker">{{ t('tokenUsage.promptTokens') }}</div>
|
||||
<div class="card-value">{{ formatNumber(data.totalPromptTokens) }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="card-kicker">{{ t('tokenUsage.completionTokens') }}</div>
|
||||
<div class="card-value">{{ formatNumber(data.totalCompletionTokens) }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="card-kicker">{{ t('tokenUsage.assistantMessages') }}</div>
|
||||
<div class="card-value">{{ formatNumber(data.totalMessages) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.totalMessages === 0" class="empty-state">
|
||||
<span class="empty-icon">📊</span>
|
||||
<p>{{ t('tokenUsage.noData') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="data.byModel && data.byModel.length > 0" class="table-section">
|
||||
<h2 class="section-title">{{ t('tokenUsage.byModel') }}</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('tokenUsage.provider') }}</th>
|
||||
<th>{{ t('tokenUsage.model') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.promptTokens') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.completionTokens') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.messageCount') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, idx) in data.byModel" :key="idx">
|
||||
<td><span class="mono-text mono-text--truncate" :title="item.runtimeProvider || '-'">{{ item.runtimeProvider || '-' }}</span></td>
|
||||
<td><span class="mono-text mono-text--truncate mono-text--wide" :title="item.runtimeModel || '-'">{{ item.runtimeModel || '-' }}</span></td>
|
||||
<td class="num-col">{{ formatNumber(item.promptTokens) }}</td>
|
||||
<td class="num-col">{{ formatNumber(item.completionTokens) }}</td>
|
||||
<td class="num-col">{{ formatNumber(item.messageCount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.byDate && data.byDate.length > 0" class="table-section">
|
||||
<h2 class="section-title">{{ t('tokenUsage.byDate') }}</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('tokenUsage.date') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.promptTokens') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.completionTokens') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.messageCount') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in data.byDate" :key="item.date">
|
||||
<td>{{ item.date }}</td>
|
||||
<td class="num-col">{{ formatNumber(item.promptTokens) }}</td>
|
||||
<td class="num-col">{{ formatNumber(item.completionTokens) }}</td>
|
||||
<td class="num-col">{{ formatNumber(item.messageCount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading && !data" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="empty-state">
|
||||
<span class="empty-icon">⚠️</span>
|
||||
<p>{{ error }}</p>
|
||||
<button class="action-btn" @click="fetchData">{{ t('tokenUsage.refresh') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else-if="data">
|
||||
<!-- Summary cards -->
|
||||
<div v-if="data.totalMessages > 0" class="summary-cards">
|
||||
<div class="summary-card">
|
||||
<div class="card-value">{{ formatNumber(data.totalPromptTokens) }}</div>
|
||||
<div class="card-label">{{ t('tokenUsage.promptTokens') }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="card-value">{{ formatNumber(data.totalCompletionTokens) }}</div>
|
||||
<div class="card-label">{{ t('tokenUsage.completionTokens') }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="card-value">{{ formatNumber(data.totalMessages) }}</div>
|
||||
<div class="card-label">{{ t('tokenUsage.assistantMessages') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No data -->
|
||||
<div v-if="data.totalMessages === 0" class="empty-state">
|
||||
<span class="empty-icon">📊</span>
|
||||
<p>{{ t('tokenUsage.noData') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- By Model Table -->
|
||||
<div v-if="data.byModel && data.byModel.length > 0" class="table-section">
|
||||
<h2 class="section-title">{{ t('tokenUsage.byModel') }}</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('tokenUsage.provider') }}</th>
|
||||
<th>{{ t('tokenUsage.model') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.promptTokens') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.completionTokens') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.messageCount') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, idx) in data.byModel" :key="idx">
|
||||
<td><span class="mono-text">{{ item.runtimeProvider || '-' }}</span></td>
|
||||
<td><span class="mono-text">{{ item.runtimeModel || '-' }}</span></td>
|
||||
<td class="num-col">{{ formatNumber(item.promptTokens) }}</td>
|
||||
<td class="num-col">{{ formatNumber(item.completionTokens) }}</td>
|
||||
<td class="num-col">{{ formatNumber(item.messageCount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- By Date Table -->
|
||||
<div v-if="data.byDate && data.byDate.length > 0" class="table-section">
|
||||
<h2 class="section-title">{{ t('tokenUsage.byDate') }}</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('tokenUsage.date') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.promptTokens') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.completionTokens') }}</th>
|
||||
<th class="num-col">{{ t('tokenUsage.messageCount') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in data.byDate" :key="item.date">
|
||||
<td>{{ item.date }}</td>
|
||||
<td class="num-col">{{ formatNumber(item.promptTokens) }}</td>
|
||||
<td class="num-col">{{ formatNumber(item.completionTokens) }}</td>
|
||||
<td class="num-col">{{ formatNumber(item.messageCount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -133,7 +131,6 @@ const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const data = ref<TokenUsageSummary | null>(null)
|
||||
|
||||
// Default: last 30 days
|
||||
const today = new Date()
|
||||
const thirtyDaysAgo = new Date(today)
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
|
||||
@ -175,32 +172,72 @@ onMounted(fetchData)
|
||||
.page-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
background: var(--mc-bg);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
.page-lead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--mc-primary) 18%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mc-primary-bg) 72%, var(--mc-bg-elevated) 28%);
|
||||
color: var(--mc-primary-hover);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-size: clamp(28px, 4vw, 40px);
|
||||
line-height: 0.95;
|
||||
font-weight: 800;
|
||||
color: var(--mc-text-primary);
|
||||
margin: 0 0 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 14px;
|
||||
max-width: 620px;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
color: var(--mc-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@ -212,12 +249,13 @@ onMounted(fetchData)
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
padding: 10px 16px;
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
color: var(--mc-text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
@ -232,67 +270,78 @@ onMounted(fetchData)
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Summary cards */
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--mc-bg-elevated);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 92%, white 8%) 0%, var(--mc-bg-elevated) 100%);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 16px 40px rgba(152, 93, 63, 0.06);
|
||||
}
|
||||
|
||||
.card-kicker {
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
font-size: clamp(28px, 4vw, 48px);
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
color: var(--mc-text-primary);
|
||||
margin-bottom: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-section {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text-primary);
|
||||
margin: 0 0 12px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
background: var(--mc-bg-elevated);
|
||||
width: 100%;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 92%, white 8%) 0%, var(--mc-bg-elevated) 100%);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
box-shadow: 0 20px 48px rgba(128, 84, 60, 0.08);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--mc-bg-sunken);
|
||||
letter-spacing: 0.08em;
|
||||
background: color-mix(in srgb, var(--mc-bg-sunken) 86%, white 14%);
|
||||
border-bottom: 1px solid var(--mc-border);
|
||||
}
|
||||
|
||||
@ -321,7 +370,19 @@ onMounted(fetchData)
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.mono-text--truncate {
|
||||
display: inline-block;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.mono-text--wide {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -363,18 +424,33 @@ onMounted(fetchData)
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Date picker overrides */
|
||||
.date-range :deep(.el-date-editor) {
|
||||
--el-date-editor-width: 260px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.summary-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
.page-body {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.date-range {
|
||||
width: 100%;
|
||||
}
|
||||
.date-range :deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
}
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,21 +1,24 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('tools.title') }}</h1>
|
||||
<p class="page-desc">{{ t('tools.desc') }}</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('tools.registerButton') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mc-page-shell">
|
||||
<div class="mc-page-frame">
|
||||
<div class="mc-page-inner tools-page">
|
||||
<div class="mc-page-header">
|
||||
<div>
|
||||
<div class="mc-page-kicker">Execution Surface</div>
|
||||
<h1 class="mc-page-title">{{ t('tools.title') }}</h1>
|
||||
<p class="mc-page-desc">{{ t('tools.desc') }}</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('tools.registerButton') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 工具列表 -->
|
||||
<div class="tools-table-wrap">
|
||||
<table class="tools-table">
|
||||
<!-- 工具列表 -->
|
||||
<div class="tools-table-wrap mc-surface-card">
|
||||
<table class="tools-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('tools.columns.tool') }}</th>
|
||||
@ -76,7 +79,9 @@
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
@ -198,24 +203,21 @@ async function toggleTool(tool: Tool) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
|
||||
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
.tools-page { gap: 18px; }
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--mc-shadow-soft); }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
||||
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 12px; font-size: 14px; cursor: pointer; }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
.tools-table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 12px; overflow: hidden; }
|
||||
.tools-table-wrap { overflow: hidden; }
|
||||
.tools-table { width: 100%; border-collapse: collapse; }
|
||||
.tools-table th { padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.05em; background: var(--mc-bg-sunken); border-bottom: 1px solid var(--mc-border); }
|
||||
.tools-table th { padding: 14px 16px; text-align: left; font-size: 12px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.08em; background: var(--mc-bg-muted); border-bottom: 1px solid var(--mc-border); }
|
||||
.tool-row { border-bottom: 1px solid var(--mc-border-light); transition: background 0.1s; }
|
||||
.tool-row:hover { background: var(--mc-bg-sunken); }
|
||||
.tool-row:hover { background: var(--mc-bg-muted); }
|
||||
.tool-row:last-child { border-bottom: none; }
|
||||
.tools-table td { padding: 14px 16px; font-size: 14px; color: var(--mc-text-primary); }
|
||||
.tool-info { display: flex; align-items: center; gap: 10px; }
|
||||
.tool-icon-wrap { width: 32px; height: 32px; background: var(--mc-bg-sunken); border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--mc-text-secondary); }
|
||||
.tool-icon-wrap { width: 36px; height: 36px; background: linear-gradient(135deg, rgba(217,109,87,0.12), rgba(24,74,69,0.08)); border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--mc-text-secondary); }
|
||||
.tool-name { font-weight: 500; color: var(--mc-text-primary); }
|
||||
.tool-desc { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 1px; }
|
||||
.bean-name { background: var(--mc-bg-sunken); padding: 2px 8px; border-radius: 4px; font-size: 12px; color: var(--mc-text-primary); }
|
||||
@ -230,7 +232,7 @@ async function toggleTool(tool: Tool) {
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
|
||||
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
|
||||
.row-actions { display: flex; gap: 4px; }
|
||||
.row-btn { width: 28px; height: 28px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
|
||||
.row-btn { width: 30px; height: 30px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
|
||||
.row-btn:hover { background: var(--mc-bg-sunken); }
|
||||
.row-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
|
||||
.empty-row { padding: 40px !important; }
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
</div>
|
||||
<div class="raw-item-meta">
|
||||
<span class="status-badge" :class="raw.processingStatus">
|
||||
{{ raw.processingStatus }}
|
||||
{{ t(`wiki.status.${raw.processingStatus}`) }}
|
||||
</span>
|
||||
<span v-if="raw.errorMessage" class="error-hint" :title="raw.errorMessage">
|
||||
{{ raw.errorMessage }}
|
||||
@ -203,28 +203,34 @@ async function handleScanDir() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.raw-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
.btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
||||
.btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; white-space: nowrap; }
|
||||
.btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; white-space: nowrap; }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
|
||||
/* Directory scan */
|
||||
.dir-scan-row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
|
||||
.dir-input-wrap { flex: 1; display: flex; align-items: center; gap: 8px; padding: 6px 12px; border: 1px solid var(--mc-border); border-radius: 8px; background: var(--mc-bg-elevated); color: var(--mc-text-tertiary); }
|
||||
.dir-scan-row { display: flex; gap: 10px; align-items: center; }
|
||||
.dir-input-wrap { flex: 1; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 12px; background: var(--mc-bg-elevated); color: var(--mc-text-tertiary); }
|
||||
.dir-input-wrap:focus-within { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
|
||||
.dir-input { flex: 1; border: none; background: transparent; font-size: 13px; color: var(--mc-text-primary); outline: none; }
|
||||
.dir-input::placeholder { color: var(--mc-text-tertiary); }
|
||||
.scan-result { font-size: 12px; color: var(--mc-text-secondary); margin-bottom: 12px; padding: 6px 10px; background: rgba(90,138,90,0.1); border-radius: 6px; }
|
||||
.scan-result { font-size: 12px; color: var(--mc-text-secondary); padding: 8px 10px; background: rgba(90,138,90,0.1); border-radius: 10px; }
|
||||
|
||||
/* Upload row: zone + add text side by side */
|
||||
.upload-row { display: flex; gap: 12px; align-items: stretch; margin-bottom: 20px; }
|
||||
.upload-row { display: flex; gap: 12px; align-items: stretch; }
|
||||
.upload-zone {
|
||||
flex: 1;
|
||||
border: 1px dashed var(--mc-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
border-radius: 16px;
|
||||
padding: 18px 20px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
display: flex;
|
||||
@ -240,12 +246,12 @@ async function handleScanDir() {
|
||||
.add-text-btn { flex-shrink: 0; }
|
||||
|
||||
/* Raw list */
|
||||
.raw-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.raw-list { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
||||
.raw-list-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mc-text-tertiary); margin-bottom: 4px; }
|
||||
.empty-hint { text-align: center; padding: 24px 0; font-size: 14px; color: var(--mc-text-tertiary); }
|
||||
|
||||
.raw-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; background: var(--mc-bg-elevated); border: 1px solid var(--mc-border-light); border-radius: 10px; font-size: 13px; transition: border-color 0.15s; }
|
||||
.raw-item:hover { border-color: var(--mc-border); }
|
||||
.raw-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: linear-gradient(180deg, var(--mc-bg-elevated), var(--mc-bg-muted)); border: 1px solid var(--mc-border-light); border-radius: 14px; font-size: 13px; transition: border-color 0.15s, transform 0.15s; }
|
||||
.raw-item:hover { border-color: var(--mc-border); transform: translateY(-1px); }
|
||||
|
||||
.raw-item-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
|
||||
.raw-item-title { font-weight: 500; color: var(--mc-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
@ -255,7 +261,7 @@ async function handleScanDir() {
|
||||
.error-hint { font-size: 11px; color: var(--mc-danger); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Icon button */
|
||||
.btn-icon { width: 28px; height: 28px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-elevated); cursor: pointer; border-radius: 6px; color: var(--mc-text-secondary); transition: all 0.15s; display: flex; align-items: center; justify-content: center; }
|
||||
.btn-icon { width: 30px; height: 30px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-elevated); cursor: pointer; border-radius: 8px; color: var(--mc-text-secondary); transition: all 0.15s; display: flex; align-items: center; justify-content: center; }
|
||||
.btn-icon:hover { background: var(--mc-bg-sunken); color: var(--mc-primary); border-color: var(--mc-border); }
|
||||
.btn-icon-danger:hover { background: var(--mc-danger-bg); color: var(--mc-danger); border-color: var(--mc-danger); }
|
||||
|
||||
@ -282,4 +288,29 @@ async function handleScanDir() {
|
||||
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
|
||||
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.upload-row,
|
||||
.dir-scan-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.add-text-btn,
|
||||
.dir-scan-row > .btn-secondary,
|
||||
.process-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.raw-item {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.raw-item-meta,
|
||||
.raw-item-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
<div class="config-actions">
|
||||
<button class="btn-secondary" @click="loadConfig">{{ t('common.reset') }}</button>
|
||||
<button class="btn-primary" @click="saveConfig" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : t('common.save') }}
|
||||
{{ saving ? t('wiki.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -61,22 +61,48 @@ watch(() => store.currentKB, () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wiki-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.config-header { margin-bottom: 16px; }
|
||||
.config-title { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin: 0 0 4px; }
|
||||
.config-desc { font-size: 13px; color: var(--mc-text-tertiary); margin: 0; }
|
||||
.config-header { padding-bottom: 10px; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.config-title { font-size: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 6px; letter-spacing: -0.02em; }
|
||||
.config-desc { font-size: 13px; color: var(--mc-text-tertiary); margin: 0; line-height: 1.6; }
|
||||
|
||||
/* Editor */
|
||||
.config-editor { width: 100%; padding: 16px; border: 1px solid var(--mc-border); border-radius: 12px; font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.7; resize: vertical; background: var(--mc-bg-elevated); color: var(--mc-text-primary); outline: none; }
|
||||
.config-editor { width: 100%; flex: 1; min-height: 0; padding: 16px; border: 1px solid var(--mc-border); border-radius: 14px; font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.7; resize: none; overflow: auto; background: var(--mc-bg-elevated); color: var(--mc-text-primary); outline: none; }
|
||||
.config-editor:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
|
||||
|
||||
/* Actions */
|
||||
.config-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
|
||||
.config-actions { display: flex; justify-content: flex-end; gap: 10px; flex-shrink: 0; }
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
.btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
||||
.btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||
.btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.config-editor {
|
||||
min-height: 42vh;
|
||||
flex: none;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.config-actions .btn-primary,
|
||||
.config-actions .btn-secondary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<div class="page-viewer" v-if="store.currentPage">
|
||||
<div class="page-viewer-header">
|
||||
<div>
|
||||
<div class="page-viewer-copy">
|
||||
<div class="page-viewer-kicker">{{ t('wiki.pageKicker') }}</div>
|
||||
<h2 class="page-viewer-title">{{ store.currentPage.title }}</h2>
|
||||
<div class="page-viewer-meta">
|
||||
<span>v{{ store.currentPage.version }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ store.currentPage.lastUpdatedBy === 'ai' ? 'AI generated' : 'Manually edited' }}</span>
|
||||
<span>{{ store.currentPage.lastUpdatedBy === 'ai' ? t('wiki.generatedByAi') : t('wiki.editedManually') }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ store.currentPage.slug }}</span>
|
||||
</div>
|
||||
@ -131,41 +132,74 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
/* Buttons */
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
.page-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
||||
.btn-primary.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
.btn-secondary.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||
|
||||
/* Header */
|
||||
.page-viewer-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
|
||||
.page-viewer-title { font-size: 24px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
.page-viewer-meta { font-size: 12px; color: var(--mc-text-secondary); display: flex; gap: 8px; margin-top: 4px; }
|
||||
.page-viewer-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.page-viewer-copy { min-width: 0; }
|
||||
.page-viewer-kicker { font-size: 10px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--mc-accent); margin-bottom: 6px; }
|
||||
.page-viewer-title { font-size: clamp(26px, 3vw, 34px); line-height: 1.02; letter-spacing: -0.04em; font-weight: 800; color: var(--mc-text-primary); margin: 0; }
|
||||
.page-viewer-meta { font-size: 12px; color: var(--mc-text-secondary); display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.page-viewer-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* Summary */
|
||||
.page-summary { padding: 12px 16px; background: var(--mc-bg-sunken); border-radius: 8px; font-size: 14px; color: var(--mc-text-secondary); margin-bottom: 16px; border-left: 3px solid var(--mc-primary); }
|
||||
.page-summary { padding: 14px 16px; background: linear-gradient(180deg, var(--mc-bg-muted), var(--mc-bg-elevated)); border-radius: 16px; font-size: 14px; color: var(--mc-text-secondary); border-left: 3px solid var(--mc-primary); line-height: 1.7; }
|
||||
|
||||
/* Content */
|
||||
.page-content { font-size: 15px; line-height: 1.75; color: var(--mc-text-primary); }
|
||||
.page-content :deep(h1) { font-size: 24px; font-weight: 600; margin: 24px 0 12px; color: var(--mc-text-primary); }
|
||||
.page-content :deep(h2) { font-size: 20px; font-weight: 600; margin: 20px 0 8px; color: var(--mc-text-primary); }
|
||||
.page-content :deep(h3) { font-size: 18px; font-weight: 600; margin: 16px 0 8px; color: var(--mc-text-primary); }
|
||||
.page-content { font-size: 15px; line-height: 1.8; color: var(--mc-text-primary); padding: 2px 2px 0; }
|
||||
.page-content :deep(p) { margin: 0 0 1.05em; }
|
||||
.page-content :deep(h1) { font-size: 26px; font-weight: 700; margin: 28px 0 14px; color: var(--mc-text-primary); letter-spacing: -0.03em; }
|
||||
.page-content :deep(h2) { font-size: 21px; font-weight: 700; margin: 24px 0 10px; color: var(--mc-text-primary); letter-spacing: -0.025em; }
|
||||
.page-content :deep(h3) { font-size: 18px; font-weight: 700; margin: 20px 0 8px; color: var(--mc-text-primary); }
|
||||
.page-content :deep(li) { margin-left: 24px; list-style: disc; }
|
||||
.page-content :deep(code) { background: var(--mc-bg-sunken); padding: 2px 6px; border-radius: 4px; font-size: 0.85em; }
|
||||
.page-content :deep(code) { background: var(--mc-bg-sunken); padding: 2px 6px; border-radius: 6px; font-size: 0.85em; }
|
||||
.page-content :deep(.wiki-link) { color: var(--mc-primary); text-decoration: none; cursor: pointer; border-bottom: 1px dashed var(--mc-primary); }
|
||||
.page-content :deep(.wiki-link:hover) { text-decoration: underline; }
|
||||
|
||||
/* Editor */
|
||||
.page-editor { width: 100%; padding: 16px; border: 1px solid var(--mc-border); border-radius: 8px; font-family: 'JetBrains Mono', monospace; font-size: 14px; line-height: 1.6; resize: vertical; background: var(--mc-bg-elevated); color: var(--mc-text-primary); outline: none; }
|
||||
.page-editor { width: 100%; min-height: 60vh; padding: 16px; border: 1px solid var(--mc-border); border-radius: 14px; font-family: 'JetBrains Mono', monospace; font-size: 14px; line-height: 1.65; resize: vertical; background: var(--mc-bg-elevated); color: var(--mc-text-primary); outline: none; }
|
||||
.page-editor:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
|
||||
|
||||
/* Backlinks */
|
||||
.backlinks-section { margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--mc-border); }
|
||||
.backlinks-section { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--mc-border); }
|
||||
.backlinks-title { font-size: 12px; font-weight: 600; text-transform: uppercase; color: var(--mc-text-secondary); margin-bottom: 8px; letter-spacing: 0.05em; }
|
||||
.backlinks-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.backlink-tag { padding: 4px 10px; background: var(--mc-bg-sunken); border-radius: 9999px; font-size: 12px; cursor: pointer; color: var(--mc-primary); transition: background 0.15s; }
|
||||
.backlink-tag { padding: 5px 10px; background: var(--mc-bg-sunken); border-radius: 9999px; font-size: 12px; cursor: pointer; color: var(--mc-primary); transition: background 0.15s, transform 0.15s; }
|
||||
.backlink-tag:hover { background: var(--mc-primary-bg); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-viewer-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-viewer-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary.btn-sm,
|
||||
.btn-secondary.btn-sm {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-viewer-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.page-editor {
|
||||
min-height: 46vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="mc-page-shell wiki-shell">
|
||||
<div class="mc-page-frame">
|
||||
<div class="mc-page-inner">
|
||||
<div class="mc-page-frame wiki-frame">
|
||||
<div class="mc-page-inner wiki-inner">
|
||||
<div class="mc-page-header">
|
||||
<div>
|
||||
<div class="mc-page-kicker">Knowledge Engine</div>
|
||||
<div class="mc-page-kicker">{{ t('wiki.kicker') }}</div>
|
||||
<h1 class="mc-page-title">{{ t('nav.wiki') }}</h1>
|
||||
<p class="mc-page-desc">{{ t('wiki.desc') }}</p>
|
||||
</div>
|
||||
@ -21,7 +21,7 @@
|
||||
<div class="wiki-sidebar mc-surface-card">
|
||||
<div class="sidebar-section">
|
||||
<h3 class="sidebar-title">{{ t('wiki.knowledgeBases') }}</h3>
|
||||
<div v-if="store.loading" class="text-center py-4 text-gray-400">Loading...</div>
|
||||
<div v-if="store.loading" class="text-center py-4 text-gray-400">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="store.knowledgeBases.length === 0" class="text-center py-4 text-gray-400">
|
||||
{{ t('wiki.noKB') }}
|
||||
</div>
|
||||
@ -33,10 +33,10 @@
|
||||
>
|
||||
<div class="kb-item-name">{{ kb.name }}</div>
|
||||
<div class="kb-item-meta">
|
||||
<span>{{ kb.pageCount }} pages</span>
|
||||
<span>{{ kb.rawCount }} sources</span>
|
||||
<span>{{ t('wiki.pageCount', { count: kb.pageCount }) }}</span>
|
||||
<span>{{ t('wiki.sourceCount', { count: kb.rawCount }) }}</span>
|
||||
</div>
|
||||
<span class="kb-status" :class="kb.status">{{ kb.status }}</span>
|
||||
<span class="kb-status" :class="kb.status">{{ t(`wiki.status.${kb.status}`) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -77,7 +77,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Tab navigation -->
|
||||
<div v-else>
|
||||
<div v-else class="wiki-content-body">
|
||||
<div class="content-tabs">
|
||||
<button
|
||||
v-for="tab in tabs" :key="tab.key"
|
||||
@ -102,7 +102,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Config Tab -->
|
||||
<div v-if="activeTab === 'config'" class="tab-content">
|
||||
<div v-if="activeTab === 'config'" class="tab-content tab-content--config">
|
||||
<WikiConfig />
|
||||
</div>
|
||||
</div>
|
||||
@ -189,6 +189,22 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.wiki-shell {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wiki-frame {
|
||||
height: min(calc(100vh - 28px), 100%);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wiki-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--mc-shadow-soft); }
|
||||
@ -198,20 +214,20 @@ onMounted(() => {
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
|
||||
/* Layout */
|
||||
.wiki-layout { display: flex; gap: 18px; min-height: calc(100vh - 200px); overflow: hidden; }
|
||||
.wiki-layout { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
|
||||
|
||||
.wiki-sidebar { width: 320px; min-width: 320px; overflow-y: auto; padding: 18px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.wiki-sidebar { width: 300px; min-width: 300px; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 14px; min-height: 0; }
|
||||
|
||||
.sidebar-section { display: flex; flex-direction: column; gap: 10px; }
|
||||
.sidebar-section { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.sidebar-title { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--mc-text-tertiary); letter-spacing: 0.1em; }
|
||||
|
||||
.sidebar-search { width: 100%; padding: 10px 12px; border: 1px solid var(--mc-border); border-radius: 12px; font-size: 13px; background: var(--mc-bg-muted); color: var(--mc-text-primary); outline: none; }
|
||||
.sidebar-search { width: 100%; padding: 9px 12px; border: 1px solid var(--mc-border); border-radius: 12px; font-size: 13px; background: var(--mc-bg-muted); color: var(--mc-text-primary); outline: none; }
|
||||
.sidebar-search:focus { border-color: var(--mc-primary); }
|
||||
|
||||
.kb-list, .page-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
|
||||
.kb-item, .page-item { padding: 12px 14px; border-radius: 16px; cursor: pointer; transition: background 0.15s, transform 0.15s; position: relative; border: 1px solid transparent; }
|
||||
.kb-item, .page-item { padding: 10px 12px; border-radius: 16px; cursor: pointer; transition: background 0.15s, transform 0.15s; position: relative; border: 1px solid transparent; }
|
||||
.kb-item:hover, .page-item:hover { background: var(--mc-bg-muted); transform: translateY(-1px); }
|
||||
.kb-item.active, .page-item.active { background: var(--mc-primary-bg); border-color: rgba(217, 109, 70, 0.12); }
|
||||
|
||||
@ -225,16 +241,28 @@ onMounted(() => {
|
||||
.kb-status.error { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
||||
|
||||
/* Content area */
|
||||
.wiki-content { flex: 1; overflow-y: auto; min-width: 0; padding: 20px; }
|
||||
.wiki-content { flex: 1; overflow: hidden; min-width: 0; padding: 16px; display: flex; flex-direction: column; min-height: 0; }
|
||||
|
||||
.content-tabs { display: inline-flex; gap: 4px; padding: 4px; background: var(--mc-bg-muted); border-radius: 16px; margin-bottom: 20px; border: 1px solid var(--mc-border-light); }
|
||||
.tab-btn { padding: 10px 16px; border: none; background: none; cursor: pointer; font-size: 14px; color: var(--mc-text-secondary); border-radius: 12px; transition: all 0.15s; font-weight: 600; }
|
||||
.wiki-content-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content-tabs { display: inline-flex; gap: 4px; padding: 4px; background: var(--mc-bg-muted); border-radius: 16px; margin-bottom: 14px; border: 1px solid var(--mc-border-light); align-self: flex-start; }
|
||||
.tab-btn { padding: 9px 14px; border: none; background: none; cursor: pointer; font-size: 13px; color: var(--mc-text-secondary); border-radius: 12px; transition: all 0.15s; font-weight: 600; }
|
||||
.tab-btn:hover { color: var(--mc-text-primary); }
|
||||
.tab-btn.active { color: var(--mc-primary); background: var(--mc-bg-elevated); box-shadow: var(--mc-shadow-soft); }
|
||||
|
||||
.tab-content { min-height: 400px; }
|
||||
.tab-content { flex: 1; min-height: 0; overflow-y: auto; padding-right: 2px; }
|
||||
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 300px; color: var(--mc-text-tertiary); text-align: center; padding: 32px; }
|
||||
.tab-content--config {
|
||||
overflow: hidden;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 240px; color: var(--mc-text-tertiary); text-align: center; padding: 24px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
|
||||
@ -250,13 +278,32 @@ onMounted(() => {
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.wiki-frame {
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 28px);
|
||||
}
|
||||
|
||||
.wiki-layout {
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.wiki-sidebar {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.wiki-content {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tab-content--config {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -66,36 +66,36 @@
|
||||
<span class="health-label">{{ t('doctor.title') }}</span>
|
||||
</button>
|
||||
|
||||
<div class="sidebar-utility-section">
|
||||
<div class="utility-label">{{ t('nav.themeLabel') }}</div>
|
||||
<div class="theme-toggle-row">
|
||||
<button
|
||||
v-for="opt in themeOptions"
|
||||
:key="opt.value"
|
||||
class="theme-btn"
|
||||
:class="{ active: themeStore.mode === opt.value }"
|
||||
:title="opt.label"
|
||||
@click="themeStore.setMode(opt.value)"
|
||||
>
|
||||
<span v-html="opt.icon"></span>
|
||||
<span class="theme-btn-label">{{ opt.label }}</span>
|
||||
</button>
|
||||
<div class="sidebar-utility-card">
|
||||
<div class="compact-utility-row">
|
||||
<span class="compact-utility-title">{{ t('nav.themeLabel') }}</span>
|
||||
<div class="theme-toggle-row theme-toggle-row--compact">
|
||||
<button
|
||||
v-for="opt in themeOptions"
|
||||
:key="opt.value"
|
||||
class="theme-btn theme-btn--compact"
|
||||
:class="{ active: themeStore.mode === opt.value }"
|
||||
:title="opt.label"
|
||||
@click="themeStore.setMode(opt.value)"
|
||||
>
|
||||
<span v-html="opt.icon"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-utility-section">
|
||||
<div class="utility-label">{{ t('nav.languageLabel') }}</div>
|
||||
<div class="language-toggle-row">
|
||||
<button
|
||||
v-for="opt in localeOptions"
|
||||
:key="opt.value"
|
||||
class="language-btn"
|
||||
:class="{ active: currentLocaleValue === opt.value }"
|
||||
@click="changeLocale(opt.value)"
|
||||
>
|
||||
<span class="language-abbr">{{ opt.short }}</span>
|
||||
<span class="language-label">{{ opt.label }}</span>
|
||||
</button>
|
||||
<div class="compact-utility-row">
|
||||
<span class="compact-utility-title">{{ t('nav.languageLabel') }}</span>
|
||||
<div class="language-toggle-row language-toggle-row--compact">
|
||||
<button
|
||||
v-for="opt in localeOptions"
|
||||
:key="opt.value"
|
||||
class="language-btn language-btn--compact"
|
||||
:class="{ active: currentLocaleValue === opt.value }"
|
||||
@click="changeLocale(opt.value)"
|
||||
>
|
||||
<span class="language-abbr">{{ opt.short }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -486,24 +486,24 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 18px 16px 14px;
|
||||
padding: 14px 14px 12px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
gap: 12px;
|
||||
min-height: 72px;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-logo {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 14px 10px;
|
||||
gap: 8px;
|
||||
min-height: 110px;
|
||||
padding: 12px 10px;
|
||||
gap: 6px;
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -514,8 +514,8 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 8px 18px rgba(217, 109, 70, 0.22));
|
||||
}
|
||||
@ -526,7 +526,7 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
|
||||
.logo-name {
|
||||
display: block;
|
||||
font-size: 17px;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--mc-sidebar-logo-name);
|
||||
white-space: nowrap;
|
||||
@ -539,15 +539,15 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
|
||||
.logo-version {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
color: var(--mc-text-tertiary);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--mc-border-light);
|
||||
background: var(--mc-bg-muted);
|
||||
cursor: pointer;
|
||||
@ -584,17 +584,19 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0 8px;
|
||||
padding: 8px 0 4px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar { width: 4px; }
|
||||
.sidebar-nav::-webkit-scrollbar-thumb { background: var(--mc-border); border-radius: 2px; }
|
||||
.sidebar-nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.nav-group { margin-bottom: 2px; }
|
||||
|
||||
.nav-group-title {
|
||||
padding: 10px 18px 6px;
|
||||
font-size: 11px;
|
||||
padding: 8px 18px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--mc-sidebar-group-title);
|
||||
text-transform: uppercase;
|
||||
@ -606,11 +608,11 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 12px;
|
||||
padding: 10px 12px;
|
||||
color: var(--mc-sidebar-text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 13px;
|
||||
border-radius: 13px;
|
||||
margin: 2px 10px;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
@ -647,19 +649,46 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
/* 底部 */
|
||||
.sidebar-footer {
|
||||
border-top: 1px solid var(--mc-border-light);
|
||||
padding: 14px 14px 16px;
|
||||
padding: 10px 12px 12px;
|
||||
background: var(--mc-sidebar-footer-bg);
|
||||
backdrop-filter: blur(14px);
|
||||
position: relative;
|
||||
}
|
||||
.health-indicator { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-muted); border-radius: 14px; cursor: pointer; color: var(--mc-text-secondary); font-size: 12px; margin-bottom: 10px; }
|
||||
.health-indicator { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-muted); border-radius: 12px; cursor: pointer; color: var(--mc-text-secondary); font-size: 12px; margin-bottom: 8px; }
|
||||
.health-indicator:hover { background: var(--mc-bg-sunken); }
|
||||
.health-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.health-indicator.healthy .health-dot { background: var(--mc-success); }
|
||||
.health-indicator.warning .health-dot { background: var(--mc-primary); }
|
||||
.health-indicator.error .health-dot { background: var(--mc-danger); }
|
||||
.health-indicator.unknown .health-dot { background: var(--mc-text-tertiary); }
|
||||
.sidebar-utility-section { margin-bottom: 12px; }
|
||||
|
||||
.sidebar-utility-card {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--mc-border-light);
|
||||
background: color-mix(in srgb, var(--mc-sidebar-footer-bg) 74%, transparent);
|
||||
}
|
||||
|
||||
.compact-utility-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.compact-utility-row + .compact-utility-row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.compact-utility-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text-secondary);
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.utility-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--mc-text-tertiary); margin: 0 0 8px; padding-left: 2px; }
|
||||
|
||||
/* 主题切换 */
|
||||
@ -673,12 +702,24 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
border: 1px solid var(--mc-border-light);
|
||||
}
|
||||
|
||||
.theme-toggle-row--compact {
|
||||
margin-bottom: 0;
|
||||
padding: 2px;
|
||||
gap: 3px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.language-toggle-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.language-toggle-row--compact {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
@ -696,6 +737,14 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.theme-btn--compact {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
@ -759,19 +808,33 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.language-btn--compact {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
justify-content: center;
|
||||
padding: 4px 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.language-btn--compact .language-abbr {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 16px;
|
||||
padding: 8px 9px;
|
||||
border-radius: 14px;
|
||||
background: var(--mc-bg-muted);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: linear-gradient(135deg, var(--mc-primary), var(--mc-accent));
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
@ -786,7 +849,7 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
.user-detail { flex: 1; overflow: hidden; }
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--mc-text-primary);
|
||||
white-space: nowrap;
|
||||
@ -794,11 +857,11 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-role { font-size: 11px; color: var(--mc-text-tertiary); }
|
||||
.user-role { font-size: 10px; color: var(--mc-text-tertiary); }
|
||||
|
||||
.logout-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
@ -1041,6 +1104,27 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-utility-card {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.compact-utility-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.theme-toggle-row--compact,
|
||||
.language-toggle-row--compact {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.theme-btn--compact,
|
||||
.language-btn--compact {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user