Files
form-center-front/src/components/form/FormConfigModal.vue
T
chenxing 4a7db3d22c t13:前端换形——audience 数组多选 + 冻结/匿名窗口开关,fillable 行去 canFill/myTier
- types/form.ts:AUDIENCE 改字符串码闭集(含 ANONYMOUS)+ audienceText();
  FormView/FormConfigRequest 加 audience 数组、frozen、anonCookieMinutes
- types/data.ts:FillableForm 去 canFill/myTier(C2,入集即可填);
  DATA_SCOPE_LABELS 由四枚举改两档(ALL/SELF,§19 作废-1)
- FormConfigModal:面向人群改复选组(多选共集)+ 冻结开关(文案指路"喂外部业务应用的表单请开启")
  + 匿名防重窗口;含 ANONYMOUS 时限次置灰并在提交前挡(与后端同判据)
- Forms.vue 面向列、My.vue「可填写」卡集、fill.ts/routes.ts 注记同步换形
- AppContext.vue:填报 tab 直接吃入集行;数据/统计两档的驱动位改由 T14 的 managed 端点接,
  本批不留"按 myTier 猜"的兼容分支(壳零自有权限逻辑)
- mock:formapi/dataapi 的 audience 数组形态 + 匿名×限次跨字段约束
2026-10-20 12:54:26 +08:00

359 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { AudienceCode, FormView, StatusDefView } from "@/types/form";
import { AUDIENCE, 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: string[];
fillLimit: number;
deadline: string;
answerNoEnabled: number;
frozen: number;
anonCookieMinutes: number;
groupTag: string;
}>({
audience: [AUDIENCE.STUDENT],
fillLimit: 0,
deadline: "",
answerNoEnabled: 0,
frozen: 0,
anonCookieMinutes: 10,
groupTag: ""
});
const statusDefs = ref<StatusDefView[]>([]);
const newStatus = reactive({ code: "", label: "" });
/** 含匿名时后端强制 fill_limit=0(§3.2/作废-11:匿名无 limit_key)——前端同判据提前挡,不给点了才报错 */
const anonymousChosen = computed(() => model.audience.includes(AUDIENCE.ANONYMOUS));
watch(
() => [visible.value, props.form?.formUuid] as const,
() => {
const f = props.form;
if (!visible.value || !f) return;
model.audience = f.audience?.length ? [...f.audience] : [AUDIENCE.STUDENT];
model.fillLimit = f.fillLimit ?? 0;
model.deadline = f.deadline ? String(f.deadline).slice(0, 10) : "";
model.answerNoEnabled = f.answerNoEnabled ?? 0;
model.frozen = f.frozen ?? 0;
model.anonCookieMinutes = f.anonCookieMinutes ?? 10;
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;
}
if (!model.audience.length) {
Message.error("面向人群至少要选一项(空集合等于谁都不能填)");
return;
}
if (anonymousChosen.value && model.fillLimit === 1) {
Message.error("含「匿名可填」的表单不支持限次(匿名没有可比对的身份),请把限次改回不限");
return;
}
saving.value = true;
try {
await formApi
.config({
uuid: f.formUuid,
audience: model.audience as AudienceCode[],
fillLimit: model.fillLimit,
deadline: model.deadline,
answerNoEnabled: model.answerNoEnabled,
frozen: model.frozen,
anonCookieMinutes: model.anonCookieMinutes,
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>
<CheckboxGroup v-model="model.audience">
<Checkbox v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Checkbox>
</CheckboxGroup>
<span class="cfg-note">
可多选(契约 v2 C1)。填报资格=登录身份类型落在集合内;「匿名可填」只对无会话的
<code>/anon</code> 通道生效,登录用户不会因此多出入口
</span>
</div>
<div class="cfg-row">
<span class="cfg-label">限次</span>
<RadioGroup v-model="model.fillLimit">
<Radio :label="0">不限次数</Radio>
<Radio :label="1" :disabled="anonymousChosen">每人限填一次</Radio>
</RadioGroup>
<span class="cfg-note">
「每人」按提交主体判:管理员代录指定了被记录人时按那个人限,否则按提交人限;软删答卷会释放名额。
<template v-if="anonymousChosen">含匿名人群时不可限次(匿名无 limit_key,防重只有 cookie)。</template>
</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>
<i-switch v-model="model.frozen" :true-value="1" :false-value="0" />
<span class="cfg-note">
开启后提交人与管理员的编辑通道同时关闭,修正只能「撤回(软删)+ 重新提交」——
<b>喂外部业务应用的表单请开启</b>(原件是档案,业务对象归应用)
</span>
</div>
<div class="cfg-row">
<span class="cfg-label">匿名防重窗口</span>
<InputNumber v-model="model.anonCookieMinutes" :min="1" :max="1440" style="width: 140px" />
<span class="cfg-note">分钟。仅对面向人群含「匿名可填」的表单有意义(默认 10 分钟)</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>