feat(t9): P76 导出锚点判树修正 + P77 应用内详情形态 + 观察项①勾选回填 + 演练桩
build / build (push) Successful in 3m17s

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:
chenxing
2026-10-15 12:57:10 +08:00
parent fdd9aaaa3b
commit 14d45f7e3c
61 changed files with 9236 additions and 72 deletions
+188
View File
@@ -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>
+179
View File
@@ -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>
+274
View File
@@ -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>
+463
View File
@@ -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>
+13
View File
@@ -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>
+31
View File
@@ -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>
+13
View File
@@ -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>
+30
View File
@@ -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>
+13
View File
@@ -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>
+150
View File
@@ -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>
+56
View File
@@ -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);
}
+13
View File
@@ -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>
+32
View File
@@ -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>
+13
View File
@@ -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>
+44
View File
@@ -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>
+13
View File
@@ -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>
+29
View File
@@ -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>
+13
View File
@@ -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>
+31
View File
@@ -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>
+13
View File
@@ -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>
+32
View File
@@ -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>
+13
View File
@@ -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>
+29
View File
@@ -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>
+13
View File
@@ -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>
+29
View File
@@ -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>
+32
View File
@@ -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") : [];
}
+13
View File
@@ -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>
+135
View File
@@ -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>
+321
View File
@@ -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>
+19 -1
View File
@@ -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) {