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
+129 -98
View File
@@ -2,180 +2,211 @@ import { theme } from 'ant-design-vue'
/**
* Ant Design Vue 主题配置
* 匹配现有 Material Design 3 色彩系统
* 严格遵循 Material Design 3 规范
* @param {boolean} isDark - 是否为暗色模式
*/
export default function getAntdTheme(isDark = false) {
return {
token: {
// 主色调 - 绿色(与 MD3 primary 保持一致)
// === Material Design 3 Color System ===
// Primary - 主色调(绿色)
colorPrimary: isDark ? '#81c784' : '#4caf50',
// 成功色
// Secondary colors
colorSuccess: isDark ? '#81c784' : '#4caf50',
// 警告色
colorWarning: '#ff9800',
// 错误色
colorError: '#f56c6c',
// 信息色 - 蓝色(与 MD3 secondary 保持一致)
colorError: '#f44336', // MD3 标准错误色
colorInfo: isDark ? '#64b5f6' : '#2196f3',
// 背景色
colorBgBase: isDark ? '#121212' : '#ffffff',
colorBgContainer: isDark ? '#1c1c1e' : '#ffffff',
colorBgElevated: isDark ? '#2c2c2e' : '#ffffff',
colorBgLayout: isDark ? '#121212' : '#fafafa',
colorBgSpotlight: isDark ? '#2c2c2e' : '#ffffff',
// === Surface & Background (MD3 规范) ===
colorBgBase: isDark ? '#1c1b1f' : '#ffffff',
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
colorBgElevated: isDark ? '#26252a' : '#ffffff',
colorBgLayout: isDark ? '#1c1b1f' : '#fefbff', // MD3 标准背景色
colorBgSpotlight: isDark ? '#26252a' : '#ffffff',
// 文字色
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorTextSecondary: isDark ? '#a0a0a3' : '#64748b',
colorTextTertiary: isDark ? '#808083' : '#94a3b8',
colorTextQuaternary: isDark ? '#606063' : '#cbd5e1',
// === Typography (MD3 规范) ===
colorText: isDark ? '#e6e1e5' : '#1c1b1f', // On-surface
colorTextSecondary: isDark ? '#cac4d0' : '#49454f', // On-surface-variant
colorTextTertiary: isDark ? '#938f99' : '#79747e',
colorTextQuaternary: isDark ? '#79747e' : '#938f99',
// 边框色
colorBorder: isDark ? '#3a3a3c' : '#e5e7eb',
colorBorderSecondary: isDark ? '#2c2c2e' : '#f3f4f6',
// === Borders ===
colorBorder: isDark ? '#49454f' : '#d1cdd6',
colorBorderSecondary: isDark ? '#3a3740' : '#e3e1e6',
colorSplit: isDark ? '#49454f' : '#e3e1e6',
// 分割线颜色
colorSplit: isDark ? '#3a3a3c' : '#e5e7eb',
// === Shape System ===
borderRadius: 12, // Medium shape
borderRadiusLG: 16, // Large shape
borderRadiusSM: 8, // Small shape
borderRadiusXS: 4, // Extra small shape
// 边框圆角 - 与 Material Design 3 一致
borderRadius: 12,
// === Typography ===
fontFamily: "'Roboto', 'Inter', system-ui, -apple-system, sans-serif",
fontSize: 14, // Body Medium
fontSizeLG: 16, // Body Large
fontSizeSM: 12, // Body Small
lineHeight: 1.428, // 20/14 = 1.428
lineHeightLG: 1.5, // 24/16 = 1.5
// 字体家族
fontFamily: "'Inter', 'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif",
// 链接色
// === Links ===
colorLink: isDark ? '#64b5f6' : '#2196f3',
colorLinkHover: isDark ? '#90caf9' : '#1976d2',
colorLinkActive: isDark ? '#42a5f5' : '#1565c0',
// 字体大小
fontSize: 14,
// 行高
lineHeight: 1.5715,
// 控制组件高度
// === Components ===
controlHeight: 40,
controlHeightLG: 48,
controlHeightSM: 32,
// === Motion (MD3 规范) ===
motionDurationSlow: '0.3s',
motionDurationMid: '0.2s',
motionDurationFast: '0.1s',
},
components: {
// Card 组件定制
// === Card 组件 (MD3 Elevated Card) ===
Card: {
borderRadiusLG: 16,
boxShadowTertiary: isDark
? '0 1px 3px 1px rgba(0, 0, 0, 0.5)'
: '0 1px 3px 1px rgba(0, 0, 0, 0.08)',
paddingLG: 24,
colorBgContainer: isDark ? '#1c1c1e' : '#ffffff',
colorBorderSecondary: isDark ? '#3a3a3c' : '#f0f0f0',
colorTextHeading: isDark ? '#e5e5e7' : '#1c1b1f',
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
colorBorderSecondary: isDark ? '#49454f' : '#e3e1e6',
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
},
// Button 组件定制
// === Button 组件 (MD3 规范) ===
Button: {
borderRadius: 24, // 圆角按钮,类似 MD3
borderRadius: 20, // MD3 Filled Button 圆角
borderRadiusLG: 24,
borderRadiusSM: 16,
controlHeight: 40,
controlHeightLG: 48,
controlHeightSM: 32,
fontSize: 14,
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorBgContainer: isDark ? '#2c2c2e' : '#ffffff',
fontSizeLG: 16,
fontSizeSM: 12,
paddingContentHorizontal: 24,
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorBgContainer: isDark ? '#26252a' : '#ffffff',
},
// Input 组件定制
// === Input 组件 (MD3 Text Field) ===
Input: {
borderRadius: 12,
controlHeight: 40,
colorBgContainer: isDark ? '#2c2c2e' : '#ffffff',
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorTextPlaceholder: isDark ? '#808083' : '#94a3b8',
colorBorder: isDark ? '#3a3a3c' : '#e5e7eb',
colorBgContainer: isDark ? '#26252a' : '#ffffff',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextPlaceholder: isDark ? '#938f99' : '#79747e',
colorBorder: isDark ? '#49454f' : '#d1cdd6',
},
// Select 组件定制
// === Select 组件 ===
Select: {
borderRadius: 12,
controlHeight: 40,
colorBgContainer: isDark ? '#2c2c2e' : '#ffffff',
colorBgElevated: isDark ? '#2c2c2e' : '#ffffff',
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorTextPlaceholder: isDark ? '#808083' : '#94a3b8',
colorBorder: isDark ? '#3a3a3c' : '#e5e7eb',
colorBgContainer: isDark ? '#26252a' : '#ffffff',
colorBgElevated: isDark ? '#26252a' : '#ffffff',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextPlaceholder: isDark ? '#938f99' : '#79747e',
colorBorder: isDark ? '#49454f' : '#d1cdd6',
},
// Modal 组件定制
// === Modal 组件 (MD3 Dialog) ===
Modal: {
borderRadiusLG: 16,
colorBgElevated: isDark ? '#1c1c1e' : '#ffffff',
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorTextHeading: isDark ? '#e5e5e7' : '#1c1b1f',
borderRadiusLG: 28, // MD3 Dialog 使用 Extra Large 圆角
colorBgElevated: isDark ? '#1c1b1f' : '#ffffff',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
},
// Table 组件定制
// === Table 组件 ===
Table: {
borderRadius: 12,
colorBgContainer: isDark ? '#1c1c1e' : '#ffffff',
colorFillAlter: isDark ? '#2c2c2e' : '#f5f7fa',
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorTextHeading: isDark ? '#e5e5e7' : '#1c1b1f',
colorBorderSecondary: isDark ? '#3a3a3c' : '#f0f0f0',
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
colorFillAlter: isDark ? '#26252a' : '#f5f5f5',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
colorBorderSecondary: isDark ? '#49454f' : '#e3e1e6',
},
// Tabs 组件定制
// === Tabs 组件 ===
Tabs: {
borderRadius: 12,
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorBgContainer: isDark ? '#1c1c1e' : '#ffffff',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
},
// Menu 组件定制
// === Menu 组件 ===
Menu: {
colorItemBg: isDark ? '#1c1c1e' : '#ffffff',
colorItemBgHover: isDark ? '#2c2c2e' : '#f5f7fa',
colorItemBgSelected: isDark ? '#2c2c2e' : '#e8f5e9',
colorItemText: isDark ? '#e5e5e7' : '#1c1b1f',
colorItemBg: isDark ? '#1c1b1f' : '#ffffff',
colorItemBgHover: isDark ? '#26252a' : '#f5f5f5',
colorItemBgSelected: isDark ? '#3a4a3f' : '#e8f5e9',
colorItemText: isDark ? '#e6e1e5' : '#1c1b1f',
colorItemTextSelected: isDark ? '#81c784' : '#4caf50',
borderRadius: 12,
},
// Dropdown 组件定制
// === Dropdown 组件 ===
Dropdown: {
colorBgElevated: isDark ? '#2c2c2e' : '#ffffff',
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorBgElevated: isDark ? '#26252a' : '#ffffff',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
borderRadiusLG: 12,
},
// Descriptions 组件定制
// === Descriptions 组件 ===
Descriptions: {
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorTextSecondary: isDark ? '#a0a0a3' : '#64748b',
colorBgContainer: isDark ? '#1c1c1e' : '#ffffff',
colorFillAlter: isDark ? '#2c2c2e' : '#f5f7fa',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextSecondary: isDark ? '#cac4d0' : '#49454f',
colorBgContainer: isDark ? '#1c1b1f' : '#ffffff',
colorFillAlter: isDark ? '#201f24' : '#f3f4f6', // Label 背景色 = surface-container
colorSplit: isDark ? '#49454f' : '#e3e1e6',
borderRadiusLG: 8, // 设置 Descriptions 容器圆角
},
// Alert 组件定制
// === Alert 组件 ===
Alert: {
borderRadiusLG: 12,
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
},
// Drawer 组件定制
// === Drawer 组件 ===
Drawer: {
colorBgElevated: isDark ? '#1c1c1e' : '#ffffff',
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorBgElevated: isDark ? '#1c1b1f' : '#ffffff',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
borderRadiusLG: 16,
},
// Form 组件定制
// === Form 组件 ===
Form: {
colorText: isDark ? '#e5e5e7' : '#1c1b1f',
colorTextHeading: isDark ? '#e5e5e7' : '#1c1b1f',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
colorTextHeading: isDark ? '#e6e1e5' : '#1c1b1f',
},
// Empty 组件定制
// === Empty 组件 ===
Empty: {
colorTextDescription: isDark ? '#a0a0a3' : '#94a3b8',
colorTextDescription: isDark ? '#938f99' : '#79747e',
},
// === Tag 组件 ===
Tag: {
borderRadiusSM: 16, // 药丸形
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
},
// === Switch 组件 ===
Switch: {
colorPrimary: isDark ? '#81c784' : '#4caf50',
colorText: isDark ? '#e6e1e5' : '#1c1b1f',
},
// === Tooltip 组件 ===
Tooltip: {
colorBgSpotlight: isDark ? '#313033' : '#f5f5f5', // Tooltip 背景色(跟随主题)
colorTextLightSolid: isDark ? '#ffffff' : '#1c1b1f', // Tooltip 文本颜色(跟随主题)
borderRadius: 8,
},
},
+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>
@@ -0,0 +1,84 @@
/**
* 通用异步操作 Composable
* 统一处理 loading、error 状态和消息提示
*
* @example
* const { loading, error, execute } = useAsyncAction()
*
* const handleSubmit = async () => {
* await execute(
* () => api.createTask(formData),
* { successMsg: '创建成功', errorMsg: '创建失败' }
* )
* }
*/
import { ref } from 'vue'
import { message } from 'ant-design-vue'
export function useAsyncAction(options = {}) {
const loading = ref(false)
const error = ref(null)
/**
* 执行异步操作
* @param {Function} asyncFn - 异步函数
* @param {Object} config - 配置选项
* @param {string} config.successMsg - 成功提示消息
* @param {string} config.errorMsg - 错误提示消息
* @param {boolean} config.throwOnError - 是否抛出错误
* @param {boolean} config.silent - 是否静默模式(不显示消息)
* @returns {Promise} 异步函数的返回值
*/
const execute = async (asyncFn, config = {}) => {
const {
successMsg = options.successMsg,
errorMsg = options.errorMsg,
throwOnError = false,
silent = false
} = config
loading.value = true
error.value = null
try {
const result = await asyncFn()
if (!silent && successMsg) {
message.success(successMsg)
}
return result
} catch (err) {
error.value = err
if (!silent) {
const msg = err.message || err.detail || errorMsg || '操作失败'
message.error(msg)
}
if (throwOnError) {
throw err
}
return null
} finally {
loading.value = false
}
}
/**
* 重置状态
*/
const reset = () => {
loading.value = false
error.value = null
}
return {
loading,
error,
execute,
reset
}
}
+128
View File
@@ -0,0 +1,128 @@
/**
* 状态轮询 Composable
* 支持指数退避、最大重试次数、自动清理
*
* @example
* const { polling, startPolling, stopPolling } = usePollStatus({
* interval: 2000,
* maxRetries: 15,
* backoff: true
* })
*
* startPolling(
* async () => {
* const status = await api.getStatus(id)
* return {
* completed: status.status !== 'pending',
* success: status.status === 'success',
* data: status
* }
* },
* {
* onSuccess: (result) => console.log('完成', result),
* onFailure: (error) => console.error('失败', error),
* onTimeout: () => console.warn('超时')
* }
* )
*/
import { ref, onUnmounted } from 'vue'
export function usePollStatus(options = {}) {
const {
interval = 2000, // 初始轮询间隔(毫秒)
maxRetries = 15, // 最大重试次数
backoff = false, // 是否使用指数退避
maxBackoffInterval = 10000 // 最大退避间隔(毫秒)
} = options
const polling = ref(false)
let pollTimer = null
let retryCount = 0
/**
* 开始轮询
* @param {Function} checkFn - 检查函数,应返回 { completed, success, data }
* @param {Object} callbacks - 回调函数
* @param {Function} callbacks.onSuccess - 成功回调
* @param {Function} callbacks.onFailure - 失败回调
* @param {Function} callbacks.onTimeout - 超时回调
*/
const startPolling = async (checkFn, callbacks = {}) => {
const { onSuccess, onFailure, onTimeout } = callbacks
// 重置状态
stopPolling()
polling.value = true
retryCount = 0
const poll = async () => {
try {
const result = await checkFn()
// 检查是否完成
if (result.completed) {
stopPolling()
if (result.success) {
onSuccess?.(result.data || result)
} else {
onFailure?.(result.data || result)
}
return
}
// 检查是否超时
retryCount++
if (retryCount >= maxRetries) {
stopPolling()
onTimeout?.()
return
}
// 计算下次轮询间隔(支持指数退避)
let nextInterval = interval
if (backoff) {
// 指数退避:2s -> 4s -> 8s -> 最大10s
nextInterval = Math.min(
interval * Math.pow(2, retryCount - 1),
maxBackoffInterval
)
}
// 继续轮询
pollTimer = setTimeout(poll, nextInterval)
} catch (error) {
stopPolling()
onFailure?.(error)
}
}
// 立即执行第一次检查
poll()
}
/**
* 停止轮询
*/
const stopPolling = () => {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
polling.value = false
retryCount = 0
}
// 组件卸载时自动清理
onUnmounted(() => {
stopPolling()
})
return {
polling,
startPolling,
stopPolling
}
}
+958 -885
View File
File diff suppressed because it is too large Load Diff
+43 -8
View File
@@ -4,7 +4,7 @@
<a-row :gutter="[20, 20]">
<!-- Token 状态卡片 -->
<a-col :xs="24" :sm="24" :md="24">
<a-card class="status-card">
<a-card class="status-card md3-card">
<template #title>
<div class="card-header">
<KeyOutlined />
@@ -60,7 +60,7 @@
<!-- 手动打卡卡片 -->
<a-col :xs="24" :sm="24" :md="24">
<a-card>
<a-card class="md3-card">
<template #title>
<div class="card-header">
<CalendarOutlined />
@@ -123,7 +123,7 @@
}}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="打卡响应" :span="2">
<a-descriptions-item label="打卡响应" :span="{ xs: 1, sm: 1, md: 2 }">
{{ lastCheckIn.response_text || lastCheckIn.error_message || '-' }}
</a-descriptions-item>
</a-descriptions>
@@ -134,7 +134,7 @@
<!-- 用户信息卡片 -->
<a-col :xs="24" :sm="24" :md="24">
<a-card>
<a-card class="md3-card">
<template #title>
<div class="card-header">
<UserOutlined />
@@ -151,10 +151,10 @@
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="邮箱" :span="2">
<a-descriptions-item label="邮箱">
{{ authStore.user?.email || '未设置' }}
</a-descriptions-item>
<a-descriptions-item label="注册时间" :span="2">
<a-descriptions-item label="注册时间">
{{ formatDateTime(authStore.user?.created_at, false) }}
</a-descriptions-item>
</a-descriptions>
@@ -335,23 +335,58 @@ onMounted(async () => {
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 500;
}
.loading-container {
padding: 20px;
}
.token-status {
padding: 10px 0;
padding: 0;
}
.token-status .ant-descriptions {
margin-bottom: 0;
}
.check-in-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
padding: 16px 20px;
gap: 12px;
}
.check-in-container .hint {
color: var(--md-sys-color-on-surface-variant);
font-size: 14px;
margin: 0 0 4px 0;
text-align: center;
}
.last-check-in {
width: 100%;
margin-top: 20px;
}
.last-check-in .label {
font-size: 14px;
font-weight: 500;
color: var(--md-sys-color-on-surface-variant);
margin: 12px 0 8px 0;
}
.ant-alert {
margin-top: 16px;
}
.ant-select {
margin-bottom: 0;
}
</style>
+10 -10
View File
@@ -2,11 +2,11 @@
<Layout>
<div class="settings-view">
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-6">个人设置</h1>
<h1 class="text-3xl font-bold text-on-surface mb-6">个人设置</h1>
<!-- 基本信息卡片 -->
<div class="md3-card p-6 mb-6">
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center">
<a-card class="md3-card mb-6">
<h2 class="text-xl font-bold text-on-surface mb-4 flex items-center">
<UserOutlined class="mr-2" />
基本信息
</h2>
@@ -28,11 +28,11 @@
{{ formatDate(user?.created_at) }}
</a-descriptions-item>
</a-descriptions>
</div>
</a-card>
<!-- 修改邮箱 -->
<div class="md3-card p-6 mb-6">
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center">
<a-card class="md3-card mb-6">
<h2 class="text-xl font-bold text-on-surface mb-4 flex items-center">
<EditOutlined class="mr-2" />
修改个人信息
</h2>
@@ -73,11 +73,11 @@
</a-space>
</a-form-item>
</a-form>
</div>
</a-card>
<!-- 设置/修改密码 -->
<div class="md3-card p-6">
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center">
<a-card class="md3-card">
<h2 class="text-xl font-bold text-on-surface mb-4 flex items-center">
<KeyOutlined class="mr-2" />
{{ hasPassword ? '修改密码' : '设置密码' }}
</h2>
@@ -136,7 +136,7 @@
</a-space>
</a-form-item>
</a-form>
</div>
</a-card>
</div>
</div>
</Layout>
+46 -46
View File
@@ -13,11 +13,11 @@
返回任务列表
</a-button>
<div v-if="currentTask" class="fluent-card p-6">
<a-card v-if="currentTask" class="md3-card">
<div class="flex items-start justify-between">
<div class="flex-1">
<h1 class="text-3xl font-bold text-gradient mb-2">{{ currentTask.name || '未命名任务' }}</h1>
<div class="flex items-center gap-4 text-sm text-gray-600">
<div class="flex items-center gap-4 text-sm text-on-surface-variant">
<span class="flex items-center">
<NumberOutlined class="mr-1" />
接龙 ID: {{ getThreadId(currentTask) }}
@@ -35,54 +35,54 @@
{{ checkInLoading ? '打卡中...' : '立即打卡' }}
</a-button>
</div>
</div>
</a-card>
</div>
<!-- Stats Summary -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up">
<p class="text-sm text-gray-600 mb-1">总打卡次数</p>
<p class="text-2xl font-bold text-gray-800">{{ recordStats.total }}</p>
</div>
<a-card class="md3-card animate-slide-up">
<p class="text-sm text-on-surface-variant mb-1">总打卡次数</p>
<p class="text-2xl font-bold text-on-surface">{{ recordStats.total }}</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.05s">
<p class="text-sm text-gray-600 mb-1">成功次数</p>
<p class="text-2xl font-bold text-green-600">{{ recordStats.success }}</p>
</div>
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.05s">
<p class="text-sm text-on-surface-variant mb-1">成功次数</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">{{ recordStats.success }}</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.1s">
<p class="text-sm text-gray-600 mb-1">时间范围外</p>
<p class="text-2xl font-bold text-blue-600">{{ recordStats.outOfTime }}</p>
</div>
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.1s">
<p class="text-sm text-on-surface-variant mb-1">时间范围外</p>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">{{ recordStats.outOfTime }}</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.15s">
<p class="text-sm text-gray-600 mb-1">失败次数</p>
<p class="text-2xl font-bold text-red-600">{{ recordStats.failure }}</p>
</div>
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.15s">
<p class="text-sm text-on-surface-variant mb-1">失败次数</p>
<p class="text-2xl font-bold text-red-600 dark:text-red-400">{{ recordStats.failure }}</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.2s">
<p class="text-sm text-gray-600 mb-1">异常次数</p>
<p class="text-2xl font-bold text-orange-600">{{ recordStats.unknown }}</p>
</div>
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.2s">
<p class="text-sm text-on-surface-variant mb-1">异常次数</p>
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400">{{ recordStats.unknown }}</p>
</a-card>
</a-col>
<a-col :xs="12" :sm="8" :md="4">
<div class="fluent-card p-5 animate-slide-up" style="animation-delay: 0.25s">
<p class="text-sm text-gray-600 mb-1">成功率</p>
<p class="text-2xl font-bold text-purple-600">{{ recordStats.successRate }}%</p>
</div>
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.25s">
<p class="text-sm text-on-surface-variant mb-1">成功率</p>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400">{{ recordStats.successRate }}%</p>
</a-card>
</a-col>
</a-row>
<!-- Filters -->
<div class="fluent-card p-4 mb-6">
<a-card class="md3-card mb-6">
<a-space wrap :size="[16, 16]">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">状态筛选:</span>
<span class="text-sm font-medium text-on-surface">状态筛选:</span>
<a-radio-group v-model:value="filterStatus" button-style="solid" size="small" @change="handleFilterChange">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="success">成功</a-radio-button>
@@ -93,7 +93,7 @@
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">触发方式:</span>
<span class="text-sm font-medium text-on-surface">触发方式:</span>
<a-radio-group v-model:value="filterTrigger" button-style="solid" size="small" @change="handleFilterChange">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="scheduler">自动</a-radio-button>
@@ -106,7 +106,7 @@
刷新
</a-button>
</a-space>
</div>
</a-card>
<!-- Records List -->
<div v-if="loading" class="space-y-4">
@@ -115,22 +115,22 @@
</a-card>
</div>
<div v-else-if="records.length === 0" class="fluent-card p-12 text-center">
<FileTextOutlined class="text-8xl text-gray-300 mb-4" />
<h3 class="text-xl font-semibold text-gray-700 mb-2">暂无打卡记录</h3>
<p class="text-gray-500">当前筛选条件下没有找到任何打卡记录</p>
</div>
<a-card v-else-if="records.length === 0" class="md3-card text-center" style="padding: 48px 20px;">
<FileTextOutlined class="text-8xl text-on-surface-variant opacity-30 mb-4" />
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无打卡记录</h3>
<p class="text-on-surface-variant">当前筛选条件下没有找到任何打卡记录</p>
</a-card>
<div v-else class="space-y-4">
<div
<a-card
v-for="record in records"
:key="record.id"
class="fluent-card p-6 hover:shadow-xl transition-all animate-slide-up"
class="md3-card hover:shadow-xl transition-all animate-slide-up"
>
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2 flex-wrap">
<h3 class="text-lg font-semibold text-gray-800">
<h3 class="text-lg font-semibold text-on-surface">
打卡记录 #{{ record.id }}
</h3>
<a-tag
@@ -153,7 +153,7 @@
{{ record.trigger_type === 'scheduled' ? '自动触发' : '手动触发' }}
</a-tag>
</div>
<div class="flex items-center text-sm text-gray-600">
<div class="flex items-center text-sm text-on-surface-variant">
<ClockCircleOutlined class="mr-1" />
{{ formatDateTime(record.check_in_time) }}
</div>
@@ -161,18 +161,18 @@
</div>
<!-- Record Details -->
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<div class="bg-surface-container-high dark:bg-surface-container rounded-lg p-4 space-y-2">
<div v-if="record.response_text" class="flex items-start">
<span class="text-sm font-medium text-gray-700 w-20">响应:</span>
<span class="text-sm text-gray-900 flex-1">{{ record.response_text }}</span>
<span class="text-sm font-medium text-on-surface-variant w-20">响应:</span>
<span class="text-sm text-on-surface flex-1">{{ record.response_text }}</span>
</div>
<div v-if="record.error_message" class="flex items-start">
<span class="text-sm font-medium text-red-700 w-20">错误:</span>
<span class="text-sm text-red-600 flex-1">{{ record.error_message }}</span>
<span class="text-sm font-medium text-error w-20">错误:</span>
<span class="text-sm text-error flex-1">{{ record.error_message }}</span>
</div>
</div>
</div>
</a-card>
</div>
<!-- Pagination -->
+45 -44
View File
@@ -7,7 +7,7 @@
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-4xl font-bold text-gradient mb-2">任务管理</h1>
<p class="text-gray-600 dark:text-gray-400">管理您的自动打卡任务</p>
<p class="text-on-surface-variant">管理您的自动打卡任务</p>
</div>
<a-button
type="primary"
@@ -25,45 +25,45 @@
<!-- Stats Cards -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="24" :sm="8" :md="8">
<div class="fluent-card p-6 animate-slide-up">
<a-card class="md3-card animate-slide-up">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">总任务数</p>
<p class="text-3xl font-bold text-primary-600 dark:text-primary-400">{{ taskStore.taskStats.total }}</p>
<p class="text-sm text-on-surface-variant mb-1">总任务数</p>
<p class="text-3xl font-bold text-primary">{{ taskStore.taskStats.total }}</p>
</div>
<div class="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-md3 flex items-center justify-center">
<FileTextOutlined class="text-2xl text-primary-600 dark:text-primary-400" />
<FileTextOutlined class="text-2xl text-primary" />
</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="8" :md="8">
<div class="fluent-card p-6 animate-slide-up" style="animation-delay: 0.1s">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.1s">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">启用中</p>
<p class="text-sm text-on-surface-variant mb-1">启用中</p>
<p class="text-3xl font-bold text-green-600 dark:text-green-400">{{ taskStore.taskStats.active }}</p>
</div>
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-md3 flex items-center justify-center">
<CheckCircleOutlined class="text-2xl text-green-600 dark:text-green-400" />
</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="8" :md="8">
<div class="fluent-card p-6 animate-slide-up" style="animation-delay: 0.2s">
<a-card class="md3-card animate-slide-up" style="animation-delay: 0.2s">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">已禁用</p>
<p class="text-3xl font-bold text-gray-600 dark:text-gray-300">{{ taskStore.taskStats.inactive }}</p>
<p class="text-sm text-on-surface-variant mb-1">已禁用</p>
<p class="text-3xl font-bold text-on-surface-variant">{{ taskStore.taskStats.inactive }}</p>
</div>
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-md3 flex items-center justify-center">
<StopOutlined class="text-2xl text-gray-600 dark:text-gray-300" />
<div class="w-12 h-12 bg-surface-container-high rounded-md3 flex items-center justify-center">
<StopOutlined class="text-2xl text-on-surface-variant" />
</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
@@ -79,14 +79,14 @@
</a-row>
</div>
<div v-else-if="taskStore.tasks.length === 0" class="fluent-card p-12 text-center">
<FileTextOutlined class="text-8xl text-gray-300 mb-4" />
<h3 class="text-xl font-semibold text-gray-700 mb-2">暂无任务</h3>
<p class="text-gray-500 mb-6">点击右上角的"创建任务"按钮开始添加您的第一个打卡任务</p>
<a-card v-else-if="taskStore.tasks.length === 0" class="md3-card text-center" style="padding: 48px 20px;">
<FileTextOutlined class="text-8xl text-on-surface-variant opacity-30 mb-4" />
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无任务</h3>
<p class="text-on-surface-variant mb-6">点击右上角的"创建任务"按钮开始添加您的第一个打卡任务</p>
<a-button type="primary" @click="showCreateDialog = true">
创建第一个任务
</a-button>
</div>
</a-card>
<a-row v-else :gutter="[16, 16]">
<a-col
@@ -94,15 +94,16 @@
v-for="task in taskStore.tasks"
:key="task.id"
>
<div
class="fluent-card p-6 hover:scale-105 transform transition-all cursor-pointer animate-slide-up"
<a-card
class="md3-card hover:scale-105 transform transition-all cursor-pointer animate-slide-up"
@click="viewTask(task)"
>
<!-- Task Header -->
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-800 mb-1">{{ task.name || '未命名任务' }}</h3>
<p class="text-sm text-gray-500">任务 ID: {{ task.id }}</p>
<h3 class="text-lg font-semibold text-on-surface mb-1">{{ task.name || '未命名任务' }}</h3>
<a-divider style="margin: 8px 0;" />
<p class="text-sm text-on-surface-variant">任务 ID: {{ task.id }}</p>
</div>
<a-tag :color="task.is_active ? 'success' : 'default'">
{{ task.is_active ? '启用' : '禁用' }}
@@ -111,21 +112,21 @@
<!-- Task Details -->
<div class="space-y-2 mb-4">
<div class="flex items-center text-sm text-gray-600">
<div class="flex items-center text-sm text-on-surface-variant">
<TagOutlined class="mr-2" />
接龙ID: {{ getThreadId(task) }}
</div>
<div class="flex items-center text-sm text-gray-600">
<div class="flex items-center text-sm text-on-surface-variant">
<ClockCircleOutlined class="mr-2" />
最后打卡: {{ task.last_check_in_time ? formatDateTime(task.last_check_in_time) : '未打卡' }}
</div>
<div class="flex items-center text-sm">
<CheckCircleOutlined class="mr-2 text-gray-600" />
<CheckCircleOutlined class="mr-2 text-on-surface-variant" />
<span v-if="task.last_check_in_status" :class="{
'text-green-600 font-medium': task.last_check_in_status === 'success',
'text-blue-600 font-medium': task.last_check_in_status === 'out_of_time',
'text-red-600 font-medium': task.last_check_in_status === 'failure',
'text-yellow-600 font-medium': task.last_check_in_status === 'unknown'
'text-green-600 dark:text-green-400 font-medium': task.last_check_in_status === 'success',
'text-blue-600 dark:text-blue-400 font-medium': task.last_check_in_status === 'out_of_time',
'text-red-600 dark:text-red-400 font-medium': task.last_check_in_status === 'failure',
'text-yellow-600 dark:text-yellow-400 font-medium': task.last_check_in_status === 'unknown'
}">
{{
task.last_check_in_status === 'success' ? '✅ 打卡成功' :
@@ -134,12 +135,12 @@
'❗ 打卡异常'
}}
</span>
<span v-else class="text-gray-500">暂无打卡记录</span>
<span v-else class="text-on-surface-variant">暂无打卡记录</span>
</div>
</div>
<!-- Task Actions -->
<div class="flex gap-2 pt-4 border-t border-gray-100">
<div class="flex gap-2 pt-4 border-t border-outline-variant">
<a-button
type="primary"
size="small"
@@ -174,7 +175,7 @@
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
@@ -192,12 +193,12 @@
<div v-if="!editingTask">
<div v-if="loadingTemplates" class="text-center py-8">
<a-spin size="large" />
<p class="text-gray-500 mt-2">加载模板中...</p>
<p class="text-on-surface-variant mt-2">加载模板中...</p>
</div>
<div v-else-if="activeTemplates.length === 0" class="text-center py-8">
<p class="text-gray-500">暂无可用模板</p>
<p class="text-sm text-gray-400 mt-2">请联系管理员创建模板</p>
<p class="text-on-surface-variant">暂无可用模板</p>
<p class="text-sm text-on-surface-variant opacity-70 mt-2">请联系管理员创建模板</p>
</div>
<div v-else>
@@ -208,10 +209,10 @@
v-for="template in activeTemplates"
:key="template.id"
@click="selectTemplate(template)"
class="border rounded-lg p-4 cursor-pointer hover:border-primary-500 hover:bg-primary-50 transition-all"
class="border border-outline-variant rounded-lg p-4 cursor-pointer hover:border-primary hover:bg-primary-container/10 transition-all"
>
<h4 class="font-semibold text-gray-800 mb-1">{{ template.name }}</h4>
<p class="text-sm text-gray-600">{{ template.description || '无描述' }}</p>
<h4 class="font-semibold text-on-surface mb-1">{{ template.name }}</h4>
<p class="text-sm text-on-surface-variant">{{ template.description || '无描述' }}</p>
</div>
</div>
</a-form-item>
@@ -281,7 +282,7 @@
</a-select-option>
</a-select>
<span v-if="fieldConfig.default_value" class="text-xs text-gray-500 mt-1">
<span v-if="fieldConfig.default_value" class="text-xs text-on-surface-variant mt-1">
默认值: {{ fieldConfig.default_value }}
</span>
</a-form-item>
@@ -298,7 +299,7 @@
<a-form-item label="启用状态">
<a-switch v-model:checked="taskForm.is_active" />
<span class="ml-2 text-sm text-gray-500">
<span class="ml-2 text-sm text-on-surface-variant">
{{ taskForm.is_active ? '启用自动打卡' : '禁用自动打卡(仍可手动打卡)' }}
</span>
</a-form-item>
@@ -312,7 +313,7 @@
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-600">完整的打卡请求配置</span>
<span class="text-sm text-on-surface-variant">完整的打卡请求配置</span>
<a-button
size="small"
type="primary"
@@ -330,7 +331,7 @@
class="font-mono text-xs"
style="resize: vertical; min-height: 200px; max-height: 400px;"
/>
<p class="text-xs text-gray-500 mt-1">
<p class="text-xs text-on-surface-variant mt-1">
💡 此配置由模板自动生成如需修改请删除任务后从模板重新创建
</p>
</div>
+31 -30
View File
@@ -7,7 +7,7 @@
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gradient mb-2">任务模板管理</h1>
<p class="text-gray-600 dark:text-gray-400">JSON 映射架构 - 配置即结构字段名保持原样</p>
<p class="text-on-surface-variant">JSON 映射架构 - 配置即结构</p>
</div>
<button @click="showCreateDialog" class="md3-button-filled">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -20,37 +20,38 @@
<!-- Templates List -->
<div v-if="loading && templates.length === 0" class="space-y-4">
<div v-for="i in 3" :key="i" class="fluent-card p-6">
<a-card v-for="i in 3" :key="i" class="md3-card">
<a-skeleton :active="true" :paragraph="{ rows: 2 }" />
</div>
</a-card>
</div>
<div v-else-if="templates.length === 0" class="fluent-card p-12 text-center">
<svg class="w-20 h-20 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<a-card v-else-if="templates.length === 0" class="md3-card text-center" style="padding: 48px 20px;">
<svg class="w-20 h-20 mx-auto text-on-surface-variant opacity-30 mb-4" 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>
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">暂无模板</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">创建第一个模板让用户更轻松地创建打卡任务</p>
<h3 class="text-xl font-semibold text-on-surface mb-2">暂无模板</h3>
<p class="text-on-surface-variant mb-4">创建第一个模板让用户更轻松地创建打卡任务</p>
<button @click="showCreateDialog" class="md3-button-filled">新建模板</button>
</div>
</a-card>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
<a-card
v-for="template in templates"
:key="template.id"
class="fluent-card p-7 hover:shadow-xl transition-all animate-slide-up"
class="md3-card hover:shadow-xl transition-all animate-slide-up"
>
<div class="flex items-start justify-between mb-5">
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-2">{{ template.name }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ template.description || '无描述' }}</p>
<span :class="template.is_active ? 'status-success' : 'status-info'">
<h3 class="text-lg font-semibold text-on-surface mb-2">{{ template.name }}</h3>
<a-divider style="margin: 8px 0;" />
<p class="text-sm text-on-surface-variant mb-2">{{ template.description || '无描述' }}</p>
<span :class="template.is_active ? 'md3-badge-success' : 'md3-badge-info'">
{{ template.is_active ? '已启用' : '已禁用' }}
</span>
</div>
</div>
<div class="mt-5 pt-4 border-t border-gray-100 space-y-2">
<div class="mt-3 pt-3 border-t border-outline-variant space-y-2">
<!-- 第一行预览在左半部分居中编辑在右半部分居中 -->
<div class="grid grid-cols-2 gap-2">
<div class="flex justify-center">
@@ -77,7 +78,7 @@
<div class="grid grid-cols-2 gap-2">
<div></div>
<div class="flex justify-center">
<button @click="deleteTemplate(template)" class="md3-button-text text-sm !text-red-600 dark:!text-red-500 flex-shrink-0">
<button @click="deleteTemplate(template)" class="md3-button-outlined text-sm !text-red-600 dark:!text-red-500 !border-red-600 dark:!border-red-500 hover:!bg-red-50 dark:hover:!bg-red-900/20 flex-shrink-0">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
@@ -86,7 +87,7 @@
</div>
</div>
</div>
</div>
</a-card>
</div>
<!-- Create/Edit Dialog -->
@@ -156,7 +157,7 @@
<!-- 字段配置编辑器 -->
<div class="field-config-editor">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-gray-800 dark:text-gray-100">字段配置</h3>
<h3 class="text-lg font-bold text-on-surface">字段配置</h3>
<a-dropdown>
<a-button type="primary">
添加字段
@@ -188,12 +189,12 @@
</div>
<!-- 递归渲染字段树 -->
<div v-if="Object.keys(formData.field_config).length === 0" class="text-center py-12 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<svg class="w-16 h-16 mx-auto text-gray-400 dark:text-gray-500 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div v-if="Object.keys(formData.field_config).length === 0" class="text-center py-12 border-2 border-dashed border-outline-variant rounded-lg bg-surface-container">
<svg class="w-16 h-16 mx-auto text-on-surface-variant opacity-40 mb-3" 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>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">暂无字段配置</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">点击上方"添加字段"开始配置模板</p>
<h3 class="text-lg font-semibold text-on-surface mb-2">暂无字段配置</h3>
<p class="text-sm text-on-surface-variant">点击上方"添加字段"开始配置模板</p>
</div>
<div v-else class="space-y-3">
@@ -215,7 +216,7 @@
<span class="text-lg font-bold">JSON 预览</span>
</a-divider>
<div class="bg-gray-900 dark:bg-black text-green-400 p-4 rounded-lg font-mono text-sm overflow-auto max-h-96">
<div class="bg-surface-container text-green-400 p-4 rounded-lg font-mono text-sm overflow-auto max-h-96">
<pre>{{ JSON.stringify(formData.field_config, null, 2) }}</pre>
</div>
</a-form>
@@ -242,7 +243,7 @@
placeholder="例如: Id, Group1, DateTarget"
@keyup.enter="confirmAddField"
/>
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1 block">
<span class="text-xs text-on-surface-variant mt-1 block">
💡 字段名将保持原样不会进行大小写转换
</span>
</a-form-item>
@@ -262,14 +263,14 @@
:style="isMobile ? { top: 0, maxWidth: '100vw' } : {}"
>
<div v-if="previewData" class="space-y-4">
<div class="bg-gray-50 dark:bg-gray-800 rounded p-4">
<h4 class="font-semibold mb-2 text-gray-800 dark:text-gray-100">生成的 Payload使用默认值</h4>
<pre class="text-xs bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 p-3 rounded border dark:border-gray-700 overflow-auto max-h-96">{{ JSON.stringify(previewData.preview_payload, null, 2) }}</pre>
<div class="bg-surface-container rounded p-4">
<h4 class="font-semibold mb-2 text-on-surface">生成的 Payload使用默认值</h4>
<pre class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96">{{ JSON.stringify(previewData.preview_payload, null, 2) }}</pre>
</div>
<div class="bg-gray-50 dark:bg-gray-800 rounded p-4">
<h4 class="font-semibold mb-2 text-gray-800 dark:text-gray-100">字段配置</h4>
<pre class="text-xs bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 p-3 rounded border dark:border-gray-700 overflow-auto max-h-96">{{ JSON.stringify(previewData.field_config, null, 2) }}</pre>
<div class="bg-surface-container rounded p-4">
<h4 class="font-semibold mb-2 text-on-surface">字段配置</h4>
<pre class="text-xs bg-surface text-on-surface p-3 rounded border border-outline-variant overflow-auto max-h-96">{{ JSON.stringify(previewData.field_config, null, 2) }}</pre>
</div>
</div>