style: use united style

This commit is contained in:
2026-01-03 16:09:22 +08:00
parent 955b09436e
commit c98aa73364
20 changed files with 2099 additions and 1319 deletions
+90 -107
View File
@@ -325,191 +325,174 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/* === Material Design 3 样式重写 === */
.crontab-editor {
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 16px;
background: #f5f7fa;
transition: all 0.3s;
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: 12px;
padding: 20px;
background-color: var(--md-sys-color-surface-container-lowest);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.dark .crontab-editor {
border-color: #3a3a3c;
background: #2c2c2e;
.crontab-editor:focus-within {
border-color: var(--md-sys-color-primary);
box-shadow: 0 0 0 1px var(--md-sys-color-primary);
}
/* 模式选择标签 */
.mode-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
border-bottom: 2px solid #ebeef5;
transition: border-color 0.3s;
}
.dark .mode-tabs {
border-bottom-color: #3a3a3c;
margin-bottom: 20px;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
padding-bottom: 0;
}
.mode-tab {
padding: 8px 16px;
padding: 10px 20px;
background: none;
border: none;
cursor: pointer;
color: #909399;
color: var(--md-sys-color-on-surface-variant);
font-size: 14px;
font-weight: 500;
letter-spacing: 0.1px;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.3s;
margin-bottom: -1px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.dark .mode-tab {
color: #a0a0a3;
.mode-tab:hover {
color: var(--md-sys-color-on-surface);
background-color: rgba(76, 175, 80, 0.04);
}
.mode-tab.active {
color: #409eff;
border-bottom-color: #409eff;
}
.dark .mode-tab.active {
color: #81c784;
border-bottom-color: #81c784;
color: var(--md-sys-color-primary);
border-bottom-color: var(--md-sys-color-primary);
font-weight: 600;
}
/* 模式内容区域 */
.mode-content {
margin: 16px 0;
margin: 20px 0;
}
/* 快速选项 */
.quick-option {
padding: 12px;
background: white;
border-radius: 4px;
transition: background 0.3s;
padding: 16px;
background-color: var(--md-sys-color-surface);
border-radius: 12px;
border: 1px solid var(--md-sys-color-outline-variant);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.dark .quick-option {
background: #1c1c1e;
.quick-option:hover {
border-color: var(--md-sys-color-outline);
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.3),
0px 1px 3px 1px rgba(0, 0, 0, 0.15);
}
.option-label {
font-weight: 600;
color: #1c1b1f;
}
.dark .option-label {
color: #e5e5e7;
font-size: 14px;
font-weight: 500;
color: var(--md-sys-color-on-surface);
letter-spacing: 0.1px;
}
.option-desc {
margin-left: 12px;
color: #909399;
color: var(--md-sys-color-on-surface-variant);
font-size: 12px;
letter-spacing: 0.4px;
}
.dark .option-desc {
color: #a0a0a3;
}
/* 表达式输入 */
.expression-input {
margin: 12px 0;
margin: 16px 0;
}
.help-text {
margin-top: 8px;
color: #909399;
color: var(--md-sys-color-on-surface-variant);
font-size: 12px;
}
.dark .help-text {
color: #a0a0a3;
line-height: 16px;
letter-spacing: 0.4px;
}
.help-text a {
color: #409eff;
color: var(--md-sys-color-secondary);
text-decoration: none;
}
.dark .help-text a {
color: #81c784;
font-weight: 500;
transition: color 0.2s;
}
.help-text a:hover {
color: var(--md-sys-color-primary);
text-decoration: underline;
}
/* 预览区域 */
.preview-section {
margin: 16px 0;
padding: 12px;
background: white;
border-radius: 4px;
transition: background 0.3s;
}
.dark .preview-section {
background: #1c1c1e;
padding: 16px;
background-color: var(--md-sys-color-surface-container-low);
border-radius: 12px;
border: 1px solid var(--md-sys-color-outline-variant);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.preview-section h4 {
margin: 0 0 8px 0;
margin: 0 0 12px 0;
font-size: 14px;
color: #1c1b1f;
}
.dark .preview-section h4 {
color: #e5e5e7;
font-weight: 500;
color: var(--md-sys-color-on-surface);
letter-spacing: 0.1px;
}
.execution-list {
margin: 0;
padding-left: 20px;
font-size: 12px;
color: #606266;
padding-left: 24px;
font-size: 13px;
line-height: 20px;
color: var(--md-sys-color-on-surface-variant);
}
.dark .execution-list {
color: #a0a0a3;
.execution-list li {
margin-bottom: 4px;
}
/* 验证消息 */
.validation-message {
padding: 8px 12px;
border-radius: 4px;
margin-top: 12px;
font-size: 12px;
padding: 12px 16px;
border-radius: 12px;
margin-top: 16px;
font-size: 13px;
line-height: 20px;
letter-spacing: 0.25px;
border: 1px solid;
display: flex;
align-items: center;
gap: 8px;
}
.validation-message.success {
background: #f0f9ff;
color: #67c23a;
border: 1px solid #c6e2ff;
}
.dark .validation-message.success {
background: rgba(129, 199, 132, 0.1);
color: #81c784;
border-color: rgba(129, 199, 132, 0.3);
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
border-color: var(--md-sys-color-primary);
}
.validation-message.error {
background: #fef0f0;
color: #f56c6c;
border: 1px solid #fde7e7;
}
.dark .validation-message.error {
background: rgba(244, 67, 54, 0.1);
color: #ef5350;
border-color: rgba(244, 67, 54, 0.3);
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-on-error-container);
border-color: var(--md-sys-color-error);
}
.validation-message.info {
background: #f4f4f5;
color: #909399;
border: 1px solid #ebeef5;
}
.dark .validation-message.info {
background: #2c2c2e;
color: #a0a0a3;
border-color: #3a3a3c;
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface-variant);
border-color: var(--md-sys-color-outline-variant);
}
</style>
+19 -19
View File
@@ -9,7 +9,7 @@
placeholder="在表单中显示的名称"
allow-clear
/>
<span class="text-xs text-gray-500 mt-1">显示名称</span>
<span class="text-xs text-on-surface-variant mt-1">显示名称</span>
</a-form-item>
<a-form-item label="字段类型" class="mb-0">
@@ -24,7 +24,7 @@
<a-select-option label="🔢 数字输入" value="number" />
<a-select-option label="📋 下拉选择" value="select" />
</a-select>
<span class="text-xs text-gray-500 mt-1">用户填写时使用的输入控件</span>
<span class="text-xs text-on-surface-variant mt-1">用户填写时使用的输入控件</span>
</a-form-item>
</div>
@@ -38,22 +38,22 @@
class="w-full"
>
<a-select-option label="字符串 (string)" value="string">
<span class="text-xs text-gray-500">字符串 (string)</span>
<span class="text-xs text-on-surface-variant">字符串 (string)</span>
</a-select-option>
<a-select-option label="整数 (int)" value="int">
<span class="text-xs text-gray-500">整数 (int)</span>
<span class="text-xs text-on-surface-variant">整数 (int)</span>
</a-select-option>
<a-select-option label="浮点数 (double)" value="double">
<span class="text-xs text-gray-500">浮点数 (double)</span>
<span class="text-xs text-on-surface-variant">浮点数 (double)</span>
</a-select-option>
<a-select-option label="布尔值 (bool)" value="bool">
<span class="text-xs text-gray-500">布尔值 (bool)</span>
<span class="text-xs text-on-surface-variant">布尔值 (bool)</span>
</a-select-option>
<a-select-option label="JSON对象 (json)" value="json">
<span class="text-xs text-gray-500">JSON对象 (json) - 用于Values字段</span>
<span class="text-xs text-on-surface-variant">JSON对象 (json) - 用于Values字段</span>
</a-select-option>
</a-select>
<span class="text-xs text-gray-500 mt-1">数据存储时的类型</span>
<span class="text-xs text-on-surface-variant mt-1">数据存储时的类型</span>
</a-form-item>
<a-form-item label="默认值" class="mb-0">
@@ -72,7 +72,7 @@
:rows="3"
allow-clear
/>
<span class="text-xs text-gray-500 mt-1">
<span class="text-xs text-on-surface-variant mt-1">
<template v-if="modelValue.value_type === 'json'">
<p>输入JSON对象,会自动序列化为字符串</p>
<p>:{"key1":value1,"key2":value2}</p>
@@ -92,15 +92,15 @@
placeholder="输入框的灰色提示文本"
allow-clear
/>
<span class="text-xs text-gray-500 mt-1">占位符</span>
<span class="text-xs text-on-surface-variant mt-1">占位符</span>
</a-form-item>
<!-- Row 4: Switches -->
<div class="grid grid-cols-2 gap-4 p-3 bg-blue-50 rounded-lg">
<div class="grid grid-cols-2 gap-4 p-3 bg-surface-container-low rounded-md3 border border-outline-variant">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700">是否必填</label>
<p class="text-xs text-gray-500">用户必须填写此字段</p>
<label class="text-sm font-medium text-on-surface">是否必填</label>
<p class="text-xs text-on-surface-variant">用户必须填写此字段</p>
</div>
<a-switch
:checked="modelValue.required"
@@ -111,8 +111,8 @@
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700">是否隐藏</label>
<p class="text-xs text-gray-500">直接使用默认值不在表单中显示</p>
<label class="text-sm font-medium text-on-surface">是否隐藏</label>
<p class="text-xs text-on-surface-variant">直接使用默认值不在表单中显示</p>
</div>
<a-switch
:checked="modelValue.hidden"
@@ -142,9 +142,9 @@
<div
v-for="(option, index) in modelValue.options || []"
:key="index"
class="flex items-center gap-2 p-2 bg-gray-50 rounded"
class="flex items-center gap-2 p-2 bg-surface-container rounded-md3"
>
<span class="text-xs text-gray-500 w-8">{{ index + 1 }}.</span>
<span class="text-xs text-on-surface-variant w-8">{{ index + 1 }}.</span>
<a-input
:value="option.label"
@change="e => updateOption(index, 'label', e.target.value)"
@@ -175,8 +175,8 @@
添加选项
</a-button>
<p class="text-xs text-gray-500 mt-2">
💡 提示显示文本是用户看到的内容选项值是实际保存的数据
<p class="text-xs text-on-surface-variant mt-2">
💡 提示显示文本是用户看到的内容,选项值是实际保存的数据
</p>
</div>
</a-form-item>
+25 -25
View File
@@ -1,16 +1,16 @@
<template>
<div class="field-tree-node border-2 rounded-lg p-4 bg-white shadow-sm hover:shadow-md transition-shadow">
<div class="field-tree-node border border-outline-variant rounded-md3 p-4 bg-surface shadow-md3-1 hover:shadow-md3-2 transition-shadow">
<!-- 普通字段 -->
<div v-if="isFieldConfig" class="field-config">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-gray-200">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-outline-variant">
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-gray-100 rounded p-1 transition-colors"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
>
<svg
class="w-4 h-4 text-gray-600 transition-transform"
class="w-4 h-4 text-on-surface-variant transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
fill="none"
stroke="currentColor"
@@ -19,10 +19,10 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span class="font-mono text-base font-bold text-blue-700">{{ fieldKey }}</span>
<span class="font-mono text-base font-bold text-primary">{{ fieldKey }}</span>
<a-tag type="primary" size="small">普通字段</a-tag>
</div>
<div class="flex gap-2">
@@ -45,22 +45,22 @@
</div>
</div>
<div v-show="!isCollapsed" class="bg-gray-50 rounded-lg p-3">
<div v-show="!isCollapsed" class="bg-surface-container-low rounded-md3 p-3">
<FieldConfigEditor v-model="localFieldConfig" :field-key="fieldKey" />
</div>
</div>
<!-- 数组字段 -->
<div v-else-if="isArray" class="array-field">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-purple-200">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-outline-variant">
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-gray-100 rounded p-1 transition-colors"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
>
<svg
class="w-4 h-4 text-gray-600 transition-transform"
class="w-4 h-4 text-on-surface-variant transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
fill="none"
stroke="currentColor"
@@ -69,10 +69,10 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<span class="font-mono text-base font-bold text-purple-700">{{ fieldKey }}</span>
<span class="font-mono text-base font-bold text-secondary">{{ fieldKey }}</span>
<a-tag type="warning" size="small">数组字段</a-tag>
</div>
<div class="flex gap-2">
@@ -102,8 +102,8 @@
</div>
<div v-show="!isCollapsed">
<div v-if="localFieldConfig.length === 0" class="text-center py-6 bg-purple-50 rounded-lg border border-dashed border-purple-300">
<p class="text-sm text-gray-500 mb-2">数组为空</p>
<div v-if="localFieldConfig.length === 0" class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline">
<p class="text-sm text-on-surface-variant mb-2">数组为空</p>
<a-button size="small" type="primary" @click="addArrayItem">添加第一个元素</a-button>
</div>
@@ -111,17 +111,17 @@
<div
v-for="(item, index) in localFieldConfig"
:key="index"
class="border-2 border-purple-200 rounded-lg p-3 bg-purple-50"
class="border border-outline-variant rounded-md3 p-3 bg-surface-container"
>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-purple-700">元素 #{{ index + 1 }}</span>
<span class="text-sm font-semibold text-secondary">元素 #{{ index + 1 }}</span>
<a-button size="small" type="danger" plain @click="removeArrayItem(index)">
删除元素
</a-button>
</div>
<!-- 如果数组元素是字段配置对象直接渲染为字段编辑器 -->
<div v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item" class="bg-white rounded-lg p-3">
<div v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item" class="bg-surface rounded-md3 p-3">
<FieldConfigEditor :model-value="item" @update:model-value="updateArrayItemField(index, $event)" :field-key="`元素${index + 1}`" />
</div>
@@ -164,15 +164,15 @@
<!-- 对象字段 -->
<div v-else-if="isObject" class="object-field">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-green-200">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-outline-variant">
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-gray-100 rounded p-1 transition-colors"
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
>
<svg
class="w-4 h-4 text-gray-600 transition-transform"
class="w-4 h-4 text-on-surface-variant transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
fill="none"
stroke="currentColor"
@@ -181,10 +181,10 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="font-mono text-base font-bold text-green-700">{{ fieldKey }}</span>
<span class="font-mono text-base font-bold text-accent">{{ fieldKey }}</span>
<a-tag type="success" size="small">对象字段</a-tag>
</div>
<div class="flex gap-2">
@@ -214,12 +214,12 @@
</div>
<div v-show="!isCollapsed">
<div v-if="Object.keys(localFieldConfig).length === 0" class="text-center py-6 bg-green-50 rounded-lg border border-dashed border-green-300">
<p class="text-sm text-gray-500 mb-2">对象为空</p>
<div v-if="Object.keys(localFieldConfig).length === 0" class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline">
<p class="text-sm text-on-surface-variant mb-2">对象为空</p>
<a-button size="small" type="primary" @click="addFieldToObject">添加第一个子字段</a-button>
</div>
<div v-else class="space-y-3 mt-3 pl-4 border-l-4 border-green-300">
<div v-else class="space-y-3 mt-3 pl-4 border-l-4 border-accent">
<!-- 递归渲染对象中的字段 -->
<FieldTreeNode
v-for="(subConfig, subKey) in localFieldConfig"
+1 -5
View File
@@ -26,11 +26,7 @@ onMounted(() => {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%);
}
.dark .layout-container {
background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%);
background: linear-gradient(135deg, var(--md-sys-color-surface-container-lowest) 0%, var(--md-sys-color-surface-container-low) 100%);
}
.main-content {
+31 -31
View File
@@ -1,5 +1,5 @@
<template>
<div class="sticky top-0 z-50 glass-effect border-b border-gray-200/50 dark:border-gray-700/50 shadow-md3-2">
<div class="sticky top-0 z-50 md3-surface elevation-2 border-b border-outline-variant">
<nav class="max-w-7xl mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<!-- Logo and Brand -->
@@ -24,7 +24,7 @@
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
: 'text-on-surface hover:bg-surface-container'
]"
>
<div class="flex items-center space-x-2">
@@ -45,7 +45,7 @@
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
: 'text-on-surface hover:bg-surface-container'
]"
>
<div class="flex items-center space-x-2">
@@ -66,7 +66,7 @@
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
: 'text-on-surface hover:bg-surface-container'
]"
>
<div class="flex items-center space-x-2">
@@ -81,7 +81,7 @@
<a
:class="[
'px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2 cursor-pointer',
isAdminPath ? 'bg-secondary-100 dark:bg-secondary-900/30 text-secondary-700 dark:text-secondary-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
isAdminPath ? 'bg-secondary-100 dark:bg-secondary-900/30 text-secondary-700 dark:text-secondary-400' : 'text-on-surface hover:bg-surface-container'
]"
>
<SettingOutlined />
@@ -121,7 +121,7 @@
<!-- Token Status Indicator (Desktop & Mobile) -->
<a-tooltip v-if="showTokenStatus" :title="tokenStatusTooltip">
<div
class="px-2 md:px-3 py-1.5 rounded-full cursor-pointer transition-all hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center space-x-1 md:space-x-2"
class="px-2 md:px-3 py-1.5 rounded-full cursor-pointer transition-all hover:bg-surface-container flex items-center space-x-1 md:space-x-2"
@click="handleTokenStatusClick"
>
<a-badge :status="tokenBadgeStatus" />
@@ -142,32 +142,32 @@
</a-tooltip>
<!-- Theme Toggle Button -->
<a-tooltip :title="isDark ? '切换到亮色模式' : '切换到暗色模式'">
<a-button
type="text"
class="!p-2 !flex !items-center !justify-center hover:!bg-gray-100 dark:hover:!bg-gray-700 transition-all"
<a-tooltip :title="isDark ? '切换到亮色模式' : '切换到暗色模式'" placement="bottom">
<button
type="button"
class="w-10 h-10 rounded-full flex items-center justify-center hover:bg-surface-container transition-all"
@click="toggleTheme"
>
<BulbFilled v-if="isDark" class="text-lg text-yellow-400" />
<BulbOutlined v-else class="text-lg text-gray-700 dark:text-gray-300" />
</a-button>
<BulbFilled v-if="isDark" class="text-xl text-yellow-400" />
<BulbOutlined v-else class="text-xl text-on-surface" />
</button>
</a-tooltip>
<!-- Desktop User Menu -->
<a-dropdown v-if="!isMobile" :trigger="['hover']">
<a class="flex items-center space-x-3 px-4 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer">
<a class="flex items-center space-x-3 px-4 py-2 rounded-full hover:bg-surface-container transition-all cursor-pointer">
<a-avatar :style="{ backgroundColor: '#f56a00' }">
{{ userInitial }}
</a-avatar>
<span class="hidden md:block font-medium text-gray-700 dark:text-gray-200">{{ authStore.user?.alias || '用户' }}</span>
<DownOutlined class="text-xs text-gray-500 dark:text-gray-400" />
<span class="hidden md:block font-medium text-on-surface">{{ authStore.user?.alias || '用户' }}</span>
<DownOutlined class="text-xs text-on-surface-variant" />
</a>
<template #overlay>
<a-menu>
<a-menu-item key="info" disabled>
<div class="px-2 py-1">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ authStore.user?.alias }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
<p class="text-sm font-medium text-on-surface">{{ authStore.user?.alias }}</p>
<p class="text-xs text-on-surface-variant mt-1">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
</div>
</a-menu-item>
<a-menu-divider />
@@ -184,14 +184,14 @@
</a-dropdown>
<!-- Mobile Hamburger Button -->
<a-button
<button
v-if="isMobile"
type="text"
type="button"
class="w-10 h-10 rounded-full flex items-center justify-center hover:bg-surface-container transition-all"
@click="drawerVisible = true"
class="!p-2"
>
<MenuOutlined class="text-xl text-gray-700 dark:text-gray-300" />
</a-button>
<MenuOutlined class="text-xl text-on-surface" />
</button>
</div>
</div>
</nav>
@@ -204,14 +204,14 @@
title="菜单"
>
<!-- User Info in Drawer -->
<div class="mb-6 pb-4 border-b border-gray-200">
<div class="mb-6 pb-4 border-b border-outline-variant">
<div class="flex items-center space-x-3">
<a-avatar :size="48" :style="{ backgroundColor: '#f56a00' }">
{{ userInitial }}
</a-avatar>
<div>
<p class="font-medium text-gray-900">{{ authStore.user?.alias || '用户' }}</p>
<p class="text-xs text-gray-500">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
<p class="font-medium text-on-surface">{{ authStore.user?.alias || '用户' }}</p>
<p class="text-xs text-on-surface-variant">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
</div>
</div>
</div>
@@ -364,11 +364,11 @@ const tokenBadgeText = computed(() => {
const tokenIconClass = computed(() => {
const mins = remainingMinutes.value
if (mins === null) return 'text-gray-500'
if (mins < 0) return 'text-red-500' // 已过期
if (mins <= 10) return 'text-red-500 animate-pulse' // 10分钟内,闪烁
if (mins <= 30) return 'text-orange-500' // 30分钟内
return 'text-blue-500' // 正常
if (mins === null) return 'text-on-surface-variant'
if (mins < 0) return 'text-red-500 dark:text-red-400' // 已过期
if (mins <= 10) return 'text-red-500 dark:text-red-400 animate-pulse' // 10分钟内,闪烁
if (mins <= 30) return 'text-orange-500 dark:text-orange-400' // 30分钟内
return 'text-blue-500 dark:text-blue-400' // 正常
})
const tokenStatusTooltip = computed(() => {
@@ -0,0 +1,119 @@
<template>
<a-card class="md3-card text-center" style="padding: 48px 20px;">
<!-- 图标 -->
<div v-if="icon" class="mb-6">
<component
:is="icon"
class="text-8xl mx-auto"
:class="iconColorClass"
/>
</div>
<!-- 标题 -->
<h3 class="md3-title-large text-on-surface mb-2">
{{ title || '暂无数据' }}
</h3>
<!-- 描述 -->
<p class="md3-body-medium text-on-surface-variant mb-6">
{{ description || '当前没有内容可显示' }}
</p>
<!-- 操作按钮可选 -->
<div v-if="$slots.action || actionText">
<slot name="action">
<a-button
v-if="actionText"
type="primary"
@click="handleAction"
:loading="loading"
>
<template v-if="actionIcon" #icon>
<component :is="actionIcon" />
</template>
{{ actionText }}
</a-button>
</slot>
</div>
</a-card>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
/**
* 图标组件
*/
icon: {
type: Object,
default: null
},
/**
* 标题文本
*/
title: {
type: String,
default: ''
},
/**
* 描述文本
*/
description: {
type: String,
default: ''
},
/**
* 操作按钮文本
*/
actionText: {
type: String,
default: ''
},
/**
* 操作按钮图标
*/
actionIcon: {
type: Object,
default: null
},
/**
* 加载状态
*/
loading: {
type: Boolean,
default: false
},
/**
* 图标颜色
*/
iconColor: {
type: String,
default: 'neutral',
validator: (v) => ['primary', 'neutral', 'success', 'warning', 'error'].includes(v)
}
})
const emit = defineEmits(['action'])
const handleAction = () => {
emit('action')
}
const iconColorClass = computed(() => {
const colors = {
primary: 'text-primary',
neutral: 'text-on-surface-variant',
success: 'text-green-500',
warning: 'text-orange-500',
error: 'text-error'
}
return colors[props.iconColor]
})
</script>
@@ -0,0 +1,106 @@
<template>
<div v-if="loading" class="loading-state">
<!-- 卡片骨架屏 -->
<div v-if="type === 'card'" class="grid grid-cols-1 gap-4">
<a-card
v-for="i in count"
:key="i"
class="md3-card"
>
<a-skeleton
:active="true"
:paragraph="{ rows: paragraphRows }"
:avatar="showAvatar"
/>
</a-card>
</div>
<!-- 列表骨架屏 -->
<div v-else-if="type === 'list'" class="space-y-4">
<a-card
v-for="i in count"
:key="i"
class="md3-card"
>
<a-skeleton
:active="true"
:paragraph="{ rows: 1 }"
:avatar="showAvatar"
/>
</a-card>
</div>
<!-- 表格骨架屏 -->
<a-card v-else-if="type === 'table'" class="md3-card">
<a-skeleton
:active="true"
:paragraph="{ rows: count * 2 }"
/>
</a-card>
<!-- 默认骨架屏 -->
<a-card v-else class="md3-card">
<a-skeleton
:active="true"
:paragraph="{ rows: paragraphRows }"
:avatar="showAvatar"
/>
</a-card>
</div>
</template>
<script setup>
const props = defineProps({
/**
* 是否显示加载状态
*/
loading: {
type: Boolean,
default: true
},
/**
* 骨架屏类型
*/
type: {
type: String,
default: 'card',
validator: (v) => ['card', 'list', 'table', 'default'].includes(v)
},
/**
* 骨架屏数量
*/
count: {
type: Number,
default: 3
},
/**
* 段落行数
*/
paragraphRows: {
type: Number,
default: 4
},
/**
* 是否显示头像
*/
showAvatar: {
type: Boolean,
default: false
}
})
</script>
<style scoped>
.loading-state {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
@@ -0,0 +1,196 @@
<template>
<a-card
class="md3-card animate-slide-up transition-standard hover:elevation-3"
:style="{ animationDelay }"
>
<div class="flex items-center justify-between">
<!-- 数值和标签 -->
<div class="flex-1">
<p class="md3-label-medium text-on-surface-variant mb-1">{{ label }}</p>
<p class="md3-headline-medium" :class="valueColorClass">
{{ formattedValue }}
</p>
<p v-if="subtitle" class="md3-body-small text-on-surface-variant mt-1">
{{ subtitle }}
</p>
</div>
<!-- 图标 -->
<div
v-if="icon"
class="w-12 h-12 rounded-md3 flex items-center justify-center flex-shrink-0 ml-4"
:class="iconBgClass"
>
<component :is="icon" :class="iconColorClass" class="text-2xl" />
</div>
</div>
<!-- 趋势指示器可选 -->
<div v-if="trend !== undefined" class="mt-3 pt-3 border-t border-outline-variant">
<div class="flex items-center text-sm">
<component
:is="trendIcon"
:class="trendColorClass"
class="mr-1"
/>
<span :class="trendColorClass" class="md3-label-small">
{{ trendText }}
</span>
</div>
</div>
</a-card>
</template>
<script setup>
import { computed } from 'vue'
import {
ArrowUpOutlined,
ArrowDownOutlined,
MinusOutlined
} from '@ant-design/icons-vue'
const props = defineProps({
/**
* 卡片标签
*/
label: {
type: String,
required: true
},
/**
* 显示的数值
*/
value: {
type: [String, Number],
required: true
},
/**
* 副标题/描述
*/
subtitle: {
type: String,
default: ''
},
/**
* 图标组件
*/
icon: {
type: Object,
default: null
},
/**
* 颜色主题
*/
color: {
type: String,
default: 'primary',
validator: (v) => ['primary', 'success', 'warning', 'error', 'info', 'neutral'].includes(v)
},
/**
* 动画延迟(秒)
*/
delay: {
type: Number,
default: 0
},
/**
* 格式化函数
*/
formatter: {
type: Function,
default: null
},
/**
* 趋势值(正数上升,负数下降,0持平)
*/
trend: {
type: Number,
default: undefined
},
/**
* 趋势文本
*/
trendText: {
type: String,
default: ''
}
})
// 动画延迟
const animationDelay = computed(() => `${props.delay}s`)
// 格式化数值
const formattedValue = computed(() => {
if (props.formatter) {
return props.formatter(props.value)
}
return props.value
})
// 颜色映射
const colorClasses = {
primary: {
value: 'text-primary',
iconBg: 'bg-primary-100 dark:bg-primary-900/30',
icon: 'text-primary'
},
success: {
value: 'text-green-600 dark:text-green-400',
iconBg: 'bg-green-100 dark:bg-green-900/30',
icon: 'text-green-600 dark:text-green-400'
},
warning: {
value: 'text-orange-600 dark:text-orange-400',
iconBg: 'bg-orange-100 dark:bg-orange-900/30',
icon: 'text-orange-600 dark:text-orange-400'
},
error: {
value: 'text-error',
iconBg: 'bg-red-100 dark:bg-red-900/30',
icon: 'text-error'
},
info: {
value: 'text-secondary',
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
icon: 'text-secondary'
},
neutral: {
value: 'text-on-surface',
iconBg: 'bg-surface-container',
icon: 'text-on-surface-variant'
}
}
const valueColorClass = computed(() => colorClasses[props.color].value)
const iconBgClass = computed(() => colorClasses[props.color].iconBg)
const iconColorClass = computed(() => colorClasses[props.color].icon)
// 趋势图标和颜色
const trendIcon = computed(() => {
if (props.trend === undefined) return null
if (props.trend > 0) return ArrowUpOutlined
if (props.trend < 0) return ArrowDownOutlined
return MinusOutlined
})
const trendColorClass = computed(() => {
if (props.trend === undefined) return ''
if (props.trend > 0) return 'text-green-600 dark:text-green-400'
if (props.trend < 0) return 'text-red-600 dark:text-red-400'
return 'text-on-surface-variant'
})
</script>
<style scoped>
.md3-card:hover {
transform: translateY(-2px);
}
</style>