feat(ui): unify workspace and platform console surfaces

This commit is contained in:
matevip 2026-04-09 19:48:17 +08:00
parent 5124842526
commit c4edcbd95c
23 changed files with 2194 additions and 1082 deletions

View File

@ -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;
}

View File

@ -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'

View File

@ -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 {

View File

@ -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()

View File

@ -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',

View File

@ -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: '密码',

View File

@ -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>

View File

@ -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;

View File

@ -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">&#9201;</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">&#9201;</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*/2dom/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=Mon0 6=Sat5
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>

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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; }

View File

@ -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>

View File

@ -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>

View File

@ -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>&middot;</span>
<span>{{ store.currentPage.lastUpdatedBy === 'ai' ? 'AI generated' : 'Manually edited' }}</span>
<span>{{ store.currentPage.lastUpdatedBy === 'ai' ? t('wiki.generatedByAi') : t('wiki.editedManually') }}</span>
<span>&middot;</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>

View File

@ -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>

View File

@ -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;