课程报名

master
lion 2 days ago
parent 95630f1507
commit 9e8048be97

@ -0,0 +1,108 @@
// Legacy value remains a readable string; selected_keys and notes carry new semantics.
export function courseArray(value, fallback = []) {
if (typeof value !== 'string') return value || fallback
try { return JSON.parse(value) || fallback } catch (e) { return fallback }
}
export function courseBlank(value) {
return value == null || (typeof value === 'string' && !value.trim()) || (Array.isArray(value) && !value.length)
}
export function courseSelected(field) {
if (Array.isArray(field.selected_keys)) return field.selected_keys.map(String)
const values = Array.isArray(field.value) ? field.value : field.edit_input === 'checkbox' ? String(field.value || '').split(',') : [field.value]
return (field.select_item || []).filter(o => values.map(String).includes(String(o.value))).map(o => String(o.key))
}
export function prepareCourseFields(fields, user = {}) {
return (fields || []).map(source => {
const field = { ...source, config: courseArray(source.config, {}), select_item: courseArray(source.select_item), option_texts: courseArray(source.option_texts, {}), other_text: source.other_text || '' }
if (!Object.prototype.hasOwnProperty.call(source, 'value') && (field.belong_user === true || Number(field.belong_user) === 1)) {
const key = field.belong_user_table || field.field
field.value = user[key] == null && key === 'username' ? user.name : user[key]
}
if (field.value == null) field.value = ''
if (Array.isArray(field.value)) field.value = field.value.join(',')
field.selected_keys = courseSelected(field)
field.valueArr = field.selected_keys.length ? field.select_item.filter(o => field.selected_keys.includes(String(o.key))).map(o => o.value) : (field.edit_input === 'checkbox' && field.value ? String(field.value).split(',') : [])
field.fileList = courseArray(field.fileList)
field.fileListArr = courseArray(field.fileListArr || field.fileList)
return field
}).sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0) || Number(a.id || 0) - Number(b.id || 0))
}
export function visibleCourseFields(fields) {
const shown = []
return (fields || []).filter(field => {
const condition = (field.config || {}).visible_when
if (condition) {
const parent = shown.find(item => item.field === condition.field)
const values = (condition.values || [condition.value]).map(String)
if (!parent || !courseSelected(parent).some(key => values.includes(key))) return false
}
shown.push(field)
return true
})
}
export function requiredCourseField(field, fields) {
if (fields && !visibleCourseFields(fields).includes(field)) return false
const condition = (field.config || {}).visible_when
if (condition && Array.isArray(condition.required_values) && fields) {
const parent = fields.find(item => item.field === condition.field)
return !!parent && courseSelected(parent).some(key => condition.required_values.map(String).includes(key))
}
return (field.rule || '').split(',').includes('required')
}
export function changeCourseChoice(field, values) {
const selected = values === undefined ? (field.edit_input === 'checkbox' ? field.valueArr : [field.value]) : values
const labels = (Array.isArray(selected) ? selected : [selected]).map(String)
field.selected_keys = field.select_item.filter(o => labels.includes(String(o.value))).map(o => String(o.key))
field.value = field.edit_input === 'checkbox' ? labels.join(',') : (labels[0] || '')
field.valueArr = field.edit_input === 'checkbox' ? labels : []
field.option_texts = Object.keys(field.option_texts || {}).reduce((result, key) => {
if (field.selected_keys.includes(String(key))) result[key] = field.option_texts[key]
return result
}, {})
if (!field.selected_keys.includes(String((field.config || {}).other_key || ''))) field.other_text = ''
}
export function courseNotes(field) {
const selected = courseSelected(field)
const config = field.config || {}
return (field.select_item || []).filter(option => selected.includes(String(option.key)) && config.option_notes && config.option_notes[option.key])
}
export function courseChoiceHint(field) {
const config = field.config || {}
return '最少选择' + (config.min_choices || 1) + '项' + (config.max_choices > 0 ? ',最多选择' + config.max_choices + '项' : '') + ',已选择' + courseSelected(field).length + '项'
}
export function courseFormError(fields) {
for (const field of visibleCourseFields(fields)) {
const fail = message => field.name + ':' + message
const value = field.value
const selected = courseSelected(field)
const empty = ['radio', 'checkbox'].includes(field.edit_input) ? !selected.length : courseBlank(value)
if (empty) { if (requiredCourseField(field, fields)) return fail('请填写此项'); continue }
const config = field.config || {}
if (field.edit_input === 'checkbox') {
if (selected.length < Number(config.min_choices || 1)) return fail('至少选择' + (config.min_choices || 1) + '项')
if (config.max_choices && selected.length > Number(config.max_choices)) return fail('最多选择' + config.max_choices + '项')
}
const rules = (field.rule || '').split(',')
if (rules.includes('mobile') && !/^1[3-9]\d{9}$/.test(String(value))) return fail('手机号格式不正确')
if (rules.includes('email') && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value))) return fail('邮箱格式不正确')
if (rules.includes('integer') && !/^-?\d+$/.test(String(value))) return fail('请输入整数')
if ((rules.includes('numeric') || field.edit_input === 'number') && !Number.isFinite(Number(value))) return fail('请输入有效数字')
if (rules.includes('idcard') && !/^(\d{15}|\d{17}[\dXx])$/.test(String(value))) return fail('身份证格式不正确')
for (const option of courseNotes(field)) {
if (config.option_notes[option.key].required && courseBlank((field.option_texts || {})[option.key])) return fail('请填写补充说明')
}
if (selected.includes(String(config.other_key)) && config.other_required && courseBlank(field.other_text)) return fail('请填写补充说明')
}
return ''
}
export function courseSubmission(fields) {
const visible = visibleCourseFields(fields)
return fields.map(field => {
const shown = visible.includes(field)
return { ...field, hidden: !shown, value: shown ? field.value : null,
selected_keys: shown ? courseSelected(field) : [],
option_texts: shown ? courseNotes(field).reduce((notes, option) => { notes[option.key] = (field.option_texts || {})[option.key] || ''; return notes }, {}) : {},
other_text: shown && courseSelected(field).includes(String((field.config || {}).other_key)) ? field.other_text : '',
fileList: shown ? field.fileList : [], fileListArr: shown ? field.fileListArr : [] }
})
}

