P76(T8 验收遗留):快应用上下文的导出按钮此前恒按中心会话树判"导出"锚点 (Data.vue exportable 读 store.modules),锚点判定不生效(fail-closed 方向, 有授权也不画按钮)。本轮把 AppContext 拉到的**目标应用树**经 modules prop 下传, Data.vue 的权限判据改为 permsTree(app=应用树 / center=会话树),canExport 纯函数不动。 Stats.vue 未接:本页无锚点判定也无 store.modules 引用,加 prop 是死代码(差异记 T9 报告)。 P77(T8 验收遗留):应用上下文内的"详情"不再跳回中心路由 /admin/data/detail。 形态二选一取**子路由 /app/:appCode/data/detail**(同一个 AnswerDetail.vue): 地址保留 uuid 可深链/刷新、详情页只有一份实现、back() 本就按 app 回应用数据视图, 写 parentPath 反而造假(menuRoute 的 ROUTE_PATHS 只收静态 path,应用段导航在页内轻导航)。 goDetail 按 props.context 分派;back() 改读 route.params.appCode,撤掉 query.app 双轨。 观察项①(T8 验收记档):确认跨触底页勾选**会被清空**——view-ui-plus 的 data 监听器 用 makeObjBaseData 重建 objData,_isChecked 只认行上的 _checked(点选不回写行), 整组替换后勾选视觉与 getSelection 归零而"已选 N 条"仍累计。新增 withSelectionState() 在 reload/loadMore 两条装载路径按 selected 回填 _checked,selected 为唯一事实源。 演练桩(本地件,dev:mock 生效):baseapi 增 fc-drill 快应用模块树(方案 §6.2 五行含 导入/导出两个 path 空锚点),dataapi 增 /api/form/resolve 桩(快应用数据视图唯一的 合规取数路径是 code→resolve→uuid,契约 §1)。其余外指 appCode 仍旧返回空树(P3 口径)。 门禁:npm run build ✓ / npm run type-check 0 错 ✓; t1~t9 全量复跑:t5 13/0/1、t6 22/0/2、t7 12/0/0、t8 21/0/1、t9 12/0/1 (skip 均为 headless Edge --dump-dom 吐 0 字节的 P73 环境事实,不假通过)。 本提交含 T6~T8 三轮堆在工作区的既有改动(沿 T8 验收 §5 推送分层)。
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 答卷编辑弹层(数据列表与管理/我的两视图共用)。
|
||||
*
|
||||
* 三条硬约束:
|
||||
* 1. **按该答卷钉住的 version 渲染**:入参是详情接口下发的 `groups`(钉住版本内联展开后的形状),
|
||||
* 不是当前发布版本——升版后新增的字段不会在这条旧答卷的弹层里出现(V8 同族,任务书 T8 原文)。
|
||||
* 2. **按同版本校验提交**:校验权威在后端 `FormValueValidator`(它对着钉住版本 `rejectUnknownKeys`
|
||||
* + 必填 + 值形状 + 字典放行集 + 附件归属),前端**不复制一份校验器**——复制了就会有第二套口径,
|
||||
* 与后端漂移时表现为"前端能过、后端拒"或反之。被拒时把后端点名的字段与原因原样显示。
|
||||
* 3. **值文档只带钉住版本里存在的 key**:后端对未知 key 一律 PARAM_ERROR,
|
||||
* 所以历史残留 key 不随保存回传,弹层里点名告知会丢(不静默)。
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { fieldWidget } from "@/components/fields";
|
||||
import { flattenFields, isFileArray } from "@/utils/dataValue";
|
||||
import { dataApi } from "@/api/module/data";
|
||||
import { editMyAnswer } from "@/api/module/fill";
|
||||
import type { AnswerDetail } from "@/types/data";
|
||||
import type { DesignField } from "@/types/formSchema";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
detail: AnswerDetail | null;
|
||||
/** admin=数据管理面(/api/form/admin/data/edit);my=本人编辑(/api/form/my/edit) */
|
||||
mode?: "admin" | "my";
|
||||
}>(),
|
||||
{ mode: "admin" }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "saved", answerId: string): void;
|
||||
}>();
|
||||
|
||||
/** 编辑中的值文档:控件的 modelValue 类型按控件各异(string/number/数组/附件对象数组),这里保持宽松 */
|
||||
const values = ref<Record<string, any>>({});
|
||||
const summary = ref("");
|
||||
const saving = ref(false);
|
||||
|
||||
/** 钉住版本的字段清单(数组序即展示序,契约 §2) */
|
||||
const fields = computed<DesignField[]>(() => flattenFields(props.detail?.groups));
|
||||
|
||||
/** 值文档里有、钉住版本 schema 里没有的 key:不随保存回传(后端 rejectUnknownKeys),点名提示 */
|
||||
const droppedKeys = computed(() => {
|
||||
const known = new Set(fields.value.map((f) => f.key));
|
||||
return Object.keys(props.detail?.values || {}).filter((k) => !known.has(k));
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.detail,
|
||||
(detail) => {
|
||||
const picked: Record<string, any> = {};
|
||||
if (detail) {
|
||||
for (const field of flattenFields(detail.groups)) {
|
||||
const raw = (detail.values || {})[field.key];
|
||||
// 附件/多选的空值给成数组,控件按数组消费;其它空值保持 undefined(后端按"未填"判)
|
||||
if (isFileArray(raw)) picked[field.key] = raw;
|
||||
else if (Array.isArray(raw)) picked[field.key] = raw.slice();
|
||||
else if (raw !== null && raw !== undefined) picked[field.key] = raw;
|
||||
}
|
||||
}
|
||||
values.value = picked;
|
||||
summary.value = "";
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function widgetFor(type: string) {
|
||||
return fieldWidget(type, "pc");
|
||||
}
|
||||
|
||||
function setValue(key: string, value: unknown) {
|
||||
values.value = { ...values.value, [key]: value };
|
||||
}
|
||||
|
||||
const errorText = ref("");
|
||||
|
||||
async function submit() {
|
||||
if (!props.detail || saving.value) return;
|
||||
saving.value = true;
|
||||
errorText.value = "";
|
||||
try {
|
||||
if (props.mode === "my") await editMyAnswer(props.detail.answerId, values.value, summary.value || undefined);
|
||||
else await dataApi.edit(props.detail.answerId, values.value, summary.value || undefined).send();
|
||||
Message.success(`已按钉住版本 v${props.detail.version} 保存`);
|
||||
emit("saved", props.detail.answerId);
|
||||
emit("close");
|
||||
} catch (e: any) {
|
||||
// 后端的 msg 已点名字段与原因(契约 §6:PARAM_ERROR + msg 指明字段)
|
||||
errorText.value = e?.message || "保存失败";
|
||||
Message.error(errorText.value);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-if="detail" :model-value="true" :title="`编辑答卷(钉住版本 v${detail.version})`" :width="720"
|
||||
:mask-closable="false" @on-cancel="emit('close')">
|
||||
<div class="edit-head">
|
||||
<span class="meta">答卷 <code>{{ detail.answerId }}</code></span>
|
||||
<span class="meta">{{ detail.formName }}|{{ detail.appCode }}/{{ detail.code }}</span>
|
||||
<span class="meta">提交时间 {{ detail.submitTime }}</span>
|
||||
</div>
|
||||
<Alert show-icon type="info" class="pin-tip">
|
||||
本弹层按该答卷<b>提交时钉住的 v{{ detail.version }}</b> 渲染与校验;当前发布版本新增的字段不会出现在这里,
|
||||
保存也不会把它们写进这条历史答卷(任务书 T8 的"新 schema 字段不得在旧数据上凭空出现")。
|
||||
</Alert>
|
||||
<Alert v-if="droppedKeys.length" show-icon type="warning" class="pin-tip">
|
||||
值文档里有 {{ droppedKeys.length }} 个不属于该版本的遗留 key({{ droppedKeys.join("、") }}),
|
||||
后端对未知 key 一律拒绝,本次保存不会把它们回传。
|
||||
</Alert>
|
||||
|
||||
<div class="edit-fields">
|
||||
<div v-for="field in fields" :key="field.key" class="edit-field">
|
||||
<label class="field-label">
|
||||
{{ field.label || field.key }}
|
||||
<span v-if="field.required" class="required">*</span>
|
||||
</label>
|
||||
<component :is="widgetFor(field.type)" :field="field" :options="Array.isArray(field.options) ? field.options : []"
|
||||
:model-value="values[field.key]" :readonly="false"
|
||||
@update:model-value="(val: unknown) => setValue(field.key, val)" />
|
||||
<div v-if="field.tip" class="field-tip">{{ field.tip }}</div>
|
||||
</div>
|
||||
<div v-if="!fields.length" class="field-tip">该版本没有可编辑字段。</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<label class="field-label">改动说明(进 answer_op_log)</label>
|
||||
<Input v-model="summary" type="textarea" :rows="2" placeholder="如:学生姓名录错,按学籍号核对后修正" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="emit('close')">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="submit">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edit-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.edit-head code {
|
||||
color: #17233d;
|
||||
}
|
||||
.pin-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.edit-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.edit-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.required {
|
||||
color: #ed4014;
|
||||
}
|
||||
.field-tip {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
.summary-row {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 答卷值的只读呈现(数据详情页 / 我的填写详情 / 编辑弹层的"原值"对照共用)。
|
||||
*
|
||||
* 三条纪律:
|
||||
* 1. **三态空值与已删字段降级**(契约 §3、V8):值在 schema 且为空 → "—";
|
||||
* values 无此 key(提交时字段还不存在)→ "—";values 有 key 但传入的 schema 已删该字段 →
|
||||
* "(已移除字段)原始key: 原始值",并在表尾单列一节,不静默丢弃;
|
||||
* 2. **默认转义**(方案 §2.3 红线):全部走文本插值与 CSS(textarea 用 pre-wrap 保换行),
|
||||
* <b>本组件永不含 v-html</b>,冒烟脚本按全仓 grep 断言零命中;
|
||||
* 3. **只读态才脱敏**(契约 §3 phone):手机号在只读呈现打码,编辑态由弹层自己传原值。
|
||||
*
|
||||
* 附件链接的 URL 前缀与 T7 的图片控件同口径(值里只有相对 key,绝对地址渲染期拼——契约 §3、V24);
|
||||
* "经 FILE_WEB_PATH 真能取到文件"属部署环境断言,按 T3/T9 的分工留到 160 复验。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { EMPTY_TEXT, cellText, flattenFields, isFileArray } from "@/utils/dataValue";
|
||||
import type { DesignField, FileValue } from "@/types/formSchema";
|
||||
import conf from "@/assets/config/host.config";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 钉住版本(或当前发布版本)内联展开后的 groups——后端读接口下发的形状 */
|
||||
groups?: any;
|
||||
/** 答卷值文档 */
|
||||
values?: Record<string, unknown> | null;
|
||||
/** 手机号是否脱敏(只读呈现默认脱敏) */
|
||||
maskPhone?: boolean;
|
||||
}>(),
|
||||
{ groups: () => [], values: null, maskPhone: true }
|
||||
);
|
||||
|
||||
interface FileLink {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ValueRow {
|
||||
key: string;
|
||||
label: string;
|
||||
text: string;
|
||||
multiline: boolean;
|
||||
files: FileLink[];
|
||||
}
|
||||
|
||||
interface GroupView {
|
||||
key: string;
|
||||
label: string;
|
||||
rows: ValueRow[];
|
||||
}
|
||||
|
||||
const schemaFields = computed<DesignField[]>(() => flattenFields(props.groups));
|
||||
|
||||
/** 附件地址由渲染期拼(值里只存相对 key,绝对 URL 不进答卷——契约 §3、方案 §4.3②) */
|
||||
function fileLinks(value: unknown): FileLink[] {
|
||||
if (!isFileArray(value)) return [];
|
||||
const prefix = `${conf.serviceHost || ""}/static/`;
|
||||
return (value as FileValue[]).map((f) => ({
|
||||
name: String(f.fileName ?? f.key ?? ""),
|
||||
url: prefix + String(f.key ?? "")
|
||||
}));
|
||||
}
|
||||
|
||||
const views = computed<GroupView[]>(() => {
|
||||
const groups = Array.isArray(props.groups) ? (props.groups as any[]) : [];
|
||||
return groups.map((group) => {
|
||||
const fields = Array.isArray(group?.fields) ? (group.fields as DesignField[]) : [];
|
||||
return {
|
||||
key: String(group?.key ?? ""),
|
||||
label: String(group?.label ?? ""),
|
||||
rows: fields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label || field.key,
|
||||
text: cellText(field, field.key, props.values, { maskPhone: props.maskPhone }),
|
||||
multiline: field.type === "textarea",
|
||||
files: fileLinks(props.values?.[field.key])
|
||||
}))
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** schema 已删、值文档里还留着的 key(V8 的降级半边) */
|
||||
const removedRows = computed<ValueRow[]>(() => {
|
||||
const known = new Set(schemaFields.value.map((f) => f.key));
|
||||
const values = props.values || {};
|
||||
return Object.keys(values)
|
||||
.filter((key) => !known.has(key))
|
||||
.map((key) => {
|
||||
const raw = values[key];
|
||||
const blank = raw === null || raw === undefined || (typeof raw === "string" && raw.trim() === "");
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
text: blank ? EMPTY_TEXT : cellText(null, key, values),
|
||||
multiline: false,
|
||||
files: []
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="answer-values">
|
||||
<section v-for="group in views" :key="group.key" class="value-group">
|
||||
<h5 v-if="group.label" class="group-title">{{ group.label }}</h5>
|
||||
<div v-for="row in group.rows" :key="row.key" class="value-row">
|
||||
<span class="value-label">{{ row.label }}</span>
|
||||
<span :class="['value-text', row.multiline ? 'pre-wrap' : '']">{{ row.text }}</span>
|
||||
<span v-if="row.files.length" class="value-files">
|
||||
<a v-for="file in row.files" :key="file.url" :href="file.url" target="_blank" rel="noopener">
|
||||
{{ file.name }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="removedRows.length" class="value-group removed-group">
|
||||
<h5 class="group-title">历史遗留字段(当前版本 schema 已删除)</h5>
|
||||
<div v-for="row in removedRows" :key="row.key" class="value-row">
|
||||
<span class="value-label">{{ row.label }}</span>
|
||||
<span class="value-text pre-wrap">{{ row.text }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.answer-values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.value-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.group-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
border-left: 3px solid #2d8cf0;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.value-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dashed #e8eaec;
|
||||
}
|
||||
.value-label {
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
}
|
||||
.value-text {
|
||||
color: #17233d;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.value-text.pre-wrap {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.value-files {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.value-files a {
|
||||
font-size: 12px;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
.removed-group .group-title {
|
||||
border-left-color: #ff9900;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 设计器左栏·控件面板(一期 11 类)。
|
||||
*
|
||||
* 面板源是 CONTROL_CATALOG(只列一期放行类型),address/user/users 在这里**不出现**——
|
||||
* 后端发布校验同样拒它们,给入口只会让人画出一张发不出去的表单(契约 §2、方案 §4.2)。
|
||||
* 拖拽用 vuedraggable 的 clone 模式:clone 时即生成完整字段(带唯一 key 与 _uid),
|
||||
* 落到画布直接可用;点击条目走同一条 add 事件,键盘用户不必拖也能加字段。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import draggable from "vuedraggable";
|
||||
import type { ControlCatalogItem, DesignField } from "@/types/formSchema";
|
||||
import { CONTROL_CATALOG } from "@/types/formSchema";
|
||||
import { defaultField } from "@/utils/designer/schema";
|
||||
|
||||
const props = defineProps<{ nextKey: (type?: string) => string; disabled?: boolean }>();
|
||||
const emit = defineEmits<{ (e: "add", field: DesignField): void }>();
|
||||
|
||||
const baseItems = computed(() => CONTROL_CATALOG.filter((t) => t.section === "base"));
|
||||
const enhanceItems = computed(() => CONTROL_CATALOG.filter((t) => t.section === "enhance"));
|
||||
const sections = computed(() => [
|
||||
{ name: "基础字段", items: baseItems.value },
|
||||
{ name: "增强字段", items: enhanceItems.value }
|
||||
]);
|
||||
|
||||
/** 拖拽克隆即生成字段;key 从共享命名空间取(分组 key 与字段 key 同池,P17) */
|
||||
function cloneItem(seed: ControlCatalogItem): DesignField {
|
||||
return defaultField(seed.type, props.nextKey(seed.type), seed.name);
|
||||
}
|
||||
|
||||
function clickAdd(seed: ControlCatalogItem) {
|
||||
if (props.disabled) return;
|
||||
emit("add", cloneItem(seed));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="d-lib">
|
||||
<div class="d-lib-title">表单控件(一期 11 类)</div>
|
||||
<div class="d-lib-scroll">
|
||||
<div v-for="section in sections" :key="section.name">
|
||||
<div class="d-lib-group-title">{{ section.name }}</div>
|
||||
<draggable
|
||||
:list="section.items as any[]"
|
||||
:sort="false"
|
||||
:disabled="!!disabled"
|
||||
:group="{ name: 'designer', pull: 'clone', put: false }"
|
||||
:clone="cloneItem as any"
|
||||
item-key="type"
|
||||
:force-fallback="true"
|
||||
class="d-lib-grid"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div class="d-lib-item" :class="{ off: disabled }" @click="clickAdd(element as ControlCatalogItem)">
|
||||
{{ (element as ControlCatalogItem).name }}
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
<Alert v-if="disabled" type="warning" class="d-lib-alert">已发布版本只读;要改版请回到草稿编辑并保存升版。</Alert>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.d-lib {
|
||||
width: 210px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #e8eaec;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.d-lib-title {
|
||||
padding: 12px 14px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.d-lib-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
.d-lib-group-title {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin: 6px 0 8px;
|
||||
}
|
||||
.d-lib-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.d-lib-item {
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
.d-lib-item:hover {
|
||||
border-color: var(--brand, #577afb);
|
||||
color: #2d4ed8;
|
||||
}
|
||||
.d-lib-item.off {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.d-lib-item.off:hover {
|
||||
border-color: #dcdee2;
|
||||
color: #515a6e;
|
||||
}
|
||||
.d-lib-alert {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 设计器中栏·画布:多分组 + 组内字段拖拽,支持跨组移动与分组自身排序。
|
||||
*
|
||||
* **数组序即展示序,没有 orderNum**(契约 §2)——拖完就是落库顺序,页面上不再排第二次。
|
||||
* 已发布字段在卡片上标「发布过」:key/type 是它的身份,要改语义只能删旧增新(契约 §2、V8 前提),
|
||||
* 所以这里不提供就地改 key 的入口,属性面板同样把输入框锁死。
|
||||
*/
|
||||
import draggable from "vuedraggable";
|
||||
import { fieldWidget } from "@/components/fields";
|
||||
import type { DesignField, DesignGroup, FieldOption } from "@/types/formSchema";
|
||||
import type { DictView } from "@/types/form";
|
||||
import { CONTROL_NAMES, isDictRef, isPhase1Type } from "@/types/formSchema";
|
||||
import { isOptionType } from "@/types/formSchema";
|
||||
import { dictRefCode } from "@/types/formSchema";
|
||||
|
||||
/**
|
||||
* Sortable 的 group 配置必须是模块级常量:模板里每次渲染都新造一个对象,
|
||||
* vuedraggable 会把它当「选项变了」反复重挂 Sortable,嵌套容器下能把自己绕死。
|
||||
*/
|
||||
const GROUP_OUTER = { name: "fc-group" };
|
||||
const GROUP_INNER = { name: "designer" };
|
||||
|
||||
const props = defineProps<{
|
||||
groups: DesignGroup[];
|
||||
selected: { gi: number; fi: number } | null;
|
||||
/** 只读态(查看已发布版本行):不给拖、不给加、不给删 */
|
||||
disabled?: boolean;
|
||||
/** 可选字典(selectable 结果),字典引用字段的预览选项从这儿展开 */
|
||||
dicts?: DictView[];
|
||||
/** 当前发布版本里已存在的字段 key(这些字段的 key/type 不可变更) */
|
||||
publishedKeys?: Set<string>;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "select", gi: number, fi: number): void;
|
||||
(e: "copy", gi: number, fi: number): void;
|
||||
(e: "remove", gi: number, fi: number): void;
|
||||
(e: "add", field: DesignField): void;
|
||||
(e: "addGroup"): void;
|
||||
(e: "removeGroup", gi: number): void;
|
||||
}>();
|
||||
|
||||
function resolvedOptions(field: DesignField): FieldOption[] {
|
||||
const options = field.options as FieldOption[] | { source?: string } | undefined;
|
||||
if (!options) return [];
|
||||
if (Array.isArray(options)) return options;
|
||||
if (isDictRef(options)) {
|
||||
const code = dictRefCode(options) || "";
|
||||
const dict = (props.dicts || []).find((d) => d.code === code);
|
||||
// 只出未删条目(软删条目不可新选,契约 §2.1)——预览按填报页会看到的候选集呈现
|
||||
return dict ? dict.items.map((i) => ({ label: i.label, value: i.value })) : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function dictMissing(field: DesignField): boolean {
|
||||
const code = dictRefCode(field.options as never);
|
||||
if (!code) return false;
|
||||
return !(props.dicts || []).some((d) => d.code === code);
|
||||
}
|
||||
|
||||
function isActive(gi: number, fi: number): boolean {
|
||||
return !!props.selected && props.selected.gi === gi && props.selected.fi === fi;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽变更统一处理:只认 added/moved。
|
||||
* removed 不动选中态——Sortable 是「先 add 后 remove」派发,在这里清选中会把刚建立选中态反手抹掉
|
||||
* (svc-hall 同款坑,注释留着给下一个接手的人)。
|
||||
*/
|
||||
function onChange(gi: number, ev: any) {
|
||||
if (props.disabled) return;
|
||||
if (ev.added) emit("select", gi, ev.added.newIndex);
|
||||
else if (ev.moved) emit("select", gi, ev.moved.newIndex);
|
||||
}
|
||||
|
||||
function optionSummary(field: DesignField): string {
|
||||
if (!isOptionType(field.type)) return "";
|
||||
if (dictRefCode(field.options as never)) {
|
||||
const code = dictRefCode(field.options as never);
|
||||
return dictMissing(field) ? `字典 dict:${code} 不在可选域` : `引用字典 ${code}`;
|
||||
}
|
||||
return `${resolvedOptions(field).length} 个选项`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="d-canvas">
|
||||
<div class="d-canvas-inner">
|
||||
<draggable :list="groups as any[]" :group="GROUP_OUTER" item-key="_uid" handle=".d-group-handle"
|
||||
:disabled="!!disabled" animation="150" ghost-class="d-ghost">
|
||||
<template #item="{ element: group, index: gi }">
|
||||
<div class="d-group">
|
||||
<div class="d-group-head">
|
||||
<span v-if="!disabled" class="d-group-handle" title="拖拽排序分组">⠿</span>
|
||||
<Input v-model="group.key" placeholder="分组 key(字母/数字/下划线)" size="small"
|
||||
style="width: 190px" :disabled="!!disabled" />
|
||||
<Input v-model="group.label" placeholder="分组名称" size="small" style="width: 190px"
|
||||
:disabled="!!disabled" />
|
||||
<span class="d-group-count">{{ (group.fields || []).length }} 个字段</span>
|
||||
<Button v-if="!disabled && groups.length > 1" size="small" type="text" style="color: #ed4014"
|
||||
@click="emit('removeGroup', gi)">删除分组</Button>
|
||||
</div>
|
||||
<div class="d-group-body">
|
||||
<draggable v-model="group.fields" :group="GROUP_INNER" item-key="_uid"
|
||||
handle=".d-field-handle" :disabled="!!disabled" animation="150" ghost-class="d-ghost"
|
||||
@change="onChange(gi, $event)">
|
||||
<template #item="{ element: field, index: fi }">
|
||||
<div class="d-field" :class="{ active: isActive(gi, fi) }"
|
||||
@click="emit('select', gi, fi)">
|
||||
<span v-if="!disabled" class="d-field-handle" title="拖拽排序">⠿</span>
|
||||
<div class="d-field-main">
|
||||
<div class="d-field-label">
|
||||
<span v-if="field.required" class="d-required">*</span>
|
||||
{{ field.label || "未命名字段" }}
|
||||
<span class="d-field-key">{{ field.key }}</span>
|
||||
<Tag v-if="publishedKeys && publishedKeys.has(field.key)" size="small"
|
||||
color="primary">发布过</Tag>
|
||||
<Tag v-if="field.searchable" size="small" color="success">可检索</Tag>
|
||||
<Tag v-if="!isPhase1Type(field.type)" size="small" color="error">二期类型</Tag>
|
||||
</div>
|
||||
<div class="d-field-meta">
|
||||
{{ CONTROL_NAMES[field.type] || field.type }}
|
||||
<span v-if="optionSummary(field)" class="d-field-opts">
|
||||
({{ optionSummary(field) }})
|
||||
</span>
|
||||
<span v-if="field.tip" class="d-field-tip">{{ field.tip }}</span>
|
||||
</div>
|
||||
<div class="d-field-widget">
|
||||
<component v-if="fieldWidget(field.type)" :is="fieldWidget(field.type)"
|
||||
:field="field as any" :options="resolvedOptions(field)" />
|
||||
<span v-else class="d-widget-none">该类型控件一期未实现,发布会被拒</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!disabled" class="d-field-tools">
|
||||
<Icon type="md-copy" title="复制字段" @click.stop="emit('copy', gi, fi)" />
|
||||
<Icon type="ios-trash" title="删除字段" @click.stop="emit('remove', gi, fi)" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div v-if="!(group.fields || []).length" class="d-empty-tip">
|
||||
从左侧拖拽或点击来添加字段(数组顺序即填报页展示顺序)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<Button v-if="!disabled" type="dashed" long icon="md-add" style="margin-top: 12px"
|
||||
@click="emit('addGroup')">添加分组</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.d-canvas {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
background: #f2f4f8;
|
||||
height: 100%;
|
||||
}
|
||||
.d-canvas-inner {
|
||||
min-height: 100%;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.d-group {
|
||||
background: #fff;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.d-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.d-group-count {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.d-group-handle,
|
||||
.d-field-handle {
|
||||
cursor: grab;
|
||||
color: #c5c8ce;
|
||||
font-size: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
.d-group-body {
|
||||
min-height: 48px;
|
||||
}
|
||||
.d-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
background: #fafbfc;
|
||||
cursor: pointer;
|
||||
}
|
||||
.d-field.active {
|
||||
border-color: var(--brand, #577afb);
|
||||
box-shadow: 0 0 0 2px rgba(87, 122, 251, 0.15);
|
||||
background: #fff;
|
||||
}
|
||||
.d-field-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.d-field-label {
|
||||
color: #34373e;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.d-field-key {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
}
|
||||
.d-required {
|
||||
color: #ed4014;
|
||||
}
|
||||
.d-field-meta {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.d-field-tip {
|
||||
margin-left: 8px;
|
||||
color: #c5c8ce;
|
||||
}
|
||||
.d-field-widget {
|
||||
margin-top: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.d-widget-none {
|
||||
color: #ed4014;
|
||||
font-size: 12px;
|
||||
}
|
||||
.d-field-tools {
|
||||
display: none;
|
||||
gap: 6px;
|
||||
color: #808695;
|
||||
font-size: 16px;
|
||||
}
|
||||
.d-field:hover .d-field-tools,
|
||||
.d-field.active .d-field-tools {
|
||||
display: flex;
|
||||
}
|
||||
.d-field-tools :deep(.ivu-icon:hover) {
|
||||
color: var(--brand, #577afb);
|
||||
cursor: pointer;
|
||||
}
|
||||
.d-ghost {
|
||||
opacity: 0.4;
|
||||
border: 1px dashed var(--brand, #577afb);
|
||||
}
|
||||
.d-empty-tip {
|
||||
color: #c5c8ce;
|
||||
text-align: center;
|
||||
padding: 14px 0;
|
||||
border: 1px dashed #dcdee2;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,463 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 设计器右栏·字段属性面板(白名单属性编辑器)。
|
||||
*
|
||||
* 三条纪律都在这一栏落地:
|
||||
* 1. **只编辑白名单 19 项**——面板上没有的键就产不出来,序列化时再过一道 pickField;
|
||||
* 2. **value 生成即不可变(P19)**:选项只给 label 输入框,value 是只读码块,
|
||||
* 改 label 永远不动 value,历史答卷的筛选与统计口径才打得穿不了(方案 §4.3①);
|
||||
* 3. **发布过的字段不换身份**:key 落在当前发布版本里 → key 与 type 两个输入框锁死,
|
||||
* 要换语义请删旧字段新增(契约 §2「改 key=换字段」,后端 assertFieldTypesStable 同样拒)。
|
||||
*
|
||||
* searchable 对 upload/image 直接不可勾(P28:契约没给附件检索算子,勾了也是个恒空集的筛选项)。
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import draggable from "vuedraggable";
|
||||
import type { DesignField, FieldOption } from "@/types/formSchema";
|
||||
import type { DictView } from "@/types/form";
|
||||
import {
|
||||
CONTROL_NAMES,
|
||||
FILE_ACCEPT,
|
||||
IMAGE_ACCEPT,
|
||||
MAX_FILE_SIZE_MB,
|
||||
MAX_FILES_PER_FIELD,
|
||||
MAX_LABEL_LEN,
|
||||
MAX_PRECISION,
|
||||
VALUE_SHAPE_HINT,
|
||||
dictRefCode,
|
||||
isDictRef,
|
||||
isOptionType,
|
||||
searchableAllowed
|
||||
} from "@/types/formSchema";
|
||||
import { fieldErrors, makeOption, seedOptionList } from "@/utils/designer/schema";
|
||||
|
||||
const props = defineProps<{
|
||||
field: DesignField;
|
||||
dicts: DictView[];
|
||||
/** 已占用的 key 集合(含分组 key,共享命名空间),用于重复提示 */
|
||||
usedKeys: Set<string>;
|
||||
/** 当前发布版本里的字段 key → type */
|
||||
publishedTypes: Map<string, string>;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const mode = computed<"inline" | "dict">(() => (isDictRef(props.field.options) ? "dict" : "inline"));
|
||||
const inlineOptions = computed<FieldOption[]>(() =>
|
||||
Array.isArray(props.field.options) ? (props.field.options as FieldOption[]) : []
|
||||
);
|
||||
const dictCode = computed(() => dictRefCode(props.field.options as never) || "");
|
||||
const errors = computed(() => fieldErrors(props.field, props.usedKeys));
|
||||
const keyLocked = computed(() => props.publishedTypes.has(props.field.key));
|
||||
const isOptionField = computed(() => isOptionType(props.field.type));
|
||||
const isTextLike = computed(() => ["text", "textarea", "phone"].includes(props.field.type));
|
||||
/** 单行文本类:textarea 的行数/长度在下一栏单独给,两块不能同时铺 maxLength */
|
||||
const isPlainText = computed(() => ["text", "phone"].includes(props.field.type));
|
||||
const isAttach = computed(() => props.field.type === "upload" || props.field.type === "image");
|
||||
const canSearchable = computed(() => searchableAllowed(props.field.type));
|
||||
/** 默认值候选项:字典引用字段用展开后的条目,内联字段用自己的 options */
|
||||
const choiceOptions = computed<FieldOption[]>(() => {
|
||||
if (mode.value === "dict" && pickedDict.value) {
|
||||
return pickedDict.value.items.map((i) => ({ label: i.label, value: i.value }));
|
||||
}
|
||||
return inlineOptions.value;
|
||||
});
|
||||
|
||||
/** 可选字典按层级分栏:平台级(可见可用不可改)与本校 */
|
||||
const platformDicts = computed(() => props.dicts.filter((d) => d.platformLevel));
|
||||
const schoolDicts = computed(() => props.dicts.filter((d) => !d.platformLevel));
|
||||
const pickedDict = computed(() => props.dicts.find((d) => d.code === dictCode.value) || null);
|
||||
|
||||
/** 选字典的临时选择:只有真的选定一个 code 才写回 options,避免留下 source:"" 的半成品 */
|
||||
const pendingDict = ref("");
|
||||
watch(
|
||||
() => props.field._uid,
|
||||
() => {
|
||||
pendingDict.value = "";
|
||||
}
|
||||
);
|
||||
|
||||
function switchMode(next: string) {
|
||||
if (next === mode.value) return;
|
||||
if (next === "inline") {
|
||||
props.field.options = inlineOptions.value.length ? inlineOptions.value : seedOptionList();
|
||||
} else {
|
||||
props.field.options = { source: "" };
|
||||
pendingDict.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function applyDict(code: string) {
|
||||
if (!code) return;
|
||||
props.field.options = { source: `dict:${code}` };
|
||||
pendingDict.value = "";
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
if (!Array.isArray(props.field.options)) props.field.options = [];
|
||||
const list = props.field.options as FieldOption[];
|
||||
list.push(makeOption(list.map((o) => o.value), `选项${list.length + 1}`));
|
||||
}
|
||||
|
||||
function removeOption(index: number) {
|
||||
const list = inlineOptions.value;
|
||||
// 至少留一项:后端对选项类控件的 options 判「不能为空数组」(契约 §2),这里提前拦住
|
||||
if (list.length <= 1) return;
|
||||
list.splice(index, 1);
|
||||
}
|
||||
|
||||
function usePresetAccept(preset: string) {
|
||||
props.field.accept = preset;
|
||||
}
|
||||
|
||||
/** defaultValue 按契约 §3 的值形状收窄:number 存数字、多选存 value 数组、其余存字符串 */
|
||||
const defText = computed<string>({
|
||||
get: () => (typeof props.field.defaultValue === "string" ? props.field.defaultValue : ""),
|
||||
set: (v) => {
|
||||
props.field.defaultValue = v === "" ? undefined : v;
|
||||
}
|
||||
});
|
||||
const defNumber = computed<number | undefined>({
|
||||
get: () => (typeof props.field.defaultValue === "number" ? props.field.defaultValue : undefined),
|
||||
set: (v) => {
|
||||
props.field.defaultValue = v === undefined || v === null ? undefined : v;
|
||||
}
|
||||
});
|
||||
const defList = computed<string[]>({
|
||||
get: () => (Array.isArray(props.field.defaultValue) ? (props.field.defaultValue as string[]) : []),
|
||||
set: (v) => {
|
||||
props.field.defaultValue = v.length ? v : undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="d-prop">
|
||||
<div class="d-prop-title">字段属性</div>
|
||||
<div class="d-prop-scroll">
|
||||
<Alert v-if="errors.length" type="error" show-icon class="mb10">
|
||||
<div v-for="(err, i) in errors" :key="i">{{ err }}</div>
|
||||
</Alert>
|
||||
|
||||
<div class="prop-block">
|
||||
<div class="prop-row">
|
||||
<label>字段名称</label>
|
||||
<Input v-model="field.label" :maxlength="MAX_LABEL_LEN" placeholder="必填,填报页显示它"
|
||||
:disabled="disabled" />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>字段 key</label>
|
||||
<Tooltip v-if="keyLocked" max-width="260" placement="top"
|
||||
content="该 key 已发布过:key 与 type 都不可变更(改 key=换字段)。要换语义请删掉本字段再新增。">
|
||||
<Input :model-value="field.key" disabled />
|
||||
</Tooltip>
|
||||
<Input v-else v-model="field.key" placeholder="字母/数字/下划线,≤64" :disabled="disabled" />
|
||||
<div class="prop-note">答卷值与检索索引都按 key 定位,发布后不可改</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>控件类型</label>
|
||||
<div class="prop-static">
|
||||
{{ CONTROL_NAMES[field.type] || field.type }}
|
||||
<Tag v-if="!searchableAllowed(field.type)" size="small">附件类不可检索</Tag>
|
||||
</div>
|
||||
<div class="prop-note">值形状:{{ VALUE_SHAPE_HINT[field.type] || "—" }}</div>
|
||||
</div>
|
||||
<div class="prop-row inline">
|
||||
<Checkbox v-model="field.required" :disabled="disabled">必填</Checkbox>
|
||||
<Tooltip v-if="!canSearchable" max-width="240" placement="top"
|
||||
content="upload/image 没有检索算子(P28):勾了也不进筛选候选,设计器直接不给入口">
|
||||
<Checkbox :model-value="false" disabled>可检索</Checkbox>
|
||||
</Tooltip>
|
||||
<Checkbox v-else v-model="field.searchable" :disabled="disabled">可检索</Checkbox>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>填写提示 tip</label>
|
||||
<Input v-model="field.tip" :disabled="disabled" placeholder="字段下方的灰字说明(可空)" />
|
||||
</div>
|
||||
<div v-if="!isOptionField && !isAttach" class="prop-row">
|
||||
<label>占位文本</label>
|
||||
<Input v-model="field.placeholder" :disabled="disabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isOptionField" class="prop-block">
|
||||
<div class="prop-head">选项</div>
|
||||
<RadioGroup :model-value="mode" type="button" size="small" :disabled="disabled"
|
||||
@on-change="switchMode">
|
||||
<Radio label="inline">内联选项</Radio>
|
||||
<Radio label="dict">引用字典</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
<template v-if="mode === 'inline'">
|
||||
<draggable :list="inlineOptions as any[]" item-key="value" handle=".opt-handle"
|
||||
:disabled="disabled" class="opt-list">
|
||||
<template #item="{ element: option, index }">
|
||||
<div class="opt-row">
|
||||
<span class="opt-handle">⠿</span>
|
||||
<Input v-model="option.label" size="small" placeholder="选项名称(可随便改)"
|
||||
:disabled="disabled" />
|
||||
<Tooltip max-width="240" placement="top"
|
||||
content="value 生成即不可变:答案存它,改 label 不影响历史统计与筛选(P19)">
|
||||
<code class="opt-value">{{ option.value }}</code>
|
||||
</Tooltip>
|
||||
<Icon v-if="!disabled && inlineOptions.length > 1" type="ios-close-circle-outline"
|
||||
class="opt-del" @click="removeOption(index)" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<Button size="small" icon="md-add" :disabled="disabled" @click="addOption">添加选项</Button>
|
||||
<div class="prop-note">顺序即填报页展示顺序(数组序,无 orderNum);多选回显也按这个顺序</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="prop-row">
|
||||
<label>选择字典</label>
|
||||
<Select v-model="pendingDict" :disabled="disabled" filterable placeholder="选择要引用的选项字典"
|
||||
@on-change="applyDict">
|
||||
<OptionGroup v-if="platformDicts.length" label="平台级(可见可用不可改)">
|
||||
<Option v-for="d in platformDicts" :key="'p' + d.code" :value="d.code"
|
||||
:label="`${d.name}(dict:${d.code},${d.items.length} 项)`" />
|
||||
</OptionGroup>
|
||||
<OptionGroup v-if="schoolDicts.length" label="本校">
|
||||
<Option v-for="d in schoolDicts" :key="'s' + d.code" :value="d.code"
|
||||
:label="`${d.name}(dict:${d.code},${d.items.length} 项)`" />
|
||||
</OptionGroup>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="pickedDict" class="dict-preview">
|
||||
<span v-for="i in pickedDict.items" :key="i.value" class="dict-item">
|
||||
{{ i.label }}<code>{{ i.value }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div class="prop-note">
|
||||
当前引用:<code>dict:{{ dictCode || "(未选)" }}</code>。字典是活引用、不进版本快照,
|
||||
下发时内联展开;软删条目不再出现在这里,但旧答卷仍可原样重存(契约 §2.1)。
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="isTextLike || field.type === 'number' || field.type === 'date' || isOptionField"
|
||||
class="prop-block">
|
||||
<div class="prop-head">校验与展示</div>
|
||||
<div v-if="isPlainText" class="prop-grid">
|
||||
<div class="prop-row">
|
||||
<label>最大长度</label>
|
||||
<InputNumber v-model="field.maxLength" :min="1" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>正则 pattern</label>
|
||||
<Input v-model="field.pattern" :disabled="disabled" placeholder="可空,例如 ^1[3-9]\d{9}$" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="field.type === 'textarea'" class="prop-grid">
|
||||
<div class="prop-row">
|
||||
<label>行数 rows</label>
|
||||
<InputNumber v-model="field.rows" :min="1" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>最大长度</label>
|
||||
<InputNumber v-model="field.maxLength" :min="1" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="field.type === 'number'" class="prop-grid">
|
||||
<div class="prop-row">
|
||||
<label>最小值 min</label>
|
||||
<InputNumber v-model="field.min" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>最大值 max</label>
|
||||
<InputNumber v-model="field.max" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>小数位 precision</label>
|
||||
<InputNumber v-model="field.precision" :min="0" :max="MAX_PRECISION" :disabled="disabled"
|
||||
controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>单位 unit</label>
|
||||
<Input v-model="field.unit" :disabled="disabled" placeholder="如 分、公斤" />
|
||||
</div>
|
||||
<div class="prop-note">precision 上限 {{ MAX_PRECISION }}:索引列 v_num 是 DECIMAL(20,6)</div>
|
||||
</div>
|
||||
<div v-if="field.type === 'date'" class="prop-note">
|
||||
日期存储格式定死 YYYY-MM-DD(区间筛选按日期类型比,不按字典序)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isAttach" class="prop-block">
|
||||
<div class="prop-head">附件约束</div>
|
||||
<div class="prop-row">
|
||||
<label>扩展名 accept</label>
|
||||
<Input v-model="field.accept" :disabled="disabled" placeholder=".pdf,.docx" />
|
||||
<div class="prop-actions">
|
||||
<Button size="small" :disabled="disabled" @click="usePresetAccept(IMAGE_ACCEPT)">图片预设</Button>
|
||||
<Button size="small" :disabled="disabled" @click="usePresetAccept(FILE_ACCEPT)">文档预设</Button>
|
||||
</div>
|
||||
<div class="prop-note">白名单不得含 .svg(P28),逗号分隔、带点前缀</div>
|
||||
</div>
|
||||
<div class="prop-grid">
|
||||
<div class="prop-row">
|
||||
<label>单文件 MB</label>
|
||||
<InputNumber v-model="field.maxSize" :min="1" :max="MAX_FILE_SIZE_MB" :disabled="disabled"
|
||||
controls-out />
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>最多个数</label>
|
||||
<InputNumber v-model="field.maxFiles" :min="1" :max="MAX_FILES_PER_FIELD" :disabled="disabled"
|
||||
controls-out />
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-note">
|
||||
硬上限:单文件 {{ MAX_FILE_SIZE_MB }}MB、每字段 {{ MAX_FILES_PER_FIELD }} 个(契约 §3);
|
||||
答卷值只存 {id,fileName,key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prop-block">
|
||||
<div class="prop-head">默认值</div>
|
||||
<div v-if="isTextLike" class="prop-row">
|
||||
<Input v-model="defText" :disabled="disabled" placeholder="留空即无默认值" />
|
||||
</div>
|
||||
<div v-else-if="field.type === 'number'" class="prop-row">
|
||||
<InputNumber v-model="defNumber" :disabled="disabled" controls-out />
|
||||
</div>
|
||||
<div v-else-if="field.type === 'date'" class="prop-row">
|
||||
<DatePicker type="date" format="yyyy-MM-dd" v-model="defText" :disabled="disabled"
|
||||
style="width: 100%" />
|
||||
</div>
|
||||
<div v-else-if="field.type === 'select' || field.type === 'radio'" class="prop-row">
|
||||
<Select v-model="defText" :disabled="disabled" clearable placeholder="无默认值">
|
||||
<Option v-for="o in choiceOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</Select>
|
||||
</div>
|
||||
<div v-else-if="field.type === 'checkbox' || field.type === 'multiselect'" class="prop-row">
|
||||
<Select v-model="defList" multiple :disabled="disabled" clearable placeholder="无默认值">
|
||||
<Option v-for="o in choiceOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</Select>
|
||||
</div>
|
||||
<div v-else class="prop-note">附件类控件不设默认值(值来自上传返回值)</div>
|
||||
<div class="prop-note">defaultValue 的类型必须与值形状一致,后端发布校验同判</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.d-prop {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid #e8eaec;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.d-prop-title {
|
||||
padding: 12px 14px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.d-prop-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px 24px;
|
||||
}
|
||||
.prop-block {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px dashed #eff0f3;
|
||||
}
|
||||
.prop-head {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.prop-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.prop-row.inline {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.prop-row > label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.prop-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.prop-static {
|
||||
color: #34373e;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.prop-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.prop-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.opt-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.opt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.opt-handle {
|
||||
cursor: grab;
|
||||
color: #c5c8ce;
|
||||
}
|
||||
.opt-value {
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.opt-del {
|
||||
color: #c5c8ce;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.opt-del:hover {
|
||||
color: #ed4014;
|
||||
}
|
||||
.dict-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.dict-item {
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.dict-item code {
|
||||
margin-left: 4px;
|
||||
color: #808695;
|
||||
}
|
||||
.mb10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
/** checkbox 多选(值 string[]=选项 value 数组,契约 §3)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { stringArrayDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string[];
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string[]): void;
|
||||
}>();
|
||||
|
||||
const items = computed(() => props.options || []);
|
||||
const displayValue = computed(() => props.modelValue ?? stringArrayDefault(props.field));
|
||||
|
||||
function onChange(val: string[]) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxGroup :model-value="displayValue" @on-change="onChange">
|
||||
<Checkbox v-for="o in items" :key="o.value" :label="o.value" :disabled="readonly">{{ o.label }}</Checkbox>
|
||||
</CheckboxGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
/** date 日期(存储格式定死 YYYY-MM-DD,方案 §4.3④——旧项目用 TEXT 字典序比日期是反面教材)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onChange(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DatePicker type="date" format="yyyy-MM-dd" :model-value="displayValue"
|
||||
:placeholder="field.placeholder || '请选择日期'" :disabled="readonly" style="width: 200px"
|
||||
@on-change="onChange" />
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* image 图片(值 [{id,fileName,key}],契约 §3——中心上传接口直接返回这三件,
|
||||
* 前端只存返回值,绝不把 fileUrl/绝对 URL 写进答卷值,否则换域名历史附件全断)。
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import type { FileValue } from "@/types/formSchema";
|
||||
import { Upload } from "view-ui-plus";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: FileValue[];
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: () => [],
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: FileValue[]): void;
|
||||
}>();
|
||||
|
||||
const fileList = computed(() => props.modelValue || []);
|
||||
const uploading = ref(false);
|
||||
|
||||
const hints = computed(() => {
|
||||
const f = props.field;
|
||||
return [
|
||||
f.accept ? `格式 ${f.accept}` : "",
|
||||
f.maxSize ? `单张 ≤${f.maxSize}MB` : "",
|
||||
f.maxFiles ? `最多 ${f.maxFiles} 张` : ""
|
||||
].filter(Boolean);
|
||||
});
|
||||
|
||||
const accept = computed(() => props.field.accept || ".jpg,.jpeg,.png,.gif,.bmp");
|
||||
|
||||
async function beforeUpload(file: File) {
|
||||
const maxSize = (props.field.maxSize || 50) * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
return false;
|
||||
}
|
||||
const maxFiles = props.field.maxFiles || 9;
|
||||
if (fileList.value.length >= maxFiles) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
uploading.value = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>(
|
||||
"/api/form/upload",
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
).send();
|
||||
const newValue = [...fileList.value, { id: result.id, fileName: result.fileName, key: result.key }];
|
||||
emit("update:modelValue", newValue);
|
||||
} catch (e) {
|
||||
console.error("Upload failed:", e);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove(index: number) {
|
||||
const newValue = fileList.value.filter((_, i) => i !== index);
|
||||
emit("update:modelValue", newValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="attach">
|
||||
<div class="image-list">
|
||||
<div v-for="(item, idx) in fileList" :key="item.id" class="image-item">
|
||||
<img :src="`/static/${item.key}`" :alt="item.fileName" class="image-preview" />
|
||||
<div v-if="!readonly" class="image-remove" @click="handleRemove(idx)">×</div>
|
||||
</div>
|
||||
<Upload v-if="!readonly" :before-upload="beforeUpload" :handle-upload="handleUpload" :accept="accept"
|
||||
:disabled="uploading">
|
||||
<div class="attach-tile"><Icon type="ios-add" /></div>
|
||||
</Upload>
|
||||
<div v-else-if="fileList.length === 0" class="attach-tile empty"><Icon type="ios-image" /></div>
|
||||
</div>
|
||||
<div v-if="hints.length" class="attach-hint">{{ hints.join("|") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.attach {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.image-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.image-item {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 1px solid #dcdee2;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.image-remove {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
.attach-tile {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 1px dashed #dcdee2;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #c5c8ce;
|
||||
font-size: 22px;
|
||||
background: #fafbfc;
|
||||
cursor: pointer;
|
||||
}
|
||||
.attach-tile.empty {
|
||||
cursor: default;
|
||||
}
|
||||
.attach-hint {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 控件注册表(V22 的第 5 条落点:控件目录按 pc.vue / app.vue 双端组织)。
|
||||
*
|
||||
* 一期只注册 pc 形态的实现,app 形态是空槽位组件(方案 §4.2「二期纯增量,不是重构」)——
|
||||
* 调用方拿到 null 就代表该类型没有控件件(二期类型 address/user/users 或脏数据里的未知类型),
|
||||
* 由画布显式降级呈现,不静默少一个字段。
|
||||
*/
|
||||
import type { Component } from "vue";
|
||||
import checkboxApp from "./checkbox/app.vue";
|
||||
import checkboxPc from "./checkbox/pc.vue";
|
||||
import dateApp from "./date/app.vue";
|
||||
import datePc from "./date/pc.vue";
|
||||
import imageApp from "./image/app.vue";
|
||||
import imagePc from "./image/pc.vue";
|
||||
import multiselectApp from "./multiselect/app.vue";
|
||||
import multiselectPc from "./multiselect/pc.vue";
|
||||
import numberApp from "./number/app.vue";
|
||||
import numberPc from "./number/pc.vue";
|
||||
import phoneApp from "./phone/app.vue";
|
||||
import phonePc from "./phone/pc.vue";
|
||||
import radioApp from "./radio/app.vue";
|
||||
import radioPc from "./radio/pc.vue";
|
||||
import selectApp from "./select/app.vue";
|
||||
import selectPc from "./select/pc.vue";
|
||||
import textApp from "./text/app.vue";
|
||||
import textPc from "./text/pc.vue";
|
||||
import textareaApp from "./textarea/app.vue";
|
||||
import textareaPc from "./textarea/pc.vue";
|
||||
import uploadApp from "./upload/app.vue";
|
||||
import uploadPc from "./upload/pc.vue";
|
||||
|
||||
/** 渲染端:pc=一期实现,app=二期槽位 */
|
||||
export type WidgetEnd = "pc" | "app";
|
||||
|
||||
const WIDGETS: Record<string, { pc: Component; app: Component }> = {
|
||||
text: { pc: textPc, app: textApp },
|
||||
textarea: { pc: textareaPc, app: textareaApp },
|
||||
number: { pc: numberPc, app: numberApp },
|
||||
date: { pc: datePc, app: dateApp },
|
||||
select: { pc: selectPc, app: selectApp },
|
||||
radio: { pc: radioPc, app: radioApp },
|
||||
checkbox: { pc: checkboxPc, app: checkboxApp },
|
||||
multiselect: { pc: multiselectPc, app: multiselectApp },
|
||||
phone: { pc: phonePc, app: phoneApp },
|
||||
image: { pc: imagePc, app: imageApp },
|
||||
upload: { pc: uploadPc, app: uploadApp }
|
||||
};
|
||||
|
||||
export function fieldWidget(type: string, end: WidgetEnd = "pc"): Component | null {
|
||||
return WIDGETS[type]?.[end] ?? null;
|
||||
}
|
||||
|
||||
/** 已建目录的控件类型(与一期放行 11 类同源,冒烟脚本拿它比对目录完整性) */
|
||||
export function widgetTypes(): string[] {
|
||||
return Object.keys(WIDGETS);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
/** multiselect 下拉多选(值 string[],展示按 options 定义序而非勾选序——契约 §3)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { stringArrayDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string[];
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string[]): void;
|
||||
}>();
|
||||
|
||||
const items = computed(() => props.options || []);
|
||||
const displayValue = computed(() => props.modelValue ?? stringArrayDefault(props.field));
|
||||
|
||||
function onChange(val: string[]) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select multiple :model-value="displayValue" :placeholder="field.placeholder || '请选择'" :disabled="readonly"
|
||||
style="width: 260px" @on-change="onChange">
|
||||
<Option v-for="o in items" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</Select>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
/** number 数字(值 number|null,空不是空串;precision/unit 是 v1.1 补进白名单的展示属性,契约 §2)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { numberDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: number | null;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: number | null): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed(() => props.modelValue ?? numberDefault(props.field));
|
||||
|
||||
function onChange(val: number | null) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="num-widget">
|
||||
<InputNumber :model-value="displayValue" :min="field.min" :max="field.max" :precision="field.precision"
|
||||
:disabled="readonly" @on-change="onChange" />
|
||||
<span v-if="field.unit" class="num-unit">{{ field.unit }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.num-widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.num-unit {
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/** phone 手机号(值 string;pattern 由字段声明,校验在填报侧执行,脱敏只读呈现属 T7)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onInput(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input :model-value="displayValue" :maxlength="field.maxLength || 11"
|
||||
:placeholder="field.placeholder || '请输入手机号'" :disabled="readonly" @input="onInput" />
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
/** radio 单选(值 string=选项 value;多选展示顺序按 options 定义序,数组序即展示序)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const items = computed(() => props.options || []);
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onChange(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RadioGroup :model-value="displayValue" @on-change="onChange">
|
||||
<Radio v-for="o in items" :key="o.value" :label="o.value" :disabled="readonly">{{ o.label }}</Radio>
|
||||
</RadioGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
/** select 下拉单选(答案存 value,只读呈现回查 options 取 label——契约 §3)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const items = computed(() => props.options || []);
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onChange(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select :model-value="displayValue" :placeholder="field.placeholder || '请选择'" :disabled="readonly"
|
||||
style="width: 220px" @on-change="onChange">
|
||||
<Option v-for="o in items" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</Select>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/** text 单行文本(契约 §3 值形状 string)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onInput(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input :model-value="displayValue" :placeholder="field.placeholder || '请输入'" :disabled="readonly"
|
||||
@input="onInput" />
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/** textarea 多行文本(值 string;只读呈现保留换行,方案 §4.3②)。 */
|
||||
import { computed } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import { textDefault } from "../types";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: string;
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: undefined,
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const displayValue = computed(() => props.modelValue ?? textDefault(props.field));
|
||||
|
||||
function onInput(val: string) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Input type="textarea" :model-value="displayValue" :rows="field.rows || 3" :maxlength="field.maxLength"
|
||||
:placeholder="field.placeholder || '请输入'" :disabled="readonly" @input="onInput" />
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 控件组件的公共入参(V22 的「控件目录 pc.vue/app.vue 双端」按本形状约定,T7 填报页复用同一批件)。
|
||||
*
|
||||
* options 由调用方解析后传入:inline 字段取字段自己的数组,字典引用字段取
|
||||
* selectable 接口展开后的条目——控件本身不去猜字典,保证「字典是中心内部组织方式、
|
||||
* 对外/对渲染都是内联展开」(契约 §2.1)。
|
||||
*/
|
||||
import type { DesignField, FieldOption } from "@/types/formSchema";
|
||||
|
||||
export interface FieldWidgetProps {
|
||||
/** 契约字段对象(白名单属性) */
|
||||
field: DesignField;
|
||||
/** 已展开的选项(非选项类控件传空数组即可) */
|
||||
options?: FieldOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览态取值:defaultValue 的 JSON 类型必须与该控件的值形状一致(契约 §3,
|
||||
* 后端 validateDefaultValue 已按这张表判),所以这里只做「按值形状收窄」,
|
||||
* 形状不对就当没填——设计器不替脏数据兜底,也不让它崩。
|
||||
*/
|
||||
export function textDefault(field: DesignField): string {
|
||||
return typeof field.defaultValue === "string" ? field.defaultValue : "";
|
||||
}
|
||||
|
||||
export function numberDefault(field: DesignField): number | undefined {
|
||||
return typeof field.defaultValue === "number" ? field.defaultValue : undefined;
|
||||
}
|
||||
|
||||
export function stringArrayDefault(field: DesignField): string[] {
|
||||
return Array.isArray(field.defaultValue) ? field.defaultValue.filter((v): v is string => typeof v === "string") : [];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动端形态槽位(方案 §4.2:控件按双端目录组织,一期只实现 pc.vue,
|
||||
* app.vue 是二期的纯增量槽位、不是重构)。一期刻意渲染空内容——
|
||||
* 「空」是事实:本控件当前没有移动端渲染,不做「拿 PC 件顶上」的假实现。
|
||||
*/
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
defineProps<FieldWidgetProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 二期接移动端时在此填本控件的 app 形态 -->
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* upload 附件(值 [{id,fileName,key}];单文件 ≤50MB、每字段 ≤9 个是契约 §3 的硬上限,
|
||||
* 字段级 accept/maxSize/maxFiles 只能往更严收,不能往宽放)。
|
||||
* accept 不含 .svg 由设计器把门(P28),这里只如实呈现配置。
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import type { FieldWidgetProps } from "../types";
|
||||
import type { FileValue } from "@/types/formSchema";
|
||||
import { Upload } from "view-ui-plus";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
|
||||
const props = withDefaults(defineProps<FieldWidgetProps & {
|
||||
modelValue?: FileValue[];
|
||||
readonly?: boolean;
|
||||
}>(), {
|
||||
modelValue: () => [],
|
||||
readonly: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: FileValue[]): void;
|
||||
}>();
|
||||
|
||||
const fileList = computed(() => props.modelValue || []);
|
||||
const uploading = ref(false);
|
||||
|
||||
const hints = computed(() => {
|
||||
const f = props.field;
|
||||
return [
|
||||
f.accept ? `格式 ${f.accept}` : "",
|
||||
f.maxSize ? `单个 ≤${f.maxSize}MB` : "",
|
||||
f.maxFiles ? `最多 ${f.maxFiles} 个` : ""
|
||||
].filter(Boolean);
|
||||
});
|
||||
|
||||
const accept = computed(() => props.field.accept || "");
|
||||
|
||||
async function beforeUpload(file: File) {
|
||||
const maxSize = (props.field.maxSize || 50) * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
return false;
|
||||
}
|
||||
const maxFiles = props.field.maxFiles || 9;
|
||||
if (fileList.value.length >= maxFiles) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
uploading.value = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>(
|
||||
"/api/form/upload",
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
).send();
|
||||
const newValue = [...fileList.value, { id: result.id, fileName: result.fileName, key: result.key }];
|
||||
emit("update:modelValue", newValue);
|
||||
} catch (e) {
|
||||
console.error("Upload failed:", e);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove(index: number) {
|
||||
const newValue = fileList.value.filter((_, i) => i !== index);
|
||||
emit("update:modelValue", newValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="attach">
|
||||
<div class="file-list">
|
||||
<div v-for="(item, idx) in fileList" :key="item.id" class="file-item">
|
||||
<Icon type="ios-document" class="file-icon" />
|
||||
<span class="file-name">{{ item.fileName }}</span>
|
||||
<Icon v-if="!readonly" type="ios-close-circle" class="file-remove" @click="handleRemove(idx)" />
|
||||
</div>
|
||||
</div>
|
||||
<Upload v-if="!readonly" :before-upload="beforeUpload" :handle-upload="handleUpload" :accept="accept"
|
||||
:disabled="uploading">
|
||||
<Button icon="ios-cloud-upload-outline" :loading="uploading">上传附件</Button>
|
||||
</Upload>
|
||||
<div v-if="hints.length" class="attach-hint">{{ hints.join("|") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.attach {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
background: #f7f7f7;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.file-icon {
|
||||
font-size: 16px;
|
||||
color: #515a6e;
|
||||
}
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-remove {
|
||||
font-size: 16px;
|
||||
color: #ed4014;
|
||||
cursor: pointer;
|
||||
}
|
||||
.attach-hint {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 表单级配置面板(方案 §5.1:配置与状态定义**不进版本快照**,改它们不升版)。
|
||||
*
|
||||
* 三处口径按后端原样,不自己发明:
|
||||
* - **限次只有 fillLimit 一个开关**:`limitScope` 不是表单可配项,它是提交时按 subject_id
|
||||
* 推导的语义(subject 非空按被代填人限、否则按提交人限——契约 §5)。所以这里给的是解释文案,
|
||||
* 不是一个假的单选框(本轮问题单 P53)。
|
||||
* - 截止时间只到日期时后端补 23:59:59(方案 §5.4),空=不限。
|
||||
* - 状态定义:code 建后不改(历史答卷按 code 存状态,改 code=改写历史),能改的只有 label
|
||||
* 与「废弃」标记;废弃是软删(V13 历史仍显示其名),且至少留一个可用状态。
|
||||
*/
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { formApi } from "@/api/module/form";
|
||||
import type { FormView, StatusDefView } from "@/types/form";
|
||||
import { AUDIENCE_OPTIONS, FORM_STATUS } from "@/types/form";
|
||||
import { MAX_NAME_LEN, CODE_PATTERN } from "@/types/formSchema";
|
||||
|
||||
const props = defineProps<{ form: FormView | null }>();
|
||||
const emit = defineEmits<{ (e: "saved", form: FormView): void }>();
|
||||
|
||||
const visible = ref(false);
|
||||
const saving = ref(false);
|
||||
const model = reactive<{
|
||||
audience: number;
|
||||
fillLimit: number;
|
||||
deadline: string;
|
||||
answerNoEnabled: number;
|
||||
groupTag: string;
|
||||
}>({
|
||||
audience: AUDIENCE_OPTIONS[0].value,
|
||||
fillLimit: 0,
|
||||
deadline: "",
|
||||
answerNoEnabled: 0,
|
||||
groupTag: ""
|
||||
});
|
||||
const statusDefs = ref<StatusDefView[]>([]);
|
||||
const newStatus = reactive({ code: "", label: "" });
|
||||
|
||||
watch(
|
||||
() => [visible.value, props.form?.formUuid] as const,
|
||||
() => {
|
||||
const f = props.form;
|
||||
if (!visible.value || !f) return;
|
||||
model.audience = f.audience ?? AUDIENCE_OPTIONS[0].value;
|
||||
model.fillLimit = f.fillLimit ?? 0;
|
||||
model.deadline = f.deadline ? String(f.deadline).slice(0, 10) : "";
|
||||
model.answerNoEnabled = f.answerNoEnabled ?? 0;
|
||||
model.groupTag = f.groupTag || "";
|
||||
statusDefs.value = (f.statusDefs || []).map((d) => ({ ...d }));
|
||||
newStatus.code = "";
|
||||
newStatus.label = "";
|
||||
}
|
||||
);
|
||||
|
||||
const isPlatform = computed(() => props.form?.schId === 0);
|
||||
const statusText = computed(() => {
|
||||
const f = props.form;
|
||||
if (!f) return "";
|
||||
if (f.status === FORM_STATUS.DRAFT) return "草稿(从未发布)";
|
||||
if (f.status === FORM_STATUS.PUBLISHED) return `已发布 v${f.currentVersion}`;
|
||||
return `已停用(当前发布 v${f.currentVersion})`;
|
||||
});
|
||||
|
||||
function open() {
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function addStatus() {
|
||||
const code = newStatus.code.trim();
|
||||
const label = newStatus.label.trim();
|
||||
if (!code || !CODE_PATTERN.test(code)) {
|
||||
Message.error("状态 code 仅限字母/数字/下划线(≤64 位)");
|
||||
return;
|
||||
}
|
||||
if (!label) {
|
||||
Message.error("状态名称不能为空");
|
||||
return;
|
||||
}
|
||||
if (statusDefs.value.some((d) => d.code === code)) {
|
||||
// 含已废弃的 code:复用它会改写历史答卷的状态名(后端同判)
|
||||
Message.error(`状态 code 已存在(含已废弃的):${code}`);
|
||||
return;
|
||||
}
|
||||
statusDefs.value.push({ code, label, is_deleted: false });
|
||||
newStatus.code = "";
|
||||
newStatus.label = "";
|
||||
}
|
||||
|
||||
function moveStatus(index: number, dir: -1 | 1) {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= statusDefs.value.length) return;
|
||||
const list = statusDefs.value;
|
||||
[list[index], list[target]] = [list[target], list[index]];
|
||||
}
|
||||
|
||||
function liveCount(defs: StatusDefView[]): number {
|
||||
return defs.filter((d) => !d.is_deleted).length;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const f = props.form;
|
||||
if (!f) return;
|
||||
if (statusDefs.value.length && liveCount(statusDefs.value) === 0) {
|
||||
Message.error("至少要保留一个可用状态(全部废弃后新数据无状态可给)");
|
||||
return;
|
||||
}
|
||||
if (model.answerNoEnabled === 1 && f.code.length > 49) {
|
||||
Message.error(`开启报名号要求 code ≤49 位(当前 ${f.code.length})`);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await formApi
|
||||
.config({
|
||||
uuid: f.formUuid,
|
||||
audience: model.audience,
|
||||
fillLimit: model.fillLimit,
|
||||
deadline: model.deadline,
|
||||
answerNoEnabled: model.answerNoEnabled,
|
||||
groupTag: model.groupTag
|
||||
})
|
||||
.send();
|
||||
// 状态定义为空列表时不能传空数组(后端拒),传 null 才是「回默认待处理/已处理」
|
||||
const defs = statusDefs.value;
|
||||
const latest = await formApi
|
||||
.statusDefs({ uuid: f.formUuid, statusDefs: defs.length ? defs : null })
|
||||
.send();
|
||||
emit("saved", latest);
|
||||
Message.success("表单配置已保存(不进版本快照,无需升版)");
|
||||
visible.value = false;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLifecycle() {
|
||||
const f = props.form;
|
||||
if (!f) return;
|
||||
try {
|
||||
const res =
|
||||
f.status === FORM_STATUS.PUBLISHED
|
||||
? await formApi.disable(f.formUuid).send()
|
||||
: await formApi.enable(f.formUuid).send();
|
||||
emit("saved", res);
|
||||
Message.success(
|
||||
f.status === FORM_STATUS.PUBLISHED
|
||||
? "已停用:填报入口失效,数据仍可读可导"
|
||||
: "已恢复当前发布版本"
|
||||
);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model="visible" :title="`表单配置|${form?.name || ''}`" :width="640" :mask-closable="false">
|
||||
<div v-if="form" class="cfg">
|
||||
<Alert v-if="isPlatform" type="warning" show-icon class="mb10">
|
||||
平台级表单(sch_id=0):跨校资产,只有超管上下文可维护。
|
||||
</Alert>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">生命周期</span>
|
||||
<span class="cfg-static">
|
||||
{{ statusText }}
|
||||
<Tag v-if="form.draftVersion" color="warning" size="small">
|
||||
待发布草稿 v{{ form.draftVersion }}
|
||||
</Tag>
|
||||
</span>
|
||||
<span class="cfg-actions">
|
||||
<Button v-if="form.status === FORM_STATUS.PUBLISHED || form.status === FORM_STATUS.DISABLED"
|
||||
size="small" @click="toggleLifecycle">
|
||||
{{ form.status === FORM_STATUS.PUBLISHED ? "停用" : "恢复" }}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">面向人群</span>
|
||||
<RadioGroup v-model="model.audience">
|
||||
<Radio v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Radio>
|
||||
</RadioGroup>
|
||||
<span class="cfg-note">决定数据范围矩阵落在哪一列(契约 §8)</span>
|
||||
</div>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">限次</span>
|
||||
<RadioGroup v-model="model.fillLimit">
|
||||
<Radio :label="0">不限次数</Radio>
|
||||
<Radio :label="1">每人限填一次</Radio>
|
||||
</RadioGroup>
|
||||
<span class="cfg-note">
|
||||
「每人」按提交主体判:管理员代填指定了被代填人时按那个人限,否则按提交人限;
|
||||
软删答卷会释放名额(契约 §5)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">截止时间</span>
|
||||
<DatePicker v-model="model.deadline" type="date" format="yyyy-MM-dd" placeholder="不限"
|
||||
clearable style="width: 180px" />
|
||||
<span class="cfg-note">只选日期时后端补 23:59:59;过期后填报页只读、提交接口拒绝</span>
|
||||
</div>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">报名号</span>
|
||||
<i-switch v-model="model.answerNoEnabled" :true-value="1" :false-value="0" />
|
||||
<span class="cfg-note">
|
||||
开启后提交生成 <code>{code}-{YYYYMMDD}-{当日序号}</code>(要求 code ≤49 位)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">分组标签</span>
|
||||
<Input v-model="model.groupTag" :maxlength="64" placeholder="可空,用于列表筛选归类" style="width: 220px" />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="cfg-status">
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">状态定义</span>
|
||||
<span class="cfg-note">
|
||||
状态是标记不是流转(无流转规则、无会签);默认「待处理/已处理」,
|
||||
<Button size="small" type="text" @click="statusDefs = []">回默认</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="(d, i) in statusDefs" :key="d.code" class="status-row"
|
||||
:class="{ dropped: d.is_deleted }">
|
||||
<code class="status-code">{{ d.code }}</code>
|
||||
<Input v-model="d.label" size="small" :maxlength="128" style="width: 160px" />
|
||||
<span class="status-order">
|
||||
<Button size="small" type="text" icon="ios-arrow-up" :disabled="i === 0"
|
||||
@click="moveStatus(i, -1)" />
|
||||
<Button size="small" type="text" icon="ios-arrow-down"
|
||||
:disabled="i === statusDefs.length - 1" @click="moveStatus(i, 1)" />
|
||||
</span>
|
||||
<Checkbox v-model="d.is_deleted">废弃</Checkbox>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<Input v-model="newStatus.code" size="small" placeholder="code" style="width: 120px"
|
||||
:maxlength="64" />
|
||||
<Input v-model="newStatus.label" size="small" placeholder="名称" style="width: 160px"
|
||||
:maxlength="128" />
|
||||
<Button size="small" icon="md-add" @click="addStatus">新增状态</Button>
|
||||
</div>
|
||||
<div class="cfg-note">
|
||||
已废弃的状态历史答卷仍显示其名(V13),只是新数据不再给;code 一经使用不改,
|
||||
改 code 等于改写历史答卷的状态名。名称长度 ≤{{ MAX_NAME_LEN }} 与后端同口径。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="submit">保存配置</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cfg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cfg-label {
|
||||
width: 78px;
|
||||
color: #515a6e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cfg-static {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cfg-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.cfg-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cfg-status {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.status-row.dropped .status-code,
|
||||
.status-row.dropped input {
|
||||
color: #c5c8ce;
|
||||
}
|
||||
.status-code {
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
min-width: 90px;
|
||||
text-align: center;
|
||||
}
|
||||
.status-order {
|
||||
display: flex;
|
||||
}
|
||||
.mb10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -51,6 +51,9 @@ watch(
|
||||
() => route.fullPath,
|
||||
() => routeChange(route)
|
||||
);
|
||||
// 深链/刷新时 onBeforeMount 那一次导航树还是空的,之后没有路由变化来触发重算,
|
||||
// 面包屑就会一直停在兜底分支的结果上;这里补一条「树到货了再算一次」
|
||||
watch(flatNav, () => routeChange(route));
|
||||
|
||||
onMounted(() => {
|
||||
readLogoUrl();
|
||||
@@ -61,7 +64,7 @@ onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", read
|
||||
/** 面包屑/高亮:优先按 path 匹配导航项,匹配不到再退回 modId 对照 */
|
||||
const routeChange = (to: any) => {
|
||||
const meta: any = to.meta || {};
|
||||
const { modId, title, parentId } = meta;
|
||||
const { modId, title, parentId, parentPath } = meta;
|
||||
const navArr: Array<{ name: string; page?: string }> = [];
|
||||
const items = flatNav.value;
|
||||
|
||||
@@ -78,6 +81,21 @@ const routeChange = (to: any) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 子页面(设计器这类)没有自己的模块行:按 meta.parentPath 把高亮与面包屑挂到父级导航行下,
|
||||
// 免得进子页就变成「无高亮 + 无面包屑」——父级行仍在导航里,这不是新增权限口径
|
||||
const parentByPath: any = parentPath ? items.find((x: any) => x.page === parentPath) : null;
|
||||
if (parentByPath) {
|
||||
currPath.value = parentByPath.page || "";
|
||||
if (parentByPath.pid) {
|
||||
const gp = navList.value.find((x) => x.id === parentByPath.pid);
|
||||
if (gp) navArr.push({ name: gp.name });
|
||||
}
|
||||
navArr.push({ name: parentByPath.name, page: parentByPath.page });
|
||||
navArr.push({ name: title });
|
||||
navPath.value = navArr;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const parent = items.find((x: any) => x.modId === parentId);
|
||||
if (parent) {
|
||||
|
||||
Reference in New Issue
Block a user