feat(hall): 新增事项指南页(先看指南再办理)
build / build (push) Failing after 2m2s

- /#/item?siteCode=&itemCode= 新页面:元数据 + 五 tab + 底部固定操作条
- HallApi.guide() 接匿名接口 /api/hall/item(契约 §3.2)
- HallItem 补 10 个承诺字段、新增 ItemGuideVo/ItemGuideAttachment(契约 §4)
- 状态角标三态与办理分流按契约 §5
- .gitignore 忽略 vite.config.ts.timestamp-* 临时文件
This commit is contained in:
chenxing
2026-10-20 18:01:16 +08:00
parent dda1a71cf5
commit 8ce8082e89
5 changed files with 433 additions and 1 deletions
+3
View File
@@ -27,3 +27,6 @@ yarn.lock
# scratch # scratch
.temp .temp
# Vite 配置热重载产生的临时文件(构建时自动生成,勿入库)
vite.config.ts.timestamp-*
+5
View File
@@ -7,6 +7,7 @@ import type {
FlowProgressVo, FlowProgressVo,
HallFilters, HallFilters,
HallItem, HallItem,
ItemGuideVo,
PageResult, PageResult,
} from "@/types/svc"; } from "@/types/svc";
@@ -68,6 +69,10 @@ class HallApi {
site(siteCode: string) { site(siteCode: string) {
return get(`${HALL}/site`, { siteCode }); return get(`${HALL}/site`, { siteCode });
} }
/** 事项指南(匿名;游客可读) */
guide(siteCode: string, itemCode: string) {
return get<ItemGuideVo>(`${HALL}/item`, { siteCode, itemCode });
}
} }
class ApplyApi { class ApplyApi {
+8 -1
View File
@@ -2,7 +2,7 @@ import type { RouteRecordRaw } from "vue-router";
/** /**
* 公共办事大厅路由(用户面;后台管理路由在 svc-hall/front,两仓互不引用)。 * 公共办事大厅路由(用户面;后台管理路由在 svc-hall/front,两仓互不引用)。
* 五个页面:事项大厅(入口)/ 在线办理 / 我的办件 / 我的审批 / 办件详情。 * 六个页面:事项大厅(入口)/ 事项指南 / 在线办理 / 我的办件 / 我的审批 / 办件详情。
* 全部走 hash 路由(部署于 /{contextPath}/hall/,hash 免服务端 rewrite)。 * 全部走 hash 路由(部署于 /{contextPath}/hall/,hash 免服务端 rewrite)。
* 参数页用 query 传参(与 CMS 指南页"我要办理"外链形态一致: * 参数页用 query 传参(与 CMS 指南页"我要办理"外链形态一致:
* {svc_hall_base}/#/apply?siteCode=&itemCode=&from=<指南页绝对URL>; * {svc_hall_base}/#/apply?siteCode=&itemCode=&from=<指南页绝对URL>;
@@ -16,6 +16,13 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/Hall.vue"), component: () => import("@/views/Hall.vue"),
meta: { title: "办事大厅" }, meta: { title: "办事大厅" },
}, },
// 事项指南(P0-1「先看指南再办理」):{base}/#/item?siteCode=&itemCode=,匿名可读
{
path: "/item",
name: "item_guide",
component: () => import("@/views/ItemGuide.vue"),
meta: { title: "事项指南" },
},
{ {
path: "/apply", path: "/apply",
name: "apply", name: "apply",
+59
View File
@@ -71,6 +71,65 @@ export interface HallItem {
handleMode?: number; handleMode?: number;
onlineFlag?: number; onlineFlag?: number;
publishTime?: string; publishTime?: string;
// ===== 《svc-hall一期接口契约》§2.2/§3.1 追加字段(全部可空,旧事项为 null)=====
/** 外链办理地址:非空即"外链最高优先"(§5 三态判定第一态) */
applyUrl?: string | null;
/** 承诺办结时限(面向用户的口径,展示优先于 handleTimeLimit) */
promiseTimeLimit?: string | null;
handleTimeLimit?: string | null;
/** 跑现场次数:0=零跑动 */
runCount?: number | null;
/** 是否快递送达:0 否 / 1 是(到付)/ 2 是(学校承担) */
expressFlag?: number | null;
/** 协办部门(顿号分隔文本快照) */
coopDepts?: string | null;
handlerName?: string | null;
/** 办理时间(办公时段,区别于办理时限) */
handleTimeDesc?: string | null;
feeDesc?: string | null;
feeBasis?: string | null;
}
/** 事项指南附件(《契约》§3.2 attachments) */
export interface ItemGuideAttachment {
fileName: string;
fileUrl: string;
fileSize?: number;
}
/** 事项指南(GET /api/hall/item 出参,匿名;《契约》§3.2/§4) */
export interface ItemGuideVo {
id: number;
itemCode: string;
title: string;
summary?: string;
/** 服务对象字典 key(中文名需查 /api/hall/filters,接口不返回 objectName) */
objectKey?: string;
categoryName?: string;
deptName?: string;
/** 办理方式:1 线上 / 2 线下 / 3 线上线下结合 */
handleMode?: number;
onlineFlag?: number;
applyUrl?: string | null;
handlePlace?: string;
handleTimeLimit?: string;
promiseTimeLimit?: string;
contactPhone?: string;
supervisePhone?: string;
coverImage?: string;
viewCount?: number;
coopDepts?: string;
handlerName?: string;
handleTimeDesc?: string;
runCount?: number;
expressFlag?: number;
feeDesc?: string;
feeBasis?: string;
/** 以下三段为入库时已 jsoup 白名单清洗的富文本,前端原样 v-html(《契约》§3.2 规则) */
processHtml?: string;
materialHtml?: string;
noticeHtml?: string;
attachments?: ItemGuideAttachment[];
} }
export interface HallFilterOption { export interface HallFilterOption {
+358
View File
@@ -0,0 +1,358 @@
<script setup lang="ts">
/**
* 事项指南页(P0-1「先看指南再办理」):{base}/#/item?siteCode=&itemCode=
* 数据源 GET /api/hall/item(《契约》§3.2,匿名)——游客可读,因此本页不得调用
* /api/apply/**、/api/svc/**(这些是 CAS 保护面,会把游客弹去登录)。
* 结构照 docs/prototype/svc-hall-P0P1-产品原型-v2.html 的 page-guide;
* 审批流程图本期不做(属 flow-center 能力),常见问题/关联事项为占位。
*/
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { hallApi } from "@/api/module/svc";
import { useSite } from "@/utils/site";
import type { ItemGuideVo } from "@/types/svc";
const route = useRoute();
const router = useRouter();
const { resolveSite, rememberItem } = useSite();
const loading = ref(false);
const guide = ref<ItemGuideVo | null>(null);
const error = ref("");
const siteCode = ref("");
const tab = ref("process");
/** 收藏:本期只做本地 UI,不接后端 */
const faved = ref(false);
/** 服务对象中文名:指南接口只回 objectKey,名称取自匿名 /api/hall/filters 字典 */
const objectName = ref("");
const EXPRESS_TEXT: Record<number, string> = { 0: "否", 1: "是(到付)", 2: "是(学校承担)" };
const HANDLE_MODE_TEXT: Record<number, string> = { 1: "线上办理", 2: "线下办理", 3: "线上线下结合办理" };
async function load() {
siteCode.value = resolveSite(route.query.siteCode);
const itemCode = String(route.query.itemCode || "").trim();
if (!siteCode.value) {
error.value = "未配置站点:请从学校门户的办事指南页进入,或联系管理员配置站点标识";
return;
}
if (!itemCode) {
error.value = "缺少事项标识(itemCode):请从办事大厅的事项卡片进入指南页";
return;
}
rememberItem(itemCode);
loading.value = true;
try {
guide.value = (await hallApi.guide(siteCode.value, itemCode).send()) as ItemGuideVo;
error.value = "";
loadObjectName();
} catch (e: any) {
guide.value = null;
error.value = String(e?.message || e || "事项指南加载失败,请稍后重试");
} finally {
loading.value = false;
}
}
/** 字典查询失败不阻塞指南(回落显示原始 key) */
async function loadObjectName() {
const key = guide.value?.objectKey;
if (!key) return;
try {
const res: any = await hallApi.filters(siteCode.value).send();
objectName.value = (res?.objects || []).find((o: any) => o.key === key)?.name || "";
} catch {
// 字典不可用时保持空,由 objectLabel 回落 key
}
}
const objectLabel = computed(() => objectName.value || guide.value?.objectKey || "");
/** 两列键值:空值直接不进数组(渲染出来就没有空行) */
const rows = computed(() => {
const g = guide.value;
if (!g) return [] as { label: string; value: string }[];
const fee = g.feeDesc
? `${g.feeDesc}${g.feeBasis ? `(依据:${g.feeBasis})` : ""}`
: g.feeBasis
? `依据:${g.feeBasis}`
: "";
const runCount = g.runCount === undefined || g.runCount === null ? "" : g.runCount === 0 ? "零跑动" : `${g.runCount} 次`;
return [
{ label: "事项简介", value: g.summary || "" },
{ label: "服务对象", value: objectLabel.value },
{ label: "服务类别", value: g.categoryName || "" },
{ label: "主办部门", value: g.deptName || "" },
{ label: "协办部门", value: g.coopDepts || "" },
{ label: "办理时限", value: g.promiseTimeLimit || g.handleTimeLimit || "" },
{ label: "办理方式", value: HANDLE_MODE_TEXT[Number(g.handleMode)] || "" },
{ label: "经办人员", value: g.handlerName || "" },
{ label: "咨询电话", value: g.contactPhone || "" },
{ label: "办理时间", value: g.handleTimeDesc || "" },
{ label: "办理地点", value: g.handlePlace || "" },
{ label: "跑现场次数", value: runCount },
{ label: "是否快递送达", value: g.expressFlag === undefined || g.expressFlag === null ? "" : EXPRESS_TEXT[g.expressFlag] || "" },
{ label: "收费标准(依据)", value: fee },
{ label: "监督电话", value: g.supervisePhone || "" },
].filter(r => r.value);
});
const headSub = computed(() =>
[
guide.value?.deptName,
guide.value?.categoryName,
objectLabel.value ? `面向${objectLabel.value}` : "",
]
.filter(Boolean)
.join(" · ")
);
/** 三态标签(《契约》§5 同口径:外链 > 在线 > 仅线下) */
const statusTag = computed(() => {
const g = guide.value;
if (!g) return { cls: "", text: "" };
if (g.applyUrl) return { cls: "sh-tag-primary", text: "外链办理" };
if (g.onlineFlag === 1) return { cls: "sh-tag-success", text: "可在线办理" };
return { cls: "", text: "仅线下办理" };
});
/** 我要办理(《契约》§5 三态分流) */
function goApply() {
const g = guide.value;
if (!g) return;
if (g.applyUrl) {
window.open(g.applyUrl, "_blank", "noopener");
return;
}
if (g.onlineFlag === 1) {
router.push({
path: "/apply",
query: { siteCode: siteCode.value, itemCode: g.itemCode, title: g.title },
});
return;
}
Message.warning("该事项仅支持线下办理");
}
function toggleFav() {
faved.value = !faved.value;
if (faved.value) Message.info("登录后可收藏");
}
/** 附件体积(原型口径:214 KB) */
function sizeText(bytes?: number) {
if (!bytes || bytes <= 0) return "";
const mb = bytes / 1048576;
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
onMounted(load);
</script>
<template>
<div class="guide">
<Spin v-if="loading" fix></Spin>
<template v-if="guide">
<div class="crumb">
<router-link :to="{ path: '/hall', query: { siteCode } }">办事大厅</router-link>
<span v-if="guide.categoryName"> / {{ guide.categoryName }}</span>
<span> / {{ guide.title }}</span>
</div>
<div class="sh-card guide-head">
<div class="head-main">
<h1>{{ guide.title }}</h1>
<div class="sub sh-muted" v-if="headSub">{{ headSub }}</div>
</div>
<div class="head-side">
<span class="sh-tag" :class="statusTag.cls">{{ statusTag.text }}</span>
<Button size="small" @click="toggleFav">{{ faved ? "★ 已收藏" : "☆ 收藏" }}</Button>
</div>
</div>
<div class="sh-card" v-if="rows.length">
<div class="section-title">事项信息</div>
<div class="kv">
<div class="kv-row" v-for="r in rows" :key="r.label">
<div class="k">{{ r.label }}</div>
<div class="v">{{ r.value }}</div>
</div>
</div>
</div>
<div class="sh-card">
<Tabs v-model="tab">
<TabPane label="办事流程" name="process" />
<TabPane label="所需材料" name="material" />
<TabPane label="注意事项" name="notice" />
<TabPane label="常见问题" name="faq" />
<TabPane label="关联事项" name="related" />
</Tabs>
<div class="pane" v-if="tab === 'process'">
<div class="rich" v-if="guide.processHtml" v-html="guide.processHtml"></div>
<div class="sh-muted" v-else>暂未提供办事流程说明</div>
</div>
<div class="pane" v-else-if="tab === 'material'">
<div class="rich" v-if="guide.materialHtml" v-html="guide.materialHtml"></div>
<div class="sh-muted" v-else>暂未提供材料清单</div>
</div>
<div class="pane" v-else-if="tab === 'notice'">
<div class="rich" v-if="guide.noticeHtml" v-html="guide.noticeHtml"></div>
<div class="sh-muted" v-else>暂无</div>
<div class="atts" v-if="guide.attachments && guide.attachments.length">
<span class="sh-tag">附件</span>
<a v-for="att in guide.attachments" :key="att.fileUrl" :href="att.fileUrl"
target="_blank" rel="noopener">
{{ att.fileName }}<span class="sh-muted" v-if="sizeText(att.fileSize)">({{ sizeText(att.fileSize) }})</span>
</a>
</div>
</div>
<div class="pane" v-else-if="tab === 'faq'">
<div class="sh-muted">常见问题整理中</div>
</div>
<div class="pane" v-else>
<div class="sh-muted">暂无关联事项</div>
</div>
</div>
<div class="actionbar">
<span class="phone" v-if="guide.contactPhone || guide.handlerName">
<template v-if="guide.contactPhone">咨询电话 {{ guide.contactPhone }}</template>
<template v-if="guide.handlerName">{{ guide.contactPhone ? " · " : "" }}{{ guide.handlerName }}</template>
</span>
<span class="sp"></span>
<Button size="small" @click="toggleFav">{{ faved ? "★ 已收藏" : "☆ 收藏" }}</Button>
<Button class="sh-btn-primary" type="primary" @click="goApply">我要办理</Button>
</div>
</template>
<div class="sh-card state" v-else-if="!loading">
<div class="state-text">{{ error || "事项不存在或未发布" }}</div>
<Button class="sh-btn-primary" type="primary" @click="router.push('/hall')">返回大厅</Button>
</div>
</div>
</template>
<style scoped>
.crumb {
font-size: 13px;
color: var(--sh-text-weak);
margin-bottom: 10px;
}
.crumb a {
color: var(--sh-primary);
text-decoration: none;
}
.guide-head {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.head-main {
flex: 1;
min-width: 260px;
}
.guide-head h1 {
font-size: 22px;
line-height: 1.35;
margin: 0;
font-weight: 600;
}
.guide-head .sub {
margin-top: 4px;
}
.head-side {
display: flex;
align-items: center;
gap: 8px;
}
.section-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
}
.kv {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0 28px;
}
@media (max-width: 820px) {
.kv {
grid-template-columns: 1fr;
}
}
.kv-row {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px dashed var(--sh-border);
align-items: flex-start;
}
.k {
color: var(--sh-text-weak);
font-size: 13px;
min-width: 96px;
flex: none;
padding-top: 1px;
}
.v {
font-size: 13.5px;
flex: 1;
}
.rich {
font-size: 13.5px;
line-height: 1.9;
color: var(--sh-text);
}
/* 富文本由后端 jsoup 白名单清洗后原样渲染,这里只做版式兜底 */
.rich :deep(p) {
margin: 0 0 8px;
}
.rich :deep(img) {
max-width: 100%;
}
.atts {
margin-top: 14px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 13px;
}
.atts a {
color: var(--sh-primary);
text-decoration: none;
}
.actionbar {
position: sticky;
bottom: 0;
background: #fff;
border-top: 1px solid var(--sh-border);
padding: 12px 0;
display: flex;
align-items: center;
gap: 12px;
margin-top: 20px;
box-shadow: 0 -4px 12px rgba(16, 24, 40, 0.05);
z-index: 50;
}
.actionbar .phone {
font-size: 12.5px;
color: var(--sh-text-weak);
}
.actionbar .sp {
flex: 1;
}
.state {
text-align: center;
padding: 48px 16px;
}
.state-text {
color: var(--sh-text-weak);
margin-bottom: 16px;
}
</style>