12 KiB
前端架构与关键决策(招生协作 H5)
面向后续开发 / AI 的speedrun 文档。记录本项目前端的结构、约定和「为什么这么做」,以及待后端配合的事项。配套接口文档见 api.md。
技术栈
- Vue 3
<script setup>+ TypeScript + Vue Router(history 模式) - Pinia(
pinia-plugin-persistedstate持久化,存储经 AES 加密,见src/store/secureStore.ts) - Vant 4 组件库(按需引入,每个组件手动
import "vant/es/xxx/style") - UnoCSS(
preset-wind4+preset-rem-to-px) - 企业微信 JS-SDK:
@wecom/jssdk - 运行环境:企业微信 / 微信 webview(老内核 X5/TBS 兼容要求高)
1. HTTP 层
已弃用 axios,统一用 fetch 封装。不要再引入 axios 或恢复 customAxios.ts / encryptUrl.ts(已删除)。
src/api/customFetch.ts— 纯粹的请求组装:拼 headers / body / query、相对路径拼baseUrl、跑拦截器管线。导出getRequest / postRequest / putRequest / deleteRequest,均返回Promise<ApiResponse>。src/api/interceptors.ts— 类 axios 的拦截器注册中心,interceptors.request/response/error.use()。内置默认拦截器:- 请求:注入
Authorization: Bearer <token>(token 取自useUserStore().getToken,本项目 store 字段是token,不是 accessToken);FormData直传不设 Content-Type;非 GET JSON body 自动加application/json。 - 全局 loading:并发计数 + 800ms 延迟才显示 Vant loading toast;
options.silent可跳过。 - 响应:非 2xx 统一
showToast报错并短路返回{error, code};业务码result.code === 2→location.reload()(登录失效)。 - 网络异常:关 loading + toast + 返回
{error, code:-1}。 - 扩展新行为请用
interceptors.*.use(),不要写进 customFetch。
- 请求:注入
src/api/fetchUrl.ts— 所有接口 URL 构造函数集中在此。baseUrl = import.meta.env.VITE_API_BASE_URL ?? "https://api.zsfz.jinzejk.com"。开发环境不设该变量 → 走相对/api,由vite.config.ts的 dev proxy 转发到内网后端;生产由.env.production注入域名。
Admin.NET 统一响应:{ code, type, message, result, extras, time },code === 200 为成功,业务数据在 result。各 API 服务里用 unwrap 取 result。
2. API 服务层
src/api/admission.ts— 招生辅助数据:fetchConsultStages()→GET /options/consult-stages,返回排序后的{label,value,disabled}[]。fetchSchoolRegions()→GET /school/regions,返回{label, value(=region 精确筛选值)}[]。searchSchools({page,pageSize,region,keyword})→POST /school/search,分页。- (历史上有过
fetchInvitationFormOptions/fetchAllSchools,已废弃删除——改为各选择器按需请求。)
src/api/wecom.ts—getWecomSignature(url, type):换取企业微信 JS-SDK 签名,见第 5 节。
3. 选择器组件「数据自取」
要点:下拉/选择器的数据请求收敛在组件内部,父页面(TeacherHome / 家长端 InvitationForm)不再重复写取数逻辑,只 v-model 绑值 + @select 拿回显示名。
SingleSelectPopup.vue(咨询学段):onBeforeMount里请求一次fetchConsultStages()(不用watch(show)——被 keep-alive 缓存时不会随激活重复请求)。- 仍保留通用
optionsprop:父级显式传options就用外部的、不请求(保持组件通用性)。
SchoolSelectPopup.vue(毕业院校):- 区域列表在
onBeforeMount请求一次(loadRegions()带 in-flight 去重)。 - 兜底:区域为空(首次请求失败)时,
watch(show)在打开时补拉一次;补齐且列表确空已结束时才resetList()触发加载(避免与 VanList 自动加载重复)。 - 学校按区域服务端分页:
VanList的@load每次请求下一页;切换区域 / 搜索 →resetList重载;搜索态跨区域查询(不带 region)。 - 选中通过
@select回传{label,value},父级存selectedSchoolName用于字段展示(父级不再持有完整学校列表)。
- 区域列表在
父页面里「毕业院校是否必填」的规则用 schoolOptionalStageLabels(src/views/teacher/invitationOptions.ts)按学段显示名匹配(不依赖后端 value 是编码还是中文)。
4. 路由与布局
- 两套布局 + 底部 tabbar:
TeacherLayout(/teacher):表单 / 我的。ReceptionLayout(/reception):任务 / 我的。- tabbar 图标用 OSS 图片按 active 态切换(
#icon="{ active }"插槽)。
- 主要页面:教师端
TeacherHome(邀约表) /TeacherMine/InvitationData;接待端ReceptionTask/ReceptionMine/ReceptionData/ReceptionRecord;家长端(公开路由/invite/:token)InvitationForm/SubmissionSuccess/PassCode。 router/index.ts的userRole目前写死"admin",登录 / 角色路由待接(见待办)。
5. 企业微信 JS-SDK(ww.register)
在 main.ts 初始化(app.use(store/router) 之后、mount 之前)。未配置 corpId 或非微信/企微环境时跳过。
register({
corpId: VITE_WECOM_CORP_ID,
agentId: VITE_WECOM_AGENT_ID,
jsApiList: ['shareWechatMessage','shareAppMessage','shareToExternalContact','getLocation','openLocation'],
getConfigSignature: (url) => getWecomSignature(url, 'config'),
getAgentConfigSignature: (url) => getWecomSignature(url, 'agent'),
})
corpId / agentId / Secret 的区别
| 值 | 级别 | 从哪拿 | 放哪 |
|---|---|---|---|
| corpId(企业ID) | 企业级,所有自建应用共用 | 管理后台 → 我的企业 → 企业信息 → 底部「企业ID」 | 前端 VITE_WECOM_CORP_ID(非敏感) |
| AgentId | 每个应用不同 | 应用管理 → 该应用页 | 前端 VITE_WECOM_AGENT_ID(非敏感) |
| Secret | 每个应用不同 | 应用页 | 只放后端,绝不进前端 |
getConfigSignature vs getAgentConfigSignature —— 后端要做什么
两者都是「对当前页面 URL 生成 JS-SDK 签名」,算法完全相同,只是用的 jsapi_ticket 不同:
- getConfigSignature →
wx.config(企业级鉴权):用企业级 jsapi_ticket。基础 JS-SDK 能力的入口,必须先成功。 - getAgentConfigSignature →
agentConfig(应用级鉴权):用应用级 jsapi_ticket。企业微信很多「应用级」接口(如shareToExternalContact、外部联系人/客户群相关,以及需要应用身份的分享)必须在 config 之后再做 agentConfig 才能调用。@wecom/jssdk的register会自动先 config 后 agentConfig。
后端每次收到 { url, type } 要做的(type = config | agent):
- 用
corpId + 应用Secret调GET /cgi-bin/gettoken拿access_token(缓存 ~7200s)。 - 用 access_token 取对应 jsapi_ticket(都缓存 ~7200s):
type=config:GET /cgi-bin/get_jsapi_ticket?access_token=...(企业 ticket)type=agent:GET /cgi-bin/ticket/get?access_token=...&type=agent_config(应用 ticket)
- 生成
noncestr、timestamp;取前端传来的url(去掉#及其后面部分)。 signature = sha1("jsapi_ticket=<ticket>&noncestr=<nonce>×tamp=<ts>&url=<url>")(小写十六进制)。- 返回
{ timestamp, nonceStr, signature }。
关键坑:
- 签名的
url必须是前端传来的当前页面 URL(工厂参数),不能用固定值。SPA/iOS 上企业微信按「进入页 URL」签名——若线上 iOS 报签名无效,需前端在入口缓存 URL 后传给后端。 - 一个应用的 Secret 就能算出这两种 ticket(企业 ticket 和 agent_config ticket 都用该应用的 access_token 换)。
- 签名接口本身不依赖用户 JWT(是企业/应用级),可做成公开或轻鉴权;前端
getWecomSignature走postRequest,若已登录会自动带 token,未登录则不带。 - 前端签名接口路径暂定
POST /api/admission/wecom/jsapi-signature(fetchUrl.ts的getWecomSignatureUrl),待与后端确认。
已接的 JS-SDK 能力
TeacherMine.shareToParent()→shareWechatMessage(转发到微信,把邀约链接发给家长);未 register 成功时 catch 降级为「右上角菜单分享」提示。TeacherMine.saveImage()→previewImage(打开原生图片预览,长按保存到相册)。webview 里<a download>无效,故改用它;未 config / 不支持时降级提示长按保存。注意:WeChat/WeCom 的previewImage对 base64 dataURL 支持不稳定(官方要求 http/https),二维码目前是前端 canvas 生成的 dataURL——要稳妥保存,应让后端把二维码作为可公网访问的图片 URL 返回(或用uploadImage换 URL),再传给 previewImage。PassCode.openSchoolLocation()→openLocation(导航到校)。
6. 样式 / 资源约定
- UnoCSS px 缩放:
preset-rem-to-pxbaseFontSize=4 + spacing DEFAULT=1px → 「数字即 px」。间距类px-15=15px、rounded-12=12px、h-22=22px;字号用text-[Nrem](N×4=px,如text-[4.5rem]=18px)。渐变/复杂值用字面量bg-[linear-gradient(...)],别用 preset-wind4 的from-*/to-*(会生成 oklab/CSS 变量,老版微信 webview 不支持)。 - 远程图片资源:用户给的 OSS 图片 URL(
lw-zk.oss-cn-hangzhou.aliyuncs.com/...)直接引用,不要下载到public/images。验证 URL / 读尺寸可临时 curl,用完删。 - 字体:
public/fonts/din-bold.ttf已在src/style.css注册@font-face(familyDIN-Bold),用全局类.din-bold(故意不叫font-din,避开 UnoCSSfont-命名空间)给数字用。 - 禁用态按钮:邀约表单提交按钮未填完/提交中禁用,禁用色
#A1CCFF(覆盖 Vant 默认半透明降级)。
7. 本地测试(后端未就绪)
分两层:
① 普通页面 / 表单流程(浏览器即可测) —— 用 dev-only 的 mock 中间件拦截接口,不需要真后端:
plugins/mockServer.ts:Vite 插件(apply: "serve"),mock 了options/consult-stages、school/regions、school/search(含分页 + 关键字过滤,「市中区」特意补到 24 条便于测 VanList 滚动分页)。返回 Admin.NET 统一响应结构。- 开关:
.env里VITE_USE_MOCK=true时vite.config.ts才挂载该插件;后端就绪后置false或删除。 - 前提:dev 的
VITE_API_BASE_URL必须留空(走相对/api,才能被本地中间件拦截);若填了绝对域名,请求会直连该域名、mock 拦不到。线上域名放在.env.production。 - 用法:
pnpm dev打开表单页 → 咨询学段 / 毕业院校下拉、区域切换、滚动加载、搜索都能跑。接待端页面本就用本地 mock ref,直接可看。
② 企业微信能力(分享 / 导航) —— 普通浏览器测不了真实调起:
- 浏览器里
env.isWeCom为 false →main.ts跳过register,shareToParent/openLocation走 catch 降级 toast,页面不报错、可验证 UI。 - 真正调起需在企业微信开发者工具或真机企微里打开,且后端签名接口就绪 + 可信域名配置好。
8. 待办 / 需后端或配置配合
- 填
VITE_WECOM_CORP_ID/VITE_WECOM_AGENT_ID(.env.production)。 - 后端实现 JS-SDK 签名接口(config 用企业 ticket、agent 用 agent_config ticket),确认路径。
- 登录流程:企业微信免登 / Mock 登录(
POST /api/admission/wecom/login,code可传MOCK:<SysUser.Id>),拿accessToken存userStore.setToken;router/index.ts按角色路由(现写死 admin)。 - 各页面 mock 数据替换为真实接口:教师端提交邀约(
invitation/create)、接待端任务(checkin/reception/*)、接待数据 / 记录、数据看板等。 - 家长端
/invite/:token是公开链接、无用户 token:consult-stages/school/*等辅助数据接口按现有文档需登录授权,需后端提供按 token 的公开取数方式,否则家长端下拉取不到数据。 - 分享缩略图
imgUrl换成公网可访问的线上 logo;link域名要在企业微信后台配为可信域名。