Files
svc-hall-front/src/views/svc/ServiceItemEdit.vue
T

490 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/** 事项编辑(22 字段 + 表单/流程绑定;迁移自 CMS ServiceItemEdit,siteId→siteCode)。 */
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import {
fileApi,
svcDictApi,
svcFlowApi,
svcFormApi,
svcItemApi,
} from "@/api/module/svc";
import type { FcFormCatalogItem, SvcDeptVo, SvcItemAttachmentVo, SvcItemSaveRequest } from "@/types/svc";
import { useSite } from "@/utils/site";
import RichTextEditor from "@/components/RichTextEditor.vue";
const route = useRoute();
const router = useRouter();
const { currentSiteCode, loadSites } = useSite();
const formRef = ref<any>(null);
// 必填校验(bsch应用页规范 §1:校验失败提示统一"请填写红色星号内容!")
const saveRules = {
itemCode: [{ required: true, message: "请填写红色星号内容!", trigger: "blur" }],
title: [{ required: true, message: "请填写红色星号内容!", trigger: "blur" }],
};
const form = ref<SvcItemSaveRequest>({
siteCode: "",
itemCode: "",
title: "",
handleMode: 1,
onlineFlag: 1,
sortNo: 0,
coopDepts: "",
handlerName: "",
handleTimeDesc: "",
runCount: undefined,
expressFlag: undefined,
feeDesc: "",
feeBasis: "",
});
const objects = ref<{ dictKey: string; dictName: string }[]>([]);
const categories = ref<{ dictKey: string; dictName: string }[]>([]);
const depts = ref<SvcDeptVo[]>([]);
/** 表单中心目录(v3/TASK_1:办理表单只能从这里选;本地表单定义已停写) */
const fcForms = ref<FcFormCatalogItem[]>([]);
const fcFormsDisabled = ref(false);
const fcFormsReason = ref("");
const flows = ref<{ flowGuid: string; name: string; version: number }[]>([]);
const flowDisabled = ref(false);
const flowDisabledReason = ref("");
const attachments = ref<SvcItemAttachmentVo[]>([]);
const uploadingCover = ref(false);
const editing = computed(() => !!route.query.id);
const pageTitle = computed(() => (editing.value ? "编辑事项" : "新建事项"));
/**
* 选中即成对存 uuid + 中心当时的发布版(v3 §4:只保存服务器验证过的坐标,不接受手填版本)。
* 清空=两列一起清——后端要求成对,留一个没人认领的版本号没有意义。
*/
function onFcFormChange(uuid?: string) {
const hit = uuid ? fcForms.value.find((f) => f.formUuid === uuid) : undefined;
form.value.fcFormUuid = hit ? hit.formUuid : undefined;
form.value.fcFormVersion = hit ? hit.version : undefined;
}
/** 绑定回显:存的是当时核验过的版本,中心又发新版时页面要点出来(不自动改绑) */
const fcBinding = computed(() => {
if (!form.value.fcFormUuid) return "";
const newest = fcForms.value.find((f) => f.formUuid === form.value.fcFormUuid)?.version;
const base = `已绑定 ${form.value.fcFormUuid} v${form.value.fcFormVersion}`;
return newest && newest !== form.value.fcFormVersion ? `${base}(中心最新已发布 v${newest},需换版请重新选择)` : base;
});
/** 部门树扁平化(缩进展示,承袭 CMS flattenDepts) */
const deptOptions = computed(() => {
const out: { id: number; label: string }[] = [];
const walk = (nodes: SvcDeptVo[], depth: number) => {
for (const n of nodes || []) {
out.push({ id: n.id, label: `${"\u00A0\u00A0\u00A0\u00A0".repeat(depth)}${n.name}` });
walk(n.children || [], depth + 1);
}
};
walk(depts.value, 0);
return out;
});
async function loadAll() {
await loadSites();
const siteCode = currentSiteCode.value;
if (!siteCode) {
Message.warning("无可管理的站点,请先在 UserBase 配置应用模块并开通站点");
return;
}
form.value.siteCode = siteCode;
objects.value = ((await svcDictApi.list(siteCode, "object").send()) as any[]) || [];
categories.value = ((await svcDictApi.list(siteCode, "category").send()) as any[]) || [];
depts.value = (await svcDictApi.deptTree(siteCode).send()) || [];
// 表单中心目录(同源代理):授权没配/中心不可达时降级为"选不了",不阻塞事项其余字段的编辑
try {
const page = await svcFormApi.centerForms(siteCode, { page: 1, pageSize: 100 }).send();
fcForms.value = page?.items || [];
fcFormsDisabled.value = false;
fcFormsReason.value = "";
} catch (e: any) {
fcForms.value = [];
fcFormsDisabled.value = true;
const msg = String(e?.message || e || "");
fcFormsReason.value = msg.includes("不可达") || msg.includes("返回空")
? msg
: `表单中心目录获取失败:${msg}`;
}
try {
flows.value = ((await svcFlowApi.list(siteCode).send()) as any[]) || [];
flowDisabled.value = false;
flowDisabledReason.value = "";
} catch (e: any) {
// BP-R4-2:仅真正"流程中心未接入"(svchall.flow.enabled=false)才提示未接入;
// 站点/鉴权/网络类错误按原始信息提示,避免排障方向被误导
flowDisabled.value = true;
const msg = String(e?.message || e || "");
flowDisabledReason.value = msg.includes("流程中心未接入")
? "流程中心未接入,暂只能配置线下事项"
: `流程列表获取失败:${msg}`;
}
if (route.query.id) {
const detail: any = await svcItemApi.get(siteCode, Number(route.query.id)).send();
form.value = { ...form.value, ...detail };
attachments.value = (await svcItemApi.attList(siteCode, Number(route.query.id)).send()) || [];
}
}
async function uploadCover(file: File) {
uploadingCover.value = true;
try {
const res: any = await fileApi.upload(file, "image", currentSiteCode.value).send();
form.value.coverImage = res.fileUrl;
} finally {
uploadingCover.value = false;
}
return false;
}
async function uploadGuide(file: File) {
const res: any = await fileApi.upload(file, "file", currentSiteCode.value).send();
attachments.value.push({ id: res.id, fileName: res.fileName, fileUrl: res.fileUrl, fileSize: res.fileSize });
return false;
}
async function save(publishAfter: boolean) {
const valid = await new Promise<boolean>(resolve => formRef.value?.validate(resolve));
if (!valid) {
Message.warning("请填写红色星号内容!");
return;
}
const payload: SvcItemSaveRequest = {
...form.value,
id: route.query.id ? Number(route.query.id) : undefined,
attachmentIds: attachments.value.map(a => a.id),
};
const id: number = await svcItemApi.save(payload).send();
if (publishAfter) {
await svcItemApi.publish(currentSiteCode.value, id).send();
Message.success("已保存并发布");
} else {
Message.success("已保存");
}
router.replace({ path: "/svc/item/edit", query: { id: String(id) } });
}
/** 流程图:走本应用后端 /api/svc/flow/diagram.png(后端换免签 token 取图)。
* 原先是 window.open 到 flow-center 前端 /#/diagram?...——跨应用页面 + 需要流程中心的登录会话;
* 现在同源出图,浏览器只认 <img src>,也不再把 flow-center 地址带进前端配置。 */
function openFlowDiagram() {
if (!currentSiteCode.value || !form.value.flowGuid) {
Message.warning("请先选择流程并确认当前站点");
return;
}
window.open(svcFlowApi.diagramUrl(currentSiteCode.value, form.value.flowGuid, "dept"), "_blank");
}
onMounted(loadAll);
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<Card :bordered="false" dis-hover>
<template #title>{{ pageTitle }}</template>
<Form ref="formRef" :model="form" :rules="saveRules" :label-width="130">
<Divider orientation="left">基本信息</Divider>
<Row :gutter="16">
<Col span="12">
<FormItem label="事项编码" prop="itemCode">
<Input v-model="form.itemCode" placeholder="字母/数字/下划线/中划线,2~64 位" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="事项名称" prop="title">
<Input v-model="form.title" :maxlength="200" />
</FormItem>
</Col>
<Col span="24">
<FormItem label="一句话简介">
<Input v-model="form.summary" type="textarea" :rows="2" :maxlength="500" />
</FormItem>
</Col>
<Col span="8">
<FormItem label="服务对象">
<Select v-model="form.objectKey" clearable>
<Option v-for="d in objects" :key="d.dictKey" :value="d.dictKey">{{ d.dictName }}</Option>
</Select>
</FormItem>
</Col>
<Col span="8">
<FormItem label="事项类别">
<Select v-model="form.categoryKey" clearable>
<Option v-for="d in categories" :key="d.dictKey" :value="d.dictKey">{{ d.dictName }}</Option>
</Select>
</FormItem>
</Col>
<Col span="8">
<FormItem label="服务部门">
<Select v-model="form.deptGroupId" clearable>
<Option v-for="d in deptOptions" :key="d.id" :value="d.id">{{ d.label }}</Option>
</Select>
</FormItem>
</Col>
</Row>
<Divider orientation="left">办理信息</Divider>
<Row :gutter="16">
<Col span="8">
<FormItem label="办理方式">
<RadioGroup v-model="form.handleMode">
<Radio :label="1">线上</Radio>
<Radio :label="2">线下</Radio>
<Radio :label="3">线上线下</Radio>
</RadioGroup>
</FormItem>
</Col>
<Col span="8">
<FormItem label="可在线办理">
<Switch v-model="form.onlineFlag" :true-value="1" :false-value="0" />
</FormItem>
</Col>
<Col span="8">
<FormItem label="办理地点">
<Input v-model="form.handlePlace" :maxlength="200" />
</FormItem>
</Col>
<Col span="8">
<FormItem label="办理时限">
<Input v-model="form.handleTimeLimit" :maxlength="64" />
</FormItem>
</Col>
<Col span="8">
<FormItem label="承诺时限">
<Input v-model="form.promiseTimeLimit" :maxlength="64" />
<div class="tip">建议填纯数字(天),详情页会用它推算预计完成日期</div>
</FormItem>
</Col>
<Col span="8">
<FormItem label="咨询电话">
<Input v-model="form.contactPhone" :maxlength="64" />
</FormItem>
</Col>
<Col span="8">
<FormItem label="监督投诉电话">
<Input v-model="form.supervisePhone" :maxlength="64" />
</FormItem>
</Col>
</Row>
<div class="section-title">
<span>服务承诺</span><span class="new-tag">新增</span>
</div>
<Row :gutter="16">
<Col span="12">
<FormItem label="协办部门">
<Input v-model="form.coopDepts" :maxlength="500" placeholder="多个部门用顿号分隔" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="经办人员">
<Input v-model="form.handlerName" :maxlength="64" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="办理时间(时段)">
<Input v-model="form.handleTimeDesc" :maxlength="200" placeholder="工作日 08:00–11:45" />
<div class="tip">区别于办理时限,指窗口办公时段</div>
</FormItem>
</Col>
<Col span="12">
<FormItem label="跑现场次数">
<InputNumber v-model="form.runCount" :min="0" style="width: 100%" />
<div class="tip">0 = 零跑动</div>
</FormItem>
</Col>
<Col span="12">
<FormItem label="是否快递送达">
<Select v-model="form.expressFlag" clearable>
<Option :value="0">否</Option>
<Option :value="1">是(到付)</Option>
<Option :value="2">是(学校承担)</Option>
</Select>
</FormItem>
</Col>
<Col span="12">
<FormItem label="收费标准">
<Input v-model="form.feeDesc" :maxlength="200" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="收费依据">
<Input v-model="form.feeBasis" :maxlength="200" />
</FormItem>
</Col>
</Row>
<Divider orientation="left">在线办理绑定</Divider>
<Row :gutter="16">
<Col span="12">
<FormItem label="办理流程">
<div class="flow-select">
<Select v-model="form.flowGuid" clearable :disabled="flowDisabled" placeholder="流程中心流程">
<Option v-for="f in flows" :key="f.flowGuid" :value="f.flowGuid">
{{ f.name }}(v{{ f.version }})
</Option>
</Select>
<Button v-if="form.flowGuid" size="small" @click="openFlowDiagram">流程图</Button>
</div>
<div v-if="flowDisabled" class="tip">{{ flowDisabledReason || "流程中心未接入,暂只能配置线下事项" }}</div>
<div v-else class="tip">可在线办理的事项须绑定流程:此处选择,或留空回退所用表单的绑定流程</div>
</FormItem>
</Col>
<Col span="12">
<FormItem label="办理表单">
<Select :value="form.fcFormUuid" clearable placeholder="从表单中心选择已发布表单"
:disabled="fcFormsDisabled" @on-change="onFcFormChange">
<Option v-for="f in fcForms" :key="f.formUuid" :value="f.formUuid">
{{ f.name }}({{ f.appCode }}/{{ f.code }} · v{{ f.version }})
</Option>
</Select>
<div v-if="fcFormsDisabled" class="tip" style="color: #ed4014">
{{ fcFormsReason || "表单中心目录不可达" }}
</div>
<div v-else-if="fcBinding" class="tip">{{ fcBinding }}</div>
<div v-else class="tip">
目录只列中心已发布、且已授权给本应用的表单;保存时服务端会再向中心核验一次坐标
</div>
<div v-if="form.formCode" class="tip" style="color: #ed4014">
历史本地表单「{{ form.formCode }}」只读保留(本地表单定义已停用,改绑请选表单中心的表)
</div>
</FormItem>
</Col>
<Col span="24">
<FormItem label="我要办理外链">
<Input v-model="form.applyUrl" placeholder="http(s):// 完整地址;填写后优先于自动生成地址" />
<div class="tip">优先级:本外链 &gt; 办事大厅自动生成 &gt; 不显示按钮</div>
</FormItem>
</Col>
</Row>
<Divider orientation="left">关联栏目与资产</Divider>
<Row :gutter="16">
<Col span="8">
<FormItem label="常见问题栏目">
<InputNumber v-model="form.faqCategoryId" :min="1" style="width: 100%" placeholder="CMS 栏目 id" />
</FormItem>
</Col>
<Col span="8">
<FormItem label="公告信息栏目">
<InputNumber v-model="form.noticeCategoryId" :min="1" style="width: 100%" placeholder="CMS 栏目 id" />
</FormItem>
</Col>
<Col span="8">
<FormItem label="文件规定栏目">
<InputNumber v-model="form.ruleCategoryId" :min="1" style="width: 100%" placeholder="CMS 栏目 id" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="事项封面">
<div class="cover">
<img v-if="form.coverImage" :src="form.coverImage" class="cover-img" />
<Upload :before-upload="uploadCover" :show-upload-list="false" accept="image/*">
<Button :loading="uploadingCover" icon="ios-image-outline">上传封面</Button>
</Upload>
<Button v-if="form.coverImage" size="small" type="text" @click="form.coverImage = ''">移除</Button>
</div>
</FormItem>
</Col>
<Col span="6">
<FormItem label="排序值">
<InputNumber v-model="form.sortNo" style="width: 100%" />
</FormItem>
</Col>
</Row>
<Divider orientation="left">指南内容</Divider>
<FormItem label="办理流程">
<RichTextEditor v-model="form.processHtml" placeholder="办理流程说明" />
</FormItem>
<FormItem label="所需材料">
<RichTextEditor v-model="form.materialHtml" placeholder="所需材料说明" />
</FormItem>
<FormItem label="注意事项">
<RichTextEditor v-model="form.noticeHtml" placeholder="注意事项说明" />
</FormItem>
<FormItem label="指南附件">
<Upload :before-upload="uploadGuide" :show-upload-list="false">
<Button icon="ios-cloud-upload-outline">上传附件</Button>
</Upload>
<div class="att-list">
<div v-for="a in attachments" :key="a.id" class="att-item">
<a :href="a.fileUrl" target="_blank" rel="noopener">{{ a.fileName }}</a>
<Button size="small" type="text" style="color: #ed4014"
@click="attachments = attachments.filter(x => x.id !== a.id)">移除</Button>
</div>
</div>
</FormItem>
<div class="actions">
<Button type="primary" :loading="false" @click="save(false)">保存</Button>
<Button type="success" @click="save(true)">保存并发布</Button>
<Button @click="router.back()">返回</Button>
</div>
</Form>
</Card>
</div>
</div>
</template>
<style scoped>
.section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 24px 0 16px;
padding-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: #17233d;
border-bottom: 1px solid #e8eaec;
}
.new-tag {
padding: 0 8px;
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: #808695;
border: 1px solid #dcdee2;
border-radius: 9px;
}
.flow-select {
display: flex;
gap: 8px;
align-items: center;
}
.tip {
font-size: 12px;
color: #999;
}
.cover {
display: flex;
gap: 8px;
align-items: center;
}
.cover-img {
height: 48px;
border-radius: 4px;
}
.att-list {
margin-top: 8px;
}
.att-item {
display: flex;
gap: 8px;
align-items: center;
}
.actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
</style>