chore: 整理项目配置与代码优化

1.  统一前端环境变量命名,新增通用项目标题配置
2.  修复后端接口参数绑定方式,将Depends改为Query适配FastAPI解析
3.  优化表格列排序、搜索过滤功能与页面布局样式
4.  重构路由与状态管理代码,移除循环依赖
5.  更新依赖版本与脚本命令,修复构建警告
6.  调整多语言文案与快捷入口配置
7.  修复通知中心样式与接口返回格式
This commit is contained in:
zhangtao
2026-07-26 23:33:17 +08:00
parent df00863b6c
commit 698e198280
78 changed files with 1142 additions and 1134 deletions
@@ -29,36 +29,36 @@
<!-- 卡片网格 -->
<div v-else-if="!isEmpty" :style="{ padding: `0 ${gutter / 2}px` }">
<ElRow :gutter="gutter">
<ElCol
v-for="(item, index) in items"
:key="item[keyField] ?? index"
:xs="xs"
:sm="sm"
:md="md"
:lg="lg"
:xl="xl"
class="mb-4"
>
<ElCard
class="fa-card"
:class="cardClass"
shadow="hover"
:header-class="headerClass"
:body-class="bodyClass"
:footer-class="footerClass"
@click="(e: MouseEvent) => emit('itemClick', item, e)"
>
<template v-if="$slots.header" #header>
<slot name="header" :item="item" :index="index" />
</template>
<ElCol
v-for="(item, index) in items"
:key="item[keyField] ?? index"
:xs="xs"
:sm="sm"
:md="md"
:lg="lg"
:xl="xl"
class="mb-4"
>
<ElCard
class="fa-card"
:class="cardClass"
shadow="hover"
:header-class="headerClass"
:body-class="bodyClass"
:footer-class="footerClass"
@click="(e: MouseEvent) => emit('itemClick', item, e)"
>
<template v-if="$slots.header" #header>
<slot name="header" :item="item" :index="index" />
</template>
<slot :item="item" :index="index" />
<slot :item="item" :index="index" />
<template v-if="$slots.footer" #footer>
<slot name="footer" :item="item" :index="index" />
</template>
</ElCard>
</ElCol>
<template v-if="$slots.footer" #footer>
<slot name="footer" :item="item" :index="index" />
</template>
</ElCard>
</ElCol>
</ElRow>
</div>
@@ -86,7 +86,15 @@
<script setup lang="ts" generic="T extends Record<string, any>">
import { computed } from "vue";
import { ElCard, ElRow, ElCol, ElEmpty, ElSkeleton, ElSkeletonItem, ElScrollbar } from "element-plus";
import {
ElCard,
ElRow,
ElCol,
ElEmpty,
ElSkeleton,
ElSkeletonItem,
ElScrollbar,
} from "element-plus";
import FaPagination from "@/components/others/fa-pagination/index.vue";
defineOptions({ name: "FaCardGrid" });
@@ -35,7 +35,7 @@
:model-value="modelValue?.created_id == null ? undefined : modelValue.created_id"
@update:model-value="
(v: number | undefined) => {
modelValue.value['created_id'] = v;
modelValue['created_id'] = v;
}
"
@confirm-click="emitImmediateSearch"
@@ -50,7 +50,7 @@
:model-value="modelValue?.updated_id == null ? undefined : modelValue.updated_id"
@update:model-value="
(v: number | undefined) => {
modelValue.value['updated_id'] = v;
modelValue['updated_id'] = v;
}
"
@confirm-click="emitImmediateSearch"
@@ -124,6 +124,7 @@ import { resolveIconForFaSvgIcon } from "@utils";
import { nextTick, onBeforeUnmount, onMounted, watch, ref, computed } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { MagicStick, Position, Tools } from "@element-plus/icons-vue";
import { useSettingsStore } from "@stores";
import { AiChatAPI, ChatSession, ChatSessionDetail } from "@/api/module_ai/chat";
@@ -25,7 +25,10 @@
<ElRadio value="specify">
<div class="flex items-center">
<span class="mr-2">指定</span>
<ElCheckboxGroup v-model="fields.second.specified" :disabled="fields.second.mode !== 'specify'">
<ElCheckboxGroup
v-model="fields.second.specified"
:disabled="fields.second.mode !== 'specify'"
>
<ElCheckbox v-for="v in secondOptions" :key="v" :label="v" :value="v" />
</ElCheckboxGroup>
</div>
@@ -57,7 +60,10 @@
<ElRadio value="specify">
<div class="flex items-center">
<span class="mr-2">指定</span>
<ElCheckboxGroup v-model="fields.minute.specified" :disabled="fields.minute.mode !== 'specify'">
<ElCheckboxGroup
v-model="fields.minute.specified"
:disabled="fields.minute.mode !== 'specify'"
>
<ElCheckbox v-for="v in minuteOptions" :key="v" :label="v" :value="v" />
</ElCheckboxGroup>
</div>
@@ -89,7 +95,10 @@
<ElRadio value="specify">
<div class="flex items-center">
<span class="mr-2">指定</span>
<ElCheckboxGroup v-model="fields.hour.specified" :disabled="fields.hour.mode !== 'specify'">
<ElCheckboxGroup
v-model="fields.hour.specified"
:disabled="fields.hour.mode !== 'specify'"
>
<ElCheckbox v-for="v in hourOptions" :key="v" :label="v" :value="v" />
</ElCheckboxGroup>
</div>
@@ -130,7 +139,10 @@
<ElRadio value="specify">
<div class="flex items-center">
<span class="mr-2">指定</span>
<ElCheckboxGroup v-model="fields.day.specified" :disabled="fields.day.mode !== 'specify'">
<ElCheckboxGroup
v-model="fields.day.specified"
:disabled="fields.day.mode !== 'specify'"
>
<ElCheckbox v-for="v in dayOptions" :key="v" :label="v" :value="v" />
</ElCheckboxGroup>
</div>
@@ -163,7 +175,10 @@
<ElRadio value="specify">
<div class="flex items-center">
<span class="mr-2">指定</span>
<ElCheckboxGroup v-model="fields.month.specified" :disabled="fields.month.mode !== 'specify'">
<ElCheckboxGroup
v-model="fields.month.specified"
:disabled="fields.month.mode !== 'specify'"
>
<ElCheckbox v-for="v in monthOptions" :key="v" :label="v" :value="v" />
</ElCheckboxGroup>
</div>
@@ -200,7 +215,10 @@
<ElRadio value="specify">
<div class="flex items-center">
<span class="mr-2">指定</span>
<ElCheckboxGroup v-model="fields.week.specified" :disabled="fields.week.mode !== 'specify'">
<ElCheckboxGroup
v-model="fields.week.specified"
:disabled="fields.week.mode !== 'specify'"
>
<ElCheckbox v-for="v in weekOptions" :key="v" :label="v" :value="v" />
</ElCheckboxGroup>
</div>
@@ -220,266 +238,293 @@
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-(--el-text-color-secondary)">下次执行:</span>
<span class="text-sm">{{ nextRun || '—' }}</span>
<span class="text-sm">{{ nextRun || "—" }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import cronParser from 'cron-parser'
import dayjs from 'dayjs'
import { computed, nextTick, ref, watch } from "vue";
import cronParser from "cron-parser";
import dayjs from "dayjs";
interface FieldState {
mode: string
rangeStart: string
rangeEnd: string
stepFrom: string
stepInterval: string
specified: string[]
extra: string
mode: string;
rangeStart: string;
rangeEnd: string;
stepFrom: string;
stepInterval: string;
specified: string[];
extra: string;
}
interface Fields {
second: FieldState
minute: FieldState
hour: FieldState
day: FieldState
month: FieldState
week: FieldState
second: FieldState;
minute: FieldState;
hour: FieldState;
day: FieldState;
month: FieldState;
week: FieldState;
}
const defaultField = (init: Partial<FieldState> = {}): FieldState => ({
mode: 'every',
rangeStart: '0',
rangeEnd: '0',
stepFrom: '0',
stepInterval: '1',
mode: "every",
rangeStart: "0",
rangeEnd: "0",
stepFrom: "0",
stepInterval: "1",
specified: [],
extra: '',
extra: "",
...init,
})
});
const fields = ref<Fields>({
second: defaultField({ rangeStart: '0', rangeEnd: '59', stepFrom: '0' }),
minute: defaultField({ rangeStart: '0', rangeEnd: '59', stepFrom: '0' }),
hour: defaultField({ rangeStart: '0', rangeEnd: '23', stepFrom: '0' }),
day: defaultField({ mode: 'every', rangeStart: '1', rangeEnd: '31', stepFrom: '1' }),
month: defaultField({ mode: 'every', rangeStart: '1', rangeEnd: '12', stepFrom: '1' }),
week: defaultField({ mode: 'any', rangeStart: '1', rangeEnd: '7', stepFrom: '1' }),
})
second: defaultField({ rangeStart: "0", rangeEnd: "59", stepFrom: "0" }),
minute: defaultField({ rangeStart: "0", rangeEnd: "59", stepFrom: "0" }),
hour: defaultField({ rangeStart: "0", rangeEnd: "23", stepFrom: "0" }),
day: defaultField({ mode: "every", rangeStart: "1", rangeEnd: "31", stepFrom: "1" }),
month: defaultField({ mode: "every", rangeStart: "1", rangeEnd: "12", stepFrom: "1" }),
week: defaultField({ mode: "any", rangeStart: "1", rangeEnd: "7", stepFrom: "1" }),
});
const activeTab = ref('秒')
const activeTab = ref("秒");
const props = defineProps<{ modelValue?: string }>()
const props = defineProps<{ modelValue?: string }>();
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
"update:modelValue": [value: string];
}>();
const pad = (n: number) => n.toString().padStart(2, '0')
const rangeOptions = (count: number, start = 0) => Array.from({ length: count }, (_, i) => pad(start + i))
const secondOptions = rangeOptions(60)
const minuteOptions = rangeOptions(60)
const hourOptions = rangeOptions(24)
const dayOptions = rangeOptions(31, 1)
const monthOptions = rangeOptions(12, 1)
const weekOptions = rangeOptions(7, 1)
const pad = (n: number) => n.toString().padStart(2, "0");
const rangeOptions = (count: number, start = 0) =>
Array.from({ length: count }, (_, i) => pad(start + i));
const secondOptions = rangeOptions(60);
const minuteOptions = rangeOptions(60);
const hourOptions = rangeOptions(24);
const dayOptions = rangeOptions(31, 1);
const monthOptions = rangeOptions(12, 1);
const weekOptions = rangeOptions(7, 1);
function toSegment(field: FieldState): string {
switch (field.mode) {
case 'every':
return '*'
case 'any':
return '?'
case 'range':
return `${field.rangeStart}-${field.rangeEnd}`
case 'step':
return `${field.stepFrom}/${field.stepInterval}`
case 'specify':
return field.specified.map((v) => parseInt(v)).sort((a, b) => a - b).join(',') || '*'
case 'last':
return 'L'
case 'lastWeekday':
return `${field.extra}W`
case 'lastWeek':
return `${field.extra}L`
case 'nthWeek':
return `${field.rangeStart}#${field.rangeEnd}`
case "every":
return "*";
case "any":
return "?";
case "range":
return `${field.rangeStart}-${field.rangeEnd}`;
case "step":
return `${field.stepFrom}/${field.stepInterval}`;
case "specify":
return (
field.specified
.map((v) => parseInt(v))
.sort((a, b) => a - b)
.join(",") || "*"
);
case "last":
return "L";
case "lastWeekday":
return `${field.extra}W`;
case "lastWeek":
return `${field.extra}L`;
case "nthWeek":
return `${field.rangeStart}#${field.rangeEnd}`;
default:
return '*'
return "*";
}
}
const cron = computed(() => {
const f = fields.value
return `${toSegment(f.second)} ${toSegment(f.minute)} ${toSegment(f.hour)} ${toSegment(f.day)} ${toSegment(f.month)} ${toSegment(f.week)}`
})
const f = fields.value;
return `${toSegment(f.second)} ${toSegment(f.minute)} ${toSegment(f.hour)} ${toSegment(f.day)} ${toSegment(f.month)} ${toSegment(f.week)}`;
});
const cronDesc = computed(() => {
const { second, minute, hour, day, month, week } = fields.value
const W = ['', '周日', '周一', '周二', '周三', '周四', '周五', '周六']
const { second, minute, hour, day, month, week } = fields.value;
const W = ["", "周日", "周一", "周二", "周三", "周四", "周五", "周六"];
// 层级: 年 -> 月 -> 日/周 -> 时 -> 分 -> 秒
const parts: string[] = []
parts.push('每年')
const parts: string[] = [];
parts.push("每年");
// 月
if (month.mode === 'specify' && month.specified.length) parts.push(`${month.specified.join('、')}月`)
else parts.push('每月')
if (month.mode === "specify" && month.specified.length)
parts.push(`${month.specified.join("、")}月`);
else parts.push("每月");
// 日/周
if (day.mode === 'specify') parts.push(`${day.specified.join('、')}号`)
else if (day.mode === 'last') parts.push('最后一天')
else if (day.mode === 'lastWeekday') parts.push(`${day.extra}号最近工作日`)
else if (week.mode === 'specify') parts.push(week.specified.map((v) => W[+v] || v).join('、'))
else if (week.mode === 'range') parts.push(`${W[+week.rangeStart] || ''}至${W[+week.rangeEnd] || ''}`)
else if (week.mode === 'lastWeek') parts.push(`最后一个${W[+week.extra] || ''}`)
else if (week.mode === 'every') parts.push(day.mode === 'any' ? '每周' : '每天')
else parts.push('每天')
if (day.mode === "specify") parts.push(`${day.specified.join("、")}号`);
else if (day.mode === "last") parts.push("最后一天");
else if (day.mode === "lastWeekday") parts.push(`${day.extra}号最近工作日`);
else if (week.mode === "specify") parts.push(week.specified.map((v) => W[+v] || v).join("、"));
else if (week.mode === "range")
parts.push(`${W[+week.rangeStart] || ""}至${W[+week.rangeEnd] || ""}`);
else if (week.mode === "lastWeek") parts.push(`最后一个${W[+week.extra] || ""}`);
else if (week.mode === "every") parts.push(day.mode === "any" ? "每周" : "每天");
else parts.push("每天");
// 时
if (hour.mode === 'every') parts.push('每时')
else if (hour.mode === 'step') parts.push(`每${hour.stepInterval}时`)
else if (hour.mode === 'specify' && hour.specified.length === 1) parts.push(`${hour.specified[0]}时`)
if (hour.mode === "every") parts.push("每时");
else if (hour.mode === "step") parts.push(`每${hour.stepInterval}时`);
else if (hour.mode === "specify" && hour.specified.length === 1)
parts.push(`${hour.specified[0]}时`);
// 分
if (minute.mode === 'every') parts.push('每分')
else if (minute.mode === 'step') parts.push(`每${minute.stepInterval}分`)
else if (minute.mode === 'specify' && minute.specified.length === 1) parts.push(`${minute.specified[0]}分`)
if (minute.mode === "every") parts.push("每分");
else if (minute.mode === "step") parts.push(`每${minute.stepInterval}分`);
else if (minute.mode === "specify" && minute.specified.length === 1)
parts.push(`${minute.specified[0]}分`);
// 秒
if (second.mode === 'every') parts.push('每秒')
else if (second.mode === 'step') parts.push(`每${second.stepInterval}秒`)
else if (second.mode === 'specify' && second.specified.length === 1) parts.push(`${second.specified[0]}秒`)
if (second.mode === "every") parts.push("每秒");
else if (second.mode === "step") parts.push(`每${second.stepInterval}秒`);
else if (second.mode === "specify" && second.specified.length === 1)
parts.push(`${second.specified[0]}秒`);
return parts.join(' ')
})
return parts.join(" ");
});
let skipParse = false
let skipParse = false;
watch(cron, (val) => {
skipParse = true
emit('update:modelValue', val)
nextTick(() => { skipParse = false })
})
skipParse = true;
emit("update:modelValue", val);
nextTick(() => {
skipParse = false;
});
});
// ---- 解析 cron 表达式 → 回填 fields ----
function parseSegment(seg: string, field: FieldState): void {
if (seg === '*') {
field.mode = 'every'
} else if (seg === '?') {
field.mode = 'any'
} else if (seg === 'L') {
field.mode = 'last'
} else if (seg.includes('W')) {
field.mode = 'lastWeekday'
field.extra = seg.replace('W', '')
} else if (seg.includes('L')) {
field.mode = 'lastWeek'
field.extra = seg.replace('L', '')
} else if (seg.includes('#')) {
field.mode = 'nthWeek'
const [a, b] = seg.split('#')
field.rangeStart = a!
field.rangeEnd = b!
} else if (seg.includes('-')) {
field.mode = 'range'
const [a, b] = seg.split('-')
field.rangeStart = a!
field.rangeEnd = b!
} else if (seg.includes('/')) {
field.mode = 'step'
const [a, b] = seg.split('/')
field.stepFrom = a!
field.stepInterval = b!
} else if (seg.includes(',')) {
field.mode = 'specify'
field.specified = seg.split(',').map((v) => pad(parseInt(v)))
if (seg === "*") {
field.mode = "every";
} else if (seg === "?") {
field.mode = "any";
} else if (seg === "L") {
field.mode = "last";
} else if (seg.includes("W")) {
field.mode = "lastWeekday";
field.extra = seg.replace("W", "");
} else if (seg.includes("L")) {
field.mode = "lastWeek";
field.extra = seg.replace("L", "");
} else if (seg.includes("#")) {
field.mode = "nthWeek";
const [a, b] = seg.split("#");
field.rangeStart = a!;
field.rangeEnd = b!;
} else if (seg.includes("-")) {
field.mode = "range";
const [a, b] = seg.split("-");
field.rangeStart = a!;
field.rangeEnd = b!;
} else if (seg.includes("/")) {
field.mode = "step";
const [a, b] = seg.split("/");
field.stepFrom = a!;
field.stepInterval = b!;
} else if (seg.includes(",")) {
field.mode = "specify";
field.specified = seg.split(",").map((v) => pad(parseInt(v)));
} else if (/^\d+$/.test(seg)) {
field.mode = 'specify'
field.specified = [pad(parseInt(seg))]
field.mode = "specify";
field.specified = [pad(parseInt(seg))];
}
}
function applyCron(expr: string) {
const parts = expr.trim().split(/\s+/)
if (parts.length < 6) return
const keys: (keyof Fields)[] = ['second', 'minute', 'hour', 'day', 'month', 'week']
const parts = expr.trim().split(/\s+/);
if (parts.length < 6) return;
const keys: (keyof Fields)[] = ["second", "minute", "hour", "day", "month", "week"];
keys.forEach((key, i) => {
parseSegment(parts[i]!, fields.value[key])
})
parseSegment(parts[i]!, fields.value[key]);
});
// 自动定位到第一个非默认的 tab
const idx = parts.findIndex((p) => p !== '*' && p !== '?')
const tabMap = ['秒', '分钟', '小时', '日', '月', '周']
activeTab.value = idx >= 0 ? tabMap[idx]! : '秒'
const idx = parts.findIndex((p) => p !== "*" && p !== "?");
const tabMap = ["秒", "分钟", "小时", "日", "月", "周"];
activeTab.value = idx >= 0 ? tabMap[idx]! : "秒";
}
watch(() => props.modelValue, (val) => {
if (skipParse) return
if (val) applyCron(val)
}, { immediate: true })
watch(
() => props.modelValue,
(val) => {
if (skipParse) return;
if (val) applyCron(val);
},
{ immediate: true }
);
// ---- 下一次执行时间 ----
const nextRun = ref('')
const nextRun = ref("");
function calcNextRun(expr: string) {
if (!expr?.trim()) { nextRun.value = ''; return }
if (!expr?.trim()) {
nextRun.value = "";
return;
}
try {
const parser = (cronParser as any).default ?? cronParser
const parser = (cronParser as any).default ?? cronParser;
const interval = parser.parse(expr, {
currentDate: new Date(),
})
nextRun.value = dayjs(interval.next().toDate()).format('YYYY-MM-DD HH:mm:ss')
});
nextRun.value = dayjs(interval.next().toDate()).format("YYYY-MM-DD HH:mm:ss");
} catch {
nextRun.value = '无效表达式'
nextRun.value = "无效表达式";
}
}
watch(cron, (val) => calcNextRun(val), { immediate: true })
watch(cron, (val) => calcNextRun(val), { immediate: true });
const clear = () => {
fields.value = {
second: defaultField({ rangeStart: '0', rangeEnd: '59', stepFrom: '0' }),
minute: defaultField({ rangeStart: '0', rangeEnd: '59', stepFrom: '0' }),
hour: defaultField({ rangeStart: '0', rangeEnd: '23', stepFrom: '0' }),
day: defaultField({ mode: 'every', rangeStart: '1', rangeEnd: '31', stepFrom: '1' }),
month: defaultField({ mode: 'every', rangeStart: '1', rangeEnd: '12', stepFrom: '1' }),
week: defaultField({ mode: 'any', rangeStart: '1', rangeEnd: '7', stepFrom: '1' }),
}
activeTab.value = '秒'
}
second: defaultField({ rangeStart: "0", rangeEnd: "59", stepFrom: "0" }),
minute: defaultField({ rangeStart: "0", rangeEnd: "59", stepFrom: "0" }),
hour: defaultField({ rangeStart: "0", rangeEnd: "23", stepFrom: "0" }),
day: defaultField({ mode: "every", rangeStart: "1", rangeEnd: "31", stepFrom: "1" }),
month: defaultField({ mode: "every", rangeStart: "1", rangeEnd: "12", stepFrom: "1" }),
week: defaultField({ mode: "any", rangeStart: "1", rangeEnd: "7", stepFrom: "1" }),
};
activeTab.value = "秒";
};
defineExpose({ clear })
defineExpose({ clear });
</script>
<style lang="scss" scoped>
.cron-selector {
padding: 12px;
:deep(.el-tabs__content) { padding: 8px; }
:deep(.el-tabs__content) {
padding: 8px;
}
:deep(.el-radio-group) {
.el-radio {
width: 100%;
height: auto;
margin-bottom: 6px;
min-height: 28px;
margin-bottom: 6px;
}
}
:deep(.el-checkbox-group) {
display: flex;
flex-wrap: wrap;
.el-checkbox { margin-right: 6px; height: auto; margin-bottom: 6px; }
.el-checkbox {
height: auto;
margin-right: 6px;
margin-bottom: 6px;
}
}
}
.cron-preview {
margin-top: 12px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
margin-top: 12px;
background-color: var(--el-fill-color);
border-radius: 4px;
}