App.vue 57 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559
  1. <script setup lang="ts">
  2. import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
  3. // ---------- 后端接口返回的数据结构 ----------
  4. // TypeScript 类型只在开发和构建时检查,不会出现在浏览器运行结果中。
  5. type Product = {
  6. product_id: string;
  7. product_version_id: string;
  8. product_code: string;
  9. name: string;
  10. summary: string;
  11. version_no?: string;
  12. plans: Array<{ id: string; code: string; name: string }>;
  13. };
  14. type AgentAction = {
  15. type:
  16. | "open_enrollment"
  17. | "open_orders"
  18. | "open_policies"
  19. | "none";
  20. label: string;
  21. payload: Record<string, unknown>;
  22. confirmation_required: boolean;
  23. };
  24. type RuntimeEvent = {
  25. event:
  26. | "run.started"
  27. | "tool.completed"
  28. | "ui.ready"
  29. | "run.completed"
  30. | "run.failed";
  31. run_id: string;
  32. sequence: number;
  33. data: Record<string, unknown>;
  34. };
  35. type ChatMessage = {
  36. role: "user" | "assistant";
  37. text: string;
  38. html?: string;
  39. products?: Product[];
  40. actions?: AgentAction[];
  41. enrollmentContext?: {
  42. age?: number;
  43. relationship?: Relationship;
  44. };
  45. };
  46. type MainTab = "advisor" | "plans" | "coverage";
  47. type Relationship = "SELF" | "PARENT" | "SPOUSE" | "CHILD";
  48. type Quote = {
  49. quote_id: string;
  50. product_id: string;
  51. plan_id: string;
  52. premium_cents: number;
  53. currency: string;
  54. expires_at: string;
  55. };
  56. type Order = {
  57. order_id: string;
  58. order_no: string;
  59. status: string;
  60. amount_cents: number;
  61. };
  62. type Payment = {
  63. payment_id: string;
  64. payment_no: string;
  65. status: string;
  66. };
  67. type Policy = {
  68. policy_id: string;
  69. policy_no: string;
  70. order_id: string;
  71. order_no: string;
  72. product_name: string;
  73. plan_name: string;
  74. applicant: { name?: string; id_no?: string };
  75. insured: { name?: string; id_no?: string };
  76. status: string;
  77. premium_cents: number;
  78. coverage_start: string;
  79. coverage_end: string;
  80. issued_at: string;
  81. };
  82. // ---------- 页面响应式状态 ----------
  83. // ref() 包装的值变化后,Vue 会自动更新模板中使用它的区域。
  84. const apiBase =
  85. import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8000/api/v1";
  86. const mobile = ref("");
  87. const code = ref("");
  88. const imageCode = ref("");
  89. const imageCodeInput = ref("");
  90. const agreementAccepted = ref(false);
  91. const codeCountdown = ref(0);
  92. const codeRequested = ref(false);
  93. let countdownTimer: ReturnType<typeof setInterval> | undefined;
  94. const token = ref("");
  95. const referralCode = new URLSearchParams(window.location.search)
  96. .get("ref")
  97. ?.trim()
  98. .toUpperCase() ?? "";
  99. const contactMobile = ref("");
  100. const products = ref<Product[]>([]);
  101. const error = ref("");
  102. const codeMessage = ref("");
  103. const loading = ref(false);
  104. const refreshing = ref(false);
  105. const restoring = ref(true);
  106. const agentInput = ref("我想给65岁的父亲买医疗险,常住成都,普通职业。");
  107. const agentThreadId = ref("");
  108. const agentMessages = ref<ChatMessage[]>([]);
  109. const agentLoading = ref(false);
  110. const agentProgress = ref("");
  111. const serviceNo = ref("");
  112. const activeTab = ref<MainTab>("advisor");
  113. const selectedProduct = ref<Product | null>(null);
  114. const quote = ref<Quote | null>(null);
  115. const order = ref<Order | null>(null);
  116. const payment = ref<Payment | null>(null);
  117. const policy = ref<Policy | null>(null);
  118. const policies = ref<Policy[]>([]);
  119. const openedPolicy = ref<Policy | null>(null);
  120. const businessLoading = ref(false);
  121. const quotingProductId = ref("");
  122. const quoteFeedback = ref("");
  123. const enrollmentOpen = ref(false);
  124. const preQuoteConfirming = ref(false);
  125. const enrollmentStep = ref(1);
  126. const applicantName = ref("");
  127. const applicantIdNo = ref("");
  128. const insuredName = ref("");
  129. const insuredIdNo = ref("");
  130. const insuredAge = ref(65);
  131. const relationship = ref<Relationship>("PARENT");
  132. const noticeAccepted = ref(false);
  133. const disclosureConfirmed = ref(false);
  134. const canRequestCode = computed(
  135. () => !loading.value && codeCountdown.value === 0,
  136. );
  137. const displayedProducts = computed(() => {
  138. if (!selectedProduct.value) return products.value;
  139. return [
  140. selectedProduct.value,
  141. ...products.value.filter(
  142. (product) => product.product_id !== selectedProduct.value?.product_id,
  143. ),
  144. ];
  145. });
  146. // ---------- 纯展示辅助函数 ----------
  147. // 这些函数只做格式化和安全展示,不发起请求,也不修改后端业务状态。
  148. function money(cents: number) {
  149. return `¥${(cents / 100).toFixed(2)}`;
  150. }
  151. function escapeHtml(value: string) {
  152. return value
  153. .replaceAll("&", "&amp;")
  154. .replaceAll("<", "&lt;")
  155. .replaceAll(">", "&gt;")
  156. .replaceAll('"', "&quot;")
  157. .replaceAll("'", "&#039;");
  158. }
  159. // Agent 返回 Markdown 文本;渲染前先转义 HTML,再转换允许的少量 Markdown 语法。
  160. // 这样既保留排版,也避免模型回复直接注入任意 HTML。
  161. function renderInlineMarkdown(value: string) {
  162. return escapeHtml(value)
  163. .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
  164. .replace(/`([^`]+)`/g, "<code>$1</code>");
  165. }
  166. function markdownCells(line: string) {
  167. return line
  168. .replace(/^\s*\|/, "")
  169. .replace(/\|\s*$/, "")
  170. .split("|")
  171. .map((cell) => cell.trim());
  172. }
  173. function renderAgentMarkdown(source: string) {
  174. const lines = source.replace(/\r\n?/g, "\n").split("\n");
  175. const output: string[] = [];
  176. let listItems: string[] = [];
  177. const flushList = () => {
  178. if (listItems.length === 0) return;
  179. output.push(`<ul>${listItems.map((item) => `<li>${item}</li>`).join("")}</ul>`);
  180. listItems = [];
  181. };
  182. for (let index = 0; index < lines.length; index += 1) {
  183. const line = lines[index].trim();
  184. if (!line || /^-{3,}$/.test(line)) {
  185. flushList();
  186. continue;
  187. }
  188. const nextLine = lines[index + 1]?.trim() ?? "";
  189. if (
  190. line.includes("|") &&
  191. /^\|?\s*:?-{3,}/.test(nextLine)
  192. ) {
  193. flushList();
  194. const headers = markdownCells(line);
  195. const rows: string[][] = [];
  196. index += 2;
  197. while (index < lines.length && lines[index].includes("|")) {
  198. rows.push(markdownCells(lines[index]));
  199. index += 1;
  200. }
  201. index -= 1;
  202. output.push(
  203. `<div class="response-table-wrap"><table><thead><tr>${headers
  204. .map((cell) => `<th>${renderInlineMarkdown(cell)}</th>`)
  205. .join("")}</tr></thead><tbody>${rows
  206. .map(
  207. (row) =>
  208. `<tr>${row
  209. .map((cell) => `<td>${renderInlineMarkdown(cell)}</td>`)
  210. .join("")}</tr>`,
  211. )
  212. .join("")}</tbody></table></div>`,
  213. );
  214. continue;
  215. }
  216. const heading = line.match(/^#{1,4}\s+(.+)$/);
  217. if (heading) {
  218. flushList();
  219. output.push(`<h4>${renderInlineMarkdown(heading[1])}</h4>`);
  220. continue;
  221. }
  222. const listItem = line.match(/^(?:[-*•]|\d+[.)])\s+(.+)$/);
  223. if (listItem) {
  224. listItems.push(renderInlineMarkdown(listItem[1]));
  225. continue;
  226. }
  227. const quoteLine = line.match(/^>\s*(.+)$/);
  228. if (quoteLine) {
  229. flushList();
  230. output.push(`<blockquote>${renderInlineMarkdown(quoteLine[1])}</blockquote>`);
  231. continue;
  232. }
  233. flushList();
  234. output.push(`<p>${renderInlineMarkdown(line)}</p>`);
  235. }
  236. flushList();
  237. return output.join("");
  238. }
  239. function dateTime(value: string) {
  240. return new Intl.DateTimeFormat("zh-CN", {
  241. year: "numeric",
  242. month: "2-digit",
  243. day: "2-digit",
  244. hour: "2-digit",
  245. minute: "2-digit",
  246. hour12: false,
  247. }).format(new Date(value));
  248. }
  249. function policyStatus(status: string) {
  250. return {
  251. ACTIVE: "保障中",
  252. PENDING: "待生效",
  253. EXPIRED: "已到期",
  254. SURRENDERING: "退保处理中",
  255. SURRENDERED: "已退保",
  256. }[status] ?? status;
  257. }
  258. function relationshipName(value: Relationship) {
  259. return {
  260. SELF: "本人",
  261. PARENT: "父母",
  262. SPOUSE: "配偶",
  263. CHILD: "子女",
  264. }[value];
  265. }
  266. function inferEnrollmentContext(text: string) {
  267. const ageMatch = text.match(/(\d{1,3})\s*岁/);
  268. const age = ageMatch ? Number(ageMatch[1]) : undefined;
  269. let inferredRelationship: Relationship | undefined;
  270. if (/(父亲|母亲|父母|爸爸|妈妈)/.test(text)) inferredRelationship = "PARENT";
  271. else if (/(爱人|配偶|丈夫|妻子|老公|老婆)/.test(text)) inferredRelationship = "SPOUSE";
  272. else if (/(孩子|子女|儿子|女儿)/.test(text)) inferredRelationship = "CHILD";
  273. else if (/(本人|自己|我想给我|为自己)/.test(text)) inferredRelationship = "SELF";
  274. return {
  275. age: age !== undefined && age >= 0 && age <= 120 ? age : undefined,
  276. relationship: inferredRelationship,
  277. };
  278. }
  279. function maskIdNo(value: string) {
  280. if (value.length < 10) return value;
  281. return `${value.slice(0, 6)}********${value.slice(-4)}`;
  282. }
  283. function createImageCode() {
  284. const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
  285. const bytes = new Uint8Array(4);
  286. crypto.getRandomValues(bytes);
  287. imageCode.value = Array.from(
  288. bytes,
  289. (value) => alphabet[value % alphabet.length],
  290. ).join("");
  291. imageCodeInput.value = "";
  292. }
  293. function startCodeCountdown() {
  294. if (countdownTimer) clearInterval(countdownTimer);
  295. codeCountdown.value = 60;
  296. countdownTimer = setInterval(() => {
  297. codeCountdown.value -= 1;
  298. if (codeCountdown.value <= 0 && countdownTimer) {
  299. clearInterval(countdownTimer);
  300. countdownTimer = undefined;
  301. }
  302. }, 1000);
  303. }
  304. function validateMobile() {
  305. if (!/^1\d{10}$/.test(mobile.value)) {
  306. error.value = "请输入正确的11位手机号";
  307. return false;
  308. }
  309. return true;
  310. }
  311. // ---------- HTTP 请求与登录态 ----------
  312. // authorizedFetch 会自动附带 Token;遇到登录过期时只尝试刷新一次。
  313. async function apiFetch(path: string, init: RequestInit = {}) {
  314. return fetch(`${apiBase}${path}`, {
  315. ...init,
  316. credentials: "include",
  317. });
  318. }
  319. async function responseError(response: Response, fallback: string) {
  320. try {
  321. const body = await response.json();
  322. return body.error?.message ?? fallback;
  323. } catch {
  324. return fallback;
  325. }
  326. }
  327. async function authorizedFetch(path: string, init: RequestInit = {}, allowRefresh = true) {
  328. const headers = new Headers(init.headers);
  329. headers.set("Authorization", `Bearer ${token.value}`);
  330. const response = await apiFetch(path, { ...init, headers });
  331. if (response.status === 401 && allowRefresh && (await refreshAccessToken())) {
  332. return authorizedFetch(path, init, false);
  333. }
  334. return response;
  335. }
  336. async function requestCode() {
  337. error.value = "";
  338. codeMessage.value = "";
  339. if (!validateMobile()) return;
  340. if (
  341. imageCodeInput.value.trim().toUpperCase() !== imageCode.value.toUpperCase()
  342. ) {
  343. error.value = "图形验证码不正确,请重新输入";
  344. createImageCode();
  345. return;
  346. }
  347. loading.value = true;
  348. try {
  349. const response = await apiFetch("/h5/auth/request-code", {
  350. method: "POST",
  351. headers: { "Content-Type": "application/json" },
  352. body: JSON.stringify({ mobile: mobile.value }),
  353. });
  354. if (!response.ok) {
  355. throw new Error(await responseError(response, "验证码请求失败"));
  356. }
  357. const body = await response.json();
  358. const validMinutes = Math.max(1, Math.floor(body.data.expires_in / 60));
  359. codeRequested.value = true;
  360. codeMessage.value = `验证码已发送,${validMinutes}分钟内有效`;
  361. startCodeCountdown();
  362. } catch (reason) {
  363. error.value = reason instanceof Error ? reason.message : "请求失败";
  364. createImageCode();
  365. } finally {
  366. loading.value = false;
  367. }
  368. }
  369. async function login() {
  370. error.value = "";
  371. if (!validateMobile()) return;
  372. if (!codeRequested.value) {
  373. error.value = "请先获取短信验证码";
  374. return;
  375. }
  376. if (!/^\d{6}$/.test(code.value)) {
  377. error.value = "请输入6位短信验证码";
  378. return;
  379. }
  380. if (!agreementAccepted.value) {
  381. error.value = "请阅读并同意服务协议与隐私政策";
  382. return;
  383. }
  384. loading.value = true;
  385. try {
  386. const response = await apiFetch("/h5/auth/login", {
  387. method: "POST",
  388. headers: { "Content-Type": "application/json" },
  389. body: JSON.stringify({
  390. mobile: mobile.value,
  391. code: code.value,
  392. referral_code: referralCode || undefined,
  393. }),
  394. });
  395. if (!response.ok) {
  396. throw new Error(await responseError(response, "登录失败"));
  397. }
  398. const body = await response.json();
  399. token.value = body.data.tokens.access_token;
  400. mobile.value = body.data.user.mobile;
  401. contactMobile.value = body.data.user.mobile;
  402. code.value = "";
  403. codeMessage.value = "";
  404. await loadProducts();
  405. await loadBusiness();
  406. } catch (reason) {
  407. error.value = reason instanceof Error ? reason.message : "请求失败";
  408. } finally {
  409. loading.value = false;
  410. }
  411. }
  412. async function refreshAccessToken() {
  413. const response = await apiFetch("/h5/auth/refresh", { method: "POST" });
  414. if (!response.ok) {
  415. token.value = "";
  416. products.value = [];
  417. return false;
  418. }
  419. const body = await response.json();
  420. token.value = body.data.tokens.access_token;
  421. mobile.value = body.data.user.mobile;
  422. contactMobile.value = body.data.user.mobile;
  423. return true;
  424. }
  425. async function loadProducts(allowRefresh = true) {
  426. const response = await apiFetch("/h5/products", {
  427. headers: { Authorization: `Bearer ${token.value}` },
  428. });
  429. if (response.status === 401 && allowRefresh && (await refreshAccessToken())) {
  430. return loadProducts(false);
  431. }
  432. if (!response.ok) {
  433. if (response.status === 401) token.value = "";
  434. throw new Error(await responseError(response, "产品加载失败"));
  435. }
  436. const body = await response.json();
  437. products.value = body.data.items;
  438. }
  439. // ---------- 客户保障顾问 Agent ----------
  440. // 先创建/复用会话,再通过 SSE 接收运行开始、工具完成、UI 就绪和最终回答。
  441. async function sendAgentMessage() {
  442. const text = agentInput.value.trim();
  443. if (!text || agentLoading.value) return;
  444. agentLoading.value = true;
  445. agentProgress.value = "正在理解保障需求";
  446. error.value = "";
  447. agentMessages.value.push({ role: "user", text });
  448. agentInput.value = "";
  449. try {
  450. if (!agentThreadId.value) {
  451. const threadResponse = await authorizedFetch("/agent/threads", {
  452. method: "POST",
  453. headers: { "Content-Type": "application/json" },
  454. body: JSON.stringify({ title: "智能投保咨询" }),
  455. });
  456. if (!threadResponse.ok) {
  457. throw new Error(await responseError(threadResponse, "创建咨询会话失败"));
  458. }
  459. agentThreadId.value = (await threadResponse.json()).data.id;
  460. }
  461. const response = await authorizedFetch(
  462. `/agent/threads/${agentThreadId.value}/messages/stream`,
  463. {
  464. method: "POST",
  465. headers: { "Content-Type": "application/json" },
  466. body: JSON.stringify({
  467. content: { type: "text", text },
  468. client_message_id: crypto.randomUUID(),
  469. }),
  470. },
  471. );
  472. if (!response.ok) {
  473. throw new Error(await responseError(response, "智能体响应失败"));
  474. }
  475. const data = await consumeAgentStream(response);
  476. const assistantText = data.assistant_message.text as string;
  477. const cards = (data.assistant_message.cards ?? []) as Array<{
  478. type: string;
  479. items?: Product[];
  480. }>;
  481. const cardItems = cards
  482. .filter((card) => card.type === "product_recommendations")
  483. .flatMap((card) => card.items ?? []);
  484. const actions = (data.assistant_message.actions ?? []) as AgentAction[];
  485. agentMessages.value.push({
  486. role: "assistant",
  487. text: assistantText,
  488. html: renderAgentMarkdown(assistantText),
  489. enrollmentContext: inferEnrollmentContext(text),
  490. products: cardItems.slice(0, 2),
  491. actions,
  492. });
  493. serviceNo.value = data.service_no ?? "";
  494. } catch (reason) {
  495. if (!agentInput.value.trim()) agentInput.value = text;
  496. error.value = reason instanceof Error ? reason.message : "智能体响应失败";
  497. } finally {
  498. agentLoading.value = false;
  499. agentProgress.value = "";
  500. }
  501. }
  502. async function consumeAgentStream(response: Response) {
  503. if (!response.body) throw new Error("浏览器不支持流式响应");
  504. const reader = response.body.getReader();
  505. const decoder = new TextDecoder();
  506. let buffer = "";
  507. let completed: Record<string, any> | null = null;
  508. while (true) {
  509. const { done, value } = await reader.read();
  510. buffer += decoder.decode(value, { stream: !done });
  511. const packets = buffer.split("\n\n");
  512. buffer = packets.pop() ?? "";
  513. for (const packet of packets) {
  514. const dataLine = packet
  515. .split("\n")
  516. .find((line) => line.startsWith("data: "));
  517. if (!dataLine) continue;
  518. const event = JSON.parse(dataLine.slice(6)) as RuntimeEvent;
  519. if (event.event === "tool.completed") {
  520. agentProgress.value = "正在核验产品与业务规则";
  521. } else if (event.event === "ui.ready") {
  522. agentProgress.value = "正在生成可操作的保障方案";
  523. } else if (event.event === "run.failed") {
  524. throw new Error(String(event.data.message ?? "智能体响应失败"));
  525. } else if (event.event === "run.completed") {
  526. completed = event.data.result as Record<string, any>;
  527. }
  528. }
  529. if (done) break;
  530. }
  531. if (!completed) throw new Error("智能体运行未正常结束");
  532. return completed;
  533. }
  534. // Action 不是模型生成的任意链接,而是后端 Harness 返回的受控动作。
  535. async function executeAgentAction(
  536. action: AgentAction,
  537. context?: ChatMessage["enrollmentContext"],
  538. ) {
  539. if (action.type === "open_orders" || action.type === "open_policies") {
  540. activeTab.value = "coverage";
  541. await loadBusiness();
  542. return;
  543. }
  544. if (action.type !== "open_enrollment") return;
  545. const productCode =
  546. typeof action.payload.product_code === "string"
  547. ? action.payload.product_code
  548. : "";
  549. const product = products.value.find(
  550. (item) => item.product_code === productCode,
  551. );
  552. if (!product) {
  553. error.value = "推荐产品已不在当前在售目录,请重新咨询";
  554. return;
  555. }
  556. const age =
  557. typeof action.payload.age === "number"
  558. ? action.payload.age
  559. : context?.age;
  560. const rawRelationship = action.payload.relationship;
  561. const actionRelationship = (
  562. ["SELF", "PARENT", "SPOUSE", "CHILD"] as const
  563. ).find((item) => item === rawRelationship);
  564. await startDirectEnrollment(product, {
  565. age,
  566. relationship: actionRelationship ?? context?.relationship,
  567. });
  568. }
  569. function viewProduct(product: Product) {
  570. selectedProduct.value = product;
  571. activeTab.value = "plans";
  572. window.scrollTo({ top: 0, behavior: "smooth" });
  573. }
  574. async function startDirectEnrollment(
  575. product: Product,
  576. context?: ChatMessage["enrollmentContext"],
  577. ) {
  578. selectedProduct.value = product;
  579. if (context?.age !== undefined) insuredAge.value = context.age;
  580. if (context?.relationship) relationship.value = context.relationship;
  581. quote.value = null;
  582. resetEnrollmentFlow();
  583. preQuoteConfirming.value = true;
  584. enrollmentOpen.value = true;
  585. error.value = "";
  586. await nextTick();
  587. document
  588. .querySelector<HTMLElement>(".enrollment-body")
  589. ?.scrollTo({ top: 0 });
  590. }
  591. async function confirmDirectEnrollment() {
  592. if (!selectedProduct.value) return;
  593. if (
  594. !Number.isInteger(insuredAge.value) ||
  595. insuredAge.value < 0 ||
  596. insuredAge.value > 120
  597. ) {
  598. error.value = "请输入0至120之间的有效年龄";
  599. return;
  600. }
  601. await createQuote(selectedProduct.value);
  602. }
  603. function openPolicyDocument(item: Policy) {
  604. openedPolicy.value = item;
  605. }
  606. function closePolicyDocument() {
  607. openedPolicy.value = null;
  608. }
  609. function printPolicyDocument() {
  610. window.print();
  611. }
  612. function resetEnrollmentFlow() {
  613. enrollmentStep.value = 1;
  614. noticeAccepted.value = false;
  615. disclosureConfirmed.value = false;
  616. order.value = null;
  617. payment.value = null;
  618. policy.value = null;
  619. }
  620. // ---------- 确定性投保流程 ----------
  621. // 报价、草稿、确认、订单、支付和出单都调用普通业务接口,不由模型直接改状态。
  622. async function setEnrollmentStep(step: number) {
  623. enrollmentStep.value = step;
  624. await nextTick();
  625. document
  626. .querySelector<HTMLElement>(".enrollment-body")
  627. ?.scrollTo({ top: 0, behavior: "smooth" });
  628. }
  629. async function createQuote(product: Product) {
  630. businessLoading.value = true;
  631. quotingProductId.value = product.product_id;
  632. quoteFeedback.value = "正在校验投保资格并试算保费…";
  633. error.value = "";
  634. try {
  635. const response = await authorizedFetch("/h5/quotes", {
  636. method: "POST",
  637. headers: { "Content-Type": "application/json" },
  638. body: JSON.stringify({
  639. product_id: product.product_id,
  640. plan_id: product.plans[0].id,
  641. insured: {
  642. age: insuredAge.value,
  643. region_code: "510100",
  644. occupation_code: "GENERAL",
  645. },
  646. relationship: relationship.value,
  647. }),
  648. });
  649. if (!response.ok) throw new Error(await responseError(response, "报价失败"));
  650. selectedProduct.value = product;
  651. quote.value = (await response.json()).data;
  652. resetEnrollmentFlow();
  653. preQuoteConfirming.value = false;
  654. enrollmentOpen.value = true;
  655. quoteFeedback.value = "";
  656. await setEnrollmentStep(1);
  657. } catch (reason) {
  658. error.value = reason instanceof Error ? reason.message : "报价失败";
  659. quoteFeedback.value = error.value;
  660. } finally {
  661. businessLoading.value = false;
  662. quotingProductId.value = "";
  663. }
  664. }
  665. function validateEnrollmentPeople() {
  666. error.value = "";
  667. if (!applicantName.value.trim() || !insuredName.value.trim()) {
  668. error.value = "请填写投保人和被保人姓名";
  669. return false;
  670. }
  671. const idPattern = /^\d{17}[\dXx]$/;
  672. if (
  673. !idPattern.test(applicantIdNo.value) ||
  674. !idPattern.test(insuredIdNo.value)
  675. ) {
  676. error.value = "请输入正确的18位身份证号码";
  677. return false;
  678. }
  679. if (!/^1\d{10}$/.test(contactMobile.value)) {
  680. error.value = "请输入正确的11位联系手机号";
  681. return false;
  682. }
  683. return true;
  684. }
  685. async function goToNotice() {
  686. if (!validateEnrollmentPeople()) return;
  687. error.value = "";
  688. await setEnrollmentStep(2);
  689. }
  690. async function goToConfirmation() {
  691. error.value = "";
  692. if (!noticeAccepted.value || !disclosureConfirmed.value) {
  693. error.value = "请阅读投保须知并完成两项确认";
  694. return;
  695. }
  696. await setEnrollmentStep(3);
  697. }
  698. async function confirmAndCreateOrder() {
  699. if (!quote.value) return;
  700. businessLoading.value = true;
  701. error.value = "";
  702. try {
  703. const draftResponse = await authorizedFetch("/h5/enrollment-drafts", {
  704. method: "POST",
  705. headers: { "Content-Type": "application/json" },
  706. body: JSON.stringify({
  707. quote_id: quote.value.quote_id,
  708. applicant: {
  709. name: applicantName.value.trim(),
  710. id_no: applicantIdNo.value.toUpperCase(),
  711. },
  712. insured: {
  713. name: insuredName.value.trim(),
  714. id_no: insuredIdNo.value.toUpperCase(),
  715. },
  716. contact: { mobile: contactMobile.value },
  717. }),
  718. });
  719. if (!draftResponse.ok) {
  720. throw new Error(await responseError(draftResponse, "投保草稿创建失败"));
  721. }
  722. const draft = (await draftResponse.json()).data;
  723. const confirmationResponse = await authorizedFetch(
  724. `/h5/enrollment-drafts/${draft.draft_id}/confirmation`,
  725. { method: "POST" },
  726. );
  727. if (!confirmationResponse.ok) {
  728. throw new Error(await responseError(confirmationResponse, "确认失败"));
  729. }
  730. const confirmation = (await confirmationResponse.json()).data;
  731. const orderResponse = await authorizedFetch("/h5/orders", {
  732. method: "POST",
  733. headers: {
  734. "Content-Type": "application/json",
  735. "Idempotency-Key": crypto.randomUUID(),
  736. },
  737. body: JSON.stringify({
  738. draft_id: draft.draft_id,
  739. confirmation_token: confirmation.confirmation_token,
  740. }),
  741. });
  742. if (!orderResponse.ok) {
  743. throw new Error(await responseError(orderResponse, "订单创建失败"));
  744. }
  745. order.value = (await orderResponse.json()).data;
  746. const paymentCreated = await startPayment();
  747. if (paymentCreated) await setEnrollmentStep(4);
  748. } catch (reason) {
  749. error.value = reason instanceof Error ? reason.message : "订单创建失败";
  750. } finally {
  751. businessLoading.value = false;
  752. }
  753. }
  754. async function startPayment() {
  755. if (!order.value) return false;
  756. businessLoading.value = true;
  757. error.value = "";
  758. try {
  759. const response = await authorizedFetch(
  760. `/h5/orders/${order.value.order_id}/payments`,
  761. {
  762. method: "POST",
  763. headers: { "Idempotency-Key": crypto.randomUUID() },
  764. },
  765. );
  766. if (!response.ok) throw new Error(await responseError(response, "支付发起失败"));
  767. payment.value = (await response.json()).data;
  768. return true;
  769. } catch (reason) {
  770. error.value = reason instanceof Error ? reason.message : "支付发起失败";
  771. return false;
  772. } finally {
  773. businessLoading.value = false;
  774. }
  775. }
  776. async function completePayment() {
  777. if (!payment.value) return;
  778. businessLoading.value = true;
  779. error.value = "";
  780. try {
  781. const response = await authorizedFetch(
  782. `/dev/mock-payments/${payment.value.payment_id}/complete`,
  783. { method: "POST" },
  784. );
  785. if (!response.ok) throw new Error(await responseError(response, "支付回调失败"));
  786. const body = await response.json();
  787. payment.value.status = body.data.payment_status;
  788. if (order.value) order.value.status = body.data.order_status;
  789. policy.value = body.data.policy;
  790. await loadBusiness();
  791. } catch (reason) {
  792. error.value = reason instanceof Error ? reason.message : "支付回调失败";
  793. } finally {
  794. businessLoading.value = false;
  795. }
  796. }
  797. async function closeEnrollment() {
  798. enrollmentOpen.value = false;
  799. preQuoteConfirming.value = false;
  800. error.value = "";
  801. await loadBusiness();
  802. }
  803. async function viewCoverageAfterPayment() {
  804. enrollmentOpen.value = false;
  805. activeTab.value = "coverage";
  806. await loadBusiness();
  807. window.scrollTo({ top: 0, behavior: "smooth" });
  808. }
  809. async function loadBusiness() {
  810. if (!token.value) return;
  811. const [ordersResponse, policiesResponse] = await Promise.all([
  812. authorizedFetch("/h5/orders"),
  813. authorizedFetch("/h5/policies"),
  814. ]);
  815. if (ordersResponse.ok) {
  816. const items = (await ordersResponse.json()).data.items as Order[];
  817. order.value = items[0] ?? null;
  818. }
  819. if (policiesResponse.ok) {
  820. const items = (await policiesResponse.json()).data.items as Policy[];
  821. policies.value = items;
  822. }
  823. }
  824. async function refreshProducts() {
  825. refreshing.value = true;
  826. error.value = "";
  827. try {
  828. await loadProducts();
  829. } catch (reason) {
  830. error.value = reason instanceof Error ? reason.message : "产品加载失败";
  831. } finally {
  832. refreshing.value = false;
  833. }
  834. }
  835. async function logout() {
  836. try {
  837. if (token.value) {
  838. await apiFetch("/h5/auth/logout", {
  839. method: "POST",
  840. headers: { Authorization: `Bearer ${token.value}` },
  841. });
  842. }
  843. } finally {
  844. token.value = "";
  845. contactMobile.value = "";
  846. products.value = [];
  847. error.value = "";
  848. agentThreadId.value = "";
  849. agentMessages.value = [];
  850. serviceNo.value = "";
  851. activeTab.value = "advisor";
  852. selectedProduct.value = null;
  853. quote.value = null;
  854. order.value = null;
  855. payment.value = null;
  856. policy.value = null;
  857. policies.value = [];
  858. openedPolicy.value = null;
  859. quotingProductId.value = "";
  860. quoteFeedback.value = "";
  861. enrollmentOpen.value = false;
  862. preQuoteConfirming.value = false;
  863. enrollmentStep.value = 1;
  864. applicantName.value = "";
  865. applicantIdNo.value = "";
  866. insuredName.value = "";
  867. insuredIdNo.value = "";
  868. relationship.value = "PARENT";
  869. noticeAccepted.value = false;
  870. disclosureConfirmed.value = false;
  871. codeRequested.value = false;
  872. codeCountdown.value = 0;
  873. agreementAccepted.value = false;
  874. if (countdownTimer) {
  875. clearInterval(countdownTimer);
  876. countdownTimer = undefined;
  877. }
  878. createImageCode();
  879. }
  880. }
  881. // 页面初始化时恢复登录态并加载产品、订单和保单;组件销毁时清理验证码计时器。
  882. onMounted(async () => {
  883. createImageCode();
  884. // 清理早期版本遗留的持久化Access Token,当前版本只在内存中保存。
  885. localStorage.removeItem("zbt_h5_token");
  886. try {
  887. if (await refreshAccessToken()) await loadProducts(false);
  888. if (token.value) await loadBusiness();
  889. } catch {
  890. token.value = "";
  891. products.value = [];
  892. } finally {
  893. restoring.value = false;
  894. }
  895. });
  896. onBeforeUnmount(() => {
  897. if (countdownTimer) clearInterval(countdownTimer);
  898. });
  899. </script>
  900. <template>
  901. <main class="phone-shell">
  902. <section v-if="restoring || !token" class="hero">
  903. <div class="brand-line"><span class="seal">智</span> 智保通</div>
  904. <p class="eyebrow">AI 智能保险服务平台</p>
  905. <h1>把复杂条款,<br /><em>说成一句明白话。</em></h1>
  906. <div class="hero-orbit" aria-hidden="true"></div>
  907. </section>
  908. <section v-if="restoring" class="login-card session-loading" aria-live="polite">
  909. <p class="step">00 / 会话检查</p>
  910. <h2>正在恢复安全会话…</h2>
  911. <p class="hint">仅从安全 Cookie 轮换登录凭证。</p>
  912. </section>
  913. <section v-else-if="!token" class="login-card">
  914. <div class="login-heading">
  915. <div>
  916. <p class="step">01 / 安全登录</p>
  917. <h2>欢迎使用智保通</h2>
  918. </div>
  919. <span class="secure-badge"><i></i>安全连接</span>
  920. </div>
  921. <p class="login-intro">登录后继续咨询方案、查询订单与管理个人保单。</p>
  922. <p v-if="referralCode" class="referral-banner">
  923. <span>邀</span> 已识别专属服务邀请 · {{ referralCode }}
  924. </p>
  925. <form class="login-form" @submit.prevent="login">
  926. <label>
  927. 手机号
  928. <span class="field-shell">
  929. <span class="field-prefix">+86</span>
  930. <input
  931. v-model.trim="mobile"
  932. inputmode="tel"
  933. autocomplete="tel"
  934. maxlength="11"
  935. placeholder="请输入手机号"
  936. aria-label="手机号"
  937. />
  938. </span>
  939. </label>
  940. <label>
  941. 图形验证码
  942. <span class="field-row">
  943. <input
  944. v-model.trim="imageCodeInput"
  945. class="image-code-input"
  946. maxlength="4"
  947. autocomplete="off"
  948. placeholder="请输入图中字符"
  949. aria-label="图形验证码"
  950. />
  951. <button
  952. type="button"
  953. class="captcha"
  954. aria-label="刷新图形验证码"
  955. title="看不清?点击换一张"
  956. @click="createImageCode"
  957. >
  958. <span
  959. v-for="(letter, index) in imageCode"
  960. :key="`${letter}-${index}`"
  961. :style="{ transform: `rotate(${index % 2 === 0 ? -8 : 7}deg) translateY(${index % 3 - 1}px)` }"
  962. >{{ letter }}</span>
  963. <i></i><i></i>
  964. </button>
  965. </span>
  966. <small class="field-help">看不清?点击图片更换</small>
  967. </label>
  968. <label>
  969. 短信验证码
  970. <span class="field-row">
  971. <input
  972. v-model.trim="code"
  973. inputmode="numeric"
  974. autocomplete="one-time-code"
  975. maxlength="6"
  976. placeholder="请输入6位验证码"
  977. aria-label="短信验证码"
  978. />
  979. <button
  980. type="button"
  981. class="send-code"
  982. :disabled="!canRequestCode"
  983. @click="requestCode"
  984. >
  985. {{ codeCountdown > 0 ? `${codeCountdown}s 后重发` : "获取验证码" }}
  986. </button>
  987. </span>
  988. </label>
  989. <label class="agreement">
  990. <input v-model="agreementAccepted" type="checkbox" />
  991. <span>我已阅读并同意<a href="#service-agreement">《用户服务协议》</a>和<a href="#privacy-policy">《隐私政策》</a></span>
  992. </label>
  993. <p v-if="codeMessage" class="success status-message" aria-live="polite">
  994. <span>✓</span>{{ codeMessage }}
  995. </p>
  996. <p v-if="error" class="error status-message" role="alert">
  997. <span>!</span>{{ error }}
  998. </p>
  999. <button class="login-submit" :disabled="loading" type="submit">
  1000. {{ loading ? "正在安全登录…" : "登录" }}
  1001. <span aria-hidden="true">→</span>
  1002. </button>
  1003. </form>
  1004. <p class="privacy-note"><span>◇</span> 你的个人信息将被加密保护</p>
  1005. </section>
  1006. <div v-else class="app-shell">
  1007. <header class="app-header">
  1008. <div class="compact-brand">
  1009. <span class="seal">智</span>
  1010. <div><strong>智保通</strong><small>懂你的保险顾问</small></div>
  1011. </div>
  1012. <button class="header-action" aria-label="退出登录" @click="logout">
  1013. <svg viewBox="0 0 24 24" aria-hidden="true">
  1014. <path d="M10 4H5v16h5M14 8l4 4-4 4M18 12H9" />
  1015. </svg>
  1016. </button>
  1017. </header>
  1018. <div class="tab-content">
  1019. <section v-if="activeTab === 'advisor'" class="tab-page advisor-page">
  1020. <div class="page-heading">
  1021. <div><p class="step">AI INSURANCE ADVISOR</p><h2>智能顾问</h2></div>
  1022. <span class="online-pill"><i></i>在线</span>
  1023. </div>
  1024. <p class="page-lead">说说你的保障需求,我会查询在售产品并给出适合的候选方案。</p>
  1025. <div class="quick-prompts">
  1026. <button @click="agentInput = '我想给65岁的父亲买医疗险,常住成都,普通职业。'">父母医疗</button>
  1027. <button @click="agentInput = '我想给一家三口配置意外保障,请帮我推荐。'">家庭意外</button>
  1028. <button @click="agentInput = '我想了解基础医疗保障,年龄32岁。'">成人医疗</button>
  1029. </div>
  1030. <div class="agent-panel">
  1031. <div class="agent-head">
  1032. <span class="agent-mark">AI</span>
  1033. <div><strong>你好,我是智保通顾问</strong><small>在线服务 · 安全连接</small></div>
  1034. </div>
  1035. <div class="messages" aria-live="polite">
  1036. <div v-if="agentMessages.length === 0" class="welcome-message">
  1037. <strong>我可以帮你做什么?</strong>
  1038. <p>告诉我为谁投保、年龄、常住地区和职业,我会先理解需求,再推荐方案。</p>
  1039. </div>
  1040. <div
  1041. v-for="(message, index) in agentMessages"
  1042. :key="index"
  1043. :class="['message', message.role]"
  1044. >
  1045. <div class="message-meta">
  1046. <span v-if="message.role === 'assistant'">AI</span>
  1047. <small>{{ message.role === "user" ? "你" : "智保通顾问" }}</small>
  1048. </div>
  1049. <div
  1050. v-if="message.role === 'assistant'"
  1051. class="assistant-content"
  1052. v-html="message.html"
  1053. ></div>
  1054. <p v-else>{{ message.text }}</p>
  1055. <div v-if="message.products?.length" class="recommendation-list">
  1056. <div
  1057. v-for="(product, productIndex) in message.products"
  1058. :key="product.product_id"
  1059. class="recommendation-card"
  1060. >
  1061. <div>
  1062. <span>{{ productIndex === 0 ? "优先推荐" : "备选方案" }}</span>
  1063. <strong>{{ product.name }}</strong>
  1064. <small>{{ product.summary }}</small>
  1065. </div>
  1066. <button @click="startDirectEnrollment(product, message.enrollmentContext)">
  1067. 立即投保 →
  1068. </button>
  1069. </div>
  1070. </div>
  1071. <div
  1072. v-if="message.actions?.length && !message.products?.length"
  1073. class="agent-actions"
  1074. >
  1075. <button
  1076. v-for="action in message.actions.filter((item) => item.type !== 'none')"
  1077. :key="`${action.type}-${action.label}`"
  1078. type="button"
  1079. @click="executeAgentAction(action, message.enrollmentContext)"
  1080. >
  1081. {{ action.label }} <span>→</span>
  1082. </button>
  1083. </div>
  1084. </div>
  1085. </div>
  1086. <div class="agent-composer">
  1087. <textarea
  1088. v-model="agentInput"
  1089. rows="3"
  1090. aria-label="投保需求"
  1091. placeholder="请描述你的投保需求…"
  1092. ></textarea>
  1093. <button
  1094. aria-label="发送消息"
  1095. :disabled="agentLoading || !agentInput.trim()"
  1096. @click="sendAgentMessage"
  1097. >
  1098. <svg viewBox="0 0 24 24" aria-hidden="true">
  1099. <path d="m4 5 16 7-16 7 3-7-3-7Zm3 7h13" />
  1100. </svg>
  1101. </button>
  1102. </div>
  1103. <p v-if="agentLoading" class="thinking"><i></i><i></i><i></i> {{ agentProgress }}</p>
  1104. <p v-if="serviceNo" class="trace">服务流水号 · {{ serviceNo }}</p>
  1105. </div>
  1106. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  1107. </section>
  1108. <section v-else-if="activeTab === 'plans'" class="tab-page plans-page">
  1109. <div class="page-heading">
  1110. <div><p class="step">PROTECTION PLANS</p><h2>保障方案</h2></div>
  1111. <button class="refresh-button" :disabled="refreshing" @click="refreshProducts">
  1112. {{ refreshing ? "刷新中" : "刷新" }}
  1113. </button>
  1114. </div>
  1115. <p class="page-lead">先填写被保人的基本情况,再查看确定性保费。</p>
  1116. <div class="quote-conditions">
  1117. <div class="condition-title"><span>01</span><strong>被保人信息</strong></div>
  1118. <label>
  1119. 与投保人关系
  1120. <span class="relation-options">
  1121. <button
  1122. v-for="item in (['SELF', 'PARENT', 'SPOUSE', 'CHILD'] as Relationship[])"
  1123. :key="item"
  1124. :class="{ active: relationship === item }"
  1125. @click="relationship = item"
  1126. >
  1127. {{ relationshipName(item) }}
  1128. </button>
  1129. </span>
  1130. </label>
  1131. <label>
  1132. 被保人年龄
  1133. <input v-model.number="insuredAge" type="number" min="0" max="120" />
  1134. </label>
  1135. <div class="fixed-conditions">
  1136. <span>常住地区:成都市</span><span>职业类别:普通职业</span>
  1137. </div>
  1138. </div>
  1139. <div class="product-list">
  1140. <div
  1141. v-for="(product, index) in displayedProducts"
  1142. :key="product.product_code"
  1143. :class="['product-card', { recommended: selectedProduct?.product_id === product.product_id }]"
  1144. >
  1145. <div class="product-topline">
  1146. <span>{{ String(index + 1).padStart(2, "0") }}</span>
  1147. <em v-if="selectedProduct?.product_id === product.product_id">顾问推荐</em>
  1148. </div>
  1149. <h3>{{ product.name }}</h3>
  1150. <p>{{ product.summary }}</p>
  1151. <div class="plans">
  1152. <span v-for="plan in product.plans" :key="plan.code">{{ plan.name }}</span>
  1153. </div>
  1154. <ul class="benefit-list">
  1155. <li>电子保单即时查询</li>
  1156. <li>支持家庭成员投保</li>
  1157. <li>保费由规则引擎确定</li>
  1158. </ul>
  1159. <button
  1160. class="product-action"
  1161. :disabled="businessLoading"
  1162. :aria-busy="quotingProductId === product.product_id"
  1163. @click="createQuote(product)"
  1164. >
  1165. {{ quotingProductId === product.product_id ? "正在试算…" : "测算保费并投保" }} <span>→</span>
  1166. </button>
  1167. </div>
  1168. </div>
  1169. <div v-if="quoteFeedback" :class="['quote-feedback', { failed: !businessLoading }]">
  1170. <span>{{ businessLoading ? "···" : "!" }}</span>{{ quoteFeedback }}
  1171. </div>
  1172. <p v-if="products.length === 0" class="empty-state">暂时没有可投保产品。</p>
  1173. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  1174. </section>
  1175. <section v-else class="tab-page coverage-page">
  1176. <div class="page-heading">
  1177. <div><p class="step">MY PROTECTION</p><h2>我的保障</h2></div>
  1178. <span class="record-count">{{ policies.length > 0 ? `${policies.length}张保单` : "暂无保单" }}</span>
  1179. </div>
  1180. <p class="page-lead">集中查看投保订单与已经生效的保障。</p>
  1181. <div v-if="policies.length > 0" class="policy-list">
  1182. <article v-for="(item, index) in policies" :key="item.policy_id" class="policy-summary">
  1183. <div class="policy-glow"></div>
  1184. <div class="policy-heading">
  1185. <span>电子保单 · {{ String(index + 1).padStart(2, "0") }}</span>
  1186. <em>{{ policyStatus(item.status) }}</em>
  1187. </div>
  1188. <h3>{{ item.product_name }}</h3>
  1189. <p class="policy-plan">{{ item.plan_name }}</p>
  1190. <p class="policy-number">{{ item.policy_no }}</p>
  1191. <div class="policy-data">
  1192. <div><small>年度保费</small><strong>{{ money(item.premium_cents) }}</strong></div>
  1193. <div><small>保障期限</small><strong>{{ item.coverage_start }} 至 {{ item.coverage_end }}</strong></div>
  1194. </div>
  1195. <button type="button" @click="openPolicyDocument(item)">
  1196. 查看电子保单 <span>→</span>
  1197. </button>
  1198. </article>
  1199. </div>
  1200. <div v-if="!order && policies.length === 0" class="coverage-empty">
  1201. <span class="empty-shield">◇</span>
  1202. <h3>还没有保障记录</h3>
  1203. <p>先和智能顾问聊聊,找到适合你的保障方案。</p>
  1204. <button @click="activeTab = 'advisor'">咨询智能顾问</button>
  1205. </div>
  1206. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  1207. </section>
  1208. </div>
  1209. <nav class="bottom-nav" aria-label="主导航">
  1210. <button :class="{ active: activeTab === 'advisor' }" @click="activeTab = 'advisor'">
  1211. <svg viewBox="0 0 24 24" aria-hidden="true">
  1212. <path d="M7 8h10M7 12h7M8 18l-4 2 1-4a8 8 0 1 1 3 2Z" />
  1213. </svg>
  1214. <span>智能顾问</span>
  1215. </button>
  1216. <button :class="{ active: activeTab === 'plans' }" @click="activeTab = 'plans'">
  1217. <svg viewBox="0 0 24 24" aria-hidden="true">
  1218. <path d="M12 3 4 6v5c0 5 3 8 8 10 5-2 8-5 8-10V6l-8-3Zm-3 9 2 2 4-5" />
  1219. </svg>
  1220. <span>保障方案</span>
  1221. </button>
  1222. <button :class="{ active: activeTab === 'coverage' }" @click="activeTab = 'coverage'">
  1223. <svg viewBox="0 0 24 24" aria-hidden="true">
  1224. <path d="M7 3h8l4 4v14H7V3Zm8 0v5h4M10 12h6M10 16h6" />
  1225. </svg>
  1226. <span>我的保障</span>
  1227. </button>
  1228. </nav>
  1229. <div v-if="enrollmentOpen" class="enrollment-layer" role="dialog" aria-modal="true">
  1230. <header class="enrollment-header">
  1231. <button aria-label="关闭投保流程" @click="closeEnrollment">×</button>
  1232. <div>
  1233. <span>{{ preQuoteConfirming ? "投保前确认" : "投保流程" }}</span>
  1234. <strong>{{ selectedProduct?.name ?? "保障计划" }}</strong>
  1235. </div>
  1236. <small>{{ preQuoteConfirming ? "准备投保" : `${enrollmentStep}/4` }}</small>
  1237. </header>
  1238. <div v-if="!preQuoteConfirming" class="progress-track">
  1239. <span v-for="stepNo in 4" :key="stepNo" :class="{ active: stepNo <= enrollmentStep }"></span>
  1240. </div>
  1241. <div v-else class="direct-entry-line"></div>
  1242. <div class="enrollment-body">
  1243. <section v-if="preQuoteConfirming" class="enrollment-step direct-enrollment-step">
  1244. <p class="step">DIRECT ENROLLMENT</p>
  1245. <h2>确认投保条件</h2>
  1246. <p class="step-description">顾问已经为你选好方案,确认以下信息后即可测算保费并继续投保。</p>
  1247. <div class="direct-product">
  1248. <div>
  1249. <span>顾问推荐</span>
  1250. <h3>{{ selectedProduct?.name }}</h3>
  1251. <p>{{ selectedProduct?.summary }}</p>
  1252. </div>
  1253. <em>已选择</em>
  1254. </div>
  1255. <div class="form-section direct-condition-form">
  1256. <div class="form-section-title"><span>被保人情况</span><small>用于资格校验和保费测算</small></div>
  1257. <label>
  1258. 与投保人关系
  1259. <span class="relation-options">
  1260. <button
  1261. v-for="item in (['SELF', 'PARENT', 'SPOUSE', 'CHILD'] as Relationship[])"
  1262. :key="item"
  1263. type="button"
  1264. :class="{ active: relationship === item }"
  1265. @click="relationship = item"
  1266. >
  1267. {{ relationshipName(item) }}
  1268. </button>
  1269. </span>
  1270. </label>
  1271. <label>
  1272. 被保人年龄
  1273. <input v-model.number="insuredAge" type="number" min="0" max="120" inputmode="numeric" />
  1274. </label>
  1275. <div class="fixed-conditions">
  1276. <span>常住地区:成都市</span><span>职业类别:普通职业</span>
  1277. </div>
  1278. </div>
  1279. <div class="direct-entry-note">
  1280. <span>AI</span>
  1281. <p>以上信息已根据你的咨询自动带入,请确认无误。最终是否可投保及保费以本次系统测算结果为准。</p>
  1282. </div>
  1283. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  1284. <button
  1285. class="flow-primary"
  1286. :disabled="businessLoading"
  1287. @click="confirmDirectEnrollment"
  1288. >
  1289. {{ businessLoading ? "正在测算保费…" : "确认并开始投保" }} <span>→</span>
  1290. </button>
  1291. </section>
  1292. <section v-else-if="enrollmentStep === 1" class="enrollment-step">
  1293. <p class="step">STEP 01</p>
  1294. <h2>填写投保信息</h2>
  1295. <p class="step-description">请确认信息真实、准确,这些信息将写入投保订单。</p>
  1296. <div class="quote-mini">
  1297. <div><span>{{ selectedProduct?.name }}</span><small>{{ relationshipName(relationship) }} · {{ insuredAge }}岁</small></div>
  1298. <strong>{{ quote ? money(quote.premium_cents) : "—" }}</strong>
  1299. </div>
  1300. <div class="form-section">
  1301. <div class="form-section-title"><span>投保人</span><small>负责缴费和接收通知</small></div>
  1302. <label>姓名<input v-model.trim="applicantName" autocomplete="name" placeholder="请输入真实姓名" /></label>
  1303. <label>身份证号<input v-model.trim="applicantIdNo" maxlength="18" placeholder="请输入18位身份证号" /></label>
  1304. <label>联系手机<input v-model.trim="contactMobile" inputmode="tel" maxlength="11" placeholder="请输入接收通知的手机号" /><small class="input-help">默认使用登录手机号,可修改</small></label>
  1305. </div>
  1306. <div class="form-section">
  1307. <div class="form-section-title"><span>被保人</span><small>实际享受保障的人</small></div>
  1308. <label>与投保人关系
  1309. <select v-model="relationship">
  1310. <option value="SELF">本人</option><option value="PARENT">父母</option>
  1311. <option value="SPOUSE">配偶</option><option value="CHILD">子女</option>
  1312. </select>
  1313. </label>
  1314. <label>姓名<input v-model.trim="insuredName" placeholder="请输入真实姓名" /></label>
  1315. <label>身份证号<input v-model.trim="insuredIdNo" maxlength="18" placeholder="请输入18位身份证号" /></label>
  1316. </div>
  1317. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  1318. <button class="flow-primary" @click="goToNotice">下一步:阅读投保须知 <span>→</span></button>
  1319. </section>
  1320. <section v-else-if="enrollmentStep === 2" class="enrollment-step notice-step">
  1321. <p class="step">STEP 02</p>
  1322. <h2>投保须知</h2>
  1323. <p class="step-description">这些内容会影响承保和理赔,请完整阅读后再确认。</p>
  1324. <div class="notice-document">
  1325. <header><span>智保通投保须知</span><small>适用于 {{ selectedProduct?.name }}</small></header>
  1326. <div>
  1327. <h3>一、投保资格</h3>
  1328. <p>被保人应符合产品约定的年龄、常住地区和职业范围。本次试算结果仅对当前填写信息有效。</p>
  1329. <h3>二、如实告知</h3>
  1330. <p>投保人应如实填写身份及健康相关信息。故意或因重大过失未履行如实告知义务,可能影响合同效力及理赔结果。</p>
  1331. <h3>三、保障生效</h3>
  1332. <p>支付成功并完成承保后生成电子保单,具体保障责任、等待期、免赔额和责任限额以电子保单及保险条款为准。</p>
  1333. <h3>四、责任免除</h3>
  1334. <p>既往症、违法行为、故意伤害以及条款明确列示的其他情形可能不在保障范围内,请重点阅读责任免除部分。</p>
  1335. <h3>五、信息与退保</h3>
  1336. <p>投保信息将用于承保、保全和服务处理。退保规则、可退金额及生效后的处理方式以保险条款和后续服务流程为准。</p>
  1337. </div>
  1338. </div>
  1339. <label class="notice-check">
  1340. <input v-model="noticeAccepted" type="checkbox" />
  1341. <span>我已阅读并理解《投保须知》《保险条款》和《责任免除》</span>
  1342. </label>
  1343. <label class="notice-check">
  1344. <input v-model="disclosureConfirmed" type="checkbox" />
  1345. <span>我确认投保信息真实、完整,并同意进行电子投保</span>
  1346. </label>
  1347. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  1348. <div class="flow-actions">
  1349. <button class="flow-back" @click="setEnrollmentStep(1)">上一步</button>
  1350. <button class="flow-primary" @click="goToConfirmation">同意并继续 <span>→</span></button>
  1351. </div>
  1352. </section>
  1353. <section v-else-if="enrollmentStep === 3" class="enrollment-step confirmation-step">
  1354. <p class="step">STEP 03</p>
  1355. <h2>确认投保订单</h2>
  1356. <p class="step-description">请最后核对保障方案和人员信息。</p>
  1357. <div class="confirm-product">
  1358. <span>保障方案</span>
  1359. <h3>{{ selectedProduct?.name }}</h3>
  1360. <p>{{ relationshipName(relationship) }} · {{ insuredAge }}岁 · 成都市 · 普通职业</p>
  1361. <strong>{{ quote ? money(quote.premium_cents) : "—" }}<small>/年</small></strong>
  1362. </div>
  1363. <dl class="confirm-list">
  1364. <div><dt>投保人</dt><dd>{{ applicantName }}</dd></div>
  1365. <div><dt>投保人证件</dt><dd>{{ maskIdNo(applicantIdNo) }}</dd></div>
  1366. <div><dt>被保人</dt><dd>{{ insuredName }}</dd></div>
  1367. <div><dt>被保人证件</dt><dd>{{ maskIdNo(insuredIdNo) }}</dd></div>
  1368. <div><dt>联系电话</dt><dd>{{ contactMobile }}</dd></div>
  1369. </dl>
  1370. <div class="confirmed-notice"><span>✓</span>已阅读并同意投保须知与责任免除</div>
  1371. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  1372. <div class="flow-actions">
  1373. <button class="flow-back" @click="setEnrollmentStep(2)">上一步</button>
  1374. <button class="flow-primary" :disabled="businessLoading" @click="confirmAndCreateOrder">
  1375. {{ businessLoading ? "正在创建订单…" : `确认投保并支付 ${quote ? money(quote.premium_cents) : ""}` }}
  1376. </button>
  1377. </div>
  1378. </section>
  1379. <section v-else class="enrollment-step payment-step">
  1380. <template v-if="!policy">
  1381. <p class="step">STEP 04</p>
  1382. <h2>安全支付</h2>
  1383. <p class="step-description">确认支付后将立即完成承保并生成电子保单。</p>
  1384. <div class="payment-amount">
  1385. <span>应付金额</span>
  1386. <strong>{{ order ? money(order.amount_cents) : quote ? money(quote.premium_cents) : "—" }}</strong>
  1387. <small>{{ selectedProduct?.name ?? "智保通保障计划" }}</small>
  1388. </div>
  1389. <div class="payment-channel">
  1390. <span class="channel-icon">¥</span>
  1391. <div><strong>快捷支付</strong><small>支付过程由安全通道保护</small></div>
  1392. <em>已选择</em>
  1393. </div>
  1394. <p v-if="order" class="payment-order">订单号 {{ order.order_no }}</p>
  1395. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  1396. <button
  1397. class="flow-primary pay-button"
  1398. :disabled="businessLoading || !payment"
  1399. @click="completePayment"
  1400. >
  1401. {{ businessLoading ? "正在确认支付…" : payment ? "确认支付" : "正在准备支付…" }}
  1402. </button>
  1403. </template>
  1404. <template v-else>
  1405. <div class="success-symbol"><span>✓</span></div>
  1406. <p class="step success-step">ENROLLMENT COMPLETED</p>
  1407. <h2>投保成功</h2>
  1408. <p class="step-description">电子保单已经生成,保障信息已保存到“我的保障”。</p>
  1409. <div class="success-policy">
  1410. <span>电子保单号</span><strong>{{ policy.policy_no }}</strong>
  1411. <div><small>保障状态</small><em>{{ policyStatus(policy.status) }}</em></div>
  1412. <div><small>保障期限</small><p>{{ policy.coverage_start }} 至 {{ policy.coverage_end }}</p></div>
  1413. </div>
  1414. <button class="flow-primary" @click="viewCoverageAfterPayment">查看我的保障 <span>→</span></button>
  1415. </template>
  1416. </section>
  1417. </div>
  1418. </div>
  1419. <div
  1420. v-if="openedPolicy"
  1421. class="policy-document-layer"
  1422. role="dialog"
  1423. aria-modal="true"
  1424. aria-label="电子保单"
  1425. @click.self="closePolicyDocument"
  1426. >
  1427. <article class="policy-document">
  1428. <header class="policy-document-header">
  1429. <button type="button" aria-label="关闭电子保单" @click="closePolicyDocument">×</button>
  1430. <div><span>智保通</span><small>电子保险凭证</small></div>
  1431. <em>{{ policyStatus(openedPolicy.status) }}</em>
  1432. </header>
  1433. <section class="policy-document-hero">
  1434. <p>POLICY CERTIFICATE</p>
  1435. <h2>{{ openedPolicy.product_name }}</h2>
  1436. <span>{{ openedPolicy.plan_name }}</span>
  1437. <div><small>电子保单号</small><strong>{{ openedPolicy.policy_no }}</strong></div>
  1438. </section>
  1439. <section class="policy-document-body">
  1440. <div class="document-section-title"><span>01</span><h3>合同信息</h3></div>
  1441. <dl class="document-fields">
  1442. <div><dt>保单状态</dt><dd class="active-status">{{ policyStatus(openedPolicy.status) }}</dd></div>
  1443. <div><dt>订单编号</dt><dd>{{ openedPolicy.order_no }}</dd></div>
  1444. <div><dt>签发时间</dt><dd>{{ dateTime(openedPolicy.issued_at) }}</dd></div>
  1445. <div><dt>年度保费</dt><dd>{{ money(openedPolicy.premium_cents) }}</dd></div>
  1446. <div class="wide-field"><dt>保障期限</dt><dd>{{ openedPolicy.coverage_start }} 至 {{ openedPolicy.coverage_end }}</dd></div>
  1447. </dl>
  1448. <div class="document-section-title"><span>02</span><h3>被保险人信息</h3></div>
  1449. <dl class="document-fields">
  1450. <div><dt>投保人</dt><dd>{{ openedPolicy.applicant.name ?? "—" }}</dd></div>
  1451. <div><dt>投保人证件</dt><dd>{{ maskIdNo(openedPolicy.applicant.id_no ?? "") || "—" }}</dd></div>
  1452. <div><dt>被保人</dt><dd>{{ openedPolicy.insured.name ?? "—" }}</dd></div>
  1453. <div><dt>被保人证件</dt><dd>{{ maskIdNo(openedPolicy.insured.id_no ?? "") || "—" }}</dd></div>
  1454. </dl>
  1455. <div class="policy-document-note">
  1456. <span>i</span>
  1457. <p>本电子凭证记录本次承保结果。具体保障责任、责任免除、等待期及赔付限额以保险条款和投保须知为准。</p>
  1458. </div>
  1459. <div class="policy-seal"><span>智保通</span><small>电子承保凭证</small></div>
  1460. <button type="button" class="document-print" @click="printPolicyDocument">打印 / 保存为 PDF</button>
  1461. </section>
  1462. </article>
  1463. </div>
  1464. </div>
  1465. </main>
  1466. </template>