- /#/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:
@@ -27,3 +27,6 @@ yarn.lock
|
||||
|
||||
# scratch
|
||||
.temp
|
||||
|
||||
# Vite 配置热重载产生的临时文件(构建时自动生成,勿入库)
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
FlowProgressVo,
|
||||
HallFilters,
|
||||
HallItem,
|
||||
ItemGuideVo,
|
||||
PageResult,
|
||||
} from "@/types/svc";
|
||||
|
||||
@@ -68,6 +69,10 @@ class HallApi {
|
||||
site(siteCode: string) {
|
||||
return get(`${HALL}/site`, { siteCode });
|
||||
}
|
||||
/** 事项指南(匿名;游客可读) */
|
||||
guide(siteCode: string, itemCode: string) {
|
||||
return get<ItemGuideVo>(`${HALL}/item`, { siteCode, itemCode });
|
||||
}
|
||||
}
|
||||
|
||||
class ApplyApi {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RouteRecordRaw } from "vue-router";
|
||||
|
||||
/**
|
||||
* 公共办事大厅路由(用户面;后台管理路由在 svc-hall/front,两仓互不引用)。
|
||||
* 五个页面:事项大厅(入口)/ 在线办理 / 我的办件 / 我的审批 / 办件详情。
|
||||
* 六个页面:事项大厅(入口)/ 事项指南 / 在线办理 / 我的办件 / 我的审批 / 办件详情。
|
||||
* 全部走 hash 路由(部署于 /{contextPath}/hall/,hash 免服务端 rewrite)。
|
||||
* 参数页用 query 传参(与 CMS 指南页"我要办理"外链形态一致:
|
||||
* {svc_hall_base}/#/apply?siteCode=&itemCode=&from=<指南页绝对URL>;
|
||||
@@ -16,6 +16,13 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/Hall.vue"),
|
||||
meta: { title: "办事大厅" },
|
||||
},
|
||||
// 事项指南(P0-1「先看指南再办理」):{base}/#/item?siteCode=&itemCode=,匿名可读
|
||||
{
|
||||
path: "/item",
|
||||
name: "item_guide",
|
||||
component: () => import("@/views/ItemGuide.vue"),
|
||||
meta: { title: "事项指南" },
|
||||
},
|
||||
{
|
||||
path: "/apply",
|
||||
name: "apply",
|
||||
|
||||
@@ -71,6 +71,65 @@ export interface HallItem {
|
||||
handleMode?: number;
|
||||
onlineFlag?: number;
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user