feat(p89+p90): 复制表单链接 + 设计器整表预览
build / build (push) Failing after 32s

P89 复制填报链接(旧系统先例对齐:Cms AttachmentManage 的 copyToClipboard
+ 文案「链接已复制」;svc-hall 同款 execCommand 兜底):
- 新增 utils/fillLink.ts:buildFillLink 从当前地址推导部署根(生产
  /form-center/front/、本地 dev=/),hash 路由 query 进 hash(#/f/{code}?appCode=),
  中心表单不带 appCode;copyToClipboard=navigator.clipboard 优先 +
  临时 textarea+execCommand 兜底,双失败亮链接提示手动复制不静默。
- Forms.vue 行操作「复制链接」文字链(P51 形态,收「更多」逻辑不动);
  FormDesigner 头部「复制链接」按钮(草稿态可复制,发布后链接即生效)。

P90 设计器整表预览:
- 新增 components/designer/PreviewModal.vue:全屏 Modal 按填报视角渲染当前
  画布内容(含未保存改动);控件件与填报页同源(fieldWidget pc),选项解析
  与 DesignerCanvas.resolvedOptions 同规则(inline 直用/字典引用展开,字典
  缺域显式降级);必填红星/占位/约束 tip 同 Fill.vue 位;值容器本地可试填,
  不校验不提交;locked 只读态也可用;空分组/未知类型显式空态。
- FormDesigner 头部「预览」按钮。

验证:vue-tsc --noEmit 干净;vite build 通过。
This commit is contained in:
chenxing
2026-10-15 23:42:25 +08:00
parent 82a9801ac6
commit 79c98e4280
4 changed files with 259 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
/**
* 设计器整表预览(P90):按填报视角全屏渲染当前画布内容(**含未保存改动**)。
*
* 口径对齐:
* - 控件件与填报页同一批(fields/index.ts 的 fieldWidget),值通道 update:modelValue——
* 「预览过了保存被拒」不允许出现两套渲染口径;
* - 选项解析与 DesignerCanvas.resolvedOptions 同规则:inline 数组直用,字典引用按
* 可选字典展开(字典不在可选域给显式降级文案,不静默空白);
* - 必填红星/占位/约束提示与 Fill.vue 同位;值放本地容器可试填交互,纯前端、不提交。
* - 已发布版本行只读态(locked)也可预览——看效果不需要写权限。
*/
import { reactive, ref, watch } from "vue";
import { Modal } from "view-ui-plus";
import { fieldWidget } from "@/components/fields";
import type { DesignField, DesignGroup, FieldOption } from "@/types/formSchema";
import { dictRefCode, isDictRef } from "@/types/formSchema";
import type { DictView } from "@/types/form";
const props = defineProps<{ groups: DesignGroup[]; dicts: DictView[] }>();
const visible = ref(false);
/** 试填值容器(key → 值),打开时清空——预览每次都是「新填一遍」的视角 */
const previewValues = reactive<Record<string, unknown>>({});
/** 记录各字段累计字符数之类的展示需求没有;错误态也不做(预览不做校验拦截) */
watch(visible, (v) => {
if (v) {
for (const k of Object.keys(previewValues)) delete previewValues[k];
}
});
function open() {
visible.value = true;
}
defineExpose({ open });
/** 选项解析(与 DesignerCanvas.resolvedOptions 同规则,此处独立成函数供模板用) */
function resolvedOptions(field: DesignField): FieldOption[] {
const options = field.options as FieldOption[] | { source?: string } | undefined;
if (!options) return [];
if (Array.isArray(options)) return options;
if (isDictRef(options)) {
const code = dictRefCode(options) || "";
const dict = (props.dicts || []).find((d) => d.code === code);
return dict ? dict.items.map((i) => ({ label: i.label, value: i.value })) : [];
}
return [];
}
function widgetOf(field: DesignField) {
return fieldWidget(field.type, "pc");
}
/** 字典引用且字典不在可选域(与画布同判定,给显式降级而非空下拉) */
function dictMissing(field: DesignField): boolean {
const code = dictRefCode(field.options as never);
if (!code) return false;
return !(props.dicts || []).some((d) => d.code === code);
}
function setValue(key: string, val: unknown) {
previewValues[key] = val;
}
</script>
<template>
<Modal v-model="visible" fullscreen title="填报预览(当前画布内容,含未保存改动)" :footer-hide="true">
<div class="pv-body">
<div class="pv-card">
<Alert show-icon type="info" class="pv-note">
这是填报者视角的整表预览:字段按真实控件渲染、可试填;约束提示与必填标记同填报页。
预览不提交、不校验,也不代表已保存。
</Alert>
<Alert v-if="!groups.length" show-icon type="warning">
画布还没有任何分组/字段。回到设计器从左侧控件栏添加。
</Alert>
<template v-else>
<div v-for="group in groups" :key="group._uid" class="pv-group">
<h3 v-if="group.label" class="pv-group-label">{{ group.label }}</h3>
<Alert v-if="!group.fields.length" show-icon type="warning" class="pv-empty-group">
分组「{{ group.label || group.key }}」还没有字段
</Alert>
<div v-for="field in group.fields" :key="field._uid" class="pv-item">
<label class="pv-label">
{{ field.label }}
<span v-if="field.required" class="pv-required">*</span>
<span class="pv-key">{{ field.key }}</span>
</label>
<div class="pv-control">
<template v-if="widgetOf(field)">
<component :is="widgetOf(field)" :field="field"
:options="resolvedOptions(field)" :model-value="previewValues[field.key]"
:readonly="false"
@update:model-value="(val: unknown) => setValue(field.key, val)" />
<div v-if="dictMissing(field)" class="pv-dict-missing">
字典 dict:{{ dictRefCode(field.options as never) }} 不在可选域,发布前请确认字典已建且可用
</div>
</template>
<span v-else class="pv-unknown">[未知类型 {{ field.type }},无渲染控件]</span>
<div v-if="field.tip" class="pv-tip">{{ field.tip }}</div>
</div>
</div>
</div>
</template>
</div>
</div>
</Modal>
</template>
<style scoped>
.pv-body {
height: 100%;
overflow: auto;
background: #f5f7f9;
padding: 16px 0 32px;
}
.pv-card {
max-width: 800px;
margin: 0 auto;
background: #fff;
border-radius: 4px;
padding: 24px;
}
.pv-note {
margin-bottom: 20px;
}
.pv-group {
margin-bottom: 24px;
}
.pv-group-label {
font-size: 16px;
font-weight: 600;
color: #17233d;
border-bottom: 1px solid #e8eaec;
padding-bottom: 8px;
margin-bottom: 16px;
}
.pv-empty-group {
margin-bottom: 8px;
}
.pv-item {
margin-bottom: 16px;
}
.pv-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #515a6e;
}
.pv-required {
color: #ed4014;
margin-left: 2px;
}
.pv-key {
color: #c5c8ce;
font-size: 12px;
margin-left: 8px;
font-family: monospace;
}
.pv-control {
position: relative;
}
.pv-dict-missing {
margin-top: 4px;
font-size: 12px;
color: #ff9900;
}
.pv-unknown {
color: #ed4014;
font-size: 12px;
}
.pv-tip {
margin-top: 4px;
font-size: 12px;
color: #808695;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
/**
* 填报链接构建与复制(P89,参考旧系统先例:Cms AttachmentManage 的「复制链接」
* 行操作 + Utils.copyToClipboard + 文案「链接已复制」;svc-hall 同款 execCommand 兜底)。
*
* 链接契约:填报页按 code 寻址(/f/:code,P87 起菜单与链接都解析),产物部署在
* /form-center/front/,hash 路由的 query 段进 hash(route.query 才读得到)。
* 这里不写死 contextPath——从当前地址推导(生产=…/form-center/front/index.html,
* 本地 dev=/),两处复制出来的链接都能用;快应用表单追加 ?appCode=(resolve 的
* 归属域参数,中心表单 form-center 不带)。
*/
import { Message } from "view-ui-plus";
/** 中心默认归属应用(与 FormConfigModal/创建弹窗的默认值同源) */
const DEFAULT_APP_CODE = "form-center";
/** 表单 → 填报链接(同源部署形态自适应,见文件头) */
export function buildFillLink(form: { code: string; appCode?: string | null }): string {
const base = window.location.origin + window.location.pathname.replace(/index\.html?$/i, "");
const appCode = String(form.appCode ?? "").trim();
const query = appCode && appCode !== DEFAULT_APP_CODE ? `?appCode=${encodeURIComponent(appCode)}` : "";
return `${base}#/f/${encodeURIComponent(form.code)}${query}`;
}
/**
* 复制文本到剪贴板:navigator.clipboard 优先(安全上下文),失败回退旧系统同款
* 临时 textarea + execCommand(http 内网环境/旧内核兜底)。返回是否成功。
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
/* 权限被拒/非焦点等,走兜底 */
}
}
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
} finally {
document.body.removeChild(ta);
}
return ok;
}
/** 行操作/按钮统一入口:构建链接→复制→按旧先例文案提示 */
export async function copyFillLink(form: { code: string; appCode?: string | null }): Promise<void> {
const link = buildFillLink(form);
const ok = await copyToClipboard(link);
if (ok) {
Message.success("链接已复制");
} else {
// 兜底也失败(极端环境):把链接亮出来让用户手动复制,绝不静默
Message.warning({ content: `复制失败,请手动复制:${link}`, duration: 8 });
}
}
+13
View File
@@ -20,11 +20,13 @@ import DesignerCanvas from "@/components/designer/DesignerCanvas.vue";
import FieldPropPanel from "@/components/designer/FieldPropPanel.vue";
import FormConfigModal from "@/components/form/FormConfigModal.vue";
import FormAdminModal from "@/components/admin/FormAdminModal.vue";
import PreviewModal from "@/components/designer/PreviewModal.vue";
import { formApi } from "@/api/module/form";
import { dictApi } from "@/api/module/dict";
import type { DictView, FormView, VersionView } from "@/types/form";
import type { DesignField, DesignGroup } from "@/types/formSchema";
import { MAX_CHANGE_LOG_LEN, MAX_NAME_LEN } from "@/types/formSchema";
import { copyFillLink } from "@/utils/fillLink";
import {
buildSchemaJson,
cloneField,
@@ -61,6 +63,8 @@ const baseline = ref("");
const configRef = ref<InstanceType<typeof FormConfigModal> | null>(null);
/** 单表单管理员弹窗(与设计器的「表单配置」并排,方案 §6.4:与表单列表行操作同一形态) */
const adminRef = ref<InstanceType<typeof FormAdminModal> | null>(null);
/** P90:整表预览弹层(含未保存改动,locked 只读态也可用) */
const previewRef = ref<InstanceType<typeof PreviewModal> | null>(null);
const publishModal = reactive({ visible: false, changeLog: "" });
const locked = computed(() => viewingPublished.value);
@@ -306,6 +310,12 @@ function onConfigSaved(res: FormView) {
form.value = res;
}
/** P89:复制当前表单的填报链接(草稿态也可复制——链接按 code 寻址,发布后即生效) */
function onCopyLink() {
if (!form.value) return;
copyFillLink(form.value);
}
async function boot() {
if (!uuid.value) {
Message.warning("请从表单列表进入设计器");
@@ -348,6 +358,8 @@ onMounted(boot);
</span>
</div>
<div class="head-right">
<Button icon="ios-link" @click="onCopyLink">复制链接</Button>
<Button icon="ios-eye" @click="previewRef?.open()">预览</Button>
<Button @click="configRef?.open()">表单配置</Button>
<Button @click="adminRef?.open(uuid, form?.name)">表单管理员</Button>
<Button @click="copyAs">另存为副本</Button>
@@ -399,6 +411,7 @@ onMounted(boot);
<FormConfigModal ref="configRef" :form="form" @saved="onConfigSaved" />
<FormAdminModal ref="adminRef" />
<PreviewModal ref="previewRef" :groups="groups" :dicts="dicts" />
<Modal v-model="publishModal.visible" title="发布新版本" :mask-closable="false">
<p class="publish-tip">
+2
View File
@@ -16,6 +16,7 @@ import { useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { formApi } from "@/api/module/form";
import { actionColumn } from "@/utils/tableActions";
import { copyFillLink } from "@/utils/fillLink";
import { useElementHeight } from "@/utils/hook/useElementHeight";
import FormConfigModal from "@/components/form/FormConfigModal.vue";
import FormAdminModal from "@/components/admin/FormAdminModal.vue";
@@ -105,6 +106,7 @@ const columns = computed(() => [
[
{ key: "design", label: "设计", on: (row: FormView) => goDesigner(row) },
{ key: "config", label: "配置", on: (row: FormView) => openConfig(row) },
{ key: "copyLink", label: "复制链接", on: (row: FormView) => copyFillLink(row) },
{
key: "publish",
label: "发布",