| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559 |
- <script setup lang="ts">
- import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
- // ---------- 后端接口返回的数据结构 ----------
- // TypeScript 类型只在开发和构建时检查,不会出现在浏览器运行结果中。
- type Product = {
- product_id: string;
- product_version_id: string;
- product_code: string;
- name: string;
- summary: string;
- version_no?: string;
- plans: Array<{ id: string; code: string; name: string }>;
- };
- type AgentAction = {
- type:
- | "open_enrollment"
- | "open_orders"
- | "open_policies"
- | "none";
- label: string;
- payload: Record<string, unknown>;
- confirmation_required: boolean;
- };
- type RuntimeEvent = {
- event:
- | "run.started"
- | "tool.completed"
- | "ui.ready"
- | "run.completed"
- | "run.failed";
- run_id: string;
- sequence: number;
- data: Record<string, unknown>;
- };
- type ChatMessage = {
- role: "user" | "assistant";
- text: string;
- html?: string;
- products?: Product[];
- actions?: AgentAction[];
- enrollmentContext?: {
- age?: number;
- relationship?: Relationship;
- };
- };
- type MainTab = "advisor" | "plans" | "coverage";
- type Relationship = "SELF" | "PARENT" | "SPOUSE" | "CHILD";
- type Quote = {
- quote_id: string;
- product_id: string;
- plan_id: string;
- premium_cents: number;
- currency: string;
- expires_at: string;
- };
- type Order = {
- order_id: string;
- order_no: string;
- status: string;
- amount_cents: number;
- };
- type Payment = {
- payment_id: string;
- payment_no: string;
- status: string;
- };
- type Policy = {
- policy_id: string;
- policy_no: string;
- order_id: string;
- order_no: string;
- product_name: string;
- plan_name: string;
- applicant: { name?: string; id_no?: string };
- insured: { name?: string; id_no?: string };
- status: string;
- premium_cents: number;
- coverage_start: string;
- coverage_end: string;
- issued_at: string;
- };
- // ---------- 页面响应式状态 ----------
- // ref() 包装的值变化后,Vue 会自动更新模板中使用它的区域。
- const apiBase =
- import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8000/api/v1";
- const mobile = ref("");
- const code = ref("");
- const imageCode = ref("");
- const imageCodeInput = ref("");
- const agreementAccepted = ref(false);
- const codeCountdown = ref(0);
- const codeRequested = ref(false);
- let countdownTimer: ReturnType<typeof setInterval> | undefined;
- const token = ref("");
- const referralCode = new URLSearchParams(window.location.search)
- .get("ref")
- ?.trim()
- .toUpperCase() ?? "";
- const contactMobile = ref("");
- const products = ref<Product[]>([]);
- const error = ref("");
- const codeMessage = ref("");
- const loading = ref(false);
- const refreshing = ref(false);
- const restoring = ref(true);
- const agentInput = ref("我想给65岁的父亲买医疗险,常住成都,普通职业。");
- const agentThreadId = ref("");
- const agentMessages = ref<ChatMessage[]>([]);
- const agentLoading = ref(false);
- const agentProgress = ref("");
- const serviceNo = ref("");
- const activeTab = ref<MainTab>("advisor");
- const selectedProduct = ref<Product | null>(null);
- const quote = ref<Quote | null>(null);
- const order = ref<Order | null>(null);
- const payment = ref<Payment | null>(null);
- const policy = ref<Policy | null>(null);
- const policies = ref<Policy[]>([]);
- const openedPolicy = ref<Policy | null>(null);
- const businessLoading = ref(false);
- const quotingProductId = ref("");
- const quoteFeedback = ref("");
- const enrollmentOpen = ref(false);
- const preQuoteConfirming = ref(false);
- const enrollmentStep = ref(1);
- const applicantName = ref("");
- const applicantIdNo = ref("");
- const insuredName = ref("");
- const insuredIdNo = ref("");
- const insuredAge = ref(65);
- const relationship = ref<Relationship>("PARENT");
- const noticeAccepted = ref(false);
- const disclosureConfirmed = ref(false);
- const canRequestCode = computed(
- () => !loading.value && codeCountdown.value === 0,
- );
- const displayedProducts = computed(() => {
- if (!selectedProduct.value) return products.value;
- return [
- selectedProduct.value,
- ...products.value.filter(
- (product) => product.product_id !== selectedProduct.value?.product_id,
- ),
- ];
- });
- // ---------- 纯展示辅助函数 ----------
- // 这些函数只做格式化和安全展示,不发起请求,也不修改后端业务状态。
- function money(cents: number) {
- return `¥${(cents / 100).toFixed(2)}`;
- }
- function escapeHtml(value: string) {
- return value
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
- }
- // Agent 返回 Markdown 文本;渲染前先转义 HTML,再转换允许的少量 Markdown 语法。
- // 这样既保留排版,也避免模型回复直接注入任意 HTML。
- function renderInlineMarkdown(value: string) {
- return escapeHtml(value)
- .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
- .replace(/`([^`]+)`/g, "<code>$1</code>");
- }
- function markdownCells(line: string) {
- return line
- .replace(/^\s*\|/, "")
- .replace(/\|\s*$/, "")
- .split("|")
- .map((cell) => cell.trim());
- }
- function renderAgentMarkdown(source: string) {
- const lines = source.replace(/\r\n?/g, "\n").split("\n");
- const output: string[] = [];
- let listItems: string[] = [];
- const flushList = () => {
- if (listItems.length === 0) return;
- output.push(`<ul>${listItems.map((item) => `<li>${item}</li>`).join("")}</ul>`);
- listItems = [];
- };
- for (let index = 0; index < lines.length; index += 1) {
- const line = lines[index].trim();
- if (!line || /^-{3,}$/.test(line)) {
- flushList();
- continue;
- }
- const nextLine = lines[index + 1]?.trim() ?? "";
- if (
- line.includes("|") &&
- /^\|?\s*:?-{3,}/.test(nextLine)
- ) {
- flushList();
- const headers = markdownCells(line);
- const rows: string[][] = [];
- index += 2;
- while (index < lines.length && lines[index].includes("|")) {
- rows.push(markdownCells(lines[index]));
- index += 1;
- }
- index -= 1;
- output.push(
- `<div class="response-table-wrap"><table><thead><tr>${headers
- .map((cell) => `<th>${renderInlineMarkdown(cell)}</th>`)
- .join("")}</tr></thead><tbody>${rows
- .map(
- (row) =>
- `<tr>${row
- .map((cell) => `<td>${renderInlineMarkdown(cell)}</td>`)
- .join("")}</tr>`,
- )
- .join("")}</tbody></table></div>`,
- );
- continue;
- }
- const heading = line.match(/^#{1,4}\s+(.+)$/);
- if (heading) {
- flushList();
- output.push(`<h4>${renderInlineMarkdown(heading[1])}</h4>`);
- continue;
- }
- const listItem = line.match(/^(?:[-*•]|\d+[.)])\s+(.+)$/);
- if (listItem) {
- listItems.push(renderInlineMarkdown(listItem[1]));
- continue;
- }
- const quoteLine = line.match(/^>\s*(.+)$/);
- if (quoteLine) {
- flushList();
- output.push(`<blockquote>${renderInlineMarkdown(quoteLine[1])}</blockquote>`);
- continue;
- }
- flushList();
- output.push(`<p>${renderInlineMarkdown(line)}</p>`);
- }
- flushList();
- return output.join("");
- }
- function dateTime(value: string) {
- return new Intl.DateTimeFormat("zh-CN", {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- hour12: false,
- }).format(new Date(value));
- }
- function policyStatus(status: string) {
- return {
- ACTIVE: "保障中",
- PENDING: "待生效",
- EXPIRED: "已到期",
- SURRENDERING: "退保处理中",
- SURRENDERED: "已退保",
- }[status] ?? status;
- }
- function relationshipName(value: Relationship) {
- return {
- SELF: "本人",
- PARENT: "父母",
- SPOUSE: "配偶",
- CHILD: "子女",
- }[value];
- }
- function inferEnrollmentContext(text: string) {
- const ageMatch = text.match(/(\d{1,3})\s*岁/);
- const age = ageMatch ? Number(ageMatch[1]) : undefined;
- let inferredRelationship: Relationship | undefined;
- if (/(父亲|母亲|父母|爸爸|妈妈)/.test(text)) inferredRelationship = "PARENT";
- else if (/(爱人|配偶|丈夫|妻子|老公|老婆)/.test(text)) inferredRelationship = "SPOUSE";
- else if (/(孩子|子女|儿子|女儿)/.test(text)) inferredRelationship = "CHILD";
- else if (/(本人|自己|我想给我|为自己)/.test(text)) inferredRelationship = "SELF";
- return {
- age: age !== undefined && age >= 0 && age <= 120 ? age : undefined,
- relationship: inferredRelationship,
- };
- }
- function maskIdNo(value: string) {
- if (value.length < 10) return value;
- return `${value.slice(0, 6)}********${value.slice(-4)}`;
- }
- function createImageCode() {
- const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
- const bytes = new Uint8Array(4);
- crypto.getRandomValues(bytes);
- imageCode.value = Array.from(
- bytes,
- (value) => alphabet[value % alphabet.length],
- ).join("");
- imageCodeInput.value = "";
- }
- function startCodeCountdown() {
- if (countdownTimer) clearInterval(countdownTimer);
- codeCountdown.value = 60;
- countdownTimer = setInterval(() => {
- codeCountdown.value -= 1;
- if (codeCountdown.value <= 0 && countdownTimer) {
- clearInterval(countdownTimer);
- countdownTimer = undefined;
- }
- }, 1000);
- }
- function validateMobile() {
- if (!/^1\d{10}$/.test(mobile.value)) {
- error.value = "请输入正确的11位手机号";
- return false;
- }
- return true;
- }
- // ---------- HTTP 请求与登录态 ----------
- // authorizedFetch 会自动附带 Token;遇到登录过期时只尝试刷新一次。
- async function apiFetch(path: string, init: RequestInit = {}) {
- return fetch(`${apiBase}${path}`, {
- ...init,
- credentials: "include",
- });
- }
- async function responseError(response: Response, fallback: string) {
- try {
- const body = await response.json();
- return body.error?.message ?? fallback;
- } catch {
- return fallback;
- }
- }
- async function authorizedFetch(path: string, init: RequestInit = {}, allowRefresh = true) {
- const headers = new Headers(init.headers);
- headers.set("Authorization", `Bearer ${token.value}`);
- const response = await apiFetch(path, { ...init, headers });
- if (response.status === 401 && allowRefresh && (await refreshAccessToken())) {
- return authorizedFetch(path, init, false);
- }
- return response;
- }
- async function requestCode() {
- error.value = "";
- codeMessage.value = "";
- if (!validateMobile()) return;
- if (
- imageCodeInput.value.trim().toUpperCase() !== imageCode.value.toUpperCase()
- ) {
- error.value = "图形验证码不正确,请重新输入";
- createImageCode();
- return;
- }
- loading.value = true;
- try {
- const response = await apiFetch("/h5/auth/request-code", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ mobile: mobile.value }),
- });
- if (!response.ok) {
- throw new Error(await responseError(response, "验证码请求失败"));
- }
- const body = await response.json();
- const validMinutes = Math.max(1, Math.floor(body.data.expires_in / 60));
- codeRequested.value = true;
- codeMessage.value = `验证码已发送,${validMinutes}分钟内有效`;
- startCodeCountdown();
- } catch (reason) {
- error.value = reason instanceof Error ? reason.message : "请求失败";
- createImageCode();
- } finally {
- loading.value = false;
- }
- }
- async function login() {
- error.value = "";
- if (!validateMobile()) return;
- if (!codeRequested.value) {
- error.value = "请先获取短信验证码";
- return;
- }
- if (!/^\d{6}$/.test(code.value)) {
- error.value = "请输入6位短信验证码";
- return;
- }
- if (!agreementAccepted.value) {
- error.value = "请阅读并同意服务协议与隐私政策";
- return;
- }
- loading.value = true;
- try {
- const response = await apiFetch("/h5/auth/login", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- mobile: mobile.value,
- code: code.value,
- referral_code: referralCode || undefined,
- }),
- });
- if (!response.ok) {
- throw new Error(await responseError(response, "登录失败"));
- }
- const body = await response.json();
- token.value = body.data.tokens.access_token;
- mobile.value = body.data.user.mobile;
- contactMobile.value = body.data.user.mobile;
- code.value = "";
- codeMessage.value = "";
- await loadProducts();
- await loadBusiness();
- } catch (reason) {
- error.value = reason instanceof Error ? reason.message : "请求失败";
- } finally {
- loading.value = false;
- }
- }
- async function refreshAccessToken() {
- const response = await apiFetch("/h5/auth/refresh", { method: "POST" });
- if (!response.ok) {
- token.value = "";
- products.value = [];
- return false;
- }
- const body = await response.json();
- token.value = body.data.tokens.access_token;
- mobile.value = body.data.user.mobile;
- contactMobile.value = body.data.user.mobile;
- return true;
- }
- async function loadProducts(allowRefresh = true) {
- const response = await apiFetch("/h5/products", {
- headers: { Authorization: `Bearer ${token.value}` },
- });
- if (response.status === 401 && allowRefresh && (await refreshAccessToken())) {
- return loadProducts(false);
- }
- if (!response.ok) {
- if (response.status === 401) token.value = "";
- throw new Error(await responseError(response, "产品加载失败"));
- }
- const body = await response.json();
- products.value = body.data.items;
- }
- // ---------- 客户保障顾问 Agent ----------
- // 先创建/复用会话,再通过 SSE 接收运行开始、工具完成、UI 就绪和最终回答。
- async function sendAgentMessage() {
- const text = agentInput.value.trim();
- if (!text || agentLoading.value) return;
- agentLoading.value = true;
- agentProgress.value = "正在理解保障需求";
- error.value = "";
- agentMessages.value.push({ role: "user", text });
- agentInput.value = "";
- try {
- if (!agentThreadId.value) {
- const threadResponse = await authorizedFetch("/agent/threads", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: "智能投保咨询" }),
- });
- if (!threadResponse.ok) {
- throw new Error(await responseError(threadResponse, "创建咨询会话失败"));
- }
- agentThreadId.value = (await threadResponse.json()).data.id;
- }
- const response = await authorizedFetch(
- `/agent/threads/${agentThreadId.value}/messages/stream`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- content: { type: "text", text },
- client_message_id: crypto.randomUUID(),
- }),
- },
- );
- if (!response.ok) {
- throw new Error(await responseError(response, "智能体响应失败"));
- }
- const data = await consumeAgentStream(response);
- const assistantText = data.assistant_message.text as string;
- const cards = (data.assistant_message.cards ?? []) as Array<{
- type: string;
- items?: Product[];
- }>;
- const cardItems = cards
- .filter((card) => card.type === "product_recommendations")
- .flatMap((card) => card.items ?? []);
- const actions = (data.assistant_message.actions ?? []) as AgentAction[];
- agentMessages.value.push({
- role: "assistant",
- text: assistantText,
- html: renderAgentMarkdown(assistantText),
- enrollmentContext: inferEnrollmentContext(text),
- products: cardItems.slice(0, 2),
- actions,
- });
- serviceNo.value = data.service_no ?? "";
- } catch (reason) {
- if (!agentInput.value.trim()) agentInput.value = text;
- error.value = reason instanceof Error ? reason.message : "智能体响应失败";
- } finally {
- agentLoading.value = false;
- agentProgress.value = "";
- }
- }
- async function consumeAgentStream(response: Response) {
- if (!response.body) throw new Error("浏览器不支持流式响应");
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- let completed: Record<string, any> | null = null;
- while (true) {
- const { done, value } = await reader.read();
- buffer += decoder.decode(value, { stream: !done });
- const packets = buffer.split("\n\n");
- buffer = packets.pop() ?? "";
- for (const packet of packets) {
- const dataLine = packet
- .split("\n")
- .find((line) => line.startsWith("data: "));
- if (!dataLine) continue;
- const event = JSON.parse(dataLine.slice(6)) as RuntimeEvent;
- if (event.event === "tool.completed") {
- agentProgress.value = "正在核验产品与业务规则";
- } else if (event.event === "ui.ready") {
- agentProgress.value = "正在生成可操作的保障方案";
- } else if (event.event === "run.failed") {
- throw new Error(String(event.data.message ?? "智能体响应失败"));
- } else if (event.event === "run.completed") {
- completed = event.data.result as Record<string, any>;
- }
- }
- if (done) break;
- }
- if (!completed) throw new Error("智能体运行未正常结束");
- return completed;
- }
- // Action 不是模型生成的任意链接,而是后端 Harness 返回的受控动作。
- async function executeAgentAction(
- action: AgentAction,
- context?: ChatMessage["enrollmentContext"],
- ) {
- if (action.type === "open_orders" || action.type === "open_policies") {
- activeTab.value = "coverage";
- await loadBusiness();
- return;
- }
- if (action.type !== "open_enrollment") return;
- const productCode =
- typeof action.payload.product_code === "string"
- ? action.payload.product_code
- : "";
- const product = products.value.find(
- (item) => item.product_code === productCode,
- );
- if (!product) {
- error.value = "推荐产品已不在当前在售目录,请重新咨询";
- return;
- }
- const age =
- typeof action.payload.age === "number"
- ? action.payload.age
- : context?.age;
- const rawRelationship = action.payload.relationship;
- const actionRelationship = (
- ["SELF", "PARENT", "SPOUSE", "CHILD"] as const
- ).find((item) => item === rawRelationship);
- await startDirectEnrollment(product, {
- age,
- relationship: actionRelationship ?? context?.relationship,
- });
- }
- function viewProduct(product: Product) {
- selectedProduct.value = product;
- activeTab.value = "plans";
- window.scrollTo({ top: 0, behavior: "smooth" });
- }
- async function startDirectEnrollment(
- product: Product,
- context?: ChatMessage["enrollmentContext"],
- ) {
- selectedProduct.value = product;
- if (context?.age !== undefined) insuredAge.value = context.age;
- if (context?.relationship) relationship.value = context.relationship;
- quote.value = null;
- resetEnrollmentFlow();
- preQuoteConfirming.value = true;
- enrollmentOpen.value = true;
- error.value = "";
- await nextTick();
- document
- .querySelector<HTMLElement>(".enrollment-body")
- ?.scrollTo({ top: 0 });
- }
- async function confirmDirectEnrollment() {
- if (!selectedProduct.value) return;
- if (
- !Number.isInteger(insuredAge.value) ||
- insuredAge.value < 0 ||
- insuredAge.value > 120
- ) {
- error.value = "请输入0至120之间的有效年龄";
- return;
- }
- await createQuote(selectedProduct.value);
- }
- function openPolicyDocument(item: Policy) {
- openedPolicy.value = item;
- }
- function closePolicyDocument() {
- openedPolicy.value = null;
- }
- function printPolicyDocument() {
- window.print();
- }
- function resetEnrollmentFlow() {
- enrollmentStep.value = 1;
- noticeAccepted.value = false;
- disclosureConfirmed.value = false;
- order.value = null;
- payment.value = null;
- policy.value = null;
- }
- // ---------- 确定性投保流程 ----------
- // 报价、草稿、确认、订单、支付和出单都调用普通业务接口,不由模型直接改状态。
- async function setEnrollmentStep(step: number) {
- enrollmentStep.value = step;
- await nextTick();
- document
- .querySelector<HTMLElement>(".enrollment-body")
- ?.scrollTo({ top: 0, behavior: "smooth" });
- }
- async function createQuote(product: Product) {
- businessLoading.value = true;
- quotingProductId.value = product.product_id;
- quoteFeedback.value = "正在校验投保资格并试算保费…";
- error.value = "";
- try {
- const response = await authorizedFetch("/h5/quotes", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- product_id: product.product_id,
- plan_id: product.plans[0].id,
- insured: {
- age: insuredAge.value,
- region_code: "510100",
- occupation_code: "GENERAL",
- },
- relationship: relationship.value,
- }),
- });
- if (!response.ok) throw new Error(await responseError(response, "报价失败"));
- selectedProduct.value = product;
- quote.value = (await response.json()).data;
- resetEnrollmentFlow();
- preQuoteConfirming.value = false;
- enrollmentOpen.value = true;
- quoteFeedback.value = "";
- await setEnrollmentStep(1);
- } catch (reason) {
- error.value = reason instanceof Error ? reason.message : "报价失败";
- quoteFeedback.value = error.value;
- } finally {
- businessLoading.value = false;
- quotingProductId.value = "";
- }
- }
- function validateEnrollmentPeople() {
- error.value = "";
- if (!applicantName.value.trim() || !insuredName.value.trim()) {
- error.value = "请填写投保人和被保人姓名";
- return false;
- }
- const idPattern = /^\d{17}[\dXx]$/;
- if (
- !idPattern.test(applicantIdNo.value) ||
- !idPattern.test(insuredIdNo.value)
- ) {
- error.value = "请输入正确的18位身份证号码";
- return false;
- }
- if (!/^1\d{10}$/.test(contactMobile.value)) {
- error.value = "请输入正确的11位联系手机号";
- return false;
- }
- return true;
- }
- async function goToNotice() {
- if (!validateEnrollmentPeople()) return;
- error.value = "";
- await setEnrollmentStep(2);
- }
- async function goToConfirmation() {
- error.value = "";
- if (!noticeAccepted.value || !disclosureConfirmed.value) {
- error.value = "请阅读投保须知并完成两项确认";
- return;
- }
- await setEnrollmentStep(3);
- }
- async function confirmAndCreateOrder() {
- if (!quote.value) return;
- businessLoading.value = true;
- error.value = "";
- try {
- const draftResponse = await authorizedFetch("/h5/enrollment-drafts", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- quote_id: quote.value.quote_id,
- applicant: {
- name: applicantName.value.trim(),
- id_no: applicantIdNo.value.toUpperCase(),
- },
- insured: {
- name: insuredName.value.trim(),
- id_no: insuredIdNo.value.toUpperCase(),
- },
- contact: { mobile: contactMobile.value },
- }),
- });
- if (!draftResponse.ok) {
- throw new Error(await responseError(draftResponse, "投保草稿创建失败"));
- }
- const draft = (await draftResponse.json()).data;
- const confirmationResponse = await authorizedFetch(
- `/h5/enrollment-drafts/${draft.draft_id}/confirmation`,
- { method: "POST" },
- );
- if (!confirmationResponse.ok) {
- throw new Error(await responseError(confirmationResponse, "确认失败"));
- }
- const confirmation = (await confirmationResponse.json()).data;
- const orderResponse = await authorizedFetch("/h5/orders", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "Idempotency-Key": crypto.randomUUID(),
- },
- body: JSON.stringify({
- draft_id: draft.draft_id,
- confirmation_token: confirmation.confirmation_token,
- }),
- });
- if (!orderResponse.ok) {
- throw new Error(await responseError(orderResponse, "订单创建失败"));
- }
- order.value = (await orderResponse.json()).data;
- const paymentCreated = await startPayment();
- if (paymentCreated) await setEnrollmentStep(4);
- } catch (reason) {
- error.value = reason instanceof Error ? reason.message : "订单创建失败";
- } finally {
- businessLoading.value = false;
- }
- }
- async function startPayment() {
- if (!order.value) return false;
- businessLoading.value = true;
- error.value = "";
- try {
- const response = await authorizedFetch(
- `/h5/orders/${order.value.order_id}/payments`,
- {
- method: "POST",
- headers: { "Idempotency-Key": crypto.randomUUID() },
- },
- );
- if (!response.ok) throw new Error(await responseError(response, "支付发起失败"));
- payment.value = (await response.json()).data;
- return true;
- } catch (reason) {
- error.value = reason instanceof Error ? reason.message : "支付发起失败";
- return false;
- } finally {
- businessLoading.value = false;
- }
- }
- async function completePayment() {
- if (!payment.value) return;
- businessLoading.value = true;
- error.value = "";
- try {
- const response = await authorizedFetch(
- `/dev/mock-payments/${payment.value.payment_id}/complete`,
- { method: "POST" },
- );
- if (!response.ok) throw new Error(await responseError(response, "支付回调失败"));
- const body = await response.json();
- payment.value.status = body.data.payment_status;
- if (order.value) order.value.status = body.data.order_status;
- policy.value = body.data.policy;
- await loadBusiness();
- } catch (reason) {
- error.value = reason instanceof Error ? reason.message : "支付回调失败";
- } finally {
- businessLoading.value = false;
- }
- }
- async function closeEnrollment() {
- enrollmentOpen.value = false;
- preQuoteConfirming.value = false;
- error.value = "";
- await loadBusiness();
- }
- async function viewCoverageAfterPayment() {
- enrollmentOpen.value = false;
- activeTab.value = "coverage";
- await loadBusiness();
- window.scrollTo({ top: 0, behavior: "smooth" });
- }
- async function loadBusiness() {
- if (!token.value) return;
- const [ordersResponse, policiesResponse] = await Promise.all([
- authorizedFetch("/h5/orders"),
- authorizedFetch("/h5/policies"),
- ]);
- if (ordersResponse.ok) {
- const items = (await ordersResponse.json()).data.items as Order[];
- order.value = items[0] ?? null;
- }
- if (policiesResponse.ok) {
- const items = (await policiesResponse.json()).data.items as Policy[];
- policies.value = items;
- }
- }
- async function refreshProducts() {
- refreshing.value = true;
- error.value = "";
- try {
- await loadProducts();
- } catch (reason) {
- error.value = reason instanceof Error ? reason.message : "产品加载失败";
- } finally {
- refreshing.value = false;
- }
- }
- async function logout() {
- try {
- if (token.value) {
- await apiFetch("/h5/auth/logout", {
- method: "POST",
- headers: { Authorization: `Bearer ${token.value}` },
- });
- }
- } finally {
- token.value = "";
- contactMobile.value = "";
- products.value = [];
- error.value = "";
- agentThreadId.value = "";
- agentMessages.value = [];
- serviceNo.value = "";
- activeTab.value = "advisor";
- selectedProduct.value = null;
- quote.value = null;
- order.value = null;
- payment.value = null;
- policy.value = null;
- policies.value = [];
- openedPolicy.value = null;
- quotingProductId.value = "";
- quoteFeedback.value = "";
- enrollmentOpen.value = false;
- preQuoteConfirming.value = false;
- enrollmentStep.value = 1;
- applicantName.value = "";
- applicantIdNo.value = "";
- insuredName.value = "";
- insuredIdNo.value = "";
- relationship.value = "PARENT";
- noticeAccepted.value = false;
- disclosureConfirmed.value = false;
- codeRequested.value = false;
- codeCountdown.value = 0;
- agreementAccepted.value = false;
- if (countdownTimer) {
- clearInterval(countdownTimer);
- countdownTimer = undefined;
- }
- createImageCode();
- }
- }
- // 页面初始化时恢复登录态并加载产品、订单和保单;组件销毁时清理验证码计时器。
- onMounted(async () => {
- createImageCode();
- // 清理早期版本遗留的持久化Access Token,当前版本只在内存中保存。
- localStorage.removeItem("zbt_h5_token");
- try {
- if (await refreshAccessToken()) await loadProducts(false);
- if (token.value) await loadBusiness();
- } catch {
- token.value = "";
- products.value = [];
- } finally {
- restoring.value = false;
- }
- });
- onBeforeUnmount(() => {
- if (countdownTimer) clearInterval(countdownTimer);
- });
- </script>
- <template>
- <main class="phone-shell">
- <section v-if="restoring || !token" class="hero">
- <div class="brand-line"><span class="seal">智</span> 智保通</div>
- <p class="eyebrow">AI 智能保险服务平台</p>
- <h1>把复杂条款,<br /><em>说成一句明白话。</em></h1>
- <div class="hero-orbit" aria-hidden="true"></div>
- </section>
- <section v-if="restoring" class="login-card session-loading" aria-live="polite">
- <p class="step">00 / 会话检查</p>
- <h2>正在恢复安全会话…</h2>
- <p class="hint">仅从安全 Cookie 轮换登录凭证。</p>
- </section>
- <section v-else-if="!token" class="login-card">
- <div class="login-heading">
- <div>
- <p class="step">01 / 安全登录</p>
- <h2>欢迎使用智保通</h2>
- </div>
- <span class="secure-badge"><i></i>安全连接</span>
- </div>
- <p class="login-intro">登录后继续咨询方案、查询订单与管理个人保单。</p>
- <p v-if="referralCode" class="referral-banner">
- <span>邀</span> 已识别专属服务邀请 · {{ referralCode }}
- </p>
- <form class="login-form" @submit.prevent="login">
- <label>
- 手机号
- <span class="field-shell">
- <span class="field-prefix">+86</span>
- <input
- v-model.trim="mobile"
- inputmode="tel"
- autocomplete="tel"
- maxlength="11"
- placeholder="请输入手机号"
- aria-label="手机号"
- />
- </span>
- </label>
- <label>
- 图形验证码
- <span class="field-row">
- <input
- v-model.trim="imageCodeInput"
- class="image-code-input"
- maxlength="4"
- autocomplete="off"
- placeholder="请输入图中字符"
- aria-label="图形验证码"
- />
- <button
- type="button"
- class="captcha"
- aria-label="刷新图形验证码"
- title="看不清?点击换一张"
- @click="createImageCode"
- >
- <span
- v-for="(letter, index) in imageCode"
- :key="`${letter}-${index}`"
- :style="{ transform: `rotate(${index % 2 === 0 ? -8 : 7}deg) translateY(${index % 3 - 1}px)` }"
- >{{ letter }}</span>
- <i></i><i></i>
- </button>
- </span>
- <small class="field-help">看不清?点击图片更换</small>
- </label>
- <label>
- 短信验证码
- <span class="field-row">
- <input
- v-model.trim="code"
- inputmode="numeric"
- autocomplete="one-time-code"
- maxlength="6"
- placeholder="请输入6位验证码"
- aria-label="短信验证码"
- />
- <button
- type="button"
- class="send-code"
- :disabled="!canRequestCode"
- @click="requestCode"
- >
- {{ codeCountdown > 0 ? `${codeCountdown}s 后重发` : "获取验证码" }}
- </button>
- </span>
- </label>
- <label class="agreement">
- <input v-model="agreementAccepted" type="checkbox" />
- <span>我已阅读并同意<a href="#service-agreement">《用户服务协议》</a>和<a href="#privacy-policy">《隐私政策》</a></span>
- </label>
- <p v-if="codeMessage" class="success status-message" aria-live="polite">
- <span>✓</span>{{ codeMessage }}
- </p>
- <p v-if="error" class="error status-message" role="alert">
- <span>!</span>{{ error }}
- </p>
- <button class="login-submit" :disabled="loading" type="submit">
- {{ loading ? "正在安全登录…" : "登录" }}
- <span aria-hidden="true">→</span>
- </button>
- </form>
- <p class="privacy-note"><span>◇</span> 你的个人信息将被加密保护</p>
- </section>
- <div v-else class="app-shell">
- <header class="app-header">
- <div class="compact-brand">
- <span class="seal">智</span>
- <div><strong>智保通</strong><small>懂你的保险顾问</small></div>
- </div>
- <button class="header-action" aria-label="退出登录" @click="logout">
- <svg viewBox="0 0 24 24" aria-hidden="true">
- <path d="M10 4H5v16h5M14 8l4 4-4 4M18 12H9" />
- </svg>
- </button>
- </header>
- <div class="tab-content">
- <section v-if="activeTab === 'advisor'" class="tab-page advisor-page">
- <div class="page-heading">
- <div><p class="step">AI INSURANCE ADVISOR</p><h2>智能顾问</h2></div>
- <span class="online-pill"><i></i>在线</span>
- </div>
- <p class="page-lead">说说你的保障需求,我会查询在售产品并给出适合的候选方案。</p>
- <div class="quick-prompts">
- <button @click="agentInput = '我想给65岁的父亲买医疗险,常住成都,普通职业。'">父母医疗</button>
- <button @click="agentInput = '我想给一家三口配置意外保障,请帮我推荐。'">家庭意外</button>
- <button @click="agentInput = '我想了解基础医疗保障,年龄32岁。'">成人医疗</button>
- </div>
- <div class="agent-panel">
- <div class="agent-head">
- <span class="agent-mark">AI</span>
- <div><strong>你好,我是智保通顾问</strong><small>在线服务 · 安全连接</small></div>
- </div>
- <div class="messages" aria-live="polite">
- <div v-if="agentMessages.length === 0" class="welcome-message">
- <strong>我可以帮你做什么?</strong>
- <p>告诉我为谁投保、年龄、常住地区和职业,我会先理解需求,再推荐方案。</p>
- </div>
- <div
- v-for="(message, index) in agentMessages"
- :key="index"
- :class="['message', message.role]"
- >
- <div class="message-meta">
- <span v-if="message.role === 'assistant'">AI</span>
- <small>{{ message.role === "user" ? "你" : "智保通顾问" }}</small>
- </div>
- <div
- v-if="message.role === 'assistant'"
- class="assistant-content"
- v-html="message.html"
- ></div>
- <p v-else>{{ message.text }}</p>
- <div v-if="message.products?.length" class="recommendation-list">
- <div
- v-for="(product, productIndex) in message.products"
- :key="product.product_id"
- class="recommendation-card"
- >
- <div>
- <span>{{ productIndex === 0 ? "优先推荐" : "备选方案" }}</span>
- <strong>{{ product.name }}</strong>
- <small>{{ product.summary }}</small>
- </div>
- <button @click="startDirectEnrollment(product, message.enrollmentContext)">
- 立即投保 →
- </button>
- </div>
- </div>
- <div
- v-if="message.actions?.length && !message.products?.length"
- class="agent-actions"
- >
- <button
- v-for="action in message.actions.filter((item) => item.type !== 'none')"
- :key="`${action.type}-${action.label}`"
- type="button"
- @click="executeAgentAction(action, message.enrollmentContext)"
- >
- {{ action.label }} <span>→</span>
- </button>
- </div>
- </div>
- </div>
- <div class="agent-composer">
- <textarea
- v-model="agentInput"
- rows="3"
- aria-label="投保需求"
- placeholder="请描述你的投保需求…"
- ></textarea>
- <button
- aria-label="发送消息"
- :disabled="agentLoading || !agentInput.trim()"
- @click="sendAgentMessage"
- >
- <svg viewBox="0 0 24 24" aria-hidden="true">
- <path d="m4 5 16 7-16 7 3-7-3-7Zm3 7h13" />
- </svg>
- </button>
- </div>
- <p v-if="agentLoading" class="thinking"><i></i><i></i><i></i> {{ agentProgress }}</p>
- <p v-if="serviceNo" class="trace">服务流水号 · {{ serviceNo }}</p>
- </div>
- <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
- </section>
- <section v-else-if="activeTab === 'plans'" class="tab-page plans-page">
- <div class="page-heading">
- <div><p class="step">PROTECTION PLANS</p><h2>保障方案</h2></div>
- <button class="refresh-button" :disabled="refreshing" @click="refreshProducts">
- {{ refreshing ? "刷新中" : "刷新" }}
- </button>
- </div>
- <p class="page-lead">先填写被保人的基本情况,再查看确定性保费。</p>
- <div class="quote-conditions">
- <div class="condition-title"><span>01</span><strong>被保人信息</strong></div>
- <label>
- 与投保人关系
- <span class="relation-options">
- <button
- v-for="item in (['SELF', 'PARENT', 'SPOUSE', 'CHILD'] as Relationship[])"
- :key="item"
- :class="{ active: relationship === item }"
- @click="relationship = item"
- >
- {{ relationshipName(item) }}
- </button>
- </span>
- </label>
- <label>
- 被保人年龄
- <input v-model.number="insuredAge" type="number" min="0" max="120" />
- </label>
- <div class="fixed-conditions">
- <span>常住地区:成都市</span><span>职业类别:普通职业</span>
- </div>
- </div>
- <div class="product-list">
- <div
- v-for="(product, index) in displayedProducts"
- :key="product.product_code"
- :class="['product-card', { recommended: selectedProduct?.product_id === product.product_id }]"
- >
- <div class="product-topline">
- <span>{{ String(index + 1).padStart(2, "0") }}</span>
- <em v-if="selectedProduct?.product_id === product.product_id">顾问推荐</em>
- </div>
- <h3>{{ product.name }}</h3>
- <p>{{ product.summary }}</p>
- <div class="plans">
- <span v-for="plan in product.plans" :key="plan.code">{{ plan.name }}</span>
- </div>
- <ul class="benefit-list">
- <li>电子保单即时查询</li>
- <li>支持家庭成员投保</li>
- <li>保费由规则引擎确定</li>
- </ul>
- <button
- class="product-action"
- :disabled="businessLoading"
- :aria-busy="quotingProductId === product.product_id"
- @click="createQuote(product)"
- >
- {{ quotingProductId === product.product_id ? "正在试算…" : "测算保费并投保" }} <span>→</span>
- </button>
- </div>
- </div>
- <div v-if="quoteFeedback" :class="['quote-feedback', { failed: !businessLoading }]">
- <span>{{ businessLoading ? "···" : "!" }}</span>{{ quoteFeedback }}
- </div>
- <p v-if="products.length === 0" class="empty-state">暂时没有可投保产品。</p>
- <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
- </section>
- <section v-else class="tab-page coverage-page">
- <div class="page-heading">
- <div><p class="step">MY PROTECTION</p><h2>我的保障</h2></div>
- <span class="record-count">{{ policies.length > 0 ? `${policies.length}张保单` : "暂无保单" }}</span>
- </div>
- <p class="page-lead">集中查看投保订单与已经生效的保障。</p>
- <div v-if="policies.length > 0" class="policy-list">
- <article v-for="(item, index) in policies" :key="item.policy_id" class="policy-summary">
- <div class="policy-glow"></div>
- <div class="policy-heading">
- <span>电子保单 · {{ String(index + 1).padStart(2, "0") }}</span>
- <em>{{ policyStatus(item.status) }}</em>
- </div>
- <h3>{{ item.product_name }}</h3>
- <p class="policy-plan">{{ item.plan_name }}</p>
- <p class="policy-number">{{ item.policy_no }}</p>
- <div class="policy-data">
- <div><small>年度保费</small><strong>{{ money(item.premium_cents) }}</strong></div>
- <div><small>保障期限</small><strong>{{ item.coverage_start }} 至 {{ item.coverage_end }}</strong></div>
- </div>
- <button type="button" @click="openPolicyDocument(item)">
- 查看电子保单 <span>→</span>
- </button>
- </article>
- </div>
- <div v-if="!order && policies.length === 0" class="coverage-empty">
- <span class="empty-shield">◇</span>
- <h3>还没有保障记录</h3>
- <p>先和智能顾问聊聊,找到适合你的保障方案。</p>
- <button @click="activeTab = 'advisor'">咨询智能顾问</button>
- </div>
- <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
- </section>
- </div>
- <nav class="bottom-nav" aria-label="主导航">
- <button :class="{ active: activeTab === 'advisor' }" @click="activeTab = 'advisor'">
- <svg viewBox="0 0 24 24" aria-hidden="true">
- <path d="M7 8h10M7 12h7M8 18l-4 2 1-4a8 8 0 1 1 3 2Z" />
- </svg>
- <span>智能顾问</span>
- </button>
- <button :class="{ active: activeTab === 'plans' }" @click="activeTab = 'plans'">
- <svg viewBox="0 0 24 24" aria-hidden="true">
- <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" />
- </svg>
- <span>保障方案</span>
- </button>
- <button :class="{ active: activeTab === 'coverage' }" @click="activeTab = 'coverage'">
- <svg viewBox="0 0 24 24" aria-hidden="true">
- <path d="M7 3h8l4 4v14H7V3Zm8 0v5h4M10 12h6M10 16h6" />
- </svg>
- <span>我的保障</span>
- </button>
- </nav>
- <div v-if="enrollmentOpen" class="enrollment-layer" role="dialog" aria-modal="true">
- <header class="enrollment-header">
- <button aria-label="关闭投保流程" @click="closeEnrollment">×</button>
- <div>
- <span>{{ preQuoteConfirming ? "投保前确认" : "投保流程" }}</span>
- <strong>{{ selectedProduct?.name ?? "保障计划" }}</strong>
- </div>
- <small>{{ preQuoteConfirming ? "准备投保" : `${enrollmentStep}/4` }}</small>
- </header>
- <div v-if="!preQuoteConfirming" class="progress-track">
- <span v-for="stepNo in 4" :key="stepNo" :class="{ active: stepNo <= enrollmentStep }"></span>
- </div>
- <div v-else class="direct-entry-line"></div>
- <div class="enrollment-body">
- <section v-if="preQuoteConfirming" class="enrollment-step direct-enrollment-step">
- <p class="step">DIRECT ENROLLMENT</p>
- <h2>确认投保条件</h2>
- <p class="step-description">顾问已经为你选好方案,确认以下信息后即可测算保费并继续投保。</p>
- <div class="direct-product">
- <div>
- <span>顾问推荐</span>
- <h3>{{ selectedProduct?.name }}</h3>
- <p>{{ selectedProduct?.summary }}</p>
- </div>
- <em>已选择</em>
- </div>
- <div class="form-section direct-condition-form">
- <div class="form-section-title"><span>被保人情况</span><small>用于资格校验和保费测算</small></div>
- <label>
- 与投保人关系
- <span class="relation-options">
- <button
- v-for="item in (['SELF', 'PARENT', 'SPOUSE', 'CHILD'] as Relationship[])"
- :key="item"
- type="button"
- :class="{ active: relationship === item }"
- @click="relationship = item"
- >
- {{ relationshipName(item) }}
- </button>
- </span>
- </label>
- <label>
- 被保人年龄
- <input v-model.number="insuredAge" type="number" min="0" max="120" inputmode="numeric" />
- </label>
- <div class="fixed-conditions">
- <span>常住地区:成都市</span><span>职业类别:普通职业</span>
- </div>
- </div>
- <div class="direct-entry-note">
- <span>AI</span>
- <p>以上信息已根据你的咨询自动带入,请确认无误。最终是否可投保及保费以本次系统测算结果为准。</p>
- </div>
- <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
- <button
- class="flow-primary"
- :disabled="businessLoading"
- @click="confirmDirectEnrollment"
- >
- {{ businessLoading ? "正在测算保费…" : "确认并开始投保" }} <span>→</span>
- </button>
- </section>
- <section v-else-if="enrollmentStep === 1" class="enrollment-step">
- <p class="step">STEP 01</p>
- <h2>填写投保信息</h2>
- <p class="step-description">请确认信息真实、准确,这些信息将写入投保订单。</p>
- <div class="quote-mini">
- <div><span>{{ selectedProduct?.name }}</span><small>{{ relationshipName(relationship) }} · {{ insuredAge }}岁</small></div>
- <strong>{{ quote ? money(quote.premium_cents) : "—" }}</strong>
- </div>
- <div class="form-section">
- <div class="form-section-title"><span>投保人</span><small>负责缴费和接收通知</small></div>
- <label>姓名<input v-model.trim="applicantName" autocomplete="name" placeholder="请输入真实姓名" /></label>
- <label>身份证号<input v-model.trim="applicantIdNo" maxlength="18" placeholder="请输入18位身份证号" /></label>
- <label>联系手机<input v-model.trim="contactMobile" inputmode="tel" maxlength="11" placeholder="请输入接收通知的手机号" /><small class="input-help">默认使用登录手机号,可修改</small></label>
- </div>
- <div class="form-section">
- <div class="form-section-title"><span>被保人</span><small>实际享受保障的人</small></div>
- <label>与投保人关系
- <select v-model="relationship">
- <option value="SELF">本人</option><option value="PARENT">父母</option>
- <option value="SPOUSE">配偶</option><option value="CHILD">子女</option>
- </select>
- </label>
- <label>姓名<input v-model.trim="insuredName" placeholder="请输入真实姓名" /></label>
- <label>身份证号<input v-model.trim="insuredIdNo" maxlength="18" placeholder="请输入18位身份证号" /></label>
- </div>
- <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
- <button class="flow-primary" @click="goToNotice">下一步:阅读投保须知 <span>→</span></button>
- </section>
- <section v-else-if="enrollmentStep === 2" class="enrollment-step notice-step">
- <p class="step">STEP 02</p>
- <h2>投保须知</h2>
- <p class="step-description">这些内容会影响承保和理赔,请完整阅读后再确认。</p>
- <div class="notice-document">
- <header><span>智保通投保须知</span><small>适用于 {{ selectedProduct?.name }}</small></header>
- <div>
- <h3>一、投保资格</h3>
- <p>被保人应符合产品约定的年龄、常住地区和职业范围。本次试算结果仅对当前填写信息有效。</p>
- <h3>二、如实告知</h3>
- <p>投保人应如实填写身份及健康相关信息。故意或因重大过失未履行如实告知义务,可能影响合同效力及理赔结果。</p>
- <h3>三、保障生效</h3>
- <p>支付成功并完成承保后生成电子保单,具体保障责任、等待期、免赔额和责任限额以电子保单及保险条款为准。</p>
- <h3>四、责任免除</h3>
- <p>既往症、违法行为、故意伤害以及条款明确列示的其他情形可能不在保障范围内,请重点阅读责任免除部分。</p>
- <h3>五、信息与退保</h3>
- <p>投保信息将用于承保、保全和服务处理。退保规则、可退金额及生效后的处理方式以保险条款和后续服务流程为准。</p>
- </div>
- </div>
- <label class="notice-check">
- <input v-model="noticeAccepted" type="checkbox" />
- <span>我已阅读并理解《投保须知》《保险条款》和《责任免除》</span>
- </label>
- <label class="notice-check">
- <input v-model="disclosureConfirmed" type="checkbox" />
- <span>我确认投保信息真实、完整,并同意进行电子投保</span>
- </label>
- <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
- <div class="flow-actions">
- <button class="flow-back" @click="setEnrollmentStep(1)">上一步</button>
- <button class="flow-primary" @click="goToConfirmation">同意并继续 <span>→</span></button>
- </div>
- </section>
- <section v-else-if="enrollmentStep === 3" class="enrollment-step confirmation-step">
- <p class="step">STEP 03</p>
- <h2>确认投保订单</h2>
- <p class="step-description">请最后核对保障方案和人员信息。</p>
- <div class="confirm-product">
- <span>保障方案</span>
- <h3>{{ selectedProduct?.name }}</h3>
- <p>{{ relationshipName(relationship) }} · {{ insuredAge }}岁 · 成都市 · 普通职业</p>
- <strong>{{ quote ? money(quote.premium_cents) : "—" }}<small>/年</small></strong>
- </div>
- <dl class="confirm-list">
- <div><dt>投保人</dt><dd>{{ applicantName }}</dd></div>
- <div><dt>投保人证件</dt><dd>{{ maskIdNo(applicantIdNo) }}</dd></div>
- <div><dt>被保人</dt><dd>{{ insuredName }}</dd></div>
- <div><dt>被保人证件</dt><dd>{{ maskIdNo(insuredIdNo) }}</dd></div>
- <div><dt>联系电话</dt><dd>{{ contactMobile }}</dd></div>
- </dl>
- <div class="confirmed-notice"><span>✓</span>已阅读并同意投保须知与责任免除</div>
- <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
- <div class="flow-actions">
- <button class="flow-back" @click="setEnrollmentStep(2)">上一步</button>
- <button class="flow-primary" :disabled="businessLoading" @click="confirmAndCreateOrder">
- {{ businessLoading ? "正在创建订单…" : `确认投保并支付 ${quote ? money(quote.premium_cents) : ""}` }}
- </button>
- </div>
- </section>
- <section v-else class="enrollment-step payment-step">
- <template v-if="!policy">
- <p class="step">STEP 04</p>
- <h2>安全支付</h2>
- <p class="step-description">确认支付后将立即完成承保并生成电子保单。</p>
- <div class="payment-amount">
- <span>应付金额</span>
- <strong>{{ order ? money(order.amount_cents) : quote ? money(quote.premium_cents) : "—" }}</strong>
- <small>{{ selectedProduct?.name ?? "智保通保障计划" }}</small>
- </div>
- <div class="payment-channel">
- <span class="channel-icon">¥</span>
- <div><strong>快捷支付</strong><small>支付过程由安全通道保护</small></div>
- <em>已选择</em>
- </div>
- <p v-if="order" class="payment-order">订单号 {{ order.order_no }}</p>
- <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
- <button
- class="flow-primary pay-button"
- :disabled="businessLoading || !payment"
- @click="completePayment"
- >
- {{ businessLoading ? "正在确认支付…" : payment ? "确认支付" : "正在准备支付…" }}
- </button>
- </template>
- <template v-else>
- <div class="success-symbol"><span>✓</span></div>
- <p class="step success-step">ENROLLMENT COMPLETED</p>
- <h2>投保成功</h2>
- <p class="step-description">电子保单已经生成,保障信息已保存到“我的保障”。</p>
- <div class="success-policy">
- <span>电子保单号</span><strong>{{ policy.policy_no }}</strong>
- <div><small>保障状态</small><em>{{ policyStatus(policy.status) }}</em></div>
- <div><small>保障期限</small><p>{{ policy.coverage_start }} 至 {{ policy.coverage_end }}</p></div>
- </div>
- <button class="flow-primary" @click="viewCoverageAfterPayment">查看我的保障 <span>→</span></button>
- </template>
- </section>
- </div>
- </div>
- <div
- v-if="openedPolicy"
- class="policy-document-layer"
- role="dialog"
- aria-modal="true"
- aria-label="电子保单"
- @click.self="closePolicyDocument"
- >
- <article class="policy-document">
- <header class="policy-document-header">
- <button type="button" aria-label="关闭电子保单" @click="closePolicyDocument">×</button>
- <div><span>智保通</span><small>电子保险凭证</small></div>
- <em>{{ policyStatus(openedPolicy.status) }}</em>
- </header>
- <section class="policy-document-hero">
- <p>POLICY CERTIFICATE</p>
- <h2>{{ openedPolicy.product_name }}</h2>
- <span>{{ openedPolicy.plan_name }}</span>
- <div><small>电子保单号</small><strong>{{ openedPolicy.policy_no }}</strong></div>
- </section>
- <section class="policy-document-body">
- <div class="document-section-title"><span>01</span><h3>合同信息</h3></div>
- <dl class="document-fields">
- <div><dt>保单状态</dt><dd class="active-status">{{ policyStatus(openedPolicy.status) }}</dd></div>
- <div><dt>订单编号</dt><dd>{{ openedPolicy.order_no }}</dd></div>
- <div><dt>签发时间</dt><dd>{{ dateTime(openedPolicy.issued_at) }}</dd></div>
- <div><dt>年度保费</dt><dd>{{ money(openedPolicy.premium_cents) }}</dd></div>
- <div class="wide-field"><dt>保障期限</dt><dd>{{ openedPolicy.coverage_start }} 至 {{ openedPolicy.coverage_end }}</dd></div>
- </dl>
- <div class="document-section-title"><span>02</span><h3>被保险人信息</h3></div>
- <dl class="document-fields">
- <div><dt>投保人</dt><dd>{{ openedPolicy.applicant.name ?? "—" }}</dd></div>
- <div><dt>投保人证件</dt><dd>{{ maskIdNo(openedPolicy.applicant.id_no ?? "") || "—" }}</dd></div>
- <div><dt>被保人</dt><dd>{{ openedPolicy.insured.name ?? "—" }}</dd></div>
- <div><dt>被保人证件</dt><dd>{{ maskIdNo(openedPolicy.insured.id_no ?? "") || "—" }}</dd></div>
- </dl>
- <div class="policy-document-note">
- <span>i</span>
- <p>本电子凭证记录本次承保结果。具体保障责任、责任免除、等待期及赔付限额以保险条款和投保须知为准。</p>
- </div>
- <div class="policy-seal"><span>智保通</span><small>电子承保凭证</small></div>
- <button type="button" class="document-print" @click="printPolicyDocument">打印 / 保存为 PDF</button>
- </section>
- </article>
- </div>
- </div>
- </main>
- </template>
|