This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { questionApi, AnswerRowVo, QuestionnaireVo } from "@/api/question";
|
||||
|
||||
/**
|
||||
* 回收明细:分页答卷投影(未删行)+ 单份查看(values 原文 × schema 字段定义渲染)+ 手动拉投影。
|
||||
* 定时投影默认 30s 一轮,此处「立即拉取」为即时补充。
|
||||
*/
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const id = computed(() => Number(route.query.id || 0));
|
||||
|
||||
const q = ref<QuestionnaireVo | null>(null);
|
||||
const rows = ref<AnswerRowVo[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 10;
|
||||
const loading = ref(false);
|
||||
const pulling = ref(false);
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const detailFields = ref<any[]>([]);
|
||||
const detailValues = ref<Record<string, any>>({});
|
||||
const detailMeta = ref<AnswerRowVo | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await questionApi.answers(id.value, page.value, pageSize).send();
|
||||
rows.value = res.rows || [];
|
||||
total.value = res.total || 0;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadQuestionnaire = async () => {
|
||||
try {
|
||||
q.value = await questionApi.detail(id.value).send();
|
||||
} catch {
|
||||
/* 列表页仍可用 */
|
||||
}
|
||||
};
|
||||
|
||||
const pullNow = async () => {
|
||||
pulling.value = true;
|
||||
try {
|
||||
const res = await questionApi.pull(id.value).send();
|
||||
Message.success(`拉取完成:新 ${res.freshRows} 行,当前回收 ${res.totalActive} 份`);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "拉取失败(form-center 不可达或问卷未发布)");
|
||||
} finally {
|
||||
pulling.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (row: AnswerRowVo) => {
|
||||
try {
|
||||
const res: any = await questionApi.answerDetail(id.value, row.answerUuid).send();
|
||||
detailMeta.value = res.row;
|
||||
detailFields.value = flattenFields(res.fields);
|
||||
detailValues.value = JSON.parse(res.row.valuesJson || "{}");
|
||||
detailVisible.value = true;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载答卷失败");
|
||||
}
|
||||
};
|
||||
|
||||
const flattenFields = (groups: any[]): any[] => {
|
||||
const out: any[] = [];
|
||||
for (const g of groups || []) {
|
||||
for (const f of g.fields || []) {
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** 按字段类型渲染值(options 按 value→label;多值顿号连接) */
|
||||
const renderValue = (field: any): string => {
|
||||
const raw = detailValues.value[field.key];
|
||||
if (raw == null) return "—";
|
||||
const labelOf = (v: any): string => {
|
||||
const opt = (field.options || []).find((o: any) => o.value === String(v));
|
||||
return opt ? opt.label : String(v);
|
||||
};
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.length ? raw.map(labelOf).join("、") : "—";
|
||||
}
|
||||
return labelOf(raw);
|
||||
};
|
||||
|
||||
const changePage = (p: number) => {
|
||||
page.value = p;
|
||||
load();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadQuestionnaire();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="bar">
|
||||
<Button @click="router.push('/')">← 返回列表</Button>
|
||||
<span class="title">回收明细{{ q ? `:${q.titleSnapshot}` : "" }}</span>
|
||||
<div class="spacer"></div>
|
||||
<Button :loading="pulling" @click="pullNow">立即拉取</Button>
|
||||
<Button type="primary" ghost style="margin-left: 8px" @click="router.push({ path: '/stats', query: { id } })">
|
||||
查看统计</Button>
|
||||
</div>
|
||||
<Table :loading="loading" :data="rows" :columns="[
|
||||
{ title: '答卷号', key: 'answerUuid', width: 280, tooltip: true },
|
||||
{ title: '提交人', key: 'submitterId', width: 100, slot: 'submitter' },
|
||||
{ title: '状态', key: 'status', width: 110 },
|
||||
{ title: '提交时间(FC)', key: 'fcCreateTime', width: 170 },
|
||||
{ title: '投影时间', key: 'pulledAt', width: 170 },
|
||||
{ title: '操作', slot: 'action', width: 110 },
|
||||
]">
|
||||
<template #submitter="{ row }">
|
||||
{{ row.submitterId === 0 ? '匿名' : (row.submitterId ?? '—') }}
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<Button size="small" @click="openDetail(row)">查看</Button>
|
||||
</template>
|
||||
</Table>
|
||||
<div style="margin-top: 12px; text-align: right">
|
||||
<Page :total="total" :model-value="page" :page-size="pageSize" show-total @on-change="changePage" />
|
||||
</div>
|
||||
|
||||
<Modal v-model="detailVisible" title="答卷详情" width="640" footer-hide>
|
||||
<Descriptions v-if="detailMeta" :column="2" size="small" border style="margin-bottom: 12px">
|
||||
<DescriptionsItem label="答卷号">{{ detailMeta.answerUuid }}</DescriptionsItem>
|
||||
<DescriptionsItem label="提交人">{{ detailMeta.submitterId === 0 ? '匿名' : (detailMeta.submitterId ?? '—') }}</DescriptionsItem>
|
||||
<DescriptionsItem label="状态">{{ detailMeta.status || '—' }}</DescriptionsItem>
|
||||
<DescriptionsItem label="提交时间">{{ detailMeta.fcCreateTime || '—' }}</DescriptionsItem>
|
||||
</Descriptions>
|
||||
<div v-for="f in detailFields" :key="f.key" class="kv">
|
||||
<span class="k">{{ f.label }}{{ f.required ? ' *' : '' }}</span>
|
||||
<span class="v">{{ renderValue(f) }}</span>
|
||||
</div>
|
||||
<div v-if="!detailFields.length" class="hint">(无 schema 字段——问卷未钉版或 FC schema 为空,values 原文:
|
||||
{{ detailMeta?.valuesJson }})</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.kv {
|
||||
display: flex;
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.k {
|
||||
width: 200px;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.v {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
.hint {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { questionApi, QuestionnaireVo } from "@/api/question";
|
||||
import { fcApi, FcFormView } from "@/api/fc";
|
||||
|
||||
/**
|
||||
* 编辑容器:iframe 嵌 FC 设计器(FC App.vue 检测 self!==top 自动剥壳——已提交能力,零 FC 改动)。
|
||||
* FC 不回发保存事件(TASK_2 才加),父页提供"我已保存完毕→刷新状态"按钮;
|
||||
* 打开时拉 FC /admin/get 显示当前版本/发布态。FC 设计器地址:{FC前端}/#/admin/forms/designer?uuid=
|
||||
*/
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const id = computed(() => Number(route.query.id || 0));
|
||||
|
||||
const q = ref<QuestionnaireVo | null>(null);
|
||||
const fcForm = ref<FcFormView | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const designerSrc = computed(() =>
|
||||
q.value ? `${config.fcFrontHost}/#/admin/forms/designer?uuid=${q.value.formUuid}` : "");
|
||||
|
||||
const fcStatusText = computed(() => {
|
||||
if (!fcForm.value) return "未知";
|
||||
if (fcForm.value.statusName === "PUBLISHED") return `已发布 v${fcForm.value.currentVersion}`;
|
||||
if (fcForm.value.statusName === "DISABLED") return "已停用";
|
||||
return fcForm.value.draftVersion ? `草稿 v${fcForm.value.draftVersion}` : "草稿(未保存过)";
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
q.value = await questionApi.refresh(id.value).send();
|
||||
fcForm.value = await fcApi.get(q.value.formUuid).send();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 我已保存完毕 → 拉最新版本/发布态(FC 不回发事件的折中) */
|
||||
const confirmSaved = async () => {
|
||||
await load();
|
||||
Message.success(`状态已刷新:${fcStatusText.value}`);
|
||||
};
|
||||
|
||||
const goPublish = () => router.push({ path: "/publish", query: { id: id.value } });
|
||||
|
||||
/** 提示:题目编辑/保存/预览/发布都在 FC 设计器自带工具条完成 */
|
||||
onMounted(() => {
|
||||
Modal.info({
|
||||
title: "编辑器使用说明",
|
||||
content: "题目在下方 FC 设计器中编辑:拖入字段 → 保存 → 预览。保存/发布均在设计器工具条内完成;保存完毕后点击右上角「我已保存完毕」刷新状态。",
|
||||
okText: "知道了",
|
||||
});
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="bar">
|
||||
<Button @click="router.push('/')">← 返回列表</Button>
|
||||
<span class="title">{{ q?.titleSnapshot || "编辑问卷" }}</span>
|
||||
<Tag v-if="q?.status === 0" color="default">草稿</Tag>
|
||||
<Tag v-else-if="q?.status === 1" color="success">收集中</Tag>
|
||||
<Tag v-else color="warning">已结束</Tag>
|
||||
<span class="fc-status">FC 表单:{{ fcStatusText }}</span>
|
||||
<div class="spacer"></div>
|
||||
<Button :loading="loading" @click="confirmSaved">我已保存完毕,刷新状态</Button>
|
||||
<Button type="primary" ghost style="margin-left: 8px" @click="goPublish">去发布投放</Button>
|
||||
</div>
|
||||
<div class="iframe-box" v-if="designerSrc">
|
||||
<iframe :src="designerSrc" class="designer" frameborder="0" allow="fullscreen"></iframe>
|
||||
</div>
|
||||
<Spin v-else size="large" fix style="position: relative; height: 400px"></Spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fc-status {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.iframe-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.designer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { questionApi, QuestionnaireVo } from "@/api/question";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const rows = ref<QuestionnaireVo[]>([]);
|
||||
|
||||
const statusText = (s: number) => (s === 0 ? "草稿" : s === 1 ? "收集中" : "已结束");
|
||||
const statusColor = (s: number) => (s === 0 ? "default" : s === 1 ? "success" : "warning");
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = (await questionApi.list().send()) || [];
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toEdit = (row: QuestionnaireVo) => router.push({ path: "/edit", query: { id: row.id } });
|
||||
const toPublish = (row: QuestionnaireVo) => router.push({ path: "/publish", query: { id: row.id } });
|
||||
const toAnswers = (row: QuestionnaireVo) => router.push({ path: "/answers", query: { id: row.id } });
|
||||
const toStats = (row: QuestionnaireVo) => router.push({ path: "/stats", query: { id: row.id } });
|
||||
|
||||
/** 有回收量再进编辑 → "建议复制新问卷"软引导(已拍板) */
|
||||
const enterEdit = async (row: QuestionnaireVo) => {
|
||||
try {
|
||||
const page = await questionApi.answers(row.id, 1, 1).send();
|
||||
if (page && page.total > 0) {
|
||||
Modal.confirm({
|
||||
title: "该问卷已有回收答卷",
|
||||
content: "继续编辑会在 FC 侧升版(新版本行),已回收答卷不受影响;若只是想再发一轮,建议「复制新问卷」避免口径混淆。是否继续编辑?",
|
||||
okText: "继续编辑",
|
||||
cancelText: "取消",
|
||||
onOk: () => toEdit(row),
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* 回收量查询失败不阻塞编辑入口 */
|
||||
}
|
||||
toEdit(row);
|
||||
};
|
||||
|
||||
const copyQuestionnaire = async (row: QuestionnaireVo) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// FC copy(新 uuid、version 从 1、deadline 不继承)→ 登记问卷侧新行 → 进编辑
|
||||
const { fcApi } = await import("@/api/fc");
|
||||
const copied = await fcApi.copy(row.formUuid, `${row.titleSnapshot || "问卷"}-副本`).send();
|
||||
const registered = await questionApi.register({
|
||||
formUuid: copied.formUuid,
|
||||
formCode: copied.code,
|
||||
title: copied.name,
|
||||
anonymous: row.anonymous,
|
||||
}).send();
|
||||
Message.success("复制成功,已进入新问卷编辑");
|
||||
router.push({ path: "/edit", query: { id: registered.id } });
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "复制失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeQuestionnaire = (row: QuestionnaireVo) => {
|
||||
Modal.confirm({
|
||||
title: "删除确认",
|
||||
content: `删除问卷「${row.titleSnapshot || row.formCode}」?仅移除问卷侧记录,FC 表单与答卷数据不动(可在"未挂接表单"页看到)。`,
|
||||
onOk: async () => {
|
||||
await questionApi.softDelete(row.id).send();
|
||||
Message.success("已删除");
|
||||
load();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="toolbar">
|
||||
<Button type="primary" @click="router.push('/new')">新建问卷</Button>
|
||||
<Button @click="router.push('/my')">我的问卷</Button>
|
||||
<Button @click="router.push('/orphans')">未挂接表单</Button>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
</div>
|
||||
<Table :loading="loading" :data="rows" :columns="[
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{ title: '问卷标题', key: 'titleSnapshot', tooltip: true },
|
||||
{ title: 'FC code', key: 'formCode', width: 150 },
|
||||
{ title: '受众', key: 'anonymous', width: 90, slot: 'anonymous' },
|
||||
{ title: '状态', key: 'status', width: 100, slot: 'status' },
|
||||
{ title: '发布版本', key: 'formVersion', width: 90, slot: 'version' },
|
||||
{ title: '创建时间', key: 'createTime', width: 170 },
|
||||
{ title: '操作', slot: 'action', width: 330 },
|
||||
]">
|
||||
<template #anonymous="{ row }">
|
||||
<Tag :color="row.anonymous ? 'purple' : 'blue'">{{ row.anonymous ? '匿名' : '实名' }}</Tag>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="statusColor(row.status)">{{ statusText(row.status) }}</Tag>
|
||||
</template>
|
||||
<template #version="{ row }">
|
||||
{{ row.formVersion != null ? 'v' + row.formVersion : '—' }}
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<Button size="small" @click="enterEdit(row)">编辑</Button>
|
||||
<Button size="small" type="primary" ghost style="margin-left: 6px" @click="toPublish(row)">投放</Button>
|
||||
<Button size="small" style="margin-left: 6px" @click="toAnswers(row)">回收明细</Button>
|
||||
<Button size="small" style="margin-left: 6px" @click="toStats(row)">统计</Button>
|
||||
<Button size="small" style="margin-left: 6px" @click="copyQuestionnaire(row)">复制</Button>
|
||||
<Button size="small" type="error" ghost style="margin-left: 6px" @click="removeQuestionnaire(row)">删除</Button>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { questionApi, QuestionnaireVo } from "@/api/question";
|
||||
import { fcApi, FcFillableRow } from "@/api/fc";
|
||||
|
||||
/**
|
||||
* 我的问卷:FC fillable(appCode=question) ∩ 问卷 status=1。
|
||||
* 点击进 FC 填写页(新窗或本页 iframe 模式);embed 消息 proto==="fc"&&v===1,origin 按"同 hostname"
|
||||
* 放行(localhost 各端口共享 Cookie 域,任务书 §3.1)——formSubmitted 后刷新列表。
|
||||
* 提交门禁(受众/截止/限次/匿名)全部由 FC 把关,问卷侧零旁路。
|
||||
*/
|
||||
const rows = ref<QuestionnaireVo[]>([]);
|
||||
const loading = ref(false);
|
||||
const filling = ref<QuestionnaireVo | null>(null);
|
||||
|
||||
const fillUrl = computed(() =>
|
||||
filling.value?.formCode ? `${config.fcFrontHost}/#/f/${filling.value.formCode}?mode=embed` : "");
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const fillable: FcFillableRow[] = (await fcApi.fillable().send()) || [];
|
||||
const codes = fillable.map((r) => r.code);
|
||||
rows.value = (await questionApi.myFillable(codes).send()) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** embed 消息监听(FC utils/embed.ts 契约:proto:"fc", v:1;同 hostname 放行) */
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const sameHost = new URL(event.origin).hostname === window.location.hostname;
|
||||
if (!sameHost) return;
|
||||
const data: any = event.data;
|
||||
if (!data || data.proto !== "fc" || data.v !== 1) return;
|
||||
if (data.type === "success" || data.type === "formSubmitted") {
|
||||
Message.success("提交成功,感谢填写!");
|
||||
closeFill();
|
||||
load();
|
||||
} else if (data.type === "needLogin") {
|
||||
Message.warning("登录已过期,请刷新页面重新登录");
|
||||
} else if (data.type === "error") {
|
||||
Message.error(`提交失败:${data.error || "请检查必填项"}`);
|
||||
}
|
||||
} catch {
|
||||
/* 非法 origin 忽略 */
|
||||
}
|
||||
};
|
||||
|
||||
const openFill = (row: QuestionnaireVo) => {
|
||||
filling.value = row;
|
||||
};
|
||||
const closeFill = () => {
|
||||
filling.value = null;
|
||||
};
|
||||
const openNewWindow = (row: QuestionnaireVo) => {
|
||||
if (!row.formCode) return;
|
||||
window.open(`${config.fcFrontHost}/#/f/${row.formCode}`, "_blank");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("message", onMessage);
|
||||
load();
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener("message", onMessage));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="toolbar">
|
||||
<span class="title">我的问卷</span>
|
||||
<span class="hint">仅显示面向您所在人群、收集中且未过截止的问卷</span>
|
||||
<div class="spacer"></div>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
</div>
|
||||
<div class="cards">
|
||||
<Card v-for="row in rows" :key="row.id" dis-hover class="card">
|
||||
<div class="card-head">
|
||||
<span class="name">{{ row.titleSnapshot }}</span>
|
||||
<Tag v-if="row.anonymous" color="purple">匿名</Tag>
|
||||
<Tag v-else color="blue">实名</Tag>
|
||||
</div>
|
||||
<p class="meta">发布于 {{ row.publishTime || "—" }}</p>
|
||||
<div style="margin-top: 10px">
|
||||
<Button type="primary" size="small" @click="openFill(row)">去填写</Button>
|
||||
<Button size="small" style="margin-left: 6px" @click="openNewWindow(row)">新窗口打开</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<div v-if="!loading && !rows.length" class="empty">暂无可填写的问卷</div>
|
||||
</div>
|
||||
|
||||
<Modal v-model="filling" :title="filling?.titleSnapshot || '填写问卷'" fullscreen footer-hide
|
||||
@on-visible-change="(v: boolean) => !v && closeFill()">
|
||||
<div v-if="filling" class="fill-frame">
|
||||
<iframe :src="fillUrl" frameborder="0" class="fill-iframe"></iframe>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hint {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.meta {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.empty {
|
||||
color: #999;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.fill-frame {
|
||||
height: 100%;
|
||||
}
|
||||
.fill-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { questionApi } from "@/api/question";
|
||||
import { fcApi, FcFormView } from "@/api/fc";
|
||||
|
||||
/**
|
||||
* 未挂接表单对账页(任务书 §4.1):FC 侧 appCode=question 的表单 ∖ 问卷侧已绑定 form_uuid。
|
||||
* 只读展示 + 可选清理按钮(调 FC softDelete);第三步登记失败留下的孤儿在此补登记或清理。
|
||||
*/
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const orphans = ref<FcFormView[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const fcForms: FcFormView[] = (await fcApi.list().send()) || [];
|
||||
const bound = (await questionApi.list().send()) || [];
|
||||
const boundUuids = new Set(bound.map((q) => q.formUuid));
|
||||
orphans.value = fcForms.filter((f) => !boundUuids.has(f.formUuid));
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载失败(需要校管理员权限访问 FC 表单列表)");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 补登记:FC 表单已有而问卷侧没行(如向导第三步失败后遗留) */
|
||||
const register = async (row: FcFormView) => {
|
||||
try {
|
||||
await questionApi.register({
|
||||
formUuid: row.formUuid,
|
||||
formCode: row.code,
|
||||
title: row.name,
|
||||
anonymous: (row.audience || []).includes("ANONYMOUS"),
|
||||
}).send();
|
||||
Message.success("已登记绑定");
|
||||
load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "登记失败");
|
||||
}
|
||||
};
|
||||
|
||||
/** 清理:调 FC 软删(唯一删除形态;FC 侧执行,问卷侧零改动) */
|
||||
const clean = (row: FcFormView) => {
|
||||
Modal.confirm({
|
||||
title: "清理未挂接表单",
|
||||
content: `将软删 FC 表单「${row.name}(${row.code})」。该表单若有答卷数据仍可导出(FC 纪律:软删不断数据面)。确定清理?`,
|
||||
okText: "清理(FC 软删)",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fcApi.softDelete(row.formUuid).send();
|
||||
Message.success("已在 FC 侧软删");
|
||||
load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "清理失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const statusText = (f: FcFormView) =>
|
||||
f.statusName === "PUBLISHED" ? `已发布 v${f.currentVersion}` : f.statusName === "DISABLED" ? "已停用" : "草稿";
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="bar">
|
||||
<Button @click="router.push('/')">← 返回列表</Button>
|
||||
<span class="title">未挂接表单(对账)</span>
|
||||
<span class="hint">FC 侧 question 应用表单中未被问卷绑定的孤儿;来源:新建向导第三步失败后未重试</span>
|
||||
<div class="spacer"></div>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
</div>
|
||||
<Table :loading="loading" :data="orphans" :columns="[
|
||||
{ title: '表单名', key: 'name', tooltip: true },
|
||||
{ title: 'FC code', key: 'code', width: 160 },
|
||||
{ title: 'uuid', key: 'formUuid', width: 290, tooltip: true },
|
||||
{ title: 'FC 状态', key: 'statusName', width: 130, slot: 'status' },
|
||||
{ title: '受众', key: 'audience', width: 160, slot: 'audience' },
|
||||
{ title: '更新时间', key: 'updateTime', width: 170 },
|
||||
{ title: '操作', slot: 'action', width: 180 },
|
||||
]">
|
||||
<template #status="{ row }">
|
||||
{{ statusText(row) }}
|
||||
</template>
|
||||
<template #audience="{ row }">
|
||||
{{ (row.audience || []).join('/') || '—' }}
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<Button size="small" type="primary" ghost @click="register(row)">补登记</Button>
|
||||
<Button size="small" type="error" ghost style="margin-left: 6px" @click="clean(row)">清理</Button>
|
||||
</template>
|
||||
</Table>
|
||||
<div v-if="!loading && !orphans.length" class="empty">无未挂接表单,绑定关系一致。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hint {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.empty {
|
||||
color: #999;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import QRCode from "qrcode";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { questionApi, QuestionnaireVo } from "@/api/question";
|
||||
import { fcApi } from "@/api/fc";
|
||||
|
||||
/**
|
||||
* 发布投放:先(可选)调整 FC 配置(受众/截止/限次,直调 FC config)→
|
||||
* 问卷侧 publish(后端 fetchSchema 核验 FC 已发布→钉版+status=1)→
|
||||
* 生成填写链接 {FC前端}/#/f/{code}(匿名问卷同链接,FC 端自动探测 /anon/form/*)+ 二维码(前端生成)。
|
||||
*/
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const id = computed(() => Number(route.query.id || 0));
|
||||
|
||||
const q = ref<QuestionnaireVo | null>(null);
|
||||
const loading = ref(false);
|
||||
const publishing = ref(false);
|
||||
const finishing = ref(false);
|
||||
const qrDataUrl = ref("");
|
||||
|
||||
const conf = ref({ audience: [] as string[], fillLimit: 0, deadline: "" });
|
||||
|
||||
const fillLink = computed(() =>
|
||||
q.value?.formCode ? `${config.fcFrontHost}/#/f/${q.value.formCode}` : "");
|
||||
|
||||
const anon = computed(() => conf.value.audience.includes("ANONYMOUS"));
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
q.value = await questionApi.detail(id.value).send();
|
||||
const fcForm = await fcApi.get(q.value.formUuid).send();
|
||||
conf.value.audience = fcForm.audience?.length ? [...fcForm.audience] : ["STUDENT"];
|
||||
conf.value.fillLimit = fcForm.fillLimit || 0;
|
||||
conf.value.deadline = fcForm.deadline ? fcForm.deadline.split(" ")[0] : "";
|
||||
if (q.value.status === 1 && fillLink.value) {
|
||||
qrDataUrl.value = await QRCode.toDataURL(fillLink.value, { width: 220, margin: 1 });
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 保存 FC 配置(受众/截止/限次;frozen 建卷时已锁 1) */
|
||||
const saveConfig = async () => {
|
||||
if (!conf.value.audience.length) {
|
||||
Message.warning("请选择面向人群");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await fcApi.config({
|
||||
uuid: q.value!.formUuid,
|
||||
audience: conf.value.audience,
|
||||
fillLimit: anon.value ? 0 : conf.value.fillLimit,
|
||||
deadline: conf.value.deadline || "",
|
||||
}).send();
|
||||
Message.success("FC 配置已保存");
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 发布:问卷侧核验(FC 已发布)→ 钉版 status=1 → 出链接/二维码 */
|
||||
const publish = async () => {
|
||||
publishing.value = true;
|
||||
try {
|
||||
q.value = await questionApi.publish(id.value).send();
|
||||
qrDataUrl.value = await QRCode.toDataURL(fillLink.value, { width: 220, margin: 1 });
|
||||
Message.success("发布成功,问卷进入收集中");
|
||||
} catch (e: any) {
|
||||
// 常见拒绝:FC 侧尚未发布(需先在设计器里点发布)
|
||||
Message.error(e?.message || "发布失败");
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fillLink.value);
|
||||
Message.success("链接已复制");
|
||||
} catch {
|
||||
Message.warning("复制失败,请手动选择复制");
|
||||
}
|
||||
};
|
||||
|
||||
/** 结束投放:问卷 status=2;可选同步 FC 停用(硬门禁——FC 直链也会被断) */
|
||||
const finish = () => {
|
||||
Modal.confirm({
|
||||
title: "结束投放",
|
||||
content: "结束后问卷侧入口隐藏并提示已结束。是否同时停用 FC 表单?(停用是硬门禁:已投出的直链也无法再提交;数据仍可读可导)",
|
||||
okText: "结束并停用 FC",
|
||||
cancelText: "仅结束问卷",
|
||||
onOk: async () => {
|
||||
finishing.value = true;
|
||||
try {
|
||||
await questionApi.finish(id.value).send();
|
||||
await fcApi.disable(q.value!.formUuid).send();
|
||||
Message.success("已结束投放并停用 FC 表单");
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
} finally {
|
||||
finishing.value = false;
|
||||
load();
|
||||
}
|
||||
},
|
||||
onCancel: async () => {
|
||||
finishing.value = true;
|
||||
try {
|
||||
await questionApi.finish(id.value).send();
|
||||
Message.success("已结束投放");
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
} finally {
|
||||
finishing.value = false;
|
||||
load();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const reopen = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
q.value = await questionApi.reopen(id.value).send();
|
||||
Message.success("已恢复收集");
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="bar">
|
||||
<Button @click="router.push('/')">← 返回列表</Button>
|
||||
<span class="title">发布投放{{ q ? `:${q.titleSnapshot}` : "" }}</span>
|
||||
<Tag v-if="q?.status === 1" color="success">收集中</Tag>
|
||||
<Tag v-else-if="q?.status === 2" color="warning">已结束</Tag>
|
||||
<Tag v-else color="default">草稿</Tag>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<Card dis-hover title="投放配置(写入 FC 表单)">
|
||||
<Form :label-width="110">
|
||||
<FormItem label="面向人群">
|
||||
<CheckboxGroup v-model="conf.audience">
|
||||
<Checkbox label="STUDENT">学生</Checkbox>
|
||||
<Checkbox label="TEACHER">教师</Checkbox>
|
||||
<Checkbox label="ANONYMOUS">匿名(无需登录)</Checkbox>
|
||||
</CheckboxGroup>
|
||||
</FormItem>
|
||||
<FormItem label="限填次数">
|
||||
<RadioGroup v-model="conf.fillLimit">
|
||||
<Radio :label="0" :disabled="anon">不限</Radio>
|
||||
<Radio :label="1" :disabled="anon">限一次(实名)</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem label="截止日期">
|
||||
<DatePicker v-model="conf.deadline" type="date" format="yyyy-MM-dd"
|
||||
placeholder="当日 23:59:59 截止" style="width: 200px" />
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button :loading="loading" @click="saveConfig">保存配置到 FC</Button>
|
||||
<Poptip v-if="q?.status !== 1" trigger="hover" title="发布前请确认"
|
||||
content="FC 设计器里需已保存并点击发布;此处发布会核验 FC 发布状态。"
|
||||
style="margin-left: 8px">
|
||||
<Button type="primary" :loading="publishing" @click="publish">发布问卷</Button>
|
||||
</Poptip>
|
||||
<Button v-if="q?.status === 1" type="error" ghost style="margin-left: 8px" :loading="finishing"
|
||||
@click="finish">结束投放</Button>
|
||||
<Button v-if="q?.status === 2" type="primary" ghost style="margin-left: 8px" :loading="loading"
|
||||
@click="reopen">恢复收集</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card dis-hover title="填写链接与二维码">
|
||||
<div v-if="q?.status === 1 || q?.status === 2" class="link-box">
|
||||
<div class="link-row">
|
||||
<Input readonly :model-value="fillLink" />
|
||||
<Button type="primary" ghost style="margin-left: 8px" @click="copyLink">复制</Button>
|
||||
</div>
|
||||
<p class="hint">匿名问卷同链接:FC 填写页自动探测未登录并切匿名通道。</p>
|
||||
<div v-if="qrDataUrl" class="qr">
|
||||
<img :src="qrDataUrl" alt="问卷二维码" />
|
||||
<p>手机扫码填写</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
<p>尚未发布。保存配置并点击「发布问卷」后,此处生成填写链接与二维码。</p>
|
||||
<p class="hint">发布顺序:FC 设计器内保存题目 → 设计器内点发布 → 此处「发布问卷」核验钉版。</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.link-box .link-row {
|
||||
display: flex;
|
||||
}
|
||||
.hint {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.qr {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.qr img {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
.empty {
|
||||
color: #666;
|
||||
padding: 20px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { questionApi, StatsVo, QuestionnaireVo } from "@/api/question";
|
||||
|
||||
/**
|
||||
* 基础统计(全部在答卷投影上算):
|
||||
* - 回收量/状态分布;
|
||||
* - 选项题按题分布——人数口径(一份答卷对一题的一个选项最多计 1;与 FC"选次"口径可区分,页面上注明);
|
||||
* - 文本题明细列表。
|
||||
*/
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const id = computed(() => Number(route.query.id || 0));
|
||||
|
||||
const q = ref<QuestionnaireVo | null>(null);
|
||||
const stats = ref<StatsVo | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
q.value = await questionApi.detail(id.value).send();
|
||||
stats.value = await questionApi.stats(id.value).send();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="bar">
|
||||
<Button @click="router.push('/')">← 返回列表</Button>
|
||||
<span class="title">基础统计{{ q ? `:${q.titleSnapshot}` : "" }}</span>
|
||||
<Tag v-if="q?.formVersion" color="blue">钉版 v{{ q.formVersion }}</Tag>
|
||||
<div class="spacer"></div>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
</div>
|
||||
|
||||
<div class="cards" v-if="stats">
|
||||
<Card dis-hover class="kpi">
|
||||
<div class="kpi-num">{{ stats.total }}</div>
|
||||
<div class="kpi-label">回收量(份,不含已删)</div>
|
||||
</Card>
|
||||
<Card dis-hover>
|
||||
<template #title>状态分布</template>
|
||||
<div v-for="s in stats.byStatus" :key="s.status" class="status-row">
|
||||
<span>{{ s.status || "未设置" }}</span>
|
||||
<span class="num">{{ s.count }}</span>
|
||||
</div>
|
||||
<div v-if="!stats.byStatus.length" class="hint">暂无数据</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card v-if="stats?.byField?.length" dis-hover title="选项题分布(人数口径)" style="margin-top: 16px">
|
||||
<Alert show-icon style="margin-bottom: 12px">
|
||||
人数口径
|
||||
<template #desc>一份答卷对一题的一个选项最多计 1;多选按答卷去重后逐选项计数。与 FC 侧 answer_index 的"选次"口径不同(一人选两项在两桶各计 1 的选次口径不可区分答卷)。</template>
|
||||
</Alert>
|
||||
<div v-for="f in stats.byField" :key="f.key" class="field-block">
|
||||
<div class="field-title">
|
||||
{{ f.label }}
|
||||
<span class="hint">({{ f.answeredSheets }} 份答卷作答本题)</span>
|
||||
</div>
|
||||
<div v-for="item in f.items" :key="item.value" class="bar-row">
|
||||
<span class="bar-label">{{ item.label }}</span>
|
||||
<div class="bar-track">
|
||||
<div class="bar-fill" :style="{ width: Math.min(item.percent, 100) + '%' }"></div>
|
||||
</div>
|
||||
<span class="bar-num">{{ item.count }} 人({{ item.percent }}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card v-if="stats?.textDetail?.length" dis-hover title="文本/数值题明细" style="margin-top: 16px">
|
||||
<div v-for="t in stats.textDetail" :key="t.key" class="field-block">
|
||||
<div class="field-title">{{ t.label }} <span class="hint">({{ t.type }},{{ t.items.length }} 条)</span></div>
|
||||
<ul class="text-list">
|
||||
<li v-for="(item, i) in t.items" :key="i">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div v-if="stats && !stats.byField.length && !stats.textDetail.length" class="empty">
|
||||
暂无统计数据(问卷发布后,答卷投影 30 秒内自动同步;也可在回收明细页点「立即拉取」)。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.kpi {
|
||||
text-align: center;
|
||||
}
|
||||
.kpi-num {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
.kpi-label {
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.status-row .num {
|
||||
font-weight: 600;
|
||||
}
|
||||
.field-block {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.field-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hint {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.bar-label {
|
||||
width: 140px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bar-track {
|
||||
flex: 1;
|
||||
height: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2d8cf0, #5cadff);
|
||||
border-radius: 8px;
|
||||
min-width: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.bar-num {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
.text-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #333;
|
||||
}
|
||||
.text-list li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.empty {
|
||||
color: #999;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { questionApi } from "@/api/question";
|
||||
import { fcApi } from "@/api/fc";
|
||||
|
||||
/**
|
||||
* 新建向导——幂等三步(任务书 §4.1):
|
||||
* ① FC create(appCode=question)② FC config(frozen=1+受众/限次/截止)③ 问卷侧登记(uk form_uuid 兜底)。
|
||||
* 第三步失败:提示仅重试第三步(不自动回滚 FC 表单——孤儿由"未挂接表单"对账页只读展示)。
|
||||
*/
|
||||
const router = useRouter();
|
||||
const step = ref(0); // 0 表单填写 1 执行中 2 完成
|
||||
const submitting = ref(false);
|
||||
const retryThird = ref(false); // 第三步失败后的重试形态(隐藏①②)
|
||||
|
||||
const form = reactive({
|
||||
title: "",
|
||||
description: "",
|
||||
audience: ["STUDENT"] as string[],
|
||||
anonymous: computed(() => form.audience.includes("ANONYMOUS")),
|
||||
fillLimit: 0 as number, // 0 不限 / 1 限一次
|
||||
deadline: "" as string, // yyyy-MM-dd(FC 端补 23:59:59)
|
||||
});
|
||||
|
||||
// 匿名+限次互斥(FC updateConfig 硬拦,前端先行提示)
|
||||
const fillLimitDisabled = computed(() => form.anonymous);
|
||||
watchAnonymousLimit();
|
||||
|
||||
function watchAnonymousLimit() {
|
||||
if (form.anonymous && form.fillLimit === 1) {
|
||||
form.fillLimit = 0;
|
||||
Message.warning("匿名问卷与限填一次互斥(FC 硬性约束),已重置为不限次数");
|
||||
}
|
||||
}
|
||||
|
||||
const created = reactive({ formUuid: "", formCode: "", questionnaireId: 0 as number });
|
||||
|
||||
const step1Create = async () => {
|
||||
const created1 = await fcApi.create(form.title).send();
|
||||
created.formUuid = created1.formUuid;
|
||||
created.formCode = created1.code;
|
||||
step.value = 1;
|
||||
};
|
||||
|
||||
const step2Config = async () => {
|
||||
await fcApi.config({
|
||||
uuid: created.formUuid,
|
||||
audience: form.audience,
|
||||
fillLimit: form.anonymous ? 0 : form.fillLimit,
|
||||
deadline: form.deadline || undefined,
|
||||
frozen: 1, // 问卷答卷一律冻结(append-only 游标拉取的前提)
|
||||
anonCookieMinutes: form.anonymous ? 60 : undefined,
|
||||
}).send();
|
||||
step.value = 2;
|
||||
};
|
||||
|
||||
const step3Register = async () => {
|
||||
const q = await questionApi.register({
|
||||
formUuid: created.formUuid,
|
||||
formCode: created.formCode,
|
||||
title: form.title,
|
||||
anonymous: form.anonymous,
|
||||
description: JSON.stringify({ description: form.description || "" }),
|
||||
}).send();
|
||||
created.questionnaireId = q.id;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.title.trim()) {
|
||||
Message.warning("请填写问卷标题");
|
||||
return;
|
||||
}
|
||||
if (!form.audience.length) {
|
||||
Message.warning("请选择面向人群");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (!retryThird.value) {
|
||||
await step1Create();
|
||||
await step2Config();
|
||||
}
|
||||
await step3Register();
|
||||
Message.success(`问卷「${form.title}」创建成功,接下来在编辑器里添加题目`);
|
||||
router.push({ path: "/edit", query: { id: created.questionnaireId } });
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || "创建失败";
|
||||
if (created.formUuid) {
|
||||
// ①② 已成功——只提示重试第三步(幂等:重复登记不产生重复行)
|
||||
retryThird.value = true;
|
||||
Message.error(`FC 表单已创建,但问卷登记失败(${msg})。请点击"重试登记",不要重复新建。`);
|
||||
} else {
|
||||
Message.error(`创建失败:${msg}`);
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<Card dis-hover>
|
||||
<template #title>新建问卷(三步:创建 FC 表单 → 投放配置 → 登记绑定)</template>
|
||||
<Steps :current="retryThird ? 2 : 0" style="margin-bottom: 20px">
|
||||
<Step title="填写信息" content="标题/人群/截止/限次"></Step>
|
||||
<Step title="FC 建表+配置" content="自动执行"></Step>
|
||||
<Step title="登记绑定" content="问卷侧行落库"></Step>
|
||||
</Steps>
|
||||
|
||||
<Form :label-width="110" style="max-width: 620px">
|
||||
<FormItem label="问卷标题" required>
|
||||
<Input v-model="form.title" :disabled="retryThird" placeholder="如:食堂满意度调查" maxlength="60" show-word-limit />
|
||||
</FormItem>
|
||||
<FormItem label="问卷说明">
|
||||
<Input v-model="form.description" type="textarea" :disabled="retryThird" :rows="3"
|
||||
placeholder="将展示在填写页顶部(选填)" />
|
||||
</FormItem>
|
||||
<FormItem label="面向人群" required>
|
||||
<CheckboxGroup v-model="form.audience">
|
||||
<Checkbox label="STUDENT" :disabled="retryThird">学生</Checkbox>
|
||||
<Checkbox label="TEACHER" :disabled="retryThird">教师</Checkbox>
|
||||
<Checkbox label="ANONYMOUS" :disabled="retryThird">匿名(无需登录)</Checkbox>
|
||||
</CheckboxGroup>
|
||||
</FormItem>
|
||||
<FormItem label="限填次数">
|
||||
<RadioGroup v-model="form.fillLimit">
|
||||
<Radio :label="0" :disabled="fillLimitDisabled || retryThird">不限</Radio>
|
||||
<Radio :label="1" :disabled="fillLimitDisabled || retryThird">限一次(实名)</Radio>
|
||||
</RadioGroup>
|
||||
<span v-if="fillLimitDisabled" class="hint">匿名问卷不可限次(FC 硬性互斥)</span>
|
||||
</FormItem>
|
||||
<FormItem label="截止日期">
|
||||
<DatePicker v-model="form.deadline" type="date" format="yyyy-MM-dd" placeholder="选填,当日 23:59:59 截止"
|
||||
style="width: 220px" :disabled="retryThird" />
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button type="primary" :loading="submitting" @click="submit">
|
||||
{{ retryThird ? "重试登记(仅第三步)" : "创建问卷" }}
|
||||
</Button>
|
||||
<Button style="margin-left: 8px" @click="router.push('/')">取消</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.hint {
|
||||
margin-left: 10px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user