@ -36,7 +36,7 @@ function customParamsSerializer(params) {
return result.slice(0, -1);
}
export async function download(url, method = "get", info, filename) {
export async function download(url, method = "get", info, filename, downloadOptions = {}) {
loading = Loading.service({
lock: true,
background: "rgba(0,0,0,0.4)",
@ -48,7 +48,7 @@ export async function download(url, method = "get", info, filename) {
url,
method,
responseType: "blob",
timeout: 30000,
timeout: downloadOptions.timeout || 30000,
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
@ -88,6 +88,11 @@ export async function download(url, method = "get", info, filename) {
"";
}
// Registration exports include attachments in a ZIP when present.
if ((response.headers["content-type"] || "").includes("application/zip")) {
filename = (filename || "报名信息.xlsx").replace(/\.(xlsx|xls|zip)$/i, "") + ".zip";
}
// 将二进制流转为blob
const blob = new Blob([response.data], {
type: "application/octet-stream",

@ -93,7 +93,7 @@
<el-button type="primary" size="small" @click="resetSelect">重置</el-button>
<el-button type="primary" size="small" @click="importTable">导入</el-button>
<el-button style="margin-right:10px" type="primary" size="small" @click="exportExcel">导出</el-button>
<el-button style="margin-right:10px" type="primary" size="small" @click="exportExcel">导出报名信息及附件</el-button>
<el-button type="primary" size="small" @click="openBatchAudit">批量审核</el-button>
</div>
</div>
@ -243,7 +243,7 @@
selectids: [],
list: [],
total: 0,
table_item: [{
table_item: [{ prop: 'replacement_text', label: '报名更换记录', width: 260, align: 'left' }, {
type: 'selection',
width: 50,
fixed: 'left',
@ -614,7 +614,7 @@
page: 1,
page_size: 999
},
`${this.subjectObj.title}报名信息.xlsx`)
`${this.subjectObj.title}报名信息.xlsx`, { timeout: 120000 })
}
}
}

@ -0,0 +1,39 @@
<template>
<div class="course-answer-field">
<el-input v-if="field.readonly" :value="field.value" type="textarea" disabled />
<el-select v-else-if="['radio', 'checkbox'].includes(field.edit_input)" :disabled="!!field.readonly" :value="selection" :multiple="field.edit_input === 'checkbox'" :multiple-limit="field.config.max_choices || 0" clearable filterable :placeholder="field.help || '请选择'" @change="choose">
<el-option v-for="option in field.select_item" :key="option.key" :value="String(option.key)" :label="String(option.value)" />
</el-select>
<el-input v-else-if="field.edit_input !== 'date' && field.edit_input !== 'datetime'" v-model="field.value" :disabled="!!field.readonly" :type="field.edit_input === 'textarea' ? 'textarea' : 'text'" :placeholder="field.help || '请输入'" />
<el-date-picker v-else v-model="field.value" :disabled="!!field.readonly" :type="field.edit_input" :value-format="field.edit_input === 'date' ? 'yyyy-MM-dd' : 'yyyy-MM-dd HH:mm:ss'" :placeholder="field.help || '请选择'" />
<div v-if="field.edit_input === 'checkbox'" class="hint">{{ hint(field) }}</div>
<div v-for="option in notes(field)" :key="option.key" class="note">
<div>{{ option.value }} · 补充说明<span v-if="field.config.option_notes[option.key].required" class="required"> *</span></div>
<el-input v-model="field.option_texts[option.key]" type="textarea" :placeholder="field.config.option_notes[option.key].placeholder || '请填写补充说明'" />
</div>
<div v-if="hasOther" class="note"><div>补充说明<span v-if="field.config.other_required" class="required"> *</span></div><el-input v-model="field.other_text" type="textarea" :placeholder="field.config.other_placeholder || '请填写补充说明'" /></div>
</div>
</template>
<script>
import { courseSelected, courseNotes, courseChoiceHint, changeCourseChoice } from '@/utils/courseForm'
export default {
props: { field: { type: Object, required: true }},
computed: {
selection() { const keys = courseSelected(this.field); return this.field.edit_input === 'checkbox' ? keys : (keys[0] || '') },
hasOther() { return this.field.config.other_key && courseSelected(this.field).includes(String(this.field.config.other_key)) }
},
methods: {
hint: courseChoiceHint, notes: courseNotes,
choose(value) {
const keys = (Array.isArray(value) ? value : [value]).map(String)
changeCourseChoice(this.field, this.field.select_item.filter(option => keys.includes(String(option.key))).map(option => option.value))
}
}
}
</script>
<style scoped>
.el-select, .el-date-editor { width: 100%; }
.hint { font-size: 12px; color: #909399; margin-top: 6px; }
.note { padding-top: 12px; white-space: normal; }
.required { color: #f56c6c; }
</style>

@ -0,0 +1,74 @@
<template>
<div>
<el-form-item label="显示条件">
<el-switch :value="!!condition" active-text="满足条件时显示" inactive-text="始终显示" @change="toggleCondition" />
<template v-if="condition">
<el-select :value="condition.field" placeholder="选择前面的单选或多选字段" @change="changeParent">
<el-option v-for="parent in parents" :key="parent.field" :value="parent.field" :label="parent.name" />
</el-select>
<el-select v-model="condition.values" multiple collapse-tags placeholder="任意命中一个选项即可显示">
<el-option v-for="option in parentOptions" :key="option.key" :value="String(option.key)" :label="String(option.value)" />
</el-select>
<el-checkbox :value="Array.isArray(condition.required_values)" @change="toggleRequired">按触发选项设置必填</el-checkbox>
<el-select v-if="Array.isArray(condition.required_values)" v-model="condition.required_values" multiple collapse-tags placeholder="命中这些选项时必填,其他显示选项为选填">
<el-option v-for="option in parentOptions" :key="option.key" :value="String(option.key)" :label="String(option.value)" :disabled="!condition.values.includes(String(option.key))" />
</el-select>
</template>
</el-form-item>
<template v-if="field.edit_input === 'checkbox'">
<el-form-item label="最少选择几项"><el-input-number :value="field.config.min_choices || 1" :min="1" :max="options.length || 1" :precision="0" @change="minimum" /></el-form-item>
<el-form-item label="最多选择几项"><el-input-number :value="field.config.max_choices || 0" :min="0" :max="options.length || 1" :precision="0" @change="maximum" /><div class="hint">0 表示不限;具体数量不能小于最少选择数。</div></el-form-item>
</template>
<el-form-item v-if="['radio', 'checkbox'].includes(field.edit_input)" label="选项补充说明">
<div v-for="option in options" :key="option.key" class="note-setting">
<div>{{ option.value || '未命名选项' }} <el-switch :value="!!notes[option.key]" active-text="选中后填写" @change="toggleNote(option.key, $event)" /></div>
<template v-if="notes[option.key]">
<el-input v-model="notes[option.key].placeholder" maxlength="255" placeholder="请输入补充说明的 placeholder" />
<el-checkbox v-model="notes[option.key].required">选中后必须填写</el-checkbox>
</template>
</div>
</el-form-item>
</div>
</template>
<script>
export default {
props: { field: { type: Object, required: true }, fields: { type: Array, default: () => [] }},
computed: {
condition() { return this.field.config.visible_when },
options() { return this.field.select_item || [] },
notes() { return this.field.config.option_notes || {} },
parents() {
const sorted = [...this.fields.filter(item => item.id !== this.field.id || !item.id), this.field].filter((item, index, all) => all.findIndex(other => other.field === item.field) === index)
.sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0) || Number(a.id || Number.MAX_SAFE_INTEGER) - Number(b.id || Number.MAX_SAFE_INTEGER))
const index = sorted.findIndex(item => item.field === this.field.field)
return sorted.slice(0, index).filter(item => ['radio', 'checkbox'].includes(item.edit_input))
},
parentOptions() { const parent = this.parents.find(item => item.field === (this.condition || {}).field); return parent ? parent.select_item || [] : [] }
},
watch: {
'field.config.visible_when.values'(values) {
if (this.condition && Array.isArray(this.condition.required_values)) {
const kept = this.condition.required_values.filter(value => (values || []).includes(value))
if (kept.length !== this.condition.required_values.length) this.$set(this.condition, 'required_values', kept)
}
}
},
methods: {
toggleCondition(enabled) { if (enabled) this.$set(this.field.config, 'visible_when', { field: '', values: [] }); else this.$delete(this.field.config, 'visible_when') },
changeParent(value) { this.condition.field = value; this.condition.values = []; if (Array.isArray(this.condition.required_values)) this.condition.required_values = [] },
toggleRequired(enabled) { if (enabled) this.$set(this.condition, 'required_values', []); else this.$delete(this.condition, 'required_values') },
minimum(value) { this.$set(this.field.config, 'min_choices', value || 1); if (this.field.config.max_choices && this.field.config.max_choices < value) this.$set(this.field.config, 'max_choices', value) },
maximum(value) { if (value && value < (this.field.config.min_choices || 1)) { this.$message.warning('最多选择项数不能小于最少选择项数'); return } this.$set(this.field.config, 'max_choices', value || 0) },
toggleNote(key, enabled) {
if (!this.field.config.option_notes) this.$set(this.field.config, 'option_notes', {})
if (enabled) this.$set(this.field.config.option_notes, key, { placeholder: '', required: false })
else this.$delete(this.field.config.option_notes, key)
}
}
}
</script>
<style scoped>
.el-select { width: 100%; margin: 6px 0; }
.hint { color: #909399; line-height: 1.6; }
.note-setting { padding: 10px 0; border-bottom: 1px solid #ebeef5; }
</style>

@ -131,6 +131,21 @@
</div>
</div>
</template>
<template v-slot:one_person_per_company v-if="active===0">
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">每家公司限一人报名:</div>
<div class="xy-table-item-content">
<el-radio-group v-model="form.one_person_per_company">
<el-radio :label="0">否</el-radio>
<el-radio :label="1">是</el-radio>
</el-radio-group>
<div style="color:#909399;font-size:12px;line-height:20px;margin-top:8px">开启后优先按本次报名填写的公司名称判断,未填写或表单无公司字段时使用学员个人信息中的公司名称;同公司更换需本人确认,原报名调整为主动放弃,新报名重新审核。</div>
<el-alert v-if="form.one_person_per_company && companyLimitDuplicates.length" type="warning" :closable="false" title="以下公司存在多条历史有效报名,请在报名管理中核实处理:">
<div v-for="item in companyLimitDuplicates" :key="item.ids.join(',')">{{ item.company || '公司名称未填写' }}:报名ID {{ item.ids.join('、') }}</div>
</el-alert>
</div>
</div>
</template>
<template v-slot:sign_end_date v-if="active===0">
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">
@ -165,7 +180,7 @@
<span style="color: red;font-weight: bold;padding-right: 4px;"></span>签到地点:
</div>
<div class="xy-table-item-content">
<xy-map raw v-model="addrMapForm" :zoom="mapparams.zoom" />
<xy-map raw v-model="addrMapForm" :zoom="mapparams.zoom" />
</div>
</div>
</template>
@ -417,6 +432,7 @@
imgList: [],
qunList: [],
publicizeList: [],
companyLimitDuplicates: [],
form: {
step: '', // 控制课程 课表
type: '',
@ -429,6 +445,7 @@
total: '',
sign_start_date: '',
sign_end_date: '',
one_person_per_company: 0,
status: 0,
address_detail: '',
@ -708,6 +725,7 @@
duration: 2000
})
this.id = res.id
this.companyLimitDuplicates = res.company_limit_duplicates || []
this.$emit('refresh')
this.active = 1
// this.active = 1
@ -771,7 +789,9 @@
id: this.id,
show_relation: ['image', 'qun_image']
}).then(res => {
this.companyLimitDuplicates = res.company_limit_duplicates || []
this.form = this.base.requestToForm(res, this.form)
this.form.one_person_per_company = Number(res.one_person_per_company || 0)
if (res.image) {
this.imgList.push(res.image)
}
@ -876,6 +896,7 @@
total: '',
sign_start_date: '',
sign_end_date: '',
one_person_per_company: 0,
status: 0,
applylabel: '',
address_detail: '',

@ -12,6 +12,7 @@
</el-select>
</div>
<div style="cursor: pointer;">
<el-switch v-model="previewMode" active-text="联动预览" style="margin-right:20px" />
<Button type="primary" @click="dialogVisible = false">完成并关闭</Button>
</div>
</div>
@ -38,20 +39,21 @@
label-position="top" size="small">
<!-- <el-divider>表单字段</el-divider> -->
<!-- <el-form-item :label="i.name" :required="
i.rule ? !!i.rule.includes('required') : false
previewMode ? isRequired(i) : (i.rule ? !!i.rule.includes('required') : false)
" v-for="(i, index) in defaultFormList">
<el-input :placeholder="i.help?i.help:'表单字段'" :disabled="true"></el-input>
</el-form-item> -->
<draggable :value="formList" group="items" @change="changeHandler" style="width:100%">
<el-form-item style="flex-basis: 100%;" :label="i.name || '字段名称'" :required="
i.rule ? !!i.rule.includes('required') : false
" v-for="(i, index) in formList" @click.native="selectPick(i, index)">
<formSlotRender :config="i" :index="index"></formSlotRender>
previewMode ? isRequired(i) : (i.rule ? !!i.rule.includes('required') : false)
" v-for="(i, index) in formList" :key="i.id || i.field || index" v-show="!previewMode || visibleForms.includes(i)" @click.native="selectPick(i, index)">
<CourseAnswerField v-if="i.edit_input !== 'files' && i.edit_input !== 'file'" :field="i" />
<formSlotRender v-else :config="i" :index="index"></formSlotRender>
</el-form-item>
</draggable>
<!-- <el-divider>选填字段</el-divider> -->
<!-- <el-form-item style="flex-basis: 100%;" :label="i.name" :required="
i.rule ? !!i.rule.includes('required') : false
previewMode ? isRequired(i) : (i.rule ? !!i.rule.includes('required') : false)
" v-for="(i, index) in selectFormList"
@click.native="selectPick(i, index)">
<el-input style="width:100%;" :placeholder="i.help?i.help:'表单字段'"></el-input>
@ -108,6 +110,8 @@
</template>
<script>
import CourseAnswerField from "./CourseAnswerField.vue";
import { prepareCourseFields, visibleCourseFields, requiredCourseField } from "@/utils/courseForm";
import formMixins from "@/mixin/formMixin.js";
import {
config
@ -131,11 +135,13 @@
mixins: [formMixins],
components: {
formSlotRender,
CourseAnswerField,
editPane
},
data() {
return {
course_id: "",
previewMode: false,
course_signs_count: 0,
select: {
page: 1,
@ -159,11 +165,13 @@
selectedIndex: 0,
}
},
computed: { visibleForms() { return visibleCourseFields(this.formList) } },
created() {
this.getConfig()
this.getCourseList()
},
methods: {
isRequired(field) { return requiredCourseField(field, this.formList) },
// 获取有 自定义表单的 课程
async getCourseList() {
const res = await courseIndex({
@ -226,32 +234,32 @@
if (!this.formList || this.formList.length === 0) {
return
}
const updatePromises = []
// 遍历克隆后的表单字段
this.formList.forEach(formField => {
// 检查是否是学员字段且有下拉选项
if (formField.belong_user_table &&
if (!(formField.config && (formField.config.visible_when || formField.config.option_notes || formField.config.min_choices > 1 || formField.config.max_choices > 0)) && formField.belong_user_table &&
(formField.edit_input === 'radio' || formField.edit_input === 'checkbox')) {
// 从 selectFormList 中查找对应的学员字段配置
const matchedField = this.selectFormList.find(item =>
const matchedField = this.selectFormList.find(item =>
item.belong_user_table === formField.belong_user_table
)
if (matchedField && matchedField.select_item && matchedField.select_item.length > 0) {
// 比较选项是否发生变化
const oldSelectItem = formField.select_item || []
const newSelectItem = matchedField.select_item
const isChanged = JSON.stringify(oldSelectItem) !== JSON.stringify(newSelectItem)
if (isChanged) {
// 更新字段的下拉选项
const updatedField = {
...formField,
select_item: deepCopy(newSelectItem)
}
// 添加到更新队列
updatePromises.push(
save(updatedField).then(() => {
@ -264,7 +272,7 @@
}
}
})
// 批量更新
if (updatePromises.length > 0) {
await Promise.all(updatePromises)
@ -296,7 +304,7 @@
}],
...this.select
})
this.formList = res.data
this.formList = prepareCourseFields(res.data)
// this.updateObjects(this.selectFormList, this.formList)
// let _arr = this.removeMatchingItems(this.selectFormList, this.formList)
// this.formList.push(..._arr)
@ -350,7 +358,8 @@
sort: newIndex,
help: "",
rule: "",
select_item: []
select_item: [],
config: {}, value: '', selected_keys: [], option_texts: {}, other_text: ''
};
this.formList.splice(newIndex, 0, newItem)
}

@ -13,7 +13,7 @@
<el-input v-model="selectedForm.name" placeholder="请输入字段名称" @input="inputHandler"></el-input>
</el-form-item>
<el-form-item required prop="field" label="字段标识">
<el-input :disabled="selectedForm.belong_user?true:false" v-model="selectedForm.field"></el-input>
<el-input :disabled="!!selectedForm.id || !!selectedForm.belong_user" v-model="selectedForm.field"></el-input>
</el-form-item>
<el-form-item prop="help" label="帮助文字">
<el-input v-model="selectedForm.help" placeholder="请输入帮助文字"></el-input>
@ -27,21 +27,26 @@
<div class="select-item__header">
<span>键</span>
<span>值</span>
<span>操作</span>
</div>
<div class="select-item__body" v-for="(i, index) in selectItem">
<div><el-input v-model="i.key" size="mini"></el-input></div>
<div class="select-item__body" v-for="(i, index) in selectItem" :key="index">
<div><el-input v-model="i.key" :disabled="isSavedOption(i)" size="mini" placeholder="请输入选项键"></el-input></div>
<div><el-input v-model="i.value" size="mini"></el-input></div>
<div>
<div class="select-item__actions">
<el-button type="text" size="mini" icon="el-icon-top" :disabled="index === 0" title="上移" @click="moveOption(index, -1)"></el-button>
<el-button type="text" size="mini" icon="el-icon-bottom" :disabled="index === selectItem.length - 1" title="下移" @click="moveOption(index, 1)"></el-button>
<el-button type="danger" size="mini" circle icon="el-icon-delete"
@click="selectItem.splice(index, 1)"></el-button>
</div>
</div>
<el-button type="primary" size="mini" circle icon="el-icon-plus"
@click="selectItem.push({ key: '', value: '' })"></el-button>
@click="addOption"></el-button>
<div class="select-item__hint">已保存选项的键保持不变;可新增选项,并通过上移、下移调整顺序。</div>
</div>
</el-form-item>
<CourseFieldRules v-if="selectedForm.config" :field="selectedForm" :fields="formList" />
<el-form-item prop="validation" label="校验规则">
<el-select filterable v-model="validation" clearable multiple collapse-tags :popper-append-to-body="false"
placeholder="请选择校验规则" style="width: 100%">
@ -69,6 +74,7 @@
</template>
<script>
import CourseFieldRules from "./CourseFieldRules.vue";
import {
save,
destroy
@ -84,6 +90,7 @@
export default {
mixins: [formMixins],
components: { CourseFieldRules },
props: {
rules: Object,
types: Array,
@ -105,6 +112,7 @@
selectedForm: {}, // 选择的表单item数据
selectedIndex: 0, // 选择的表单item index
selectItem: [], //自定义下拉内容,先绑定数组后转为对象
savedOptions: [], // Track original objects, not keys: a new row remains editable even before duplicate-key validation.
validation: [], //效验规则
parameters: [], //数据字典
formList:[],
@ -112,6 +120,18 @@
};
},
methods: {
isSavedOption(option) {
return this.savedOptions.includes(option)
},
addOption() {
this.selectItem.push({ key: '', value: '' })
},
moveOption(index, direction) {
const target = index + direction
if (target < 0 || target >= this.selectItem.length) return
const option = this.selectItem.splice(index, 1)[0]
this.selectItem.splice(target, 0, option)
},
checkChinese(rule, value, callback) {
const reg = /^[\u4e00-\u9fa5]+$/;
if (reg.test(value)) {
@ -167,7 +187,7 @@
return
}
// 确保使用最新的 selectItem(深拷贝避免引用问题)
this.selectedForm.select_item = deepCopy(this.selectItem)
this.selectedForm.select_item = this.selectItem
this.selectedForm.rule = this.validation.join(",")
// 字段标识不能重复
@ -192,8 +212,12 @@
}
}
}
if (this.selectedForm.config && this.selectedForm.config.option_notes) {
const keys = this.selectItem.map(option => String(option.key))
Object.keys(this.selectedForm.config.option_notes).forEach(key => { if (!keys.includes(String(key))) this.$delete(this.selectedForm.config.option_notes, key) })
}
// return
save(this.selectedForm).then(res => {
save(deepCopy(this.selectedForm)).then(res => {
this.$Message.success('保存成功')
this.$emit("refreshFormItem", 'save')
this.showForm = false
@ -221,6 +245,8 @@
console.log("newval", newVal)
if (newVal) {
this.savedOptions = newVal.id && Array.isArray(newVal.select_item) ? newVal.select_item.slice() : []
if (!newVal.config || Array.isArray(newVal.config)) this.$set(newVal, 'config', {})
if (newVal.select_item) {
this.selectItem = [];
this.selectItem = newVal.select_item
@ -268,26 +294,41 @@
&__header {
font-weight: 600;
display: flex;
gap: 6px;
&>span {
flex-basis: 30%;
flex: 1;
min-width: 0;
&+span {
margin-left: 10px;
margin-left: 0;
}
&:last-child { flex: 0 0 90px; }
}
}
&__body {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
&>div {
flex-basis: 30%;
flex: 1;
min-width: 0;
&+div {
margin-left: 10px;
margin-left: 0;
}
}
}
&__body > &__actions {
flex: 0 0 90px;
display: flex;
align-items: center;
justify-content: flex-end;
.el-button + .el-button { margin-left: 5px; }
}
&__hint { color: #909399; font-size: 12px; line-height: 1.6; margin-top: 8px; text-align: left; }
}
</style>

@ -4,13 +4,15 @@
:title="type === 'add' ? '新增报名信息' : '编辑报名信息'" :form="form" :rules='rules' @submit="submit">
<template v-slot:show>
<div class="flex-content">
<div v-for="(item,index) in fieldForm" :style="item.edit_input==='files'?'flex-basis:100%':'flex-basis:50%'">
<div v-for="(item,index) in visibleFields" :key="item.field" :style="item.edit_input==='files'?'flex-basis:100%':'flex-basis:50%'">
<div class="xy-table-item">
<div class="xy-table-item-label" style="font-weight: bold">
<span v-if="!base.isNull(item.rule) && item.rule.indexOf('required')!=-1"
<span v-if="isRequired(item)"
style="color: red;font-weight: bold;padding-right: 4px;">*</span>{{item.name}}:
</div>
<div class="xy-table-item-content">
<CourseAnswerField v-if="item.edit_input !== 'files'" :field="item" />
<template v-else>
<!-- 输入框 -->
<div v-if="item.edit_input==='text'">
<el-input v-model="item.value" :placeholder="item.help" clearable
@ -59,6 +61,7 @@
<el-input v-model="item.value" :placeholder="item.help" clearable
style="width: 100%;"></el-input>
</div>
</template>
</div>
</div>
</div>
@ -69,6 +72,8 @@
</template>
<script>
import CourseAnswerField from "./CourseAnswerField.vue";
import { prepareCourseFields, visibleCourseFields, requiredCourseField, courseFormError, courseSubmission } from "@/utils/courseForm";
import formMixin from "@/mixin/formMixin.js";
import {
save
@ -79,6 +84,8 @@
export default {
mixins: [formMixin],
components: { CourseAnswerField },
computed: { visibleFields() { return visibleCourseFields(this.fieldForm) } },
data() {
return {
isShow: false,
@ -97,6 +104,7 @@
}
},
methods: {
isRequired(item) { return requiredCourseField(item, this.fieldForm) },
uploadSuccess(response, file, fileList,item){
console.log("succ",fileList)
this.$set(item,'fileList',fileList)
@ -131,11 +139,13 @@
}
}
// 个人信息
if(!this.base.isNull(item.belong_user_table)){
if(!item.readonly && visibleCourseFields(this.fieldForm).includes(item) && !this.base.isNull(item.belong_user_table)){
user[item.belong_user_table] = item.value
}
//
})
const validationError = courseFormError(this.fieldForm)
if (validationError) { this.$message.warning(validationError); return }
console.log("this.fieldForm",this.fieldForm)
// 更新个人信息
@ -144,8 +154,9 @@
// 提交报名信息
saveData({
...this.row,
data:this.fieldForm
data:courseSubmission(this.fieldForm)
}).then(res=>{
if (!res || res.errcode) return
save({
id:this.row.user_id,
...user
@ -164,26 +175,16 @@
isShow(newVal) {
if (newVal) {
if (this.type === 'editor') {
console.log("row", this.row,this.formList)
let _obj = {}
if (this.row.data && this.row.data.length > 0) {
this.row.data.map(item => {
this.formList.map(item1=>{
if(item.field===item1.field){
// 多选的改为 数组
if (item1.edit_input === 'checkbox') {
console.log(item.value)
item1.valueArr = item.value?item.value.split(","):[]
}else{
item1.value = item.value
}
}
})
})
this.fieldForm = this.base.deepCopy(this.formList)
}else{
this.fieldForm = []
}
const stored = this.row.data || []
const current = this.formList
const snapshot = (this.row.form_snapshot || {}).fields
const definitions = snapshot || (stored.length ? stored : current)
this.formList = prepareCourseFields(definitions.map(field => {
const matched = current.find(item => item.field === field.field)
const answer = stored.find(item => item.field === field.field) || {}
return { ...field, ...answer, config: snapshot ? field.config : {}, readonly: !snapshot && !matched, belong_user_table: (matched || field).belong_user_table }
}))
this.fieldForm = this.base.deepCopy(this.formList)
// this.submitForm = this.base.deepCopy(_obj)
}
} else {

@ -56,6 +56,7 @@
</lx-header> -->
<template v-if="type==='check' || type==='checkshow' || type==='setPosition'">
<!-- <el-descriptions class="margin-top" :column="2" border> -->
<el-descriptions-item :span="2" label="本次报名表单"><span style="color:#909399">以下为本次提交的答案,个人资料后续变更不会覆盖。</span></el-descriptions-item>
<template v-for="item in sign_info">
<el-descriptions-item :span="2" v-if="item.submit_files && item.submit_files.length>0">
<template slot="label">
@ -74,7 +75,7 @@
<div v-else style="display:inline-block;margin:10px;">
<a :href="host+file" target="_blank" style="color:#409EFF;text-decoration:none;">
<i class="el-icon-document"></i>
{{getFileName(file)}}
{{getAnswerFileName(item, file)}}
</a>
</div>
</template>
@ -84,7 +85,11 @@
<template slot="label">
{{item.name}}
</template>
<div style="word-break: break-word; word-wrap: break-word; overflow-wrap: break-word;">{{item.value}}</div>
<div v-if="item.edit_input === 'radio' || item.edit_input === 'checkbox'" style="margin-bottom:8px">
<el-tag v-for="option in item.select_item" :key="option.key" :effect="answerSelected(item, option) ? 'dark' : 'plain'" :type="answerSelected(item, option) ? '' : 'info'" style="margin:0 8px 4px 0">{{option.value}}</el-tag>
</div>
<div style="white-space:pre-wrap;word-break: break-word; word-wrap: break-word; overflow-wrap: break-word;">{{item.display_value !== undefined ? item.display_value : item.value}}</div>
<div v-if="item.supplement_text" style="white-space:pre-wrap;color:#606266;margin-top:8px">{{item.supplement_text}}</div>
</el-descriptions-item>
</template>
@ -172,6 +177,7 @@
<span>评分:</span>
<span>{{sign_result.score}}</span>
</div>
<div v-if="sign_result.replacement_text"><span>报名更换记录:</span>{{ sign_result.replacement_text }}</div>
<div v-if="sign_result.status===5">
<span>放弃原因:</span>
<span style="word-break: break-word; word-wrap: break-word; overflow-wrap: break-word;">{{sign_result.giveup_reason}}</span>
@ -342,6 +348,16 @@
},
created() {},
methods: {
getAnswerFileName(item, url) {
const files = item.fileList || item.fileListArr || []
const found = files.map(file => file.response || file).find(file => String(file.url || '').endsWith(url))
return found ? (found.original_name || found.name || this.getFileName(url)) : this.getFileName(url)
},
answerSelected(item, option) {
if (Array.isArray(item.selected_keys)) return item.selected_keys.map(String).includes(String(option.key))
const values = item.edit_input === 'checkbox' ? String(item.value || '').split(',') : [String(item.value == null ? '' : item.value)]
return values.includes(String(option.value))
},
// 判断文件是否为图片
isImageFile(filePath) {
if (!filePath) return false
@ -437,14 +453,15 @@
// 提取出 自定义表单中不属于user的字段
let _arr = []
if (res.data && Array.isArray(res.data)) {
res.data.map(item => {
(res.form_answers || res.data).map(item => {
if (item.hidden) return
if (item.edit_input === 'files') {
if (!this.base.isNull(item.value) && item.value.indexOf("/storage/files/") !== -1) {
item.submit_files = item.value.split(",")
}
}
// 处理自定义字段中是否 是个人用户信息
if (!this.student_info.hasOwnProperty(item.field)) {
if (item.field) {
console.log("item.field")
_arr.push(item)
}

@ -32,8 +32,11 @@
可自由添加、删除选项并编辑每项分值(支持 0、负数和小数);5~1
分仅为快捷预设。
</p>
<el-form-item v-if="field.edit_input === 'checkbox'" label="最少选择几项">
<el-input-number :value="field.config.min_choices || 1" :min="1" :max="field.select_item.length || 1" :precision="0" @change="changeMinimum" />
</el-form-item>
<el-form-item v-if="field.edit_input === 'checkbox'" label="最多选择几项">
<el-input-number :value="field.config.max_choices || 0" :min="0" :max="field.select_item.length || 1" :precision="0" @change="$set(field.config, 'max_choices', $event || 0)" />
<el-input-number :value="field.config.max_choices || 0" :min="0" :max="field.select_item.length || 1" :precision="0" @change="changeMaximum" />
<span class="hint">0 表示不限</span>
</el-form-item>
<el-form-item label="选项">
@ -134,6 +137,14 @@ export default {
props: { field: { type: Object, required: true }, dimension: Boolean },
data() { return { fileExtensions: ['zip', 'rar', 'ppt', 'pptx', 'xls', 'xlsx', 'doc', 'docx', 'png', 'gif', 'jpg', 'jpeg', 'pdf', 'mp4'] } },
methods: {
changeMaximum(value) {
if (value && value < (this.field.config.min_choices || 1)) { this.$message.warning('最多选择项数不能小于最少选择项数'); return }
this.$set(this.field.config, 'max_choices', value || 0)
},
changeMinimum(value) {
this.$set(this.field.config, 'min_choices', value || 1)
if (this.field.config.max_choices && this.field.config.max_choices < value) this.$set(this.field.config, 'max_choices', value)
},
noteFor(key) { return (this.field.config.option_notes || {})[key] },
toggleNote(key, enabled) {
if (!this.field.config.option_notes) this.$set(this.field.config, 'option_notes', {})
@ -157,6 +168,7 @@ export default {
}
if (this.field.config.option_notes) this.$delete(this.field.config.option_notes, this.field.select_item[index].key)
this.field.select_item.splice(index, 1)
if (this.field.config.min_choices > this.field.select_item.length) this.field.config.min_choices = Math.max(1, this.field.select_item.length)
if (this.field.config.max_choices > this.field.select_item.length) this.field.config.max_choices = this.field.select_item.length
},
ratingOptions() {

@ -15,6 +15,7 @@
>*</span>
</h3>
<p v-if="question.help && question.edit_input !== 'text' && question.edit_input !== 'textarea'" class="help" v-text="question.help" />
<p v-if="question.edit_input === 'checkbox'" class="help">最少选择{{ (question.config || {}).min_choices || 1 }}项<template v-if="(question.config || {}).max_choices">,最多选择{{ question.config.max_choices }}项</template></p>
<p v-if="question.course_content" class="help">
{{ question.course_content.theme }}
<span
@ -61,7 +62,8 @@
/>
<SurveyAttachments
v-if="question.edit_input === 'file'"
:files="question.value || []" :response-id="responseId"
:files="question.value || []"
:response-id="responseId"
/>
<div v-else class="answer">
{{ hasOptions(question) ? "学员选择:" : ""

@ -0,0 +1,115 @@
import { shallowMount, createLocalVue } from '@vue/test-utils'
import ElementUI from 'element-ui'
import Rules from '@/views/course/components/CourseFieldRules.vue'
import Answer from '@/views/course/components/CourseAnswerField.vue'
import SurveySettings from '@/views/survey/components/SurveyFieldSettings.vue'
import CourseEditor from '@/views/course/components/editPane.vue'
import { save as saveCourseField } from '@/api/course/form'
import { prepareCourseFields, visibleCourseFields, courseFormError, courseSubmission } from '@/utils/courseForm'
jest.mock('@/api/course/form', () => ({ save: jest.fn(() => Promise.resolve({ id: 7 })), destroy: jest.fn() }))
const localVue = createLocalVue()
localVue.use(ElementUI)
function mountForm(component, props, mocks = {}) {
const state = new localVue({ data() { return { fields: props } } })
return shallowMount(component, { localVue, propsData: state.fields, mocks })
}
const choices = [{ key: 'tax', value: '财税' }, { key: 'legal', value: '法律' }, { key: 'ip', value: '知识产权' }]
describe('Course and survey form compatibility', () => {
it('allows new option keys and ordering after reopening a saved field, while locking only original keys', async () => {
saveCourseField.mockClear()
const field = { id: 7, field: 'venues', name: '会场', edit_input: 'checkbox', select_item: choices.slice(0, 2).map(option => ({ ...option })), config: { option_notes: { tax: { placeholder: '咨询内容', required: false } } } }
const wrapper = shallowMount(CourseEditor, {
localVue, propsData: { rules: {}, types: [] },
methods: { getAllPara() {}, getCompanyConfig() {} },
mocks: { base: { isNull: value => value == null || String(value).trim() === '' }, $Message: { warning: jest.fn(), success: jest.fn() } }
})
await wrapper.setData({ showForm: true, selectedForm: field })
const original = wrapper.vm.selectItem[0]
expect(wrapper.vm.isSavedOption(original)).toBe(true)
expect(wrapper.findAll('.select-item__body').at(0).find('el-input-stub').attributes('disabled')).toBeDefined()
wrapper.find('el-button-stub[icon="el-icon-plus"]').vm.$emit('click')
await wrapper.vm.$nextTick()
const added = wrapper.vm.selectItem[2]
expect(wrapper.vm.isSavedOption(added)).toBe(false)
expect(wrapper.findAll('.select-item__body').at(2).find('el-input-stub').attributes('disabled')).toBeUndefined()
added.key = 'new'; added.value = '其他'
wrapper.vm.moveOption(2, -1)
wrapper.vm.moveOption(0, 1)
expect(wrapper.vm.selectItem.map(option => option.key)).toEqual(['new', 'tax', 'legal'])
expect(wrapper.vm.isSavedOption(added)).toBe(false)
expect(wrapper.vm.isSavedOption(original)).toBe(true)
wrapper.vm.saveField()
await new Promise(resolve => setTimeout(resolve, 0))
const saved = saveCourseField.mock.calls[0][0]
expect(saved.select_item.map(option => option.key)).toEqual(['new', 'tax', 'legal'])
expect(saved.config.option_notes.tax.placeholder).toBe('咨询内容')
expect(wrapper.emitted('refreshFormItem')).toBeTruthy()
await wrapper.setData({ showForm: true, selectedForm: { ...saved, id: 7 } })
expect(wrapper.vm.selectItem.every(option => wrapper.vm.isSavedOption(option))).toBe(true)
wrapper.destroy()
})
it('sets default minima, keeps unlimited maxima, and rejects a maximum below the minimum', () => {
for (const component of [Rules, SurveySettings]) {
const field = { id: 1, field: 'venues', name: '会场', edit_input: 'checkbox', config: {}, select_item: [...choices] }
const warning = jest.fn()
const wrapper = mountForm(component, { field }, { $message: { warning } })
expect(wrapper.findAll('el-input-number-stub').at(0).props('min')).toBe(1)
if (component === Rules) { wrapper.vm.minimum(2); wrapper.vm.maximum(1); wrapper.vm.maximum(0) } else { wrapper.vm.changeMinimum(2); wrapper.vm.changeMaximum(1); wrapper.vm.changeMaximum(0) }
expect(field.config.min_choices).toBe(2)
expect(field.config.max_choices).toBe(0)
expect(warning).toHaveBeenCalledWith('最多选择项数不能小于最少选择项数')
wrapper.destroy()
}
})
it('configures multiple triggers and required overrides without changing existing field identifiers', () => {
const parent = { id: 1, field: 'finance', sort: 1, name: '融资', edit_input: 'radio', select_item: choices }
const field = { id: 2, field: 'legacy_field', sort: 2, name: '材料', edit_input: 'text', config: {} }
const wrapper = mountForm(Rules, { field, fields: [parent, field] })
wrapper.vm.toggleCondition(true)
wrapper.vm.changeParent('finance')
field.config.visible_when.values = ['tax', 'legal']
wrapper.vm.toggleRequired(true)
field.config.visible_when.required_values = ['tax']
expect(wrapper.vm.parents).toEqual([parent])
expect(field.field).toBe('legacy_field')
expect(field.config.visible_when.required_values).toEqual(['tax'])
wrapper.vm.changeParent('finance')
expect(field.config.visible_when.values).toEqual([])
expect(field.config.visible_when.required_values).toEqual([])
wrapper.destroy()
})
it('restores comma answers, shows selected supplements, enforces limits, and clears deselected notes', async () => {
const fields = prepareCourseFields([{ id: 1, field: 'venues', name: '会场', edit_input: 'checkbox', value: '财税,法律', select_item: choices, config: { min_choices: 2, max_choices: 2, option_notes: { tax: { placeholder: '咨询内容', required: true } } } }])
const wrapper = mountForm(Answer, { field: fields[0] })
expect(wrapper.vm.selection).toEqual(['tax', 'legal'])
expect(wrapper.find('el-input-stub').attributes('placeholder')).toBe('咨询内容')
expect(courseFormError(fields)).toContain('请填写补充说明')
fields[0].option_texts.tax = '问题'
expect(courseFormError(fields)).toBe('')
wrapper.vm.choose(['legal'])
await wrapper.vm.$nextTick()
expect(wrapper.find('el-input-stub').exists()).toBe(false)
expect(fields[0].option_texts).toEqual({})
expect(courseFormError(fields)).toContain('至少选择2项')
wrapper.destroy()
})
it('keeps saved identity values and zero; hidden answers never enter profile/update payloads', () => {
const fields = prepareCourseFields([
{ field: 'username', name: '姓名', edit_input: 'text', belong_user: true, value: '原报名姓名' },
{ field: 'parent', name: '前置', edit_input: 'radio', value: '否', select_item: [{ key: 'no', value: '否' }] },
{ field: 'hidden', name: '条件题', edit_input: 'checkbox', value: '财税', config: { visible_when: { field: 'parent', values: ['yes'] } }, select_item: choices, option_texts: { tax: '旧说明' } },
{ field: 'zero', name: '数量', edit_input: 'number', value: 0 }
], { username: '最新资料姓名' })
expect(fields[0].value).toBe('原报名姓名')
expect(fields[3].value).toBe(0)
expect(visibleCourseFields(fields).map(field => field.field)).toEqual(['username', 'parent', 'zero'])
expect(courseSubmission(fields)[2]).toMatchObject({ hidden: true, value: null, option_texts: {}, selected_keys: [] })
})
})
Loading…
Cancel
Save