- 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 数组形态 + 匿名×限次跨字段约束
359 lines
14 KiB
Vue
359 lines
14 KiB
Vue
<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>
|