重构ScheduledJobList.vue为根据字段类型动态渲染

This commit is contained in:
jingrow 2025-11-05 04:49:57 +08:00
parent 7089e04abd
commit f711090e8d

View File

@ -38,12 +38,15 @@
<div v-else> <div v-else>
<!-- 列表视图 --> <!-- 列表视图 -->
<div class="job-list"> <div class="job-list">
<div class="list-header"> <div class="list-header" :style="{ gridTemplateColumns: getGridTemplateColumns() }">
<div class="col-name">{{ t('Method') }}</div> <div
<div class="col-frequency">{{ t('Frequency') }}</div> v-for="field in listViewFields"
<div class="col-cron">{{ t('Cron Format') }}</div> :key="field.fieldname"
<div class="col-status">{{ t('Status') }}</div> class="col-header"
<div class="col-last-execution">{{ t('Last Execution') }}</div> :class="`col-${field.fieldname}`"
>
{{ t(field.label || field.fieldname) }}
</div>
<div class="col-actions">{{ t('Actions') }}</div> <div class="col-actions">{{ t('Actions') }}</div>
</div> </div>
<div class="list-body"> <div class="list-body">
@ -51,30 +54,53 @@
v-for="job in jobs" v-for="job in jobs"
:key="job.name" :key="job.name"
class="list-item" class="list-item"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
@click="openDetail(job.name)" @click="openDetail(job.name)"
> >
<div class="col-name"> <template v-for="field in listViewFields" :key="field.fieldname">
<div class="name">{{ job.method || job.name }}</div> <div class="col-field" :class="`col-${field.fieldname}`">
<div class="description">{{ job.name }}</div> <template v-if="field.fieldtype === 'Check'">
</div> <n-checkbox
<div class="col-frequency"> v-model:checked="job[field.fieldname]"
<span class="badge">{{ t(job.frequency || '—') }}</span> @update:checked="field.fieldname === 'stopped' ? () => handleStatusChange(job) : undefined"
</div> @click.stop
<div class="col-cron"> />
<span v-if="job.cron_format" class="cron-text">{{ job.cron_format }}</span> <span
<span v-else class="text-muted"></span> :class="['status-badge', job[field.fieldname] ? 'stopped' : 'running']"
</div> v-if="field.fieldname === 'stopped'"
<div class="col-status"> >
<n-checkbox v-model:checked="job.stopped" @update:checked="() => handleStatusChange(job)"> {{ job[field.fieldname] ? t('Stopped') : t('Running') }}
</n-checkbox> </span>
<span :class="['status-badge', job.stopped ? 'stopped' : 'running']"> </template>
{{ job.stopped ? t('Stopped') : t('Running') }} <template v-else>
</span> <div v-if="field.fieldname === 'method' || field.fieldname === 'name'" class="col-name-wrapper">
</div> <div class="name">{{ renderFieldValue(field, job).value }}</div>
<div class="col-last-execution"> <div v-if="field.fieldname === 'method' && job.name !== job.method" class="description">{{ job.name }}</div>
<span v-if="job.last_execution" class="datetime-text">{{ formatDateTime(job.last_execution) }}</span> </div>
<span v-else class="text-muted"></span> <span
</div> v-else-if="field.fieldtype === 'Select' && field.fieldname === 'frequency'"
class="badge"
>
{{ t(renderFieldValue(field, job).value) }}
</span>
<span
v-else-if="field.fieldname === 'cron_format' && job.cron_format"
class="cron-text"
>
{{ renderFieldValue(field, job).value }}
</span>
<span
v-else-if="field.fieldtype === 'Datetime' || field.fieldtype === 'Date'"
:class="job[field.fieldname] ? 'datetime-text' : 'text-muted'"
>
{{ renderFieldValue(field, job).value }}
</span>
<span v-else :class="job[field.fieldname] ? '' : 'text-muted'">
{{ renderFieldValue(field, job).value }}
</span>
</template>
</div>
</template>
<div class="col-actions"> <div class="col-actions">
<button <button
:class="['action-btn', job.stopped ? 'start-btn' : 'stop-btn']" :class="['action-btn', job.stopped ? 'start-btn' : 'stop-btn']"
@ -104,10 +130,11 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { t } from '../../shared/i18n' import { t } from '../../shared/i18n'
import { NInput, NSelect, NPagination, NCheckbox, useMessage } from 'naive-ui' import { NInput, NSelect, NPagination, NCheckbox, useMessage } from 'naive-ui'
import axios from 'axios'
import { get_session_api_headers } from '../../shared/api/auth'
import { import {
getScheduledJobs, getScheduledJobs,
toggleScheduledJobStatus, toggleScheduledJobStatus
getFrequencyOptions
} from '../../shared/api/scheduledJobs' } from '../../shared/api/scheduledJobs'
const router = useRouter() const router = useRouter()
@ -124,6 +151,10 @@ const statusFilter = ref('all')
const page = ref(1) const page = ref(1)
const pageSize = ref(parseInt(localStorage.getItem('itemsPerPage') || '10')) const pageSize = ref(parseInt(localStorage.getItem('itemsPerPage') || '10'))
//
const metaFields = ref<any[]>([])
const listViewFields = ref<any[]>([]) //
// //
const frequencyOptions = ref<{ label: string; value: string }[]>([]) const frequencyOptions = ref<{ label: string; value: string }[]>([])
const statusOptions = computed(() => [ const statusOptions = computed(() => [
@ -252,19 +283,139 @@ function formatDateTime(dateTimeStr: string) {
} }
} }
// //
async function loadFrequencyOptions() { function getGridTemplateColumns(): string {
if (listViewFields.value.length === 0) {
return '1fr 120px 150px 100px 180px 120px'
}
//
const columns = listViewFields.value.map((field: any) => {
//
if (field.fieldname === 'method' || field.fieldname === 'name') {
return '1fr' //
} else if (field.fieldname === 'frequency') {
return '120px'
} else if (field.fieldname === 'cron_format') {
return '150px'
} else if (field.fieldname === 'stopped') {
return '120px'
} else if (field.fieldtype === 'Datetime' || field.fieldtype === 'Date') {
return '180px'
} else if (field.fieldtype === 'Check') {
return '100px'
} else {
return '120px' //
}
})
//
columns.push('120px')
return columns.join(' ')
}
//
async function loadMetaFields() {
try { try {
const options = await getFrequencyOptions() const response = await axios.get(
frequencyOptions.value = [ `/api/data/PageType/${encodeURIComponent('Local Scheduled Job')}`,
{ label: t('All'), value: 'all' }, { headers: get_session_api_headers(), withCredentials: true }
...options.map(option => ({ )
label: t(option), const data = response.data?.data || {}
value: option metaFields.value = data.fields || []
}))
] //
listViewFields.value = metaFields.value.filter((field: any) => {
// in_list_view 1
return field.in_list_view === 1 || ['method', 'frequency', 'cron_format', 'stopped', 'last_execution'].includes(field.fieldname)
})
// field_order
const fieldOrder = data.field_order || []
listViewFields.value.sort((a: any, b: any) => {
const indexA = fieldOrder.indexOf(a.fieldname)
const indexB = fieldOrder.indexOf(b.fieldname)
if (indexA === -1 && indexB === -1) return 0
if (indexA === -1) return 1
if (indexB === -1) return -1
return indexA - indexB
})
// frequency
const frequencyField = metaFields.value.find((f: any) => f.fieldname === 'frequency')
if (frequencyField && frequencyField.options) {
const options = typeof frequencyField.options === 'string'
? frequencyField.options.split('\n').filter((opt: string) => opt.trim())
: []
frequencyOptions.value = [
{ label: t('All'), value: 'all' },
...options.map((option: string) => ({
label: t(option.trim()),
value: option.trim()
}))
]
}
} catch (error) { } catch (error) {
console.error('Failed to load frequency options:', error) console.error('Failed to load meta fields:', error)
// 使
listViewFields.value = [
{ fieldname: 'method', label: 'Method', fieldtype: 'Data' },
{ fieldname: 'frequency', label: 'Frequency', fieldtype: 'Select' },
{ fieldname: 'cron_format', label: 'Cron Format', fieldtype: 'Data' },
{ fieldname: 'stopped', label: 'Status', fieldtype: 'Check' },
{ fieldname: 'last_execution', label: 'Last Execution', fieldtype: 'Datetime' }
]
}
}
//
function renderFieldValue(field: any, job: any): any {
const value = job[field.fieldname]
const fieldtype = field.fieldtype || 'Data'
switch (fieldtype) {
case 'Check':
return {
type: 'checkbox',
value: Boolean(value)
}
case 'Datetime':
case 'Date':
if (!value) return { type: 'text', value: '—' }
try {
const date = new Date(value)
return {
type: 'text',
value: date.toLocaleString()
}
} catch {
return { type: 'text', value: value }
}
case 'Select':
// Select
return {
type: 'text',
value: value || '—'
}
case 'Link':
// Link
return {
type: 'text',
value: value || '—'
}
case 'Data':
case 'Small Text':
case 'Text':
default:
return {
type: 'text',
value: value || '—'
}
} }
} }
@ -289,7 +440,7 @@ watch(() => localStorage.getItem('itemsPerPage'), (newValue) => {
}) })
onMounted(() => { onMounted(() => {
loadFrequencyOptions() loadMetaFields()
fetchJobs() fetchJobs()
}) })
</script> </script>
@ -399,7 +550,6 @@ onMounted(() => {
.list-header { .list-header {
display: grid; display: grid;
grid-template-columns: 1fr 120px 150px 100px 180px 120px;
gap: 16px; gap: 16px;
padding: 16px 20px; padding: 16px 20px;
background: #f9fafb; background: #f9fafb;
@ -410,7 +560,7 @@ onMounted(() => {
align-items: center; align-items: center;
} }
.list-header > div { .col-header {
color: #374151; color: #374151;
} }
@ -421,7 +571,6 @@ onMounted(() => {
.list-item { .list-item {
display: grid; display: grid;
grid-template-columns: 1fr 120px 150px 100px 180px 120px;
gap: 16px; gap: 16px;
padding: 16px 20px; padding: 16px 20px;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid #f3f4f6;
@ -438,6 +587,36 @@ onMounted(() => {
border-bottom: none; border-bottom: none;
} }
.col-field {
display: flex;
align-items: center;
gap: 8px;
}
.col-name-wrapper {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
}
.col-name-wrapper .name {
font-weight: 600;
color: #111827;
font-size: 14px;
margin-bottom: 2px;
}
.col-name-wrapper .description {
color: #6b7280;
font-size: 12px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 保留向后兼容的类名 */
.col-name { .col-name {
display: flex; display: flex;
flex-direction: column; flex-direction: column;