R1 先行批:问卷前端(Notice 模板移植 8 页面,tsc/vite build 通过)+ 菜单契约说明
build / build (push) Successful in 1m50s

This commit is contained in:
chenxing
2026-10-22 10:14:39 +08:00
parent 14269d0dae
commit 2d110498cd
81 changed files with 13248 additions and 2 deletions
+191
View File
@@ -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>
+121
View File
@@ -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>
+134
View File
@@ -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>
+160
View File
@@ -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>
+130
View File
@@ -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>
+250
View File
@@ -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>
+197
View File
@@ -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>
+159
View File
@@ -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>