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 v-if="pendingApproval?.status === 'pending_approval'" class="approval-bar">
<div class="approval-bar__info"> <div class="approval-bar__info">
<span class="approval-bar__icon"> <span class="approval-bar__icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon><WarningFilled /></el-icon>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</span> </span>
<span class="approval-bar__label">{{ t('chat.approvalAllow') }}</span> <span class="approval-bar__label">{{ t('chat.approvalAllow') }}</span>
<span class="approval-bar__tool">{{ pendingApproval.toolName }}</span> <span class="approval-bar__tool">{{ pendingApproval.toolName }}</span>
@ -75,9 +73,7 @@
class="approval-bar__btn approval-bar__btn--deny" class="approval-bar__btn approval-bar__btn--deny"
@click="emit('deny', pendingApproval.pendingId)" @click="emit('deny', pendingApproval.pendingId)"
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <el-icon><CloseBold /></el-icon>
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
{{ t('chat.deny') }} {{ t('chat.deny') }}
</button> </button>
<button <button
@ -85,9 +81,7 @@
class="approval-bar__btn approval-bar__btn--approve" class="approval-bar__btn approval-bar__btn--approve"
@click="emit('approve', pendingApproval.pendingId)" @click="emit('approve', pendingApproval.pendingId)"
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <el-icon><Select /></el-icon>
<polyline points="20 6 9 17 4 12"/>
</svg>
{{ t('chat.approve') }} {{ t('chat.approve') }}
</button> </button>
</div> </div>
@ -98,9 +92,7 @@
<!-- 排队消息指示器 --> <!-- 排队消息指示器 -->
<div v-if="queuedMessage && queuedMessage.status !== 'cancelled'" class="queued-indicator"> <div v-if="queuedMessage && queuedMessage.status !== 'cancelled'" class="queued-indicator">
<div class="queued-indicator__info"> <div class="queued-indicator__info">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon><Timer /></el-icon>
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span class="queued-indicator__text"> <span class="queued-indicator__text">
{{ queuedMessage.status === 'sending' ? t('chat.queuedSending') : t('chat.queuedWillSend') }} {{ queuedMessage.status === 'sending' ? t('chat.queuedSending') : t('chat.queuedWillSend') }}
<span v-if="queueSize > 1" class="queued-indicator__count">({{ queueSize }})</span> <span v-if="queueSize > 1" class="queued-indicator__count">({{ queueSize }})</span>
@ -141,9 +133,7 @@
:disabled="disabled || loading || uploading" :disabled="disabled || loading || uploading"
@click="openFilePicker" @click="openFilePicker"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon><Paperclip /></el-icon>
<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>
</button> </button>
<!-- Talk Mode 按钮 --> <!-- Talk Mode 按钮 -->
@ -155,11 +145,7 @@
@click="emit('talk')" @click="emit('talk')"
:title="t('talk.title')" :title="t('talk.title')"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon><Microphone /></el-icon>
<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>
</button> </button>
<!-- 发送/停止/中断按钮 --> <!-- 发送/停止/中断按钮 -->
@ -171,19 +157,11 @@
@click="handleSubmit" @click="handleSubmit"
> >
<!-- 有输入时始终显示发送图标运行中发送 = interrupt --> <!-- 有输入时始终显示发送图标运行中发送 = interrupt -->
<svg v-if="canSend" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon v-if="canSend"><Promotion /></el-icon>
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
<!-- 运行中无输入停止图标 --> <!-- 运行中无输入停止图标 -->
<svg v-else-if="loading" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> <el-icon v-else-if="loading"><CloseBold /></el-icon>
<rect x="3" y="3" width="18" height="18" rx="2"/>
</svg>
<!-- 空闲无输入 --> <!-- 空闲无输入 -->
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon v-else><Promotion /></el-icon>
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button> </button>
</div> </div>
</div> </div>
@ -212,6 +190,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue' import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n' 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' import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage } from '@/types'
interface Props { interface Props {
@ -418,8 +397,9 @@ defineExpose({
<style scoped> <style scoped>
.chat-input-wrapper { .chat-input-wrapper {
padding: 18px 24px 22px; padding: 10px 14px 12px;
background: var(--mc-bg-elevated, #f8fafc); background: var(--mc-bg-elevated, #f8fafc);
flex-shrink: 0;
} }
.chat-input-wrapper.is-focused { .chat-input-wrapper.is-focused {
@ -431,7 +411,7 @@ defineExpose({
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
margin-bottom: 12px; margin-bottom: 8px;
} }
.attachment-chip { .attachment-chip {
@ -519,8 +499,8 @@ defineExpose({
align-items: flex-end; align-items: flex-end;
background: var(--mc-input-bg, #ffffff); background: var(--mc-input-bg, #ffffff);
border: none; border: none;
border-radius: 18px; border-radius: 16px;
padding: 12px 12px 12px 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); 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; transition: box-shadow 0.15s;
} }
@ -538,7 +518,7 @@ defineExpose({
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
color: var(--mc-input-text, #1e293b); color: var(--mc-input-text, #1e293b);
min-height: 44px; min-height: 38px;
max-height: 160px; max-height: 160px;
font-family: inherit; font-family: inherit;
} }
@ -555,13 +535,13 @@ defineExpose({
/* 操作按钮 */ /* 操作按钮 */
.input-actions { .input-actions {
display: flex; display: flex;
gap: 8px; gap: 6px;
align-items: center; align-items: center;
} }
.action-btn { .action-btn {
width: 36px; width: 34px;
height: 36px; height: 34px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
@ -615,7 +595,7 @@ defineExpose({
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 8px; margin-top: 6px;
padding: 0 4px; padding: 0 4px;
} }
@ -645,10 +625,10 @@ defineExpose({
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
background: var(--mc-input-bg, #ffffff); background: var(--mc-input-bg, #ffffff);
border-radius: 18px; border-radius: 16px;
padding: 10px 10px 10px 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); 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 { .approval-bar__info {
@ -741,8 +721,8 @@ defineExpose({
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 6px 16px; padding: 5px 12px;
margin-bottom: 4px; margin-bottom: 3px;
border-radius: 10px; border-radius: 10px;
background: rgba(59, 130, 246, 0.06); background: rgba(59, 130, 246, 0.06);
border: 1px solid rgba(59, 130, 246, 0.15); border: 1px solid rgba(59, 130, 246, 0.15);
@ -756,6 +736,32 @@ defineExpose({
color: #3b82f6; 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 { .queued-indicator__text {
font-weight: 500; font-weight: 500;
} }

View File

@ -22,18 +22,12 @@
<div v-if="showThinkingPanel" class="thinking-section"> <div v-if="showThinkingPanel" class="thinking-section">
<button class="thinking-toggle" type="button" @click="toggleThinking"> <button class="thinking-toggle" type="button" @click="toggleThinking">
<span class="thinking-toggle__indicator" :class="{ active: isGenerating && !hasContent }"> <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"> <el-icon><Opportunity /></el-icon>
<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>
</span> </span>
<span class="thinking-toggle__label">{{ $t('chat.thinking') }}</span> <span class="thinking-toggle__label">{{ $t('chat.thinking') }}</span>
<span class="thinking-toggle__duration" v-if="thinkingDuration">{{ thinkingDuration }}</span> <span class="thinking-toggle__duration" v-if="thinkingDuration">{{ thinkingDuration }}</span>
<span class="thinking-toggle__arrow" :class="{ expanded: localThinkingExpanded }"> <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"> <el-icon><ArrowDown /></el-icon>
<polyline points="6 9 12 15 18 9"/>
</svg>
</span> </span>
</button> </button>
@ -51,16 +45,12 @@
<div v-if="showExecutionPanel" class="execution-section"> <div v-if="showExecutionPanel" class="execution-section">
<button class="execution-toggle" type="button" @click="executionExpanded = !executionExpanded"> <button class="execution-toggle" type="button" @click="executionExpanded = !executionExpanded">
<span class="execution-toggle__indicator" :class="{ active: isGenerating }"> <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"> <el-icon><Tools /></el-icon>
<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>
</span> </span>
<span class="execution-toggle__label">{{ executionPhaseLabel }}</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__count" v-if="toolCallsMeta.length">{{ toolCallsMeta.length }} calls</span>
<span class="execution-toggle__arrow" :class="{ expanded: executionExpanded }"> <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"> <el-icon><ArrowDown /></el-icon>
<polyline points="6 9 12 15 18 9"/>
</svg>
</span> </span>
</button> </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 }" :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"> <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"> <el-icon v-if="tc.status === 'running'" class="spin"><Loading /></el-icon>
<path d="M21 12a9 9 0 11-6.219-8.56"/> <el-icon v-else-if="tc.status === 'awaiting_approval'" style="color: #f59e0b;"><WarningFilled /></el-icon>
</svg> <el-icon v-else-if="tc.success !== false" style="color: #10b981;"><Select /></el-icon>
<svg v-else-if="tc.status === 'awaiting_approval'" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2"> <el-icon v-else style="color: #ef4444;"><CloseBold /></el-icon>
<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>
</span> </span>
<span class="tool-call__name">{{ tc.name }}</span> <span class="tool-call__name">{{ tc.name }}</span>
<span class="tool-call__args" v-if="tc.arguments">{{ truncateArgs(tc.arguments) }}</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 v-if="pendingApproval" class="approval-section" :class="approvalSeverityClass">
<div class="approval-header"> <div class="approval-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon><WarningFilled /></el-icon>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span class="approval-title">{{ $t('chat.approvalRequired') || 'Approval Required' }}</span> <span class="approval-title">{{ $t('chat.approvalRequired') || 'Approval Required' }}</span>
<span v-if="pendingApproval.maxSeverity" class="approval-severity-badge" :class="'severity-' + pendingApproval.maxSeverity?.toLowerCase()"> <span v-if="pendingApproval.maxSeverity" class="approval-severity-badge" :class="'severity-' + pendingApproval.maxSeverity?.toLowerCase()">
{{ pendingApproval.maxSeverity }} {{ pendingApproval.maxSeverity }}
@ -149,9 +129,7 @@
</div> </div>
</div> </div>
<div v-if="pendingApproval.status === 'pending_approval'" class="approval-waiting"> <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"> <el-icon class="approval-waiting__spin"><Loading /></el-icon>
<path d="M21 12a9 9 0 11-6.219-8.56"/>
</svg>
<span>{{ $t('chat.approvalWaiting') }}</span> <span>{{ $t('chat.approvalWaiting') }}</span>
</div> </div>
<div v-else-if="pendingApproval.status === 'approved'" class="approval-resolved approval-resolved--approved"> <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"> <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"> <el-icon><CloseBold /></el-icon>
<rect x="3" y="3" width="18" height="18" rx="2"/>
</svg>
<span>{{ status === 'interrupted' ? ($t('chat.interrupted') || '已中断') : $t('chat.stopped') }}</span> <span>{{ status === 'interrupted' ? ($t('chat.interrupted') || '已中断') : $t('chat.stopped') }}</span>
</div> </div>
<!-- 错误卡片 --> <!-- 错误卡片 -->
<div v-if="status === 'failed'" class="error-card"> <div v-if="status === 'failed'" class="error-card">
<div class="error-card__header"> <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"> <el-icon class="error-card__icon"><WarningFilled /></el-icon>
<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>
<span class="error-card__title">{{ errorTitle }}</span> <span class="error-card__title">{{ errorTitle }}</span>
</div> </div>
<p class="error-card__description">{{ errorDescription }}</p> <p class="error-card__description">{{ errorDescription }}</p>
@ -200,10 +172,7 @@
<div class="error-card__footer"> <div class="error-card__footer">
<span v-if="errorCode" class="error-card__code">{{ errorCode }}</span> <span v-if="errorCode" class="error-card__code">{{ errorCode }}</span>
<button v-if="errorRetryable" class="error-card__retry" type="button" @click="$emit('regenerate')"> <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"> <el-icon><RefreshRight /></el-icon>
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
{{ $t('chat.retry') }} {{ $t('chat.retry') }}
</button> </button>
</div> </div>
@ -244,10 +213,7 @@
type="button" type="button"
@click="downloadFile(attachment)" @click="downloadFile(attachment)"
> >
<svg class="message-attachment__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon class="message-attachment__icon"><Document /></el-icon>
<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>
<span class="message-attachment__name">{{ attachment.name }}</span> <span class="message-attachment__name">{{ attachment.name }}</span>
<span class="message-attachment__meta">{{ formatFileSize(attachment.size) }}</span> <span class="message-attachment__meta">{{ formatFileSize(attachment.size) }}</span>
</button> </button>
@ -270,13 +236,8 @@
:title="copyState === 'copied' ? $t('chat.copied') : $t('chat.copy')" :title="copyState === 'copied' ? $t('chat.copied') : $t('chat.copy')"
@click="copyMessage" @click="copyMessage"
> >
<svg v-if="copyState !== 'copied'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon v-if="copyState !== 'copied'"><CopyDocument /></el-icon>
<rect x="9" y="9" width="13" height="13" rx="2"/> <el-icon v-else><Select /></el-icon>
<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>
</button> </button>
<!-- 朗读 TTS assistant --> <!-- 朗读 TTS assistant -->
<button <button
@ -288,18 +249,9 @@
:disabled="ttsState === 'loading'" :disabled="ttsState === 'loading'"
@click="handleTts" @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"> <el-icon v-if="ttsState === 'loading'" class="tts-loading-icon"><Loading /></el-icon>
<circle cx="12" cy="12" r="10"/> <el-icon v-else-if="ttsState === 'playing'"><VideoPause /></el-icon>
<path d="M12 6v6l4 2"/> <el-icon v-else><Microphone /></el-icon>
</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>
</button> </button>
<!-- 重新生成 assistant --> <!-- 重新生成 assistant -->
<button <button
@ -309,10 +261,7 @@
:title="$t('chat.regenerate')" :title="$t('chat.regenerate')"
@click="$emit('regenerate')" @click="$emit('regenerate')"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon><RefreshRight /></el-icon>
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button> </button>
<!-- 时间戳inline --> <!-- 时间戳inline -->
<span class="action-time">{{ formattedTime }}</span> <span class="action-time">{{ formattedTime }}</span>
@ -324,6 +273,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from 'vue' import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' 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 { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment' import { useAuthenticatedAttachment } from '@/composables/useAuthenticatedAttachment'
import { http } from '@/api' import { http } from '@/api'

View File

@ -22,12 +22,14 @@
class="suggestion-card" class="suggestion-card"
@click="$emit('suggestion-click', s)" @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> <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"> <el-icon class="suggestion-card__arrow"><Right /></el-icon>
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
</button> </button>
</div> </div>
</div> </div>
@ -65,6 +67,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch, nextTick } from 'vue' import { computed, watch, nextTick } from 'vue'
import { ChatDotRound, DataLine, EditPen, Monitor, Right } from '@element-plus/icons-vue'
import MessageBubble from './MessageBubble.vue' import MessageBubble from './MessageBubble.vue'
import { useStickToBottom } from '@/composables/chat/useStickToBottom' import { useStickToBottom } from '@/composables/chat/useStickToBottom'
import type { Message } from '@/types' import type { Message } from '@/types'
@ -149,8 +152,9 @@ watch(
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 28px 28px 20px; padding: 18px 20px 12px;
scroll-behavior: smooth; scroll-behavior: smooth;
min-height: 0;
} }
.message-list::-webkit-scrollbar { .message-list::-webkit-scrollbar {
@ -165,7 +169,7 @@ watch(
.message-list-content { .message-list-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 14px;
min-height: 100%; min-height: 100%;
} }
@ -176,12 +180,12 @@ watch(
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 400px; min-height: 320px;
} }
.welcome-screen { .welcome-screen {
text-align: center; text-align: center;
padding: 40px 20px; padding: 24px 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -231,7 +235,7 @@ watch(
.welcome-subtitle { .welcome-subtitle {
font-size: 14px; font-size: 14px;
color: var(--mc-text-secondary, #64748b); color: var(--mc-text-secondary, #64748b);
margin: 0 0 36px; margin: 0 0 24px;
max-width: 360px; max-width: 360px;
line-height: 1.6; line-height: 1.6;
} }
@ -341,19 +345,19 @@ watch(
/* ===== 移动端适配 ===== */ /* ===== 移动端适配 ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.message-list { .message-list {
padding: 16px 12px 12px; padding: 12px 10px 8px;
} }
.message-list-content { .message-list-content {
gap: 12px; gap: 10px;
} }
.empty-state { .empty-state {
min-height: 300px; min-height: 240px;
} }
.welcome-screen { .welcome-screen {
padding: 24px 12px; padding: 16px 10px;
} }
.welcome-logo__icon { .welcome-logo__icon {

View File

@ -5,9 +5,7 @@
<div class="talk-header"> <div class="talk-header">
<span class="talk-title">{{ t('talk.title') }}</span> <span class="talk-title">{{ t('talk.title') }}</span>
<button class="talk-close" @click="$emit('close')"> <button class="talk-close" @click="$emit('close')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon><CloseBold /></el-icon>
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button> </button>
</div> </div>
@ -17,22 +15,10 @@
<div class="talk-pulse-ring"></div> <div class="talk-pulse-ring"></div>
<div class="talk-pulse-ring talk-pulse-ring--2"></div> <div class="talk-pulse-ring talk-pulse-ring--2"></div>
<div class="talk-pulse-core"> <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"> <el-icon v-if="state === 'idle'"><Microphone /></el-icon>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/> <el-icon v-else-if="state === 'listening'" class="pulse-anim"><Microphone /></el-icon>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/> <el-icon v-else-if="state === 'processing'" class="spin"><Loading /></el-icon>
<line x1="8" y1="23" x2="16" y2="23"/> <el-icon v-else><Service /></el-icon>
</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>
</div> </div>
</div> </div>
<div class="talk-state-label">{{ stateLabel }}</div> <div class="talk-state-label">{{ stateLabel }}</div>
@ -68,6 +54,7 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { CloseBold, Loading, Microphone, Service } from '@element-plus/icons-vue'
const { t } = useI18n() const { t } = useI18n()

View File

@ -223,6 +223,8 @@ export default {
}, },
settings: { settings: {
title: 'Settings', title: 'Settings',
kicker: 'Configuration',
layoutDesc: 'Tune models, system behavior, and media capabilities without letting the product collapse into a pile of controls.',
sections: { sections: {
model: 'Model Management', model: 'Model Management',
system: 'System', system: 'System',
@ -542,6 +544,7 @@ export default {
}, },
}, },
agentContext: { agentContext: {
kicker: 'Context Studio',
title: 'Agent Context', title: 'Agent Context',
desc: 'Manage prompt files and memory for your agents', desc: 'Manage prompt files and memory for your agents',
selectAgent: 'Select Agent', selectAgent: 'Select Agent',
@ -645,6 +648,8 @@ export default {
}, },
security: { security: {
title: '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: { sections: {
toolGuard: 'Tool Guard', toolGuard: 'Tool Guard',
fileGuard: 'File Guard', fileGuard: 'File Guard',
@ -873,9 +878,12 @@ export default {
}, },
}, },
tokenUsage: { tokenUsage: {
kicker: 'Runtime Cost',
title: 'Token Usage', title: 'Token Usage',
desc: 'View token consumption and model invocation statistics', desc: 'View token consumption and model invocation statistics',
refresh: 'Refresh', refresh: 'Refresh',
startDate: 'Start',
endDate: 'End',
promptTokens: 'Prompt Tokens', promptTokens: 'Prompt Tokens',
completionTokens: 'Completion Tokens', completionTokens: 'Completion Tokens',
assistantMessages: 'Assistant Messages', assistantMessages: 'Assistant Messages',
@ -889,6 +897,7 @@ export default {
noData: 'No token usage data', noData: 'No token usage data',
}, },
mcp: { mcp: {
kicker: 'Capability Bridge',
title: 'MCP Server Management', title: 'MCP Server Management',
desc: 'Manage MCP (Model Context Protocol) server connections', desc: 'Manage MCP (Model Context Protocol) server connections',
addServer: 'Add Server', addServer: 'Add Server',
@ -1033,6 +1042,7 @@ export default {
}, },
}, },
datasources: { datasources: {
kicker: 'Data Fabric',
title: 'Datasources', title: 'Datasources',
desc: 'Manage external database connections for natural language queries', desc: 'Manage external database connections for natural language queries',
addButton: 'Add Datasource', addButton: 'Add Datasource',
@ -1092,12 +1102,14 @@ export default {
}, },
}, },
wiki: { wiki: {
kicker: 'Knowledge Engine',
desc: 'AI-powered structured knowledge base that digests raw materials into Wiki pages', desc: 'AI-powered structured knowledge base that digests raw materials into Wiki pages',
createKB: 'New Knowledge Base', createKB: 'New Knowledge Base',
knowledgeBases: 'Knowledge Bases', knowledgeBases: 'Knowledge Bases',
noKB: 'No knowledge bases yet', noKB: 'No knowledge bases yet',
selectKB: 'Select a knowledge base', selectKB: 'Select a knowledge base',
selectPage: 'Select a page from the sidebar', selectPage: 'Select a page from the sidebar',
pageKicker: 'Knowledge Page',
kbName: 'Name', kbName: 'Name',
kbNamePlaceholder: 'Enter knowledge base name', kbNamePlaceholder: 'Enter knowledge base name',
kbDescription: 'Description', kbDescription: 'Description',
@ -1115,15 +1127,30 @@ export default {
materialContent: 'Content', materialContent: 'Content',
pasteContent: 'Paste or type content here...', pasteContent: 'Paste or type content here...',
backlinks: 'Backlinks', backlinks: 'Backlinks',
generatedByAi: 'AI generated',
editedManually: 'Manually edited',
configTitle: 'Wiki Processing Rules', configTitle: 'Wiki Processing Rules',
configDesc: 'Configure the rules AI follows when digesting raw materials', configDesc: 'Configure the rules AI follows when digesting raw materials',
configPlaceholder: 'Edit wiki processing rules here...', configPlaceholder: 'Edit wiki processing rules here...',
pageCount: '{count} pages',
sourceCount: '{count} sources',
dirPlaceholder: 'Enter local directory path, e.g. /Users/me/docs', dirPlaceholder: 'Enter local directory path, e.g. /Users/me/docs',
scan: 'Scan', scan: 'Scan',
scanning: 'Scanning...', scanning: 'Scanning...',
scanResult: 'Scanned {scanned} files, added {added}, skipped {skipped}', 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: { cronJobs: {
kicker: 'Automation',
title: 'Cron Jobs', title: 'Cron Jobs',
desc: 'Schedule agents to run messages or goals on a timer', desc: 'Schedule agents to run messages or goals on a timer',
createJob: 'New Job', createJob: 'New Job',
@ -1178,7 +1205,15 @@ export default {
}, },
}, },
login: { login: {
kicker: 'AI Operating System',
subtitle: 'Your intelligent AI assistant', 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: { fields: {
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',

View File

@ -213,6 +213,8 @@ export default {
}, },
settings: { settings: {
title: '设置', title: '设置',
kicker: '配置中心',
layoutDesc: '调整模型、系统行为与媒体能力,让整个产品在扩张时仍然保持一致。',
sections: { sections: {
model: '模型管理', model: '模型管理',
system: '系统设置', system: '系统设置',
@ -542,6 +544,7 @@ export default {
}, },
}, },
agentContext: { agentContext: {
kicker: '上下文工作面',
title: 'Agent 上下文', title: 'Agent 上下文',
desc: '管理 Agent 的提示文件和记忆', desc: '管理 Agent 的提示文件和记忆',
selectAgent: '选择 Agent', selectAgent: '选择 Agent',
@ -645,6 +648,8 @@ export default {
}, },
security: { security: {
title: '安全管理', title: '安全管理',
kicker: '治理中心',
layoutDesc: '边界、审批、审计和工作区治理必须在这里说同一种语言,否则系统不值得被信任。',
sections: { sections: {
toolGuard: '工具防护', toolGuard: '工具防护',
fileGuard: '文件防护', fileGuard: '文件防护',
@ -873,9 +878,12 @@ export default {
}, },
}, },
tokenUsage: { tokenUsage: {
kicker: '运行成本',
title: 'Token 统计', title: 'Token 统计',
desc: '查看 Token 使用量和模型调用统计', desc: '查看 Token 使用量和模型调用统计',
refresh: '刷新', refresh: '刷新',
startDate: '开始日期',
endDate: '结束日期',
promptTokens: 'Prompt Tokens', promptTokens: 'Prompt Tokens',
completionTokens: 'Completion Tokens', completionTokens: 'Completion Tokens',
assistantMessages: 'Assistant Messages', assistantMessages: 'Assistant Messages',
@ -889,6 +897,7 @@ export default {
noData: '暂无 Token 使用数据', noData: '暂无 Token 使用数据',
}, },
mcp: { mcp: {
kicker: '能力桥接',
title: 'MCP 服务管理', title: 'MCP 服务管理',
desc: '管理 MCP (Model Context Protocol) 服务器连接', desc: '管理 MCP (Model Context Protocol) 服务器连接',
addServer: '添加服务', addServer: '添加服务',
@ -1033,6 +1042,7 @@ export default {
}, },
}, },
datasources: { datasources: {
kicker: '数据接入',
title: '数据源管理', title: '数据源管理',
desc: '管理外部数据库连接,用于自然语言查数', desc: '管理外部数据库连接,用于自然语言查数',
addButton: '添加数据源', addButton: '添加数据源',
@ -1102,12 +1112,14 @@ export default {
diagnose: '诊断问题', diagnose: '诊断问题',
}, },
wiki: { wiki: {
kicker: '知识引擎',
desc: 'AI 驱动的结构化知识库,自动消化原始材料为 Wiki 页面', desc: 'AI 驱动的结构化知识库,自动消化原始材料为 Wiki 页面',
createKB: '新建知识库', createKB: '新建知识库',
knowledgeBases: '知识库', knowledgeBases: '知识库',
noKB: '暂无知识库', noKB: '暂无知识库',
selectKB: '请选择一个知识库', selectKB: '请选择一个知识库',
selectPage: '从左侧选择一个页面查看', selectPage: '从左侧选择一个页面查看',
pageKicker: '知识页面',
kbName: '名称', kbName: '名称',
kbNamePlaceholder: '输入知识库名称', kbNamePlaceholder: '输入知识库名称',
kbDescription: '描述', kbDescription: '描述',
@ -1125,15 +1137,30 @@ export default {
materialContent: '内容', materialContent: '内容',
pasteContent: '粘贴或输入文本内容...', pasteContent: '粘贴或输入文本内容...',
backlinks: '反向链接', backlinks: '反向链接',
generatedByAi: 'AI 生成',
editedManually: '人工编辑',
configTitle: '知识库处理规则', configTitle: '知识库处理规则',
configDesc: '配置 AI 消化原始材料时遵循的规则', configDesc: '配置 AI 消化原始材料时遵循的规则',
configPlaceholder: '在此编辑 Wiki 处理规则...', configPlaceholder: '在此编辑 Wiki 处理规则...',
pageCount: '{count} 页',
sourceCount: '{count} 份材料',
dirPlaceholder: '输入本地目录路径,如 /Users/me/docs', dirPlaceholder: '输入本地目录路径,如 /Users/me/docs',
scan: '扫描', scan: '扫描',
scanning: '扫描中...', scanning: '扫描中...',
scanResult: '已扫描 {scanned} 个文件,新增 {added} 个,跳过 {skipped} 个', scanResult: '已扫描 {scanned} 个文件,新增 {added} 个,跳过 {skipped} 个',
saving: '保存中...',
status: {
active: '启用',
processing: '处理中',
error: '错误',
pending: '待处理',
completed: '已完成',
partial: '部分完成',
failed: '失败',
},
}, },
cronJobs: { cronJobs: {
kicker: '自动执行',
title: '定时任务', title: '定时任务',
desc: '定时触发 Agent 执行消息或目标任务', desc: '定时触发 Agent 执行消息或目标任务',
createJob: '新建任务', createJob: '新建任务',
@ -1188,7 +1215,15 @@ export default {
}, },
}, },
login: { login: {
kicker: 'AI Operating System',
subtitle: '你的智能 AI 助手', subtitle: '你的智能 AI 助手',
heroTitle: '让 AI 不止会回答,而是真正进入持续工作的系统。',
heroDesc: 'MateClaw 的重点不是把更多能力堆进聊天框,而是把上下文、知识、执行和记忆收拢成一个可靠的工作面。',
pointContext: '上下文保持连续',
pointKnowledge: '知识被组织起来',
pointExecution: '能力真正能执行',
cardTitle: '登录并进入工作面',
cardDesc: '从这里进入你的 AI 工作系统。模型、知识、工作区和运行状态会在同一个界面里协同。 ',
fields: { fields: {
username: '用户名', username: '用户名',
password: '密码', password: '密码',

View File

@ -1,12 +1,15 @@
<template> <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 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> <h1 class="page-title">{{ t('agentContext.title') }}</h1>
<p class="page-desc">{{ t('agentContext.desc') }}</p> <p class="page-desc">{{ t('agentContext.desc') }}</p>
</div> </div>
<div class="header-actions"> <div class="header-actions mc-surface-card">
<select v-model="selectedAgentId" class="agent-select" @change="onAgentChange"> <select v-model="selectedAgentId" class="agent-select" @change="onAgentChange">
<option value="" disabled>{{ t('agentContext.selectAgent') }}</option> <option value="" disabled>{{ t('agentContext.selectAgent') }}</option>
<option v-for="agent in agents" :key="agent.id" :value="agent.id"> <option v-for="agent in agents" :key="agent.id" :value="agent.id">
@ -197,6 +200,8 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
</template> </template>
@ -437,12 +442,32 @@ function formatTime(time?: string): string {
</script> </script>
<style scoped> <style scoped>
.workspace-container { height: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--mc-bg); } .agent-context-shell {
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; padding: 20px 24px 12px; flex-shrink: 0; } background: transparent;
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; } height: 100%;
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; } min-height: 0;
.header-actions { display: flex; gap: 8px; align-items: center; } overflow: hidden;
.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-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); } .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; } .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; } .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; } .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; } .section-title { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
.panel-actions { display: flex; gap: 4px; } .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); } .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; } .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; } .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 .editor-textarea { flex: 1; min-width: 0; }
.editor-content.mode-split .markdown-preview { 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); } .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 { width: 4px; }
.markdown-preview::-webkit-scrollbar-thumb { background: var(--mc-border); border-radius: 2px; } .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-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: 8px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); } .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> </style>

View File

@ -10,12 +10,12 @@
<!-- 会话侧边栏 --> <!-- 会话侧边栏 -->
<div class="conversation-panel" :class="{ 'mobile-open': convPanelOpen }"> <div class="conversation-panel" :class="{ 'mobile-open': convPanelOpen }">
<div class="panel-header"> <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')"> <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"> <el-icon><Plus /></el-icon>
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button> </button>
</div> </div>
@ -50,10 +50,7 @@
</div> </div>
</div> </div>
<button class="conv-delete" @click.stop="deleteConversation(conv.conversationId)" :title="$t('common.delete')"> <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"> <el-icon><Delete /></el-icon>
<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> </button>
</div> </div>
</template> </template>
@ -77,11 +74,7 @@
<Transition name="fade"> <Transition name="fade">
<div v-if="isDragging" class="drop-overlay"> <div v-if="isDragging" class="drop-overlay">
<div class="drop-overlay__content"> <div class="drop-overlay__content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <el-icon><UploadFilled /></el-icon>
<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>
<span>{{ $t('chat.dropToUpload') }}</span> <span>{{ $t('chat.dropToUpload') }}</span>
</div> </div>
</div> </div>
@ -90,14 +83,15 @@
<div class="chat-header"> <div class="chat-header">
<div class="chat-header-left"> <div class="chat-header-left">
<button v-if="isMobile" class="conv-toggle-btn" @click="convPanelOpen = !convPanelOpen" :title="$t('chat.conversations')"> <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"> <el-icon><ChatDotRound /></el-icon>
<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>
</button> </button>
<div class="agent-badge" v-if="currentAgent" :title="currentAgent.name"> <div class="chat-stage-copy" v-if="currentAgent">
<span class="agent-badge-icon">{{ currentAgent.icon || '🤖' }}</span> <div class="chat-stage-kicker">{{ $t('nav.chat') }}</div>
<span class="agent-badge-name">{{ currentAgent.name }}</span> <div class="agent-badge" :title="currentAgent.name">
<span class="agent-badge-type">{{ currentAgent.agentType === 'react' ? 'ReAct' : 'Plan-Execute' }}</span> <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>
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div> <div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
</div> </div>
@ -114,16 +108,10 @@
</option> </option>
</select> </select>
<button v-else class="header-btn" @click="goToModelSettings" :title="$t('chat.configModel')"> <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"> <el-icon><Setting /></el-icon>
<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>
</button> </button>
<button class="header-btn" @click="clearMessages" :title="$t('chat.clearMessages')"> <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"> <el-icon><Delete /></el-icon>
<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> </button>
</div> </div>
</div> </div>
@ -210,6 +198,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus' 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 { conversationApi, agentApi, modelApi, chatApi } from '@/api/index'
import { channelIconUrl } from '@/utils/channelSource' import { channelIconUrl } from '@/utils/channelSource'
import { useChat } from '@/composables/chat/useChat' import { useChat } from '@/composables/chat/useChat'
@ -1137,10 +1126,15 @@ function handleCodeCopy(e: MouseEvent) {
<style scoped> <style scoped>
.chat-console-shell { .chat-console-shell {
background: transparent; background: transparent;
min-height: 0;
height: 100%;
overflow: hidden;
} }
.chat-console-frame { .chat-console-frame {
height: calc(100vh - 28px); height: min(calc(100vh - 28px), 100%);
min-height: 0;
overflow: hidden;
} }
.chat-layout { .chat-layout {
@ -1151,8 +1145,8 @@ function handleCodeCopy(e: MouseEvent) {
} }
.conversation-panel { .conversation-panel {
width: 260px; width: 248px;
min-width: 260px; min-width: 248px;
background: linear-gradient(180deg, var(--mc-panel-top), var(--mc-panel-bottom)); background: linear-gradient(180deg, var(--mc-panel-top), var(--mc-panel-bottom));
border-right: 1px solid var(--mc-border-light); border-right: 1px solid var(--mc-border-light);
display: flex; display: flex;
@ -1164,15 +1158,29 @@ function handleCodeCopy(e: MouseEvent) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 16px; padding: 14px 14px 12px;
border-bottom: 1px solid var(--mc-border-light); 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 { .panel-title {
font-size: 15px; font-size: 16px;
font-weight: 600; font-weight: 700;
color: var(--mc-text-primary); color: var(--mc-text-primary);
margin: 0; margin: 0;
letter-spacing: -0.03em;
} }
.new-chat-btn { .new-chat-btn {
@ -1196,15 +1204,15 @@ function handleCodeCopy(e: MouseEvent) {
} }
.agent-selector { .agent-selector {
padding: 10px 12px; padding: 10px 12px 12px;
border-bottom: 1px solid var(--mc-border-light); border-bottom: 1px solid var(--mc-border-light);
} }
.agent-select { .agent-select {
width: 100%; width: 100%;
padding: 7px 10px; padding: 9px 12px;
border: 1px solid var(--mc-border); border: 1px solid var(--mc-border);
border-radius: 6px; border-radius: 12px;
font-size: 13px; font-size: 13px;
color: var(--mc-text-primary); color: var(--mc-text-primary);
background: var(--mc-bg-sunken); background: var(--mc-bg-sunken);
@ -1224,26 +1232,27 @@ function handleCodeCopy(e: MouseEvent) {
} }
.conv-group-title { .conv-group-title {
padding: 8px 10px 4px; padding: 10px 10px 6px;
font-size: 11px; font-size: 10px;
font-weight: 600; font-weight: 700;
color: var(--mc-text-tertiary); color: var(--mc-text-tertiary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.12em;
} }
.conv-item { .conv-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 9px 10px; padding: 10px 11px;
border-radius: 12px; border-radius: 14px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
} }
.conv-item:hover { .conv-item:hover {
background: var(--mc-bg-sunken); background: var(--mc-bg-sunken);
transform: translateY(-1px);
} }
.conv-item.active { .conv-item.active {
@ -1331,6 +1340,7 @@ function handleCodeCopy(e: MouseEvent) {
overflow: hidden; overflow: hidden;
background: linear-gradient(180deg, var(--mc-chat-header-bg), var(--mc-chat-bg)); background: linear-gradient(180deg, var(--mc-chat-header-bg), var(--mc-chat-bg));
position: relative; position: relative;
min-height: 0;
} }
/* 拖拽上传遮罩 */ /* 拖拽上传遮罩 */
@ -1373,26 +1383,51 @@ function handleCodeCopy(e: MouseEvent) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 20px; padding: 10px 16px;
background: linear-gradient(180deg, var(--mc-panel-raised), var(--mc-surface-overlay)); background: linear-gradient(180deg, var(--mc-panel-raised), var(--mc-surface-overlay));
border-bottom: 1px solid var(--mc-border); border-bottom: 1px solid var(--mc-border);
min-height: 52px; min-height: 52px;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
gap: 10px;
}
.chat-header-left {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
} }
.chat-header-right { .chat-header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; 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 { .agent-badge {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 4px 10px; padding: 5px 10px;
background: var(--mc-primary-bg); background: var(--mc-primary-bg);
border-radius: 20px; border-radius: 999px;
max-width: 100%;
} }
.agent-badge-icon { .agent-badge-icon {
@ -1401,8 +1436,11 @@ function handleCodeCopy(e: MouseEvent) {
.agent-badge-name { .agent-badge-name {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 600;
color: var(--mc-primary); color: var(--mc-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.agent-badge-type { .agent-badge-type {
@ -1420,18 +1458,18 @@ function handleCodeCopy(e: MouseEvent) {
.model-select { .model-select {
min-width: 260px; min-width: 260px;
height: 32px; height: 34px;
border: 1px solid var(--mc-border); border: 1px solid var(--mc-border);
border-radius: 8px; border-radius: 12px;
background: var(--mc-bg-elevated); background: var(--mc-panel-raised);
color: var(--mc-text-primary); color: var(--mc-text-primary);
font-size: 13px; font-size: 13px;
padding: 0 10px; padding: 0 12px;
} }
.header-btn { .header-btn {
width: 32px; width: 30px;
height: 32px; height: 30px;
border: 1px solid var(--mc-border); border: 1px solid var(--mc-border);
background: var(--mc-panel-raised); background: var(--mc-panel-raised);
border-radius: 10px; border-radius: 10px;
@ -1500,7 +1538,7 @@ function handleCodeCopy(e: MouseEvent) {
/* ===== 移动端适配 ===== */ /* ===== 移动端适配 ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.chat-console-frame { .chat-console-frame {
height: auto; height: 100%;
min-height: calc(100vh - 28px); min-height: calc(100vh - 28px);
} }
@ -1510,8 +1548,8 @@ function handleCodeCopy(e: MouseEvent) {
top: 0; top: 0;
bottom: 0; bottom: 0;
z-index: 100; z-index: 100;
width: 280px; width: 272px;
min-width: 280px; min-width: 272px;
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.25s ease; transition: transform 0.25s ease;
box-shadow: none; box-shadow: none;
@ -1551,21 +1589,18 @@ function handleCodeCopy(e: MouseEvent) {
} }
.chat-header { .chat-header {
padding: 10px 12px; padding: 9px 12px;
gap: 8px; gap: 8px;
} }
.chat-header-left {
display: flex;
align-items: center;
min-width: 0;
gap: 6px;
}
.agent-badge { .agent-badge {
padding: 4px 8px; padding: 4px 8px;
} }
.chat-stage-kicker {
display: none;
}
.agent-badge-name, .agent-badge-name,
.agent-badge-type { .agent-badge-type {
display: none; display: none;

View File

@ -1,99 +1,152 @@
<template> <template>
<div class="page-container"> <div class="page-container">
<div class="page-header"> <div class="page-shell">
<div> <div class="page-header">
<h1 class="page-title">{{ t('cronJobs.title') }}</h1> <div class="page-lead">
<p class="page-desc">{{ t('cronJobs.desc') }}</p> <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> </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>
<!-- 任务列表 --> <div v-if="detailJob" class="modal-overlay" @click.self="closeDetailModal">
<div class="table-wrap"> <div class="modal">
<table class="data-table"> <div class="modal-header">
<thead> <h2>{{ detailJob.name }}</h2>
<tr> <button class="modal-close" @click="closeDetailModal">
<th>{{ t('cronJobs.columns.name') }}</th> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<th>{{ t('cronJobs.columns.agent') }}</th> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
<th>{{ t('cronJobs.columns.taskType') }}</th> </svg>
<th>{{ t('cronJobs.columns.cron') }}</th> </button>
<th>{{ t('cronJobs.columns.nextRun') }}</th> </div>
<th>{{ t('cronJobs.columns.lastRun') }}</th> <div class="modal-body detail-grid">
<th>{{ t('cronJobs.columns.enabled') }}</th> <div class="detail-item">
<th>{{ t('cronJobs.columns.actions') }}</th> <div class="detail-label">{{ t('cronJobs.columns.agent') }}</div>
</tr> <div class="detail-value">{{ detailJob.agentName || 'Unknown' }}</div>
</thead> </div>
<tbody> <div class="detail-item">
<tr v-for="job in store.jobs" :key="job.id" class="data-row"> <div class="detail-label">{{ t('cronJobs.columns.taskType') }}</div>
<td> <div class="detail-value">{{ t('cronJobs.taskTypes.' + detailJob.taskType) }}</div>
<div class="job-name">{{ job.name }}</div> </div>
</td> <div class="detail-item">
<td> <div class="detail-label">{{ t('cronJobs.columns.cron') }}</div>
<span class="agent-badge">{{ job.agentName || 'Unknown' }}</span> <div class="detail-value mono">{{ detailJob.cronExpression }}</div>
</td> <div class="detail-subvalue">{{ cronToHumanReadable(detailJob.cronExpression, detailJob.timezone) }}</div>
<td> </div>
<span class="type-badge" :class="'type-' + job.taskType"> <div class="detail-item">
{{ t('cronJobs.taskTypes.' + job.taskType) }} <div class="detail-label">{{ t('cronJobs.fields.timezone') }}</div>
</span> <div class="detail-value">{{ detailJob.timezone || '-' }}</div>
</td> </div>
<td> <div class="detail-item">
<code class="cron-code" :title="cronToHumanReadable(job.cronExpression, job.timezone)"> <div class="detail-label">{{ t('cronJobs.columns.nextRun') }}</div>
{{ job.cronExpression }} <div class="detail-value">{{ detailJob.nextRunTime ? formatTime(detailJob.nextRunTime) : '-' }}</div>
</code> </div>
<div class="cron-readable">{{ cronToHumanReadable(job.cronExpression, job.timezone) }}</div> <div class="detail-item">
</td> <div class="detail-label">{{ t('cronJobs.columns.lastRun') }}</div>
<td> <div class="detail-value">{{ detailJob.lastRunTime ? formatTime(detailJob.lastRunTime) : '-' }}</div>
<span v-if="job.nextRunTime" class="time-text">{{ formatTime(job.nextRunTime) }}</span> </div>
<span v-else class="time-empty">-</span> <div class="detail-item detail-item-full" v-if="detailJob.taskType === 'text'">
</td> <div class="detail-label">{{ t('cronJobs.fields.triggerMessage') }}</div>
<td> <div class="detail-value detail-block">{{ detailJob.triggerMessage || '-' }}</div>
<span v-if="job.lastRunTime" class="time-text">{{ formatTime(job.lastRunTime) }}</span> </div>
<span v-else class="time-empty">-</span> <div class="detail-item detail-item-full" v-else>
</td> <div class="detail-label">{{ t('cronJobs.fields.requestBody') }}</div>
<td> <div class="detail-value detail-block">{{ detailJob.requestBody || '-' }}</div>
<label class="toggle-switch"> </div>
<input type="checkbox" :checked="job.enabled" @change="handleToggle(job)" /> </div>
<span class="toggle-slider"></span> </div>
</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> </div>
<!-- 新建/编辑弹窗 --> <!-- 新建/编辑弹窗 -->
@ -108,13 +161,11 @@
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- 名称 -->
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ t('cronJobs.fields.name') }} *</label> <label class="form-label">{{ t('cronJobs.fields.name') }} *</label>
<input v-model="form.name" class="form-input" :placeholder="t('cronJobs.fields.namePlaceholder')" /> <input v-model="form.name" class="form-input" :placeholder="t('cronJobs.fields.namePlaceholder')" />
</div> </div>
<!-- 关联 Agent -->
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ t('cronJobs.fields.agent') }} *</label> <label class="form-label">{{ t('cronJobs.fields.agent') }} *</label>
<select v-model="form.agentId" class="form-input"> <select v-model="form.agentId" class="form-input">
@ -123,7 +174,6 @@
</select> </select>
</div> </div>
<!-- 任务类型 -->
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ t('cronJobs.fields.taskType') }}</label> <label class="form-label">{{ t('cronJobs.fields.taskType') }}</label>
<div class="radio-group"> <div class="radio-group">
@ -138,7 +188,6 @@
</div> </div>
</div> </div>
<!-- 触发消息 / 执行目标 -->
<div v-if="form.taskType === 'text'" class="form-group"> <div v-if="form.taskType === 'text'" class="form-group">
<label class="form-label">{{ t('cronJobs.fields.triggerMessage') }} *</label> <label class="form-label">{{ t('cronJobs.fields.triggerMessage') }} *</label>
<textarea v-model="form.triggerMessage" class="form-textarea" rows="3" <textarea v-model="form.triggerMessage" class="form-textarea" rows="3"
@ -150,7 +199,6 @@
:placeholder="t('cronJobs.fields.requestBodyPlaceholder')"></textarea> :placeholder="t('cronJobs.fields.requestBodyPlaceholder')"></textarea>
</div> </div>
<!-- Cron 频率 -->
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ t('cronJobs.fields.cronFrequency') }}</label> <label class="form-label">{{ t('cronJobs.fields.cronFrequency') }}</label>
<div class="radio-group"> <div class="radio-group">
@ -161,7 +209,6 @@
</div> </div>
</div> </div>
<!-- 时间选择器daily/weekly -->
<div v-if="cronType === 'daily' || cronType === 'weekly'" class="form-row"> <div v-if="cronType === 'daily' || cronType === 'weekly'" class="form-row">
<div v-if="cronType === 'weekly'" class="form-group"> <div v-if="cronType === 'weekly'" class="form-group">
<label class="form-label">{{ t('cronJobs.fields.cronDays') }}</label> <label class="form-label">{{ t('cronJobs.fields.cronDays') }}</label>
@ -181,14 +228,12 @@
</div> </div>
</div> </div>
<!-- 自定义表达式 -->
<div v-if="cronType === 'custom'" class="form-group"> <div v-if="cronType === 'custom'" class="form-group">
<label class="form-label">{{ t('cronJobs.fields.cronExpression') }}</label> <label class="form-label">{{ t('cronJobs.fields.cronExpression') }}</label>
<input v-model="form.cronExpression" class="form-input mono" <input v-model="form.cronExpression" class="form-input mono"
:placeholder="t('cronJobs.fields.cronExpressionPlaceholder')" /> :placeholder="t('cronJobs.fields.cronExpressionPlaceholder')" />
</div> </div>
<!-- 时区 -->
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ t('cronJobs.fields.timezone') }}</label> <label class="form-label">{{ t('cronJobs.fields.timezone') }}</label>
<select v-model="form.timezone" class="form-input"> <select v-model="form.timezone" class="form-input">
@ -196,7 +241,6 @@
</select> </select>
</div> </div>
<!-- 启用 -->
<div class="form-group"> <div class="form-group">
<label class="toggle-label"> <label class="toggle-label">
<label class="toggle-switch"> <label class="toggle-switch">
@ -231,6 +275,7 @@ const agents = computed(() => agentStore.agents)
const showModal = ref(false) const showModal = ref(false)
const editing = ref<CronJob | null>(null) const editing = ref<CronJob | null>(null)
const detailJob = ref<CronJob | null>(null)
const cronTypeOptions = ['hourly', 'daily', 'weekly', 'custom'] as const const cronTypeOptions = ['hourly', 'daily', 'weekly', 'custom'] as const
const cronType = ref<string>('daily') const cronType = ref<string>('daily')
@ -270,7 +315,6 @@ onMounted(() => {
agentStore.fetchAgents() agentStore.fetchAgents()
}) })
// cronType/cronTime/selectedDays cronExpression
watch([cronType, cronTime, selectedDays], () => { watch([cronType, cronTime, selectedDays], () => {
if (cronType.value === 'custom') return if (cronType.value === 'custom') return
const [h, m] = cronTime.value.split(':').map(Number) const [h, m] = cronTime.value.split(':').map(Number)
@ -317,6 +361,14 @@ function closeModal() {
editing.value = null editing.value = null
} }
function openDetailModal(job: CronJob) {
detailJob.value = job
}
function closeDetailModal() {
detailJob.value = null
}
async function saveJob() { async function saveJob() {
try { try {
if (editing.value) { if (editing.value) {
@ -327,7 +379,6 @@ async function saveJob() {
ElMessage.success(t('cronJobs.messages.createSuccess')) ElMessage.success(t('cronJobs.messages.createSuccess'))
} }
closeModal() closeModal()
// store.createJob / updateJob
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.message || e) ElMessage.error(e?.message || e)
} }
@ -363,22 +414,18 @@ async function handleRunNow(job: CronJob) {
try { try {
await store.runNow(job.id) await store.runNow(job.id)
ElMessage.success(t('cronJobs.messages.runTriggered', { id: job.id })) ElMessage.success(t('cronJobs.messages.runTriggered', { id: job.id }))
// Agent lastRunTime
setTimeout(() => store.fetchJobs(), 3000) setTimeout(() => store.fetchJobs(), 3000)
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.message || e) ElMessage.error(e?.message || e)
} }
} }
// ==================== Cron ====================
interface CronFormParts { interface CronFormParts {
type: string type: string
time: string time: string
days: number[] days: number[]
} }
/** dow 是否为纯整数逗号列表,如 "1"、"1,3,5",不含范围/步长 */
function isSimpleIntList(s: string): boolean { function isSimpleIntList(s: string): boolean {
return s.split(',').every((v) => /^\d+$/.test(v.trim())) 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 const [min, hour, dom, mon, dow] = parts
//
if (min === '0' && hour === '*' && dom === '*' && mon === '*' && dow === '*') { if (min === '0' && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
return { type: 'hourly', time: '00:00', days: [] } return { type: 'hourly', time: '00:00', days: [] }
} }
// min / hour custom */5 * * * *
if (!/^\d+$/.test(min) || !/^\d+$/.test(hour)) { if (!/^\d+$/.test(min) || !/^\d+$/.test(hour)) {
return { type: 'custom', time: '09:00', days: [] } return { type: 'custom', time: '09:00', days: [] }
} }
const timeStr = pad(+hour) + ':' + pad(+min) const timeStr = pad(+hour) + ':' + pad(+min)
// dom/mon/dow *
if (dom === '*' && mon === '*' && dow === '*') { if (dom === '*' && mon === '*' && dow === '*') {
return { type: 'daily', time: timeStr, days: [] } return { type: 'daily', time: timeStr, days: [] }
} }
//
// 1. dom='*' mon='*'
// 2. dow 1-5 1-7/2
// custom watch
if (dom === '*' && mon === '*' && dow !== '*' && isSimpleIntList(dow)) { if (dom === '*' && mon === '*' && dow !== '*' && isSimpleIntList(dow)) {
const days = dow.split(',').map(Number) const days = dow.split(',').map(Number)
return { type: 'weekly', time: timeStr, days } return { type: 'weekly', time: timeStr, days }
} }
// 1-5*/2dom/mon * custom
return { type: 'custom', time: timeStr, days: [] } 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)) { if (dom === '*' && mon === '*' && dow !== '*' && !isNaN(+min) && !isNaN(+hour)) {
const dayNames = dow.split(',').map((d) => { const dayNames = dow.split(',').map((d) => {
const n = +d const n = +d
// 0 7 Sunday dayKeys[6]1=Mon0 6=Sat5
const idx = n === 0 ? 6 : n - 1 const idx = n === 0 ? 6 : n - 1
return idx >= 0 && idx < dayKeys.length ? t('cronJobs.days.' + dayKeys[idx]) : d return idx >= 0 && idx < dayKeys.length ? t('cronJobs.days.' + dayKeys[idx]) : d
}) })
@ -463,38 +501,158 @@ function formatTime(datetime: string | undefined): string {
</script> </script>
<style scoped> <style scoped>
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); } .page-container {
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; } height: 100%;
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; } overflow-y: auto;
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; } 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:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; } .btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-primary.btn-sm { padding: 6px 14px; font-size: 13px; } .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: 8px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); } .btn-secondary:hover { background: var(--mc-bg-sunken); }
/* 表格 */ .page-stage {
.table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 12px; overflow: hidden; } padding: 0 24px 24px;
.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; }
.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 { border-bottom: 1px solid var(--mc-border-light); transition: background 0.1s; }
.data-row:hover { background: var(--mc-bg-sunken); } .data-row:hover { background: var(--mc-bg-sunken); }
.data-row:last-child { border-bottom: none; } .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); } .job-main {
.agent-badge { padding: 2px 8px; border-radius: 6px; font-size: 12px; background: var(--mc-bg-sunken); color: var(--mc-text-secondary); } display: flex;
.type-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; } 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-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)); } .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-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: 11px; color: var(--mc-text-tertiary); margin-top: 2px; } .cron-readable { font-size: 12px; line-height: 1.45; color: var(--mc-text-tertiary); margin-top: 6px; }
.time-text { font-size: 13px; color: var(--mc-text-secondary); } .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); } .time-empty { color: var(--mc-text-tertiary); }
/* Toggle */
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; } .toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
.toggle-switch input { opacity: 0; width: 0; height: 0; } .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; } .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 { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); } .toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
/* 操作按钮 */ .row-actions { display: flex; gap: 6px; }
.row-actions { display: flex; gap: 4px; } .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 { 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:hover { background: var(--mc-bg-sunken); color: var(--mc-primary); } .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); } .row-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
/* 空状态 */
.empty-row { padding: 40px !important; } .empty-row { padding: 40px !important; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; color: var(--mc-text-tertiary); } .empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; color: var(--mc-text-tertiary); }
.empty-icon { font-size: 32px; } .empty-icon { font-size: 32px; }
.empty-state p { font-size: 14px; margin: 0; } .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-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 { 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; } .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 { 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-close:hover { background: var(--mc-bg-sunken); }
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 16px; } .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); } .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-group { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); } .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%; } .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 { display: flex; gap: 16px; }
.form-row .form-group { flex: 1; } .form-row .form-group { flex: 1; }
/* Radio 组 */
.radio-group { display: flex; gap: 8px; flex-wrap: wrap; } .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 { 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: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.active { border-color: var(--mc-primary); background: var(--mc-primary-bg); color: var(--mc-primary); }
.radio-option input { display: none; } .radio-option input { display: none; }
/* 星期选择 */
.day-picker { display: flex; gap: 6px; flex-wrap: wrap; } .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 { 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: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.active { border-color: var(--mc-primary); background: var(--mc-primary-bg); color: var(--mc-primary); }
.day-chip input { display: none; } .day-chip input { display: none; }
/* Toggle label */
.toggle-label { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--mc-text-primary); } .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> </style>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="mc-page-shell dashboard-shell"> <div class="mc-page-shell dashboard-shell">
<div class="mc-page-frame"> <div class="mc-page-frame dashboard-frame">
<div class="mc-page-inner"> <div class="mc-page-inner dashboard-inner">
<div class="mc-page-header"> <div class="mc-page-header">
<div> <div>
<div class="mc-page-kicker">Operations Pulse</div> <div class="mc-page-kicker">Operations Pulse</div>
@ -15,96 +15,106 @@
</div> </div>
</div> </div>
<div class="stats-grid"> <div class="dashboard-body">
<div class="stat-card mc-surface-card"> <div class="stats-grid">
<div class="stat-icon">💬</div> <div class="stat-card mc-surface-card">
<div class="stat-body"> <div class="stat-icon">
<div class="stat-value">{{ todayStats.conversations }}</div> <el-icon><ChatDotRound /></el-icon>
<div class="stat-label">{{ t('dashboard.conversations') }}</div> </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> </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="comparison-section">
<div class="section-head"> <div class="section-head">
<h2 class="section-title">{{ t('dashboard.periodComparison') }}</h2> <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> <p class="section-subtitle">A sharper view of how your system behaves across short, medium, and monthly horizons.</p>
</div> </div>
<div class="comparison-grid"> <div class="comparison-grid">
<div class="comparison-card mc-surface-card" v-for="(period, key) in overview" :key="key"> <div class="comparison-card mc-surface-card" v-for="(period, key) in overview" :key="key">
<h3 class="comparison-title">{{ t('dashboard.periods.' + key) }}</h3> <h3 class="comparison-title">{{ t('dashboard.periods.' + key) }}</h3>
<div class="comparison-row"> <div class="comparison-row">
<span class="comparison-label">{{ t('dashboard.conversations') }}</span> <span class="comparison-label">{{ t('dashboard.conversations') }}</span>
<span class="comparison-value">{{ period.conversations }}</span> <span class="comparison-value">{{ period.conversations }}</span>
</div> </div>
<div class="comparison-row"> <div class="comparison-row">
<span class="comparison-label">{{ t('dashboard.messages') }}</span> <span class="comparison-label">{{ t('dashboard.messages') }}</span>
<span class="comparison-value">{{ period.messages }}</span> <span class="comparison-value">{{ period.messages }}</span>
</div> </div>
<div class="comparison-row"> <div class="comparison-row">
<span class="comparison-label">{{ t('dashboard.tokens') }}</span> <span class="comparison-label">{{ t('dashboard.tokens') }}</span>
<span class="comparison-value">{{ formatTokens(period.totalTokens) }}</span> <span class="comparison-value">{{ formatTokens(period.totalTokens) }}</span>
</div> </div>
<div class="comparison-row"> <div class="comparison-row">
<span class="comparison-label">{{ t('dashboard.toolCalls') }}</span> <span class="comparison-label">{{ t('dashboard.toolCalls') }}</span>
<span class="comparison-value">{{ period.toolCalls }}</span> <span class="comparison-value">{{ period.toolCalls }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="runs-section"> <div class="runs-section">
<div class="section-head"> <div class="section-head">
<h2 class="section-title">{{ t('dashboard.recentRuns') }}</h2> <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> <p class="section-subtitle">Execution should feel legible. If it runs, you should see its rhythm, cost, and outcome instantly.</p>
</div> </div>
<div class="runs-table-wrapper mc-surface-card"> <div class="runs-table-wrapper mc-surface-card">
<table v-if="recentRuns.length" class="runs-table"> <table v-if="recentRuns.length" class="runs-table">
<thead> <thead>
<tr> <tr>
<th>{{ t('dashboard.runColumns.time') }}</th> <th>{{ t('dashboard.runColumns.time') }}</th>
<th>{{ t('dashboard.runColumns.job') }}</th> <th>{{ t('dashboard.runColumns.job') }}</th>
<th>{{ t('dashboard.runColumns.status') }}</th> <th>{{ t('dashboard.runColumns.status') }}</th>
<th>{{ t('dashboard.runColumns.trigger') }}</th> <th>{{ t('dashboard.runColumns.trigger') }}</th>
<th>{{ t('dashboard.runColumns.duration') }}</th> <th>{{ t('dashboard.runColumns.duration') }}</th>
<th>{{ t('dashboard.runColumns.tokens') }}</th> <th>{{ t('dashboard.runColumns.tokens') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="run in recentRuns" :key="run.id"> <tr v-for="run in recentRuns" :key="run.id">
<td class="cell-time">{{ formatTime(run.startedAt) }}</td> <td class="cell-time">{{ formatTime(run.startedAt) }}</td>
<td class="cell-job">#{{ run.cronJobId }}</td> <td class="cell-job">#{{ run.cronJobId }}</td>
<td> <td>
<span class="status-badge" :class="'status-' + run.status">{{ run.status }}</span> <span class="status-badge" :class="'status-' + run.status">{{ run.status }}</span>
</td> </td>
<td class="cell-trigger">{{ run.triggerType }}</td> <td class="cell-trigger">{{ run.triggerType }}</td>
<td class="cell-duration">{{ calcDuration(run) }}</td> <td class="cell-duration">{{ calcDuration(run) }}</td>
<td class="cell-tokens">{{ run.tokenUsage || '-' }}</td> <td class="cell-tokens">{{ run.tokenUsage || '-' }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div v-else class="empty-state">{{ t('dashboard.noRuns') }}</div> <div v-else class="empty-state">{{ t('dashboard.noRuns') }}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -115,6 +125,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue'
import { dashboardApi } from '@/api' import { dashboardApi } from '@/api'
const { t } = useI18n() const { t } = useI18n()
@ -168,11 +179,34 @@ function calcDuration(run: any): string {
<style scoped> <style scoped>
.dashboard-shell { .dashboard-shell {
background: transparent; 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 { .hero-note {
min-width: 220px; min-width: 220px;
padding: 18px 20px; padding: 16px 18px;
} }
.hero-note__label { .hero-note__label {
@ -199,7 +233,7 @@ function calcDuration(run: any): string {
} }
.section-head { .section-head {
margin-bottom: 16px; margin-bottom: 12px;
} }
.section-title { .section-title {
@ -216,10 +250,10 @@ function calcDuration(run: any): string {
line-height: 1.6; 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 { .stat-card {
display: flex; align-items: center; gap: 14px; display: flex; align-items: center; gap: 14px;
padding: 22px; padding: 18px;
} }
.stat-icon { .stat-icon {
width: 52px; width: 52px;
@ -229,16 +263,17 @@ function calcDuration(run: any): string {
justify-content: center; justify-content: center;
border-radius: 16px; border-radius: 16px;
background: linear-gradient(135deg, rgba(217, 109, 70, 0.12), rgba(24, 74, 69, 0.08)); 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-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-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; } .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-section { margin-bottom: 22px; }
.comparison-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } .comparison-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.comparison-card { .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-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); } .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); } .comparison-value { font-size: 14px; font-weight: 700; color: var(--mc-text-primary); }
/* Runs Section */ /* Runs Section */
.runs-section { margin-bottom: 32px; } .runs-section { min-height: 0; }
.runs-table-wrapper { overflow: hidden; } .runs-table-wrapper {
overflow: auto;
max-height: 100%;
}
.runs-table { width: 100%; border-collapse: collapse; font-size: 13px; } .runs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.runs-table th { .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; 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); 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 tr:last-child td { border-bottom: none; }
.runs-table tbody tr:hover { background: rgba(217, 109, 70, 0.04); } .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; } .empty-state { padding: 48px; text-align: center; color: var(--mc-text-tertiary); font-size: 14px; }
@media (max-width: 768px) { @media (max-width: 768px) {
.dashboard-frame {
height: 100%;
min-height: calc(100vh - 28px);
}
.dashboard-body {
overflow: visible;
padding-right: 0;
}
.hero-note { .hero-note {
width: 100%; width: 100%;
min-width: 0; min-width: 0;

View File

@ -1,25 +1,25 @@
<template> <template>
<div class="page-container"> <div class="page-container">
<div class="page-header"> <div class="page-shell">
<div> <div class="page-header">
<h1 class="page-title">{{ t('datasources.title') }}</h1> <div class="page-lead">
<p class="page-desc">{{ t('datasources.desc') }}</p> <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> </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="page-stage">
<div class="tools-table-wrap"> <!-- 数据源列表 -->
<table class="tools-table"> <div class="tools-table-wrap">
<table class="tools-table">
<thead> <thead>
<tr> <tr>
<th>{{ t('datasources.columns.name') }}</th> <th>{{ t('datasources.columns.name') }}</th>
<th>{{ t('datasources.columns.type') }}</th>
<th>{{ t('datasources.columns.connection') }}</th> <th>{{ t('datasources.columns.connection') }}</th>
<th>{{ t('datasources.columns.status') }}</th> <th>{{ t('datasources.columns.status') }}</th>
<th>{{ t('datasources.columns.actions') }}</th> <th>{{ t('datasources.columns.actions') }}</th>
@ -27,69 +27,58 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="ds in datasources" :key="ds.id" class="tool-row"> <tr v-for="ds in datasources" :key="ds.id" class="tool-row">
<!-- 名称 + 描述 -->
<td> <td>
<div class="tool-info"> <div class="tool-info">
<div class="tool-icon-wrap" :class="{ 'icon-ok': ds.lastTestOk === true, 'icon-fail': ds.lastTestOk === false }"> <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"> <el-icon><Coin /></el-icon>
<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>
</div> </div>
<div> <div>
<div class="tool-name">{{ ds.name }}</div> <div class="tool-name" :title="ds.name">{{ ds.name }}</div>
<div class="tool-desc" v-if="ds.description">{{ ds.description }}</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>
</div> </div>
</td> </td>
<!-- 类型 -->
<td>
<span class="type-badge" :class="'type-' + ds.dbType">{{ dbTypeLabel(ds.dbType) }}</span>
</td>
<!-- 连接信息 -->
<td> <td>
<div class="conn-info"> <div class="conn-info">
<code class="conn-host">{{ ds.host }}:{{ ds.port }}</code> <code class="conn-host" :title="`${ds.host}:${ds.port}`">{{ ds.host }}:{{ ds.port }}</code>
<span class="conn-db">{{ ds.databaseName }}<template v-if="ds.schemaName"> / {{ ds.schemaName }}</template></span> <span class="conn-db" :title="`${ds.databaseName}${ds.schemaName ? ` / ${ds.schemaName}` : ''}`">{{ ds.databaseName }}<template v-if="ds.schemaName"> / {{ ds.schemaName }}</template></span>
</div> </div>
</td> </td>
<!-- 状态 -->
<td> <td>
<div class="status-cell"> <div class="status-cell">
<span class="status-dot" :class="statusClass(ds)"></span> <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"> <label class="toggle-switch">
<input type="checkbox" :checked="ds.enabled" @change="toggleDs(ds)" /> <input type="checkbox" :checked="ds.enabled" @change="toggleDs(ds)" />
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </div>
</td> </td>
<!-- 操作 -->
<td> <td>
<div class="row-actions"> <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')"> <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"> <el-icon v-if="testing !== ds.id"><Select /></el-icon>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span v-else class="spinner"></span> <span v-else class="spinner"></span>
</button> </button>
<button class="row-btn" @click="openEditModal(ds)" :title="t('common.edit')"> <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"> <el-icon><EditPen /></el-icon>
<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>
<button class="row-btn danger" @click="deleteDs(ds.id)" :title="t('common.delete')"> <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"> <el-icon><Delete /></el-icon>
<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> </button>
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="datasources.length === 0"> <tr v-if="datasources.length === 0">
<td colspan="5" class="empty-row"> <td colspan="4" class="empty-row">
<div class="empty-state"> <div class="empty-state">
<span class="empty-icon">🗄</span> <el-icon class="empty-icon"><Coin /></el-icon>
<p>{{ t('datasources.empty') }}</p> <p>{{ t('datasources.empty') }}</p>
<button class="btn-primary" style="margin-top: 8px" @click="openCreateModal"> <button class="btn-primary" style="margin-top: 8px" @click="openCreateModal">
{{ t('datasources.addButton') }} {{ t('datasources.addButton') }}
@ -98,22 +87,62 @@
</td> </td>
</tr> </tr>
</tbody> </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> </div>
<!-- Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal"> <div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<h2>{{ editingDs ? t('datasources.modal.editTitle') : t('datasources.modal.newTitle') }}</h2> <h2>{{ editingDs ? t('datasources.modal.editTitle') : t('datasources.modal.newTitle') }}</h2>
<button class="modal-close" @click="closeModal"> <button class="modal-close" @click="closeModal">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <el-icon><CloseBold /></el-icon>
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- 基本信息 -->
<div class="form-section-title">{{ t('datasources.sections.basic') }}</div> <div class="form-section-title">{{ t('datasources.sections.basic') }}</div>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
@ -135,7 +164,6 @@
</div> </div>
</div> </div>
<!-- 连接信息 -->
<div class="form-section-title">{{ t('datasources.sections.connection') }}</div> <div class="form-section-title">{{ t('datasources.sections.connection') }}</div>
<div class="form-grid"> <div class="form-grid">
<div class="form-group" style="flex: 2"> <div class="form-group" style="flex: 2">
@ -156,7 +184,6 @@
</div> </div>
</div> </div>
<!-- 认证信息 -->
<div class="form-section-title">{{ t('datasources.sections.auth') }}</div> <div class="form-section-title">{{ t('datasources.sections.auth') }}</div>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
@ -168,17 +195,16 @@
<div class="password-wrap"> <div class="password-wrap">
<input v-model="form.password" :type="showPassword ? 'text' : 'password'" class="form-input" autocomplete="new-password" :placeholder="t('datasources.placeholders.password')" /> <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"> <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> <el-icon v-if="!showPassword"><View /></el-icon>
<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-else><Hide /></el-icon>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- 高级配置 -->
<div class="form-section-title advanced-toggle" @click="showAdvanced = !showAdvanced"> <div class="form-section-title advanced-toggle" @click="showAdvanced = !showAdvanced">
{{ t('datasources.sections.advanced') }} {{ 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>
<div class="form-grid" v-if="showAdvanced"> <div class="form-grid" v-if="showAdvanced">
<div class="form-group full-width"> <div class="form-group full-width">
@ -188,10 +214,9 @@
</div> </div>
</div> </div>
<!-- 连接测试结果 -->
<div v-if="modalTestResult !== null" class="test-result" :class="modalTestResult ? 'test-ok' : 'test-fail'"> <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> <el-icon v-if="modalTestResult"><CircleCheckFilled /></el-icon>
<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-else><CircleCloseFilled /></el-icon>
{{ modalTestResult ? t('datasources.messages.testSuccess') : t('datasources.messages.testFailed') }} {{ modalTestResult ? t('datasources.messages.testSuccess') : t('datasources.messages.testFailed') }}
</div> </div>
</div> </div>
@ -213,6 +238,19 @@
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus' 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' import { datasourceApi } from '@/api/index'
const { t } = useI18n() const { t } = useI18n()
@ -237,6 +275,7 @@ interface Datasource {
const datasources = ref<Datasource[]>([]) const datasources = ref<Datasource[]>([])
const showModal = ref(false) const showModal = ref(false)
const editingDs = ref<Datasource | null>(null) const editingDs = ref<Datasource | null>(null)
const detailDs = ref<Datasource | null>(null)
const testing = ref<number | string | null>(null) const testing = ref<number | string | null>(null)
const showPassword = ref(false) const showPassword = ref(false)
const showAdvanced = ref(false) const showAdvanced = ref(false)
@ -307,6 +346,14 @@ function closeModal() {
modalTestResult.value = null modalTestResult.value = null
} }
function openDetailModal(ds: Datasource) {
detailDs.value = ds
}
function closeDetailModal() {
detailDs.value = null
}
async function saveDs() { async function saveDs() {
try { try {
let saved: any let saved: any
@ -317,11 +364,8 @@ async function saveDs() {
} }
closeModal() closeModal()
await loadDatasources() await loadDatasources()
//
const id = saved?.data?.id const id = saved?.data?.id
if (id) { if (id) autoTestAfterSave(id)
autoTestAfterSave(id)
}
} catch (e: any) { ElMessage.error(e?.message || t('datasources.messages.saveFailed')) } } 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 const ok = res.data?.success
ElMessage({ type: ok ? 'success' : 'warning', message: ok ? t('datasources.messages.testSuccess') : t('datasources.messages.testFailed') }) ElMessage({ type: ok ? 'success' : 'warning', message: ok ? t('datasources.messages.testSuccess') : t('datasources.messages.testFailed') })
await loadDatasources() await loadDatasources()
} catch { /* ignore */ } } catch {
finally { testing.value = null } } finally { testing.value = null }
} }
async function testInModal() { async function testInModal() {
if (!editingDs.value) { if (!editingDs.value) {
//
try { try {
const saved: any = await datasourceApi.create(form.value) const saved: any = await datasourceApi.create(form.value)
editingDs.value = saved.data editingDs.value = saved.data
@ -349,7 +392,6 @@ async function testInModal() {
return return
} }
} else { } else {
//
try { try {
await datasourceApi.update(editingDs.value.id, form.value) await datasourceApi.update(editingDs.value.id, form.value)
await loadDatasources() await loadDatasources()
@ -399,10 +441,40 @@ async function testConnection(ds: Datasource) {
<style scoped> <style scoped>
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); } .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-container {
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; } height: 100%;
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; } overflow-y: auto;
.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; } 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:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; } .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: 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:hover { background: var(--mc-primary-bg); }
.btn-test:disabled { opacity: 0.5; cursor: not-allowed; } .btn-test:disabled { opacity: 0.5; cursor: not-allowed; }
/* Table */ .page-stage {
.tools-table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 12px; overflow: hidden; } background: linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 96%, white 4%) 0%, var(--mc-bg-elevated) 100%);
.tools-table { width: 100%; border-collapse: collapse; } border: 1px solid var(--mc-border);
.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); } 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 { 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-sunken); }
.tool-row:last-child { border-bottom: none; } .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-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: 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-ok { background: #e8f5e9; color: #2e7d32; }
.tool-icon-wrap.icon-fail { background: #fce4ec; color: #c62828; } .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; } .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-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-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 { font-size: 12px; color: var(--mc-text-tertiary); } .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-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
.type-mysql { background: #e8f4fd; color: #1a73e8; } .type-mysql { background: #e8f4fd; color: #1a73e8; }
.type-postgresql { background: #e8f0fe; color: #336791; } .type-postgresql { background: #e8f0fe; color: #336791; }
.type-clickhouse { background: #fff8e1; color: #e6a817; } .type-clickhouse { background: #fff8e1; color: #e6a817; }
.type-mariadb { background: #fce4ec; color: #c0392b; } .type-mariadb { background: #fce4ec; color: #c0392b; }
/* Status */
.status-cell { display: flex; align-items: center; gap: 8px; } .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; } .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-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-fail { background: #f44336; box-shadow: 0 0 4px rgba(244,67,54,0.4); }
.dot-unknown { background: var(--mc-border); } .dot-unknown { background: var(--mc-border); }
.dot-disabled { background: var(--mc-border); opacity: 0.5; } .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 { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
.toggle-switch input { opacity: 0; width: 0; height: 0; } .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; } .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 { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); } .toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
/* Actions */ .row-actions { display: flex; gap: 6px; }
.row-actions { display: flex; gap: 4px; } .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 { 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:hover { background: var(--mc-bg-sunken); } .row-btn:hover { background: var(--mc-bg-sunken); }
.row-btn:disabled { opacity: 0.5; cursor: not-allowed; } .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.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); } .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; } .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); } } @keyframes spin { to { transform: rotate(360deg); } }
/* Empty */
.empty-row { padding: 40px !important; } .empty-row { padding: 40px !important; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--mc-text-tertiary); } .empty-state { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--mc-text-tertiary); }
.empty-icon { font-size: 32px; } .empty-icon { font-size: 32px; }
.empty-state p { font-size: 14px; margin: 0; } .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-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 { 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); } .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 { 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-close:hover { background: var(--mc-bg-sunken); }
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; } .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 { 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; } .form-section-title:first-child { margin-top: 0; }
.advanced-toggle { cursor: pointer; display: flex; align-items: center; gap: 4px; user-select: none; } .advanced-toggle { cursor: pointer; display: flex; align-items: center; gap: 4px; user-select: none; }
.advanced-toggle svg { transition: transform 0.2s; } .advanced-toggle svg { transition: transform 0.2s; }
.advanced-toggle svg.rotated { transform: rotate(180deg); } .advanced-toggle svg.rotated { transform: rotate(180deg); }
/* Form */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; } .form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group.full-width { grid-column: 1 / -1; } .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-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; } .form-hint { font-size: 11px; color: var(--mc-text-tertiary); margin-top: 2px; }
/* Password field */
.password-wrap { position: relative; } .password-wrap { position: relative; }
.password-wrap .form-input { padding-right: 36px; } .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 { 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); } .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-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-ok { background: #e8f5e9; color: #2e7d32; }
.test-fail { background: #fce4ec; color: #c62828; } .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); } .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> </style>

View File

@ -1,79 +1,96 @@
<template> <template>
<div class="login-page"> <div class="login-page">
<div class="login-card"> <div class="login-frame">
<!-- Logo --> <div class="login-hero">
<div class="login-logo"> <div class="login-hero__kicker">{{ t('login.kicker') }}</div>
<img src="/logo/mateclaw_logo_s.png" alt="MateClaw" class="logo-image" /> <div class="login-logo">
<h1 class="logo-title">Mate<span class="logo-title-highlight">Claw</span></h1> <img src="/logo/mateclaw_logo_s.png" alt="MateClaw" class="logo-image" />
<p class="logo-subtitle">{{ t('login.subtitle') }}</p> <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> </div>
<!-- 登录表单 --> <div class="login-card">
<form class="login-form" @submit.prevent="handleLogin"> <div class="login-card__intro">
<div class="form-group"> <div class="login-card__kicker">{{ t('login.signIn') }}</div>
<label class="form-label">{{ t('login.fields.username') }}</label> <h3 class="login-card__title">{{ t('login.cardTitle') }}</h3>
<div class="input-wrap"> <p class="login-card__desc">{{ t('login.cardDesc') }}</p>
<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> </div>
<div class="form-group"> <!-- 登录表单 -->
<label class="form-label">{{ t('login.fields.password') }}</label> <form class="login-form" @submit.prevent="handleLogin">
<div class="input-wrap"> <div class="form-group">
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <label class="form-label">{{ t('login.fields.username') }}</label>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/> <div class="input-wrap">
<path d="M7 11V7a5 5 0 0 1 10 0v4"/> <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
</svg> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<input <circle cx="12" cy="7" r="4"/>
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>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <input
<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"/> v-model="form.username"
<line x1="1" y1="1" x2="23" y2="23"/> type="text"
</svg> class="form-input"
</button> :placeholder="t('login.placeholders.username')"
autocomplete="username"
required
/>
</div>
</div> </div>
</div>
<div v-if="errorMsg" class="error-msg"> <div class="form-group">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <label class="form-label">{{ t('login.fields.password') }}</label>
<circle cx="12" cy="12" r="10"/> <div class="input-wrap">
<line x1="12" y1="8" x2="12" y2="12"/> <svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="16" x2="12.01" y2="16"/> <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
</svg> <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
{{ errorMsg }} </svg>
</div> <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"> <div v-if="errorMsg" class="error-msg">
<span v-if="!loading">{{ t('login.signIn') }}</span> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<span v-else class="loading-dots"> <circle cx="12" cy="12" r="10"/>
<span></span><span></span><span></span> <line x1="12" y1="8" x2="12" y2="12"/>
</span> <line x1="12" y1="16" x2="12.01" y2="16"/>
</button> </svg>
</form> {{ 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> </div>
<!-- 背景装饰 --> <!-- 背景装饰 -->
@ -126,6 +143,7 @@ async function handleLogin() {
background: linear-gradient(135deg, var(--mc-primary-bg) 0%, #FAF5F0 50%, #F5EDE5 100%); background: linear-gradient(135deg, var(--mc-primary-bg) 0%, #FAF5F0 50%, #F5EDE5 100%);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
padding: 28px;
} }
:root.dark .login-page, :root.dark .login-page,
@ -133,22 +151,112 @@ html.dark .login-page {
background: linear-gradient(135deg, var(--mc-bg) 0%, #1E1814 50%, #1A1210 100%); background: linear-gradient(135deg, var(--mc-bg) 0%, #1E1814 50%, #1A1210 100%);
} }
.login-card { .login-frame {
background: var(--mc-bg-elevated); width: min(1120px, 100%);
border: 1px solid var(--mc-border); display: grid;
border-radius: 20px; grid-template-columns: 1.15fr 0.85fr;
padding: 40px; gap: 24px;
width: 100%;
max-width: 400px;
box-shadow: 0 20px 60px rgba(217, 119, 87, 0.12);
position: relative; position: relative;
z-index: 1; 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 */ /* Logo */
.login-logo { .login-logo {
text-align: center; margin-top: 22px;
margin-bottom: 32px;
} }
.logo-image { .logo-image {
@ -161,10 +269,11 @@ html.dark .login-page {
} }
.logo-title { .logo-title {
font-size: 26px; font-size: 42px;
font-weight: 700; font-weight: 800;
color: var(--mc-text-primary); color: var(--mc-text-primary);
margin: 0 0 6px; margin: 0 0 8px;
letter-spacing: -0.05em;
} }
.logo-title-highlight { .logo-title-highlight {
@ -172,7 +281,7 @@ html.dark .login-page {
} }
.logo-subtitle { .logo-subtitle {
font-size: 14px; font-size: 15px;
color: var(--mc-text-tertiary); color: var(--mc-text-tertiary);
margin: 0; margin: 0;
} }
@ -313,7 +422,7 @@ html.dark .login-page {
/* 提示 */ /* 提示 */
.login-hint { .login-hint {
text-align: center; text-align: left;
font-size: 12px; font-size: 12px;
color: var(--mc-text-tertiary); color: var(--mc-text-tertiary);
margin: 20px 0 0; margin: 20px 0 0;
@ -376,4 +485,27 @@ html.dark .bg-circle-2 {
html.dark .bg-circle-3 { html.dark .bg-circle-3 {
background: radial-gradient(circle, rgba(123, 63, 30, 0.12), transparent); 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> </style>

View File

@ -1,34 +1,35 @@
<template> <template>
<div class="page-container"> <div class="page-container">
<div class="page-header"> <div class="page-shell">
<div> <div class="page-header">
<h1 class="page-title">{{ t('mcp.title') }}</h1> <div class="page-lead">
<p class="page-desc">{{ t('mcp.desc') }}</p> <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>
<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="page-stage">
<div class="tools-table-wrap"> <div class="tools-table-wrap">
<table class="tools-table"> <table class="tools-table">
<thead> <thead>
<tr> <tr>
<th>{{ t('mcp.columns.name') }}</th> <th>{{ t('mcp.columns.name') }}</th>
<th>{{ t('mcp.columns.transport') }}</th>
<th>{{ t('mcp.columns.lastStatus') }}</th> <th>{{ t('mcp.columns.lastStatus') }}</th>
<th>{{ t('mcp.columns.toolCount') }}</th> <th>{{ t('mcp.columns.toolCount') }}</th>
<th>{{ t('mcp.columns.enabled') }}</th> <th>{{ t('mcp.columns.enabled') }}</th>
@ -48,20 +49,23 @@
</svg> </svg>
</div> </div>
<div> <div>
<div class="tool-name">{{ server.name }}</div> <div class="tool-name" :title="server.name">{{ server.name }}</div>
<div class="tool-desc">{{ server.description || '-' }}</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>
</div> </div>
</td> </td>
<td> <td>
<span class="type-badge" :class="'type-' + server.transport"> <div class="status-stack">
{{ t('mcp.transport.' + server.transport) }} <span class="status-badge" :class="'status-' + server.lastStatus" :title="t('mcp.status.' + (server.lastStatus || 'disconnected'))">
</span> {{ t('mcp.status.' + (server.lastStatus || 'disconnected')) }}
</td> </span>
<td> <span v-if="server.lastConnectedTime" class="status-time" :title="server.lastConnectedTime">{{ server.lastConnectedTime }}</span>
<span class="status-badge" :class="'status-' + server.lastStatus"> </div>
{{ t('mcp.status.' + (server.lastStatus || 'disconnected')) }}
</span>
<div v-if="server.lastError" class="status-error" :title="server.lastError"> <div v-if="server.lastError" class="status-error" :title="server.lastError">
{{ truncate(server.lastError, 40) }} {{ truncate(server.lastError, 40) }}
</div> </div>
@ -77,6 +81,11 @@
</td> </td>
<td> <td>
<div class="row-actions"> <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')"> <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"> <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"/> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
@ -105,7 +114,7 @@
</td> </td>
</tr> </tr>
<tr v-if="servers.length === 0"> <tr v-if="servers.length === 0">
<td colspan="6" class="empty-row"> <td colspan="5" class="empty-row">
<div class="empty-state"> <div class="empty-state">
<span class="empty-icon"> <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)"> <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> </td>
</tr> </tr>
</tbody> </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> </div>
<!-- Test Result Toast -->
<transition name="toast"> <transition name="toast">
<div v-if="testResult" class="test-toast" :class="testResult.success ? 'toast-success' : 'toast-error'"> <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> <div class="toast-title">{{ testResult.success ? t('mcp.testResult.success') : t('mcp.testResult.failed') }}</div>
@ -136,7 +202,6 @@
</div> </div>
</transition> </transition>
<!-- Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal"> <div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal modal-wide"> <div class="modal modal-wide">
<div class="modal-header"> <div class="modal-header">
@ -167,7 +232,6 @@
<input v-model="form.description" class="form-input" :placeholder="t('mcp.placeholders.description')" /> <input v-model="form.description" class="form-input" :placeholder="t('mcp.placeholders.description')" />
</div> </div>
<!-- stdio fields -->
<template v-if="form.transport === 'stdio'"> <template v-if="form.transport === 'stdio'">
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ t('mcp.fields.command') }} *</label> <label class="form-label">{{ t('mcp.fields.command') }} *</label>
@ -187,7 +251,6 @@
</div> </div>
</template> </template>
<!-- http/sse fields -->
<template v-else> <template v-else>
<div class="form-group full-width"> <div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.url') }} *</label> <label class="form-label">{{ t('mcp.fields.url') }} *</label>
@ -266,6 +329,7 @@ interface TestResultData {
const servers = ref<McpServer[]>([]) const servers = ref<McpServer[]>([])
const showModal = ref(false) const showModal = ref(false)
const editing = ref<McpServer | null>(null) const editing = ref<McpServer | null>(null)
const detailServer = ref<McpServer | null>(null)
const refreshing = ref(false) const refreshing = ref(false)
const testingId = ref<number | null>(null) const testingId = ref<number | null>(null)
const testResult = ref<TestResultData | null>(null) const testResult = ref<TestResultData | null>(null)
@ -295,14 +359,12 @@ const canSave = computed(() => {
onMounted(loadServers) onMounted(loadServers)
/** 防止并发 loadServers 导致旧数据覆盖新数据 */
let loadGeneration = 0 let loadGeneration = 0
async function loadServers() { async function loadServers() {
const gen = ++loadGeneration const gen = ++loadGeneration
try { try {
const res: any = await mcpApi.list() const res: any = await mcpApi.list()
// servers
if (gen === loadGeneration) { if (gen === loadGeneration) {
servers.value = res.data || [] servers.value = res.data || []
} }
@ -343,6 +405,14 @@ function closeModal() {
editing.value = null editing.value = null
} }
function openDetailModal(server: McpServer) {
detailServer.value = server
}
function closeDetailModal() {
detailServer.value = null
}
async function saveServer() { async function saveServer() {
try { try {
if (editing.value) { if (editing.value) {
@ -392,7 +462,6 @@ async function testConnection(server: McpServer) {
try { try {
const res: any = await mcpApi.test(server.id) const res: any = await mcpApi.test(server.id)
testResult.value = res.data testResult.value = res.data
// Auto-hide after 4s
setTimeout(() => { testResult.value = null }, 4000) setTimeout(() => { testResult.value = null }, 4000)
} catch (e: any) { } catch (e: any) {
testResult.value = { success: false, message: e?.message || 'Unknown error', toolCount: 0, latencyMs: 0, discoveredTools: [] } 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> </script>
<style scoped> <style scoped>
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); } .page-container {
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; } height: 100%;
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; } overflow-y: auto;
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; } padding: 0;
.header-actions { display: flex; gap: 8px; } 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%;
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:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; } .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:hover { background: var(--mc-bg-sunken); }
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; } .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; } .page-stage {
.tools-table { width: 100%; border-collapse: collapse; } background: linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 96%, white 4%) 0%, var(--mc-bg-elevated) 100%);
.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); } 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 { 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-sunken); }
.tool-row:last-child { border-bottom: none; } .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-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: 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-connected { color: var(--mc-primary); background: var(--mc-primary-bg); }
.status-icon-error { color: var(--mc-danger); background: var(--mc-danger-bg); } .status-icon-error { color: var(--mc-danger); background: var(--mc-danger-bg); }
.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-desc { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 1px; } .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-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-stdio { background: var(--mc-primary-bg); color: var(--mc-primary); }
.type-sse { background: var(--mc-primary-bg); color: var(--mc-primary-hover); } .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-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-connected { background: var(--mc-primary-bg); color: var(--mc-primary); }
.status-disconnected { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); } .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; } .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); } .tool-count { font-weight: 600; color: var(--mc-text-primary); }
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; } .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-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 { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); } .toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
.row-actions { display: flex; gap: 4px; } .row-actions { display: flex; gap: 6px; }
.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-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:hover { background: var(--mc-bg-sunken); }
.row-btn:disabled { opacity: 0.4; cursor: not-allowed; } .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); } .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-sub { font-size: 13px; margin: 0; }
.empty-state p { font-size: 14px; 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); } .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-success { background: var(--mc-primary); color: white; }
.toast-error { background: var(--mc-danger); 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-active, .toast-leave-active { transition: all 0.3s ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(20px); } .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-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 { 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; } .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 { 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-close:hover { background: var(--mc-bg-sunken); }
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; } .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-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; } .form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group.full-width { grid-column: 1 / -1; } .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); } .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); } .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); } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; } .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> </style>

View File

@ -1,12 +1,12 @@
<template> <template>
<div class="mc-page-shell security-shell"> <div class="mc-page-shell security-shell">
<div class="mc-page-frame"> <div class="mc-page-frame security-frame">
<div class="mc-page-inner settings-layout"> <div class="mc-page-inner security-layout">
<div class="settings-nav mc-surface-card"> <div class="settings-nav mc-surface-card">
<div class="settings-nav__intro"> <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> <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> </div>
<router-link <router-link
v-for="section in sections" v-for="section in sections"
@ -21,7 +21,9 @@
</div> </div>
<div class="settings-content mc-surface-card"> <div class="settings-content mc-surface-card">
<router-view /> <div class="settings-content__inner">
<router-view />
</div>
</div> </div>
</div> </div>
</div> </div>
@ -83,24 +85,36 @@ function isActive(path: string) {
<style scoped> <style scoped>
.security-shell { .security-shell {
background: transparent; 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; display: flex;
min-height: calc(100vh - 120px); height: 100%;
gap: 20px; min-height: 0;
gap: 18px;
} }
.settings-nav { .settings-nav {
width: 270px; width: 286px;
min-width: 270px; min-width: 286px;
padding: 18px 14px; padding: 18px 14px;
overflow-y: auto; overflow-y: auto;
align-self: flex-start; align-self: stretch;
} }
.settings-nav__intro { .settings-nav__intro {
padding: 6px 8px 16px; padding: 6px 8px 16px;
border-bottom: 1px solid var(--mc-border-light);
margin-bottom: 8px;
} }
.nav-title { .nav-title {
@ -141,8 +155,45 @@ function isActive(path: string) {
.settings-content { .settings-content {
flex: 1; flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 22px;
}
.settings-content__inner {
height: 100%;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 24px 32px; overflow-x: hidden;
min-height: 720px; 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> </style>

View File

@ -1,12 +1,12 @@
<template> <template>
<div class="mc-page-shell settings-shell"> <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="mc-page-inner settings-layout">
<div class="settings-nav mc-surface-card"> <div class="settings-nav mc-surface-card">
<div class="settings-nav__intro"> <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> <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> </div>
<template v-for="section in sections" :key="section.id"> <template v-for="section in sections" :key="section.id">
<div v-if="section.isDivider" class="nav-divider">{{ section.label }}</div> <div v-if="section.isDivider" class="nav-divider">{{ section.label }}</div>
@ -23,7 +23,9 @@
</div> </div>
<div class="settings-content mc-surface-card"> <div class="settings-content mc-surface-card">
<router-view /> <div class="settings-content__inner">
<router-view />
</div>
</div> </div>
</div> </div>
</div> </div>
@ -129,13 +131,40 @@ function isActive(path: string) {
<style scoped> <style scoped>
.settings-shell { .settings-shell {
background: transparent; 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-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 { 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: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); } .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 { 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-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; } .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) { @media (max-width: 900px) {
.settings-layout { flex-direction: column; } .settings-frame {
.settings-nav { width: 100%; min-width: 100%; } height: auto;
.settings-content { min-height: 0; } 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> </style>

View File

@ -1,46 +1,49 @@
<template> <template>
<div class="page-container"> <div class="mc-page-shell">
<div class="page-header"> <div class="mc-page-frame">
<div> <div class="mc-page-inner skills-page">
<h1 class="page-title">{{ t('skills.title') }}</h1> <div class="mc-page-header">
<p class="page-desc">{{ t('skills.desc') }}</p> <div>
</div> <div class="mc-page-kicker">Capabilities</div>
<div class="header-actions"> <h1 class="mc-page-title">{{ t('skills.title') }}</h1>
<button class="btn-secondary" @click="handleRefreshRuntime" :disabled="refreshing"> <p class="mc-page-desc">{{ t('skills.desc') }}</p>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> </div>
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/> <div class="header-actions">
<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"/> <button class="btn-secondary" @click="handleRefreshRuntime" :disabled="refreshing">
</svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{{ refreshing ? t('skills.refreshing') : t('skills.refreshRuntime') }} <polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
</button> <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"/>
<button class="btn-secondary" @click="showImportDialog = true"> </svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> {{ refreshing ? t('skills.refreshing') : t('skills.refreshRuntime') }}
<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"/> </button>
</svg> <button class="btn-secondary" @click="showImportDialog = true">
{{ t('skills.importSkill') }} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
</button> <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"/>
<button class="btn-primary" @click="openCreateModal"> </svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> {{ t('skills.importSkill') }}
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/> </button>
</svg> <button class="btn-primary" @click="openCreateModal">
{{ t('skills.newSkill') }} <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
</button> <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</div> </svg>
</div> {{ t('skills.newSkill') }}
</button>
</div>
</div>
<!-- 分类 Tab --> <!-- 分类 Tab -->
<div class="category-tabs"> <div class="category-tabs mc-surface-card">
<button v-for="tab in categoryTabs" :key="tab.value" class="cat-tab" <button v-for="tab in categoryTabs" :key="tab.value" class="cat-tab"
:class="{ active: activeCategory === tab.value }" @click="activeCategory = tab.value"> :class="{ active: activeCategory === tab.value }" @click="activeCategory = tab.value">
<span class="cat-icon">{{ tab.icon }}</span> <span class="cat-icon">{{ tab.icon }}</span>
{{ tab.label }} {{ tab.label }}
<span class="cat-count">{{ getCategoryCount(tab.value) }}</span> <span class="cat-count">{{ getCategoryCount(tab.value) }}</span>
</button> </button>
</div> </div>
<!-- 技能列表 --> <!-- 技能列表 -->
<div class="skill-grid" v-if="filteredSkills.length > 0"> <div class="skill-grid" v-if="filteredSkills.length > 0">
<div v-for="skill in filteredSkills" :key="skill.id" class="skill-card" <div v-for="skill in filteredSkills" :key="skill.id" class="skill-card mc-surface-card"
:class="{ disabled: !skill.enabled }"> :class="{ disabled: !skill.enabled }">
<div class="skill-header"> <div class="skill-header">
<div class="skill-icon-wrap" :class="getSkillIconBg(skill.skillType)"> <div class="skill-icon-wrap" :class="getSkillIconBg(skill.skillType)">
@ -110,13 +113,15 @@
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="empty-state"> <div v-else class="empty-state mc-surface-card">
<div class="empty-icon">🛠</div> <div class="empty-icon">🛠</div>
<h3>{{ t('skills.empty') }}</h3> <h3>{{ t('skills.empty') }}</h3>
<p>{{ t('skills.emptyDesc') }}</p> <p>{{ t('skills.emptyDesc') }}</p>
</div>
</div>
</div> </div>
<!-- Import Hub Dialog --> <!-- Import Hub Dialog -->
@ -504,21 +509,18 @@ function getSkillTypeLabel(type: string) {
</script> </script>
<style scoped> <style scoped>
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); } .skills-page { gap: 18px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; } .header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; } .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); }
.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; }
.btn-primary:hover { background: var(--mc-primary-hover); } .btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; } .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:hover { background: var(--mc-bg-sunken); }
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; } .btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
/* 分类 Tab */ /* 分类 Tab */
.category-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; } .category-tabs { display: flex; gap: 8px; flex-wrap: wrap; padding: 14px; }
.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; } .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: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-tab.active { background: var(--mc-primary-bg); border-color: var(--mc-primary); color: var(--mc-primary); font-weight: 500; }
.cat-icon { font-size: 14px; } .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); } .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-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
.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 { 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: 0 4px 12px rgba(217, 119, 87, 0.08); } .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-card.disabled { opacity: 0.6; }
.skill-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 10px; } .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-blue { background: var(--mc-primary-bg); }
.bg-purple { background: var(--mc-primary-bg); } .bg-purple { background: var(--mc-primary-bg); }
.bg-green { background: var(--mc-primary-bg); } .bg-green { background: var(--mc-primary-bg); }
.bg-gray { background: var(--mc-bg-sunken); } .bg-gray { background: var(--mc-bg-sunken); }
.skill-icon { font-size: 20px; } .skill-icon { font-size: 20px; }
.skill-meta { flex: 1; overflow: hidden; } .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-meta-row { display: flex; align-items: center; gap: 6px; }
.skill-type-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; } .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); } .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-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-author { font-size: 12px; color: var(--mc-text-tertiary); }
.skill-actions { display: flex; gap: 6px; } .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:hover { background: var(--mc-bg-sunken); }
.skill-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); } .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 { resize: vertical; font-family: inherit; }
.form-textarea.code { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 13px; background: var(--mc-bg-sunken); } .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); } .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> </style>

View File

@ -1,123 +1,121 @@
<template> <template>
<div class="page-container"> <div class="page-container">
<div class="page-header"> <div class="page-shell">
<div> <div class="page-header">
<h1 class="page-title">{{ t('tokenUsage.title') }}</h1> <div class="page-lead">
<p class="page-desc">{{ t('tokenUsage.desc') }}</p> <div class="page-kicker">{{ t('tokenUsage.kicker') }}</div>
</div> <h1 class="page-title">{{ t('tokenUsage.title') }}</h1>
<div class="header-actions"> <p class="page-desc">{{ t('tokenUsage.desc') }}</p>
<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> </div>
<button class="action-btn" :disabled="loading" @click="fetchData"> <div class="header-actions">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <div class="date-range">
<polyline points="23 4 23 10 17 10"/> <el-date-picker
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/> v-model="dateRange"
</svg> type="daterange"
{{ t('tokenUsage.refresh') }} range-separator=""
</button> :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>
</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> </div>
</template> </template>
@ -133,7 +131,6 @@ const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const data = ref<TokenUsageSummary | null>(null) const data = ref<TokenUsageSummary | null>(null)
// Default: last 30 days
const today = new Date() const today = new Date()
const thirtyDaysAgo = new Date(today) const thirtyDaysAgo = new Date(today)
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
@ -175,32 +172,72 @@ onMounted(fetchData)
.page-container { .page-container {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
padding: 24px; padding: 0;
background: var(--mc-bg); background: transparent;
}
.page-shell {
min-height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
padding: 0;
background: transparent;
} }
.page-header { .page-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap; 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 { .page-title {
font-size: 20px; font-size: clamp(28px, 4vw, 40px);
font-weight: 700; line-height: 0.95;
font-weight: 800;
color: var(--mc-text-primary); color: var(--mc-text-primary);
margin: 0 0 4px; margin: 0;
} }
.page-desc { .page-desc {
font-size: 14px; max-width: 620px;
font-size: 15px;
line-height: 1.55;
color: var(--mc-text-secondary); color: var(--mc-text-secondary);
margin: 0; margin: 0;
} }
.page-body {
display: flex;
flex-direction: column;
gap: 18px;
padding: 0 24px 24px;
}
.header-actions { .header-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@ -212,12 +249,13 @@ onMounted(fetchData)
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 8px 16px; padding: 10px 16px;
background: var(--mc-bg-elevated); background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border); border: 1px solid var(--mc-border);
border-radius: 8px; border-radius: 10px;
color: var(--mc-text-primary); color: var(--mc-text-primary);
font-size: 14px; font-size: 14px;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
white-space: nowrap; white-space: nowrap;
@ -232,67 +270,78 @@ onMounted(fetchData)
cursor: not-allowed; cursor: not-allowed;
} }
/* Summary cards */
.summary-cards { .summary-cards {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 16px; gap: 16px;
margin-bottom: 24px; margin-bottom: 20px;
} }
.summary-card { .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: 1px solid var(--mc-border);
border-radius: 12px; border-radius: 16px;
padding: 20px; 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 { .card-value {
font-size: 28px; font-size: clamp(28px, 4vw, 48px);
font-weight: 700; line-height: 1;
font-weight: 800;
color: var(--mc-text-primary); color: var(--mc-text-primary);
margin-bottom: 4px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.card-label {
font-size: 13px;
color: var(--mc-text-secondary);
}
/* Tables */
.table-section { .table-section {
margin-bottom: 24px; margin-bottom: 20px;
} }
.section-title { .section-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 700;
color: var(--mc-text-primary); color: var(--mc-text-primary);
margin: 0 0 12px; margin: 0 0 10px;
} }
.table-wrap { .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: 1px solid var(--mc-border);
border-radius: 12px; border-radius: 24px;
overflow: hidden; overflow-x: auto;
overflow-y: hidden;
box-shadow: 0 20px 48px rgba(128, 84, 60, 0.08);
} }
.data-table { .data-table {
width: 100%; width: 100%;
min-width: 0;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed;
} }
.data-table th { .data-table th {
position: sticky;
top: 0;
z-index: 1;
padding: 12px 16px; padding: 12px 16px;
text-align: left; text-align: left;
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 700;
color: var(--mc-text-secondary); color: var(--mc-text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.08em;
background: var(--mc-bg-sunken); background: color-mix(in srgb, var(--mc-bg-sunken) 86%, white 14%);
border-bottom: 1px solid var(--mc-border); border-bottom: 1px solid var(--mc-border);
} }
@ -321,7 +370,19 @@ onMounted(fetchData)
font-size: 13px; 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 { .loading-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -363,18 +424,33 @@ onMounted(fetchData)
margin: 0; margin: 0;
} }
/* Date picker overrides */
.date-range :deep(.el-date-editor) { .date-range :deep(.el-date-editor) {
--el-date-editor-width: 260px; --el-date-editor-width: 260px;
} }
/* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.summary-cards { .summary-cards {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.page-header { .page-header {
flex-direction: column; 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> </style>

View File

@ -1,21 +1,24 @@
<template> <template>
<div class="page-container"> <div class="mc-page-shell">
<div class="page-header"> <div class="mc-page-frame">
<div> <div class="mc-page-inner tools-page">
<h1 class="page-title">{{ t('tools.title') }}</h1> <div class="mc-page-header">
<p class="page-desc">{{ t('tools.desc') }}</p> <div>
</div> <div class="mc-page-kicker">Execution Surface</div>
<button class="btn-primary" @click="openCreateModal"> <h1 class="mc-page-title">{{ t('tools.title') }}</h1>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <p class="mc-page-desc">{{ t('tools.desc') }}</p>
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/> </div>
</svg> <button class="btn-primary" @click="openCreateModal">
{{ t('tools.registerButton') }} <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
</button> <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</div> </svg>
{{ t('tools.registerButton') }}
</button>
</div>
<!-- 工具列表 --> <!-- 工具列表 -->
<div class="tools-table-wrap"> <div class="tools-table-wrap mc-surface-card">
<table class="tools-table"> <table class="tools-table">
<thead> <thead>
<tr> <tr>
<th>{{ t('tools.columns.tool') }}</th> <th>{{ t('tools.columns.tool') }}</th>
@ -76,7 +79,9 @@
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
</div>
</div> </div>
<!-- Modal --> <!-- Modal -->
@ -198,24 +203,21 @@ async function toggleTool(tool: Tool) {
</script> </script>
<style scoped> <style scoped>
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); } .tools-page { gap: 18px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; } .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); }
.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; }
.btn-primary:hover { background: var(--mc-primary-hover); } .btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; } .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); } .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 { 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 { 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; } .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: 14px 16px; font-size: 14px; color: var(--mc-text-primary); }
.tool-info { display: flex; align-items: center; gap: 10px; } .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-name { font-weight: 500; color: var(--mc-text-primary); }
.tool-desc { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 1px; } .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); } .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 { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); } .toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
.row-actions { display: flex; gap: 4px; } .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:hover { background: var(--mc-bg-sunken); }
.row-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); } .row-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
.empty-row { padding: 40px !important; } .empty-row { padding: 40px !important; }

View File

@ -62,7 +62,7 @@
</div> </div>
<div class="raw-item-meta"> <div class="raw-item-meta">
<span class="status-badge" :class="raw.processingStatus"> <span class="status-badge" :class="raw.processingStatus">
{{ raw.processingStatus }} {{ t(`wiki.status.${raw.processingStatus}`) }}
</span> </span>
<span v-if="raw.errorMessage" class="error-hint" :title="raw.errorMessage"> <span v-if="raw.errorMessage" class="error-hint" :title="raw.errorMessage">
{{ raw.errorMessage }} {{ raw.errorMessage }}
@ -203,28 +203,34 @@ async function handleScanDir() {
</script> </script>
<style scoped> <style scoped>
.raw-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
/* Buttons */ /* 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:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; } .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); } .btn-secondary:hover { background: var(--mc-bg-sunken); }
/* Directory scan */ /* Directory scan */
.dir-scan-row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; } .dir-scan-row { display: flex; gap: 10px; align-items: center; }
.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-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-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 { flex: 1; border: none; background: transparent; font-size: 13px; color: var(--mc-text-primary); outline: none; }
.dir-input::placeholder { color: var(--mc-text-tertiary); } .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: 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 { .upload-zone {
flex: 1; flex: 1;
border: 1px dashed var(--mc-border); border: 1px dashed var(--mc-border);
border-radius: 12px; border-radius: 16px;
padding: 20px 24px; padding: 18px 20px;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s, background 0.15s; transition: border-color 0.15s, background 0.15s;
display: flex; display: flex;
@ -240,12 +246,12 @@ async function handleScanDir() {
.add-text-btn { flex-shrink: 0; } .add-text-btn { flex-shrink: 0; }
/* Raw list */ /* 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; } .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); } .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 { 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); } .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-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; } .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; } .error-hint { font-size: 11px; color: var(--mc-danger); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Icon button */ /* 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: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); } .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); } .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; } .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> </style>

View File

@ -15,7 +15,7 @@
<div class="config-actions"> <div class="config-actions">
<button class="btn-secondary" @click="loadConfig">{{ t('common.reset') }}</button> <button class="btn-secondary" @click="loadConfig">{{ t('common.reset') }}</button>
<button class="btn-primary" @click="saveConfig" :disabled="saving"> <button class="btn-primary" @click="saveConfig" :disabled="saving">
{{ saving ? 'Saving...' : t('common.save') }} {{ saving ? t('wiki.saving') : t('common.save') }}
</button> </button>
</div> </div>
</div> </div>
@ -61,22 +61,48 @@ watch(() => store.currentKB, () => {
</script> </script>
<style scoped> <style scoped>
.wiki-config {
display: flex;
flex-direction: column;
gap: 14px;
height: 100%;
min-height: 0;
}
/* Header */ /* Header */
.config-header { margin-bottom: 16px; } .config-header { padding-bottom: 10px; border-bottom: 1px solid var(--mc-border-light); }
.config-title { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin: 0 0 4px; } .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; } .config-desc { font-size: 13px; color: var(--mc-text-tertiary); margin: 0; line-height: 1.6; }
/* Editor */ /* 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); } .config-editor:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
/* Actions */ /* 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 */ /* 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:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; } .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); } .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> </style>

View File

@ -1,12 +1,13 @@
<template> <template>
<div class="page-viewer" v-if="store.currentPage"> <div class="page-viewer" v-if="store.currentPage">
<div class="page-viewer-header"> <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> <h2 class="page-viewer-title">{{ store.currentPage.title }}</h2>
<div class="page-viewer-meta"> <div class="page-viewer-meta">
<span>v{{ store.currentPage.version }}</span> <span>v{{ store.currentPage.version }}</span>
<span>&middot;</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>&middot;</span>
<span>{{ store.currentPage.slug }}</span> <span>{{ store.currentPage.slug }}</span>
</div> </div>
@ -131,41 +132,74 @@ onMounted(() => {
<style scoped> <style scoped>
/* Buttons */ /* 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:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; } .btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-primary.btn-sm { padding: 6px 14px; font-size: 13px; } .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:hover { background: var(--mc-bg-sunken); }
.btn-secondary.btn-sm { padding: 6px 14px; font-size: 13px; } .btn-secondary.btn-sm { padding: 6px 14px; font-size: 13px; }
/* Header */ /* Header */
.page-viewer-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; } .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-title { font-size: 24px; font-weight: 600; color: var(--mc-text-primary); } .page-viewer-copy { min-width: 0; }
.page-viewer-meta { font-size: 12px; color: var(--mc-text-secondary); display: flex; gap: 8px; margin-top: 4px; } .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; } .page-viewer-actions { display: flex; gap: 8px; }
/* Summary */ /* 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 */ /* Content */
.page-content { font-size: 15px; line-height: 1.75; 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(h1) { font-size: 24px; font-weight: 600; margin: 24px 0 12px; color: var(--mc-text-primary); } .page-content :deep(p) { margin: 0 0 1.05em; }
.page-content :deep(h2) { font-size: 20px; font-weight: 600; margin: 20px 0 8px; color: var(--mc-text-primary); } .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(h3) { font-size: 18px; font-weight: 600; margin: 16px 0 8px; color: var(--mc-text-primary); } .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(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) { 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; } .page-content :deep(.wiki-link:hover) { text-decoration: underline; }
/* Editor */ /* 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); } .page-editor:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
/* Backlinks */ /* 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-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; } .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); } .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> </style>

View File

@ -1,10 +1,10 @@
<template> <template>
<div class="mc-page-shell wiki-shell"> <div class="mc-page-shell wiki-shell">
<div class="mc-page-frame"> <div class="mc-page-frame wiki-frame">
<div class="mc-page-inner"> <div class="mc-page-inner wiki-inner">
<div class="mc-page-header"> <div class="mc-page-header">
<div> <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> <h1 class="mc-page-title">{{ t('nav.wiki') }}</h1>
<p class="mc-page-desc">{{ t('wiki.desc') }}</p> <p class="mc-page-desc">{{ t('wiki.desc') }}</p>
</div> </div>
@ -21,7 +21,7 @@
<div class="wiki-sidebar mc-surface-card"> <div class="wiki-sidebar mc-surface-card">
<div class="sidebar-section"> <div class="sidebar-section">
<h3 class="sidebar-title">{{ t('wiki.knowledgeBases') }}</h3> <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"> <div v-else-if="store.knowledgeBases.length === 0" class="text-center py-4 text-gray-400">
{{ t('wiki.noKB') }} {{ t('wiki.noKB') }}
</div> </div>
@ -33,10 +33,10 @@
> >
<div class="kb-item-name">{{ kb.name }}</div> <div class="kb-item-name">{{ kb.name }}</div>
<div class="kb-item-meta"> <div class="kb-item-meta">
<span>{{ kb.pageCount }} pages</span> <span>{{ t('wiki.pageCount', { count: kb.pageCount }) }}</span>
<span>{{ kb.rawCount }} sources</span> <span>{{ t('wiki.sourceCount', { count: kb.rawCount }) }}</span>
</div> </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> </div>
</div> </div>
@ -77,7 +77,7 @@
</div> </div>
<!-- Tab navigation --> <!-- Tab navigation -->
<div v-else> <div v-else class="wiki-content-body">
<div class="content-tabs"> <div class="content-tabs">
<button <button
v-for="tab in tabs" :key="tab.key" v-for="tab in tabs" :key="tab.key"
@ -102,7 +102,7 @@
</div> </div>
<!-- Config Tab --> <!-- Config Tab -->
<div v-if="activeTab === 'config'" class="tab-content"> <div v-if="activeTab === 'config'" class="tab-content tab-content--config">
<WikiConfig /> <WikiConfig />
</div> </div>
</div> </div>
@ -189,6 +189,22 @@ onMounted(() => {
<style scoped> <style scoped>
.wiki-shell { .wiki-shell {
background: transparent; 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); } .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); } .btn-secondary:hover { background: var(--mc-bg-sunken); }
/* Layout */ /* 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-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); } .sidebar-search:focus { border-color: var(--mc-primary); }
.kb-list, .page-list { display: flex; flex-direction: column; gap: 4px; } .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: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); } .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); } .kb-status.error { background: var(--mc-danger-bg); color: var(--mc-danger); }
/* Content area */ /* 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); } .wiki-content-body {
.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; } 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: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-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 */
.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-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; } .modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
@media (max-width: 980px) { @media (max-width: 980px) {
.wiki-frame {
height: 100%;
min-height: calc(100vh - 28px);
}
.wiki-layout { .wiki-layout {
flex-direction: column; flex-direction: column;
overflow: visible;
} }
.wiki-sidebar { .wiki-sidebar {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
max-height: 280px;
}
.wiki-content {
overflow: visible;
}
.tab-content {
overflow: visible;
}
.tab-content--config {
overflow: visible;
} }
} }
</style> </style>

View File

@ -66,36 +66,36 @@
<span class="health-label">{{ t('doctor.title') }}</span> <span class="health-label">{{ t('doctor.title') }}</span>
</button> </button>
<div class="sidebar-utility-section"> <div class="sidebar-utility-card">
<div class="utility-label">{{ t('nav.themeLabel') }}</div> <div class="compact-utility-row">
<div class="theme-toggle-row"> <span class="compact-utility-title">{{ t('nav.themeLabel') }}</span>
<button <div class="theme-toggle-row theme-toggle-row--compact">
v-for="opt in themeOptions" <button
:key="opt.value" v-for="opt in themeOptions"
class="theme-btn" :key="opt.value"
:class="{ active: themeStore.mode === opt.value }" class="theme-btn theme-btn--compact"
:title="opt.label" :class="{ active: themeStore.mode === opt.value }"
@click="themeStore.setMode(opt.value)" :title="opt.label"
> @click="themeStore.setMode(opt.value)"
<span v-html="opt.icon"></span> >
<span class="theme-btn-label">{{ opt.label }}</span> <span v-html="opt.icon"></span>
</button> </button>
</div>
</div> </div>
</div>
<div class="sidebar-utility-section"> <div class="compact-utility-row">
<div class="utility-label">{{ t('nav.languageLabel') }}</div> <span class="compact-utility-title">{{ t('nav.languageLabel') }}</span>
<div class="language-toggle-row"> <div class="language-toggle-row language-toggle-row--compact">
<button <button
v-for="opt in localeOptions" v-for="opt in localeOptions"
:key="opt.value" :key="opt.value"
class="language-btn" class="language-btn language-btn--compact"
:class="{ active: currentLocaleValue === opt.value }" :class="{ active: currentLocaleValue === opt.value }"
@click="changeLocale(opt.value)" @click="changeLocale(opt.value)"
> >
<span class="language-abbr">{{ opt.short }}</span> <span class="language-abbr">{{ opt.short }}</span>
<span class="language-label">{{ opt.label }}</span> </button>
</button> </div>
</div> </div>
</div> </div>
@ -486,24 +486,24 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
.sidebar-logo { .sidebar-logo {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 18px 16px 14px; padding: 14px 14px 12px;
border-bottom: 1px solid var(--mc-border-light); border-bottom: 1px solid var(--mc-border-light);
gap: 12px; gap: 12px;
min-height: 72px; min-height: 64px;
} }
.sidebar.collapsed .sidebar-logo { .sidebar.collapsed .sidebar-logo {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 14px 10px; padding: 12px 10px;
gap: 8px; gap: 6px;
min-height: 110px; min-height: 92px;
} }
.logo-icon { .logo-icon {
width: 42px; width: 40px;
height: 42px; height: 40px;
border-radius: 14px; border-radius: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -514,8 +514,8 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
} }
.logo-img { .logo-img {
width: 36px; width: 34px;
height: 36px; height: 34px;
object-fit: contain; object-fit: contain;
filter: drop-shadow(0 8px 18px rgba(217, 109, 70, 0.22)); filter: drop-shadow(0 8px 18px rgba(217, 109, 70, 0.22));
} }
@ -526,7 +526,7 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
.logo-name { .logo-name {
display: block; display: block;
font-size: 17px; font-size: 16px;
font-weight: 800; font-weight: 800;
color: var(--mc-sidebar-logo-name); color: var(--mc-sidebar-logo-name);
white-space: nowrap; white-space: nowrap;
@ -539,15 +539,15 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
.logo-version { .logo-version {
display: block; display: block;
font-size: 11px; font-size: 10px;
color: var(--mc-text-tertiary); color: var(--mc-text-tertiary);
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
} }
.collapse-btn { .collapse-btn {
width: 30px; width: 28px;
height: 30px; height: 28px;
border: 1px solid var(--mc-border-light); border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted); background: var(--mc-bg-muted);
cursor: pointer; cursor: pointer;
@ -584,17 +584,19 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
.sidebar-nav { .sidebar-nav {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 12px 0 8px; padding: 8px 0 4px;
scrollbar-width: none;
} }
.sidebar-nav::-webkit-scrollbar { width: 4px; } .sidebar-nav::-webkit-scrollbar { width: 4px; }
.sidebar-nav::-webkit-scrollbar-thumb { background: var(--mc-border); border-radius: 2px; } .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 { margin-bottom: 2px; }
.nav-group-title { .nav-group-title {
padding: 10px 18px 6px; padding: 8px 18px 4px;
font-size: 11px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: var(--mc-sidebar-group-title); color: var(--mc-sidebar-group-title);
text-transform: uppercase; text-transform: uppercase;
@ -606,11 +608,11 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 11px 12px; padding: 10px 12px;
color: var(--mc-sidebar-text); color: var(--mc-sidebar-text);
text-decoration: none; text-decoration: none;
font-size: 14px; font-size: 13px;
border-radius: 14px; border-radius: 13px;
margin: 2px 10px; margin: 2px 10px;
transition: all 0.15s ease; transition: all 0.15s ease;
white-space: nowrap; white-space: nowrap;
@ -647,19 +649,46 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
/* 底部 */ /* 底部 */
.sidebar-footer { .sidebar-footer {
border-top: 1px solid var(--mc-border-light); border-top: 1px solid var(--mc-border-light);
padding: 14px 14px 16px; padding: 10px 12px 12px;
background: var(--mc-sidebar-footer-bg); background: var(--mc-sidebar-footer-bg);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
position: relative; 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-indicator:hover { background: var(--mc-bg-sunken); }
.health-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .health-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.health-indicator.healthy .health-dot { background: var(--mc-success); } .health-indicator.healthy .health-dot { background: var(--mc-success); }
.health-indicator.warning .health-dot { background: var(--mc-primary); } .health-indicator.warning .health-dot { background: var(--mc-primary); }
.health-indicator.error .health-dot { background: var(--mc-danger); } .health-indicator.error .health-dot { background: var(--mc-danger); }
.health-indicator.unknown .health-dot { background: var(--mc-text-tertiary); } .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; } .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); border: 1px solid var(--mc-border-light);
} }
.theme-toggle-row--compact {
margin-bottom: 0;
padding: 2px;
gap: 3px;
border-radius: 999px;
}
.language-toggle-row { .language-toggle-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 6px; gap: 6px;
} }
.language-toggle-row--compact {
display: flex;
gap: 6px;
}
.theme-btn { .theme-btn {
flex: 1; flex: 1;
display: inline-flex; display: inline-flex;
@ -696,6 +737,14 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
white-space: nowrap; white-space: nowrap;
} }
.theme-btn--compact {
width: 28px;
height: 28px;
padding: 0;
border-radius: 999px;
flex: 0 0 auto;
}
.theme-btn:hover { .theme-btn:hover {
color: var(--mc-text-secondary); color: var(--mc-text-secondary);
} }
@ -759,19 +808,33 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
white-space: nowrap; 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 { .user-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 10px; padding: 8px 9px;
border-radius: 16px; border-radius: 14px;
background: var(--mc-bg-muted); background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light); border: 1px solid var(--mc-border-light);
} }
.user-avatar { .user-avatar {
width: 32px; width: 30px;
height: 32px; height: 30px;
background: linear-gradient(135deg, var(--mc-primary), var(--mc-accent)); background: linear-gradient(135deg, var(--mc-primary), var(--mc-accent));
border-radius: 12px; border-radius: 12px;
display: flex; display: flex;
@ -786,7 +849,7 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
.user-detail { flex: 1; overflow: hidden; } .user-detail { flex: 1; overflow: hidden; }
.user-name { .user-name {
font-size: 13px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--mc-text-primary); color: var(--mc-text-primary);
white-space: nowrap; white-space: nowrap;
@ -794,11 +857,11 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
text-overflow: ellipsis; 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 { .logout-btn {
width: 28px; width: 26px;
height: 28px; height: 26px;
border: none; border: none;
background: none; background: none;
cursor: pointer; cursor: pointer;
@ -1041,6 +1104,27 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
display: none; 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 { .sidebar-footer {
background: transparent; background: transparent;
backdrop-filter: none; backdrop-filter: none;