feat: 办事大厅前端源码首次入库(事项列表/详情/申报/管理端)
build / build (push) Successful in 1m55s

This commit is contained in:
chenxing
2026-09-15 11:00:57 +08:00
parent 7a2ea27f1e
commit 22dfb70377
328 changed files with 26089 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
/** 在线办理发起页(CMS 指南页"我要办理"跳入:/#/apply?siteCode=&itemCode=)。 */
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { applyApi } from "@/api/module/svc";
import { useSite } from "@/utils/site";
import DynamicForm from "@/components/DynamicForm.vue";
const route = useRoute();
const router = useRouter();
const { setSite } = useSite();
const loading = ref(false);
const submitting = ref(false);
const item = ref<Record<string, any> | null>(null);
const loadError = ref("");
const formData = reactive<Record<string, any>>({});
const schemaJson = computed(() => (item.value?.schemaJson as string) || "");
async function load() {
const siteCode = String(route.query.siteCode || "");
const itemCode = String(route.query.itemCode || "");
if (!siteCode || !itemCode) {
Message.error("缺少 siteCode/itemCode 参数");
return;
}
setSite(siteCode);
loading.value = true;
try {
item.value = (await applyApi.item(siteCode, itemCode).send()) as any;
loadError.value = "";
} catch (e: any) {
// 区分加载失败与业务性不可办理(走查 §七.8):网络/服务异常不当成"事项不可办理"
loadError.value = String(e?.message || e || "网络或服务异常");
} finally {
loading.value = false;
}
}
async function submit() {
if (!item.value) return;
submitting.value = true;
try {
const apply: any = await applyApi
.submit({
siteCode: String(route.query.siteCode),
itemCode: String(route.query.itemCode),
formData: Object.keys(formData).length ? JSON.stringify(formData) : undefined,
})
.send();
if (apply.flowStatus === 0) {
Message.warning("流程中心暂不可用,已保存为待提交,可稍后在「我的办件」中重试");
} else {
Message.success("已提交,进入审批流程");
}
router.replace(`/apply-detail?id=${apply.id}`);
} finally {
submitting.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="page">
<Card :bordered="false" dis-hover v-if="item">
<template #title>在线办理 · {{ item.title }}</template>
<div class="summary" v-if="item.summary || item.deptName || item.handleTimeLimit">
<div v-if="item.summary">{{ item.summary }}</div>
<div class="meta">
<span v-if="item.deptName">办理部门:{{ item.deptName }}</span>
<span v-if="item.handleTimeLimit">办理时限:{{ item.handleTimeLimit }}</span>
<span v-if="item.contactPhone">咨询电话:{{ item.contactPhone }}</span>
</div>
</div>
<Divider v-if="schemaJson" orientation="left">申请表单</Divider>
<DynamicForm v-if="schemaJson" :schema-json="schemaJson" v-model:form-data="formData" />
<div v-else class="no-form">该事项无需填写表单,直接提交申请材料请联系办理部门。</div>
<div class="actions">
<Button type="primary" :loading="submitting" @click="submit">提交申请</Button>
<Button @click="router.push('/my')">我的办件</Button>
</div>
</Card>
<Card v-else :bordered="false" dis-hover>
<div style="text-align: center; padding: 40px; color: #999">
<template v-if="loading">加载中…</template>
<template v-else-if="loadError">
事项加载失败:{{ loadError }}(网络或服务异常,请稍后重试)
</template>
<template v-else>事项不可办理(未发布或未开通在线办理)</template>
</div>
</Card>
</div>
</template>
<style scoped>
.summary {
padding-bottom: 12px;
}
.meta {
margin-top: 8px;
display: flex;
gap: 16px;
color: #888;
font-size: 13px;
flex-wrap: wrap;
}
.no-form {
color: #999;
padding: 12px 0;
}
.actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
</style>
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
/** 办件详情:快照回显 + 镜像状态 + 结果意见;撤回/前往流程中心直调 flow-center(同主域 CAS)。 */
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { applyApi } from "@/api/module/svc";
import config from "@/assets/config/host.config";
import type { ApplyVo } from "@/types/svc";
const route = useRoute();
const router = useRouter();
const apply = ref<ApplyVo | null>(null);
const withdrawing = ref(false);
const STATUS: Record<number, { text: string; color: string }> = {
0: { text: "待提交", color: "default" },
10: { text: "审批中", color: "primary" },
20: { text: "已完成", color: "success" },
30: { text: "已拒绝", color: "error" },
40: { text: "已撤回", color: "warning" },
};
const snapshot = computed<Record<string, any>>(() => {
try {
return JSON.parse(apply.value?.itemSnapshotJson || "{}");
} catch {
return {};
}
});
const formData = computed<Record<string, any>>(() => {
try {
return JSON.parse(apply.value?.formDataJson || "{}");
} catch {
return {};
}
});
const result = computed<Record<string, any>>(() => {
try {
return JSON.parse(apply.value?.flowResultJson || "{}");
} catch {
return {};
}
});
async function load() {
const id = Number(route.query.id || 0);
if (id) {
apply.value = (await applyApi.detail(id).send()) as any;
}
}
/** 前往流程中心:flowHost 完整地址(不再依赖部署层反代 /flow-center/,BP-R3-3)
* 路由段须与 flow-center 前端一致(routes.ts path="/my" 我的办件;/#/my-instances 会 no-match 空白页) */
const flowCenterUrl = computed(() =>
config.flowHost ? `${config.flowHost}/#/my` : ""
);
/** 查看流程图:flow-center 示意图深链(办件 instanceId + 科室视图,整页跳转不受 CORS 约束) */
function openFlowDiagram() {
const instanceId = apply.value?.flowInstanceId;
if (!instanceId || !config.flowHost) {
Message.warning("流程中心地址未配置");
return;
}
window.open(`${config.flowHost}/#/diagram?instanceId=${instanceId}&mode=dept`, "_blank");
}
/** 撤回:直调 flow-center 用户面(BP-R2-1;同主域 CAS 免二次登录,本地 dev 走 flowHost CORS) */
async function withdraw() {
const instanceId = apply.value?.flowInstanceId;
if (!instanceId || !config.flowHost) {
Message.warning("流程中心地址未配置,无法撤回");
return;
}
withdrawing.value = true;
try {
const resp = await fetch(`${config.flowHost}/api/flow/instance/${instanceId}/withdraw`, {
method: "POST",
credentials: "include",
});
const res = await resp.json().catch(() => null);
if (resp.ok && res && res.code === 0) {
Message.success("已撤回,可重新发起或编辑后重提");
load();
} else {
Message.error(res?.msg || "撤回失败(已有任务被办理时不可撤回)");
}
} catch (e: any) {
Message.error("流程中心不可达:" + (e.message || e));
} finally {
withdrawing.value = false;
}
}
/** 一键重提(30/40 态;同 bizId R4 重启,表单数据沿用原填报) */
function resubmit() {
if (!apply.value) return;
Modal.confirm({
title: "重新提交确认",
content: `将以原填报内容重新提交「${apply.value.itemTitle}」,审批轨迹跨轮连续保留。确认继续?`,
onOk: async () => {
await applyApi.resubmit(apply.value!.id).send();
Message.success("已重新提交,进入审批流程");
load();
},
});
}
onMounted(load);
</script>
<template>
<div class="page">
<Card :bordered="false" dis-hover v-if="apply">
<template #title>
办件详情 #{{ apply.id }}
<Tag :color="STATUS[apply.flowStatus]?.color || 'default'">
{{ STATUS[apply.flowStatus]?.text || apply.flowStatus }}
</Tag>
</template>
<Descriptions :column="2" border>
<DescriptionsItem label="事项">{{ apply.itemTitle }}</DescriptionsItem>
<DescriptionsItem label="事项编码">{{ apply.itemCode }}</DescriptionsItem>
<DescriptionsItem label="提交人">{{ apply.submitterName }}</DescriptionsItem>
<DescriptionsItem label="提交时间">{{ apply.submitTime || "—" }}</DescriptionsItem>
<DescriptionsItem label="办理部门">{{ snapshot.deptName || "—" }}</DescriptionsItem>
<DescriptionsItem label="办理时限">{{ snapshot.handleTimeLimit || "—" }}</DescriptionsItem>
</Descriptions>
<template v-if="apply.formDataJson">
<Divider orientation="left">申请内容</Divider>
<div class="kv" v-for="(v, k) in formData" :key="String(k)">
<span class="k">{{ k }}</span>
<span class="v">{{ Array.isArray(v) ? v.map((f: any) => f.fileName || f).join("、") : v }}</span>
</div>
</template>
<template v-if="result.opinion || result.result">
<Divider orientation="left">审批结果</Divider>
<p>结果:{{ result.result || "—" }}</p>
<p v-if="result.opinion">意见:{{ result.opinion }}</p>
</template>
<div class="actions">
<Button @click="router.push('/my')">返回我的办件</Button>
<Button v-if="apply.flowInstanceId" @click="openFlowDiagram">查看流程图</Button>
<Button v-if="apply.flowStatus === 10 && apply.flowInstanceId" :loading="withdrawing"
@click="withdraw">撤回</Button>
<Button v-if="apply.flowStatus === 30 || apply.flowStatus === 40" @click="resubmit">重新提交</Button>
<Button v-if="flowCenterUrl && apply.flowInstanceId" type="primary" target="_blank"
:href="flowCenterUrl">前往流程中心查看进度</Button>
</div>
</Card>
</div>
</template>
<style scoped>
.kv {
display: flex;
padding: 6px 0;
border-bottom: 1px dashed #eee;
}
.k {
width: 220px;
color: #888;
}
.v {
flex: 1;
}
.actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
/** 办件记录(管理面,modId 26):本校在途与历史办件分页。 */
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { applyApi } from "@/api/module/svc";
import type { ApplyVo } from "@/types/svc";
import { useSite } from "@/utils/site";
const router = useRouter();
const { currentSiteCode, sites, loadSites, setSite } = useSite();
const loading = ref(false);
const list = ref<ApplyVo[]>([]);
const total = ref(0);
const page = ref(1);
const flowStatus = ref<number | undefined>(undefined);
const keyword = ref("");
const STATUS: Record<number, { text: string; color: string }> = {
0: { text: "待提交", color: "default" },
10: { text: "审批中", color: "primary" },
20: { text: "已完成", color: "success" },
30: { text: "已拒绝", color: "error" },
40: { text: "已撤回", color: "warning" },
};
const cols = [
{ title: "办件号", key: "id", width: 90 },
{ title: "事项", key: "itemTitle", minWidth: 180 },
{ title: "编码", key: "itemCode", width: 130 },
{ title: "提交人", key: "submitterName", width: 100 },
{ title: "提交时间", key: "submitTime", width: 160 },
{ title: "状态", slot: "status", width: 100 },
{ title: "操作", slot: "action", width: 100 },
];
async function load() {
if (!currentSiteCode.value) return;
loading.value = true;
try {
const res: any = await applyApi
.adminPage({
siteCode: currentSiteCode.value,
flowStatus: flowStatus.value,
keyword: keyword.value,
page: page.value,
pageSize: 10,
})
.send();
total.value = res.total || 0;
list.value = res.list || [];
} finally {
loading.value = false;
}
}
onMounted(async () => {
await loadSites();
load();
});
</script>
<template>
<div class="page">
<Card :bordered="false" dis-hover>
<div class="bar">
<Select :model-value="currentSiteCode" style="width: 240px" @on-change="(c: string) => { setSite(c); page = 1; load(); }">
<Option v-for="s in sites" :key="s.siteCode" :value="s.siteCode">{{ s.name }}({{ s.siteCode }})</Option>
</Select>
<Select v-model="flowStatus" clearable placeholder="状态" style="width: 140px" @on-change="() => { page = 1; load(); }">
<Option v-for="(v, k) in STATUS" :key="k" :value="Number(k)">{{ v.text }}</Option>
</Select>
<Input v-model="keyword" search enter-button placeholder="事项/提交人" style="width: 220px" @on-search="load" />
</div>
<Table :loading="loading" :columns="cols" :data="list">
<template #status="{ row }">
<Tag :color="STATUS[row.flowStatus]?.color || 'default'">
{{ STATUS[row.flowStatus]?.text || row.flowStatus }}
</Tag>
</template>
<template #action="{ row }">
<Button size="small" type="text" @click="router.push(`/apply-detail?id=${row.id}`)">详情</Button>
</template>
</Table>
<Page class="pager" :total="total" :current="page" :page-size="10" show-total
@on-change="(p: number) => { page = p; load(); }" />
</Card>
</div>
</template>
<style scoped>
.bar {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.pager {
margin-top: 12px;
text-align: right;
}
</style>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
/** 我的办件(modId 27):镜像状态列表;撤回直调流程中心(同主域 CAS),审批轨迹后续接。 */
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { applyApi } from "@/api/module/svc";
import config from "@/assets/config/host.config";
import type { ApplyVo } from "@/types/svc";
const router = useRouter();
const loading = ref(false);
const list = ref<ApplyVo[]>([]);
const total = ref(0);
const page = ref(1);
const flowStatus = ref<number | undefined>(undefined);
const withdrawingId = ref<number>(0);
const STATUS: Record<number, { text: string; color: string }> = {
0: { text: "待提交", color: "default" },
10: { text: "审批中", color: "primary" },
20: { text: "已完成", color: "success" },
30: { text: "已拒绝", color: "error" },
40: { text: "已撤回", color: "warning" },
};
const cols = [
{ title: "办件号", key: "id", width: 90 },
{ title: "事项", key: "itemTitle", minWidth: 200 },
{ title: "提交人", key: "submitterName", width: 110 },
{ title: "提交时间", key: "submitTime", width: 160 },
{ title: "状态", slot: "status", width: 100 },
{ title: "操作", slot: "action", width: 220 },
];
async function load() {
loading.value = true;
try {
const res: any = await applyApi.my({ flowStatus: flowStatus.value, page: page.value, pageSize: 10 }).send();
total.value = res.total || 0;
list.value = res.list || [];
} finally {
loading.value = false;
}
}
async function retry(row: ApplyVo) {
await applyApi.retry(row.id).send();
Message.success("已重新提交流程中心");
load();
}
/** 一键重提(30/40 态;同 bizId R4 重启,表单数据沿用原填报) */
async function resubmit(row: ApplyVo) {
Modal.confirm({
title: "重新提交确认",
content: `将以原填报内容重新提交「${row.itemTitle}」,审批轨迹跨轮连续保留。确认继续?`,
onOk: async () => {
await applyApi.resubmit(row.id).send();
Message.success("已重新提交,进入审批流程");
load();
},
});
}
/** 撤回:直调 flow-center 用户面(BP-R2-1;同主域 CAS 免二次登录,本地 dev 走 flowHost CORS) */
async function withdraw(row: ApplyVo) {
if (!row.flowInstanceId || !config.flowHost) {
Message.warning("流程中心地址未配置,无法撤回");
return;
}
withdrawingId.value = row.id;
try {
const resp = await fetch(`${config.flowHost}/api/flow/instance/${row.flowInstanceId}/withdraw`, {
method: "POST",
credentials: "include",
});
const res = await resp.json().catch(() => null);
if (resp.ok && res && res.code === 0) {
Message.success("已撤回,可重新发起或编辑后重提");
load();
} else {
Message.error(res?.msg || "撤回失败(已有任务被办理时不可撤回)");
}
} catch (e: any) {
Message.error("流程中心不可达:" + (e.message || e));
} finally {
withdrawingId.value = 0;
}
}
onMounted(load);
</script>
<template>
<div class="page">
<Card :bordered="false" dis-hover>
<div class="bar">
<Select v-model="flowStatus" clearable placeholder="状态" style="width: 140px" @on-change="() => { page = 1; load(); }">
<Option v-for="(v, k) in STATUS" :key="k" :value="Number(k)">{{ v.text }}</Option>
</Select>
</div>
<Table :loading="loading" :columns="cols" :data="list">
<template #status="{ row }">
<Tag :color="STATUS[row.flowStatus]?.color || 'default'">
{{ STATUS[row.flowStatus]?.text || row.flowStatus }}
</Tag>
</template>
<template #action="{ row }">
<Button size="small" type="text" @click="router.push(`/apply-detail?id=${row.id}`)">详情</Button>
<Button v-if="row.flowStatus === 0" size="small" type="text" @click="retry(row)">重试提交</Button>
<Button v-if="row.flowStatus === 10 && row.flowInstanceId" size="small" type="text"
:loading="withdrawingId === row.id" @click="withdraw(row)">撤回</Button>
<Button v-if="row.flowStatus === 30 || row.flowStatus === 40" size="small" type="text"
@click="resubmit(row)">重新提交</Button>
</template>
</Table>
<Page class="pager" :total="total" :current="page" :page-size="10" show-total
@on-change="(p: number) => { page = p; load(); }" />
</Card>
</div>
</template>
<style scoped>
.bar {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.pager {
margin-top: 12px;
text-align: right;
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
/** 服务字典管理(modId 24):object/category 双 Tab,引用守护。 */
import { onMounted, ref } from "vue";
import { Message, Modal } from "view-ui-plus";
import { svcDictApi } from "@/api/module/svc";
import type { SvcDictVo } from "@/types/svc";
import { useSite } from "@/utils/site";
const { currentSiteCode, sites, loadSites, setSite } = useSite();
const tab = ref<"object" | "category">("object");
const loading = ref(false);
const list = ref<SvcDictVo[]>([]);
const modal = ref(false);
const saving = ref(false);
const form = ref<any>({});
const cols = [
{ title: "键", key: "dictKey", width: 160 },
{ title: "名称", key: "dictName", minWidth: 160 },
{ title: "排序", key: "sortNo", width: 80 },
{ title: "被引用", key: "referenced", width: 90 },
{ title: "状态", slot: "status", width: 90 },
{ title: "操作", slot: "action", width: 140 },
];
async function load() {
if (!currentSiteCode.value) return;
loading.value = true;
try {
list.value = (await svcDictApi.list(currentSiteCode.value, tab.value).send()) || [];
} finally {
loading.value = false;
}
}
function open(row?: SvcDictVo) {
form.value = row
? { ...row }
: { siteCode: currentSiteCode.value, dictType: tab.value, dictKey: "", dictName: "", sortNo: 0, status: 1 };
modal.value = true;
}
async function save() {
saving.value = true;
try {
await svcDictApi.save(form.value).send();
Message.success("已保存");
modal.value = false;
load();
} finally {
saving.value = false;
}
}
function remove(row: SvcDictVo) {
Modal.confirm({
title: "删除确认",
content: `确定删除字典「${row.dictName}」吗?被引用的删除会被拒绝。`,
onOk: async () => {
await svcDictApi.delete(currentSiteCode.value, row.id).send();
Message.success("已删除");
load();
},
});
}
onMounted(async () => {
await loadSites();
load();
});
</script>
<template>
<div class="page">
<Card :bordered="false" dis-hover>
<div class="bar">
<Select :model-value="currentSiteCode" style="width: 240px" @on-change="(c: string) => { setSite(c); load(); }">
<Option v-for="s in sites" :key="s.siteCode" :value="s.siteCode">{{ s.name }}({{ s.siteCode }})</Option>
</Select>
<Button type="primary" @click="open()">新建{{ tab === "object" ? "服务对象" : "事项类别" }}</Button>
</div>
<Tabs v-model="tab" @on-click="() => load()">
<TabPane label="服务对象" name="object" />
<TabPane label="事项类别" name="category" />
</Tabs>
<Table :loading="loading" :columns="cols" :data="list">
<template #status="{ row }">
<Tag :color="row.status === 1 ? 'success' : 'default'">{{ row.status === 1 ? "启用" : "停用" }}</Tag>
</template>
<template #action="{ row }">
<Button size="small" type="text" @click="open(row)">编辑</Button>
<Button size="small" type="text" style="color: #ed4014" @click="remove(row)">删除</Button>
</template>
</Table>
</Card>
<Modal v-model="modal" :title="form.id ? '编辑字典' : '新建字典'">
<Form :label-width="90">
<FormItem label="字典键" required>
<Input v-model="form.dictKey" :disabled="!!form.id && (form.referenced || 0) > 0" placeholder="字母/数字/下划线" />
<div v-if="(form.referenced || 0) > 0" class="tip">已被 {{ form.referenced }} 个事项引用,仅可改名称</div>
</FormItem>
<FormItem label="名称" required>
<Input v-model="form.dictName" :maxlength="128" />
</FormItem>
<FormItem label="排序">
<InputNumber v-model="form.sortNo" style="width: 100%" />
</FormItem>
<FormItem label="状态">
<Switch v-model="form.status" :true-value="1" :false-value="0" />
</FormItem>
</Form>
<template #footer>
<Button @click="modal = false">取消</Button>
<Button type="primary" :loading="saving" @click="save">保存</Button>
</template>
</Modal>
</div>
</template>
<style scoped>
.bar {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.tip {
font-size: 12px;
color: #999;
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
/** 表单管理(modId 25):列表/JSON 编辑(发布版保存=自动升版本)/发布/删除/版本轨迹。 */
import { onMounted, ref } from "vue";
import { Message, Modal } from "view-ui-plus";
import { svcFormApi } from "@/api/module/svc";
import type { SvcFormVo } from "@/types/svc";
import { useSite } from "@/utils/site";
const { currentSiteCode, sites, loadSites, setSite } = useSite();
const loading = ref(false);
const list = ref<SvcFormVo[]>([]);
const modal = ref(false);
const versionsModal = ref(false);
const saving = ref(false);
const form = ref<any>({ schemaJson: '{\n "groups": [\n { "title": "基本信息", "fields": [\n { "key": "reason", "label": "申请事由", "type": "textarea", "required": true }\n ] }\n ]\n}' });
const versionRows = ref<SvcFormVo[]>([]);
const statusName = (s: number) => (s === 1 ? "已发布" : s === 2 ? "停用" : "草稿");
const statusColor = (s: number) => (s === 1 ? "success" : "default");
async function load() {
if (!currentSiteCode.value) return;
loading.value = true;
try {
list.value = (await svcFormApi.list(currentSiteCode.value).send()) || [];
} finally {
loading.value = false;
}
}
function open(row?: SvcFormVo) {
if (row) {
form.value = { id: row.id, code: row.code, name: row.name, schemaJson: row.schemaJson };
} else {
form.value = { code: "", name: "", schemaJson: form.value.schemaJson };
}
modal.value = true;
}
async function save() {
// 客户端先过一遍 JSON 合法性(服务端还有契约静态校验)
try {
JSON.parse(form.value.schemaJson);
} catch {
Message.error("表单结构不是合法 JSON");
return;
}
saving.value = true;
try {
await svcFormApi.save(currentSiteCode.value, form.value).send();
Message.success("已保存(已发布版本会自动生成新草稿版本)");
modal.value = false;
load();
} finally {
saving.value = false;
}
}
async function publish(row: SvcFormVo) {
await svcFormApi.publish(currentSiteCode.value, row.id).send();
Message.success("已发布");
load();
}
function remove(row: SvcFormVo) {
Modal.confirm({
title: "删除确认",
content: `确定删除表单「${row.name}」的全部版本吗?被事项绑定的删除会被拒绝。`,
onOk: async () => {
await svcFormApi.delete(currentSiteCode.value, row.code).send();
Message.success("已删除");
load();
},
});
}
async function showVersions(row: SvcFormVo) {
versionRows.value = (await svcFormApi.versions(currentSiteCode.value, row.code).send()) || [];
versionsModal.value = true;
}
onMounted(async () => {
await loadSites();
load();
});
const cols = [
{ title: "编码", key: "code", width: 160 },
{ title: "名称", key: "name", minWidth: 140 },
{ title: "最新版本", key: "version", width: 90 },
{ title: "状态", slot: "status", width: 90 },
{ title: "被绑定", slot: "referenced", width: 90 },
{ title: "发布时间", key: "publishTime", width: 160 },
{ title: "操作", slot: "action", width: 220 },
];
</script>
<template>
<div class="page">
<Card :bordered="false" dis-hover>
<div class="bar">
<Select :model-value="currentSiteCode" style="width: 240px" @on-change="(c: string) => { setSite(c); load(); }">
<Option v-for="s in sites" :key="s.siteCode" :value="s.siteCode">{{ s.name }}({{ s.siteCode }})</Option>
</Select>
<Button type="primary" @click="open()">新建表单</Button>
</div>
<Table :loading="loading" :columns="cols" :data="list">
<template #status="{ row }">
<Tag :color="statusColor(row.status)">{{ statusName(row.status) }}</Tag>
</template>
<template #referenced="{ row }">
<Tag v-if="row.referenced" color="blue">已绑定事项</Tag>
<span v-else>—</span>
</template>
<template #action="{ row }">
<Button size="small" type="text" @click="open(row)">编辑</Button>
<Button v-if="row.status === 0" size="small" type="text" @click="publish(row)">发布</Button>
<Button size="small" type="text" @click="showVersions(row)">版本</Button>
<Button size="small" type="text" style="color: #ed4014" @click="remove(row)">删除</Button>
</template>
</Table>
</Card>
<Modal v-model="modal" width="720" :title="form.id ? `编辑表单(已发布版本将自动升版)` : '新建表单'">
<Form :label-width="90">
<FormItem label="表单编码" required>
<Input v-model="form.code" :disabled="!!form.id" placeholder="字母/数字/下划线/中划线" />
</FormItem>
<FormItem label="表单名称" required>
<Input v-model="form.name" :maxlength="128" />
</FormItem>
<FormItem label="表单结构" required>
<Input v-model="form.schemaJson" type="textarea" :rows="14" />
<div class="tip">
契约:{"groups":[{title, fields:[{key,label,type,required,pattern,min,max,maxLength,options,accept,maxSize,maxFiles,…}]}]}
;一期控件:text/textarea/number/date/select/radio/upload
</div>
</FormItem>
</Form>
<template #footer>
<Button @click="modal = false">取消</Button>
<Button type="primary" :loading="saving" @click="save">保存</Button>
</template>
</Modal>
<Modal v-model="versionsModal" title="版本轨迹">
<Table size="small" :columns="[
{ title: '版本', key: 'version', width: 80 },
{ title: '状态', key: 'status', width: 80 },
{ title: '发布时间', key: 'publishTime' },
]" :data="versionRows" />
<template #footer>
<Button @click="versionsModal = false">关闭</Button>
</template>
</Modal>
</div>
</template>
<style scoped>
.bar {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.tip {
font-size: 12px;
color: #999;
line-height: 1.6;
}
</style>
+348
View File
@@ -0,0 +1,348 @@
<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 { SvcDeptVo, SvcFormVo, SvcItemAttachmentVo, SvcItemSaveRequest } from "@/types/svc";
import config from "@/assets/config/host.config";
import { useSite } from "@/utils/site";
import RichTextEditor from "@/components/RichTextEditor.vue";
const route = useRoute();
const router = useRouter();
const { currentSiteCode, loadSites } = useSite();
const form = ref<SvcItemSaveRequest & Record<string, any>>({
siteCode: "",
itemCode: "",
title: "",
handleMode: 1,
onlineFlag: 1,
sortNo: 0,
});
const objects = ref<{ dictKey: string; dictName: string }[]>([]);
const categories = ref<{ dictKey: string; dictName: string }[]>([]);
const depts = ref<SvcDeptVo[]>([]);
const forms = ref<SvcFormVo[]>([]);
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 ? "编辑事项" : "新建事项"));
/** 部门树扁平化(缩进展示,承袭 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()) || [];
forms.value = ((await svcFormApi.list(siteCode).send()) as SvcFormVo[]) || [];
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 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) } });
}
/** 流程图:flow-center 深链整页跳转(事项库场景用科室视图) */
function openFlowDiagram() {
window.open(`${config.flowHost}/#/diagram?flowGuid=${form.value.flowGuid}&mode=dept`, "_blank");
}
onMounted(loadAll);
</script>
<template>
<div class="page">
<Card :bordered="false" dis-hover>
<template #title>{{ pageTitle }}</template>
<Form :label-width="130">
<Divider orientation="left">基本信息</Divider>
<Row :gutter="16">
<Col span="12">
<FormItem label="事项编码" required>
<Input v-model="form.itemCode" placeholder="字母/数字/下划线/中划线,2~64 位" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="事项名称" required>
<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" />
</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>
<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 v-model="form.formCode" clearable placeholder="该事项发起时填写的自定义表单">
<Option v-for="f in forms" :key="f.code" :value="f.code">{{ f.name }}</Option>
</Select>
</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>
</template>
<style scoped>
.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>
+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
/** 办事事项列表(modId 23):筛选/发布/下架/删除/同步部门名。 */
import { h, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { svcItemApi } from "@/api/module/svc";
import type { SvcItemPageItem } from "@/types/svc";
import { useSite } from "@/utils/site";
const router = useRouter();
const { currentSiteCode, sites, loadSites, setSite } = useSite();
const loading = ref(false);
const list = ref<SvcItemPageItem[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const status = ref<number | undefined>(undefined);
const keyword = ref("");
const statusName = (s: number) => (s === 1 ? "已发布" : s === 2 ? "已下架" : "草稿");
const statusColor = (s: number) => (s === 1 ? "success" : s === 2 ? "warning" : "default");
const handleModeName = (m: number) => (m === 1 ? "线上" : m === 2 ? "线下" : "线上线下");
const columns = [
{ title: "编码", key: "itemCode", width: 140 },
{ title: "名称", key: "title", minWidth: 200 },
{ title: "类别", key: "categoryName", width: 110 },
{ title: "部门", key: "deptName", width: 130 },
{
title: "办理方式",
width: 90,
render: (h: any, p: any) => h("span", handleModeName(p.row.handleMode)),
},
{ title: "在线办理", slot: "online", width: 110 },
{ title: "状态", slot: "status", width: 90 },
{ title: "浏览量", key: "viewCount", width: 80 },
{ title: "操作", slot: "action", width: 200 },
];
async function load() {
if (!currentSiteCode.value) return;
loading.value = true;
try {
const res: any = await svcItemApi
.page({
siteCode: currentSiteCode.value,
status: status.value,
keyword: keyword.value,
page: page.value,
pageSize: pageSize.value,
})
.send();
total.value = res.total || 0;
list.value = res.list || [];
} finally {
loading.value = false;
}
}
function onSiteChange(code: string) {
setSite(code);
page.value = 1;
load();
}
function edit(row?: SvcItemPageItem) {
router.push({ path: "/svc/item/edit", query: { id: row?.id ? String(row.id) : undefined } });
}
async function publish(row: SvcItemPageItem) {
await svcItemApi.publish(currentSiteCode.value, row.id).send();
Message.success("已发布");
load();
}
async function offline(row: SvcItemPageItem) {
await svcItemApi.offline(currentSiteCode.value, row.id).send();
Message.success("已下架");
load();
}
function remove(row: SvcItemPageItem) {
Modal.confirm({
title: "删除确认",
content: `确定删除事项「${row.title}」吗?删除后不可恢复。`,
onOk: async () => {
await svcItemApi.delete(currentSiteCode.value, row.id).send();
Message.success("已删除");
load();
},
});
}
async function syncDept() {
const res: any = await svcItemApi.syncDeptName(currentSiteCode.value).send();
Message.success(`已同步 ${res.updated} 条部门名快照`);
load();
}
onMounted(async () => {
await loadSites();
load();
});
</script>
<template>
<div class="page">
<Card :bordered="false" dis-hover>
<div class="bar">
<Select :model-value="currentSiteCode" style="width: 240px" @on-change="onSiteChange">
<Option v-for="s in sites" :key="s.siteCode" :value="s.siteCode">
{{ s.name }}({{ s.siteCode }})
</Option>
</Select>
<Select v-model="status" clearable placeholder="状态" style="width: 120px" @on-change="load">
<Option :value="0">草稿</Option>
<Option :value="1">已发布</Option>
<Option :value="2">已下架</Option>
</Select>
<Input
v-model="keyword"
search
enter-button
placeholder="名称/编码"
style="width: 240px"
@on-search="load"
/>
<Button type="primary" @click="edit()">新建事项</Button>
<Button @click="syncDept">同步部门名</Button>
</div>
<Table :loading="loading" :columns="columns" :data="list">
<template #status="{ row }">
<Tag :color="statusColor(row.status)">{{ statusName(row.status) }}</Tag>
</template>
<template #online="{ row }">
<Tag v-if="row.onlineFlag === 1" color="green">可在线办理</Tag>
<span v-else>—</span>
</template>
<template #action="{ row }">
<Button size="small" type="text" @click="edit(row)">编辑</Button>
<Button v-if="row.status !== 1" size="small" type="text" @click="publish(row)">发布</Button>
<Button v-if="row.status === 1" size="small" type="text" @click="offline(row)">下架</Button>
<Button size="small" type="text" style="color: #ed4014" @click="remove(row)">删除</Button>
</template>
</Table>
<Page
class="pager"
:total="total"
:current="page"
:page-size="pageSize"
show-total
@on-change="(p: number) => { page = p; load(); }"
/>
</Card>
</div>
</template>
<style scoped>
.bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.pager {
margin-top: 12px;
text-align: right;
}
</style>