| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696 |
- <script setup lang="ts">
- import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
- 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"
- | "open_surrenders"
- | "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 KnowledgeSource = {
- document_id: string;
- document_version: number;
- title: string;
- source_name: string;
- excerpt: string;
- excerpts?: string[];
- score: number;
- };
- function normalizeKnowledgeSources(sources: KnowledgeSource[]): KnowledgeSource[] {
- const grouped = new Map<string, KnowledgeSource>();
- for (const source of sources) {
- const key = `${source.document_id}:${source.document_version}`;
- const excerpts = source.excerpts?.length
- ? source.excerpts
- : [source.excerpt];
- const existing = grouped.get(key);
- if (!existing) {
- const normalizedExcerpts = [...new Set(excerpts.filter(Boolean))].slice(0, 2);
- grouped.set(key, {
- ...source,
- excerpt: normalizedExcerpts[0] ?? source.excerpt,
- excerpts: normalizedExcerpts,
- });
- continue;
- }
- const mergedExcerpts = [
- ...(existing.excerpts ?? [existing.excerpt]),
- ...excerpts,
- ];
- existing.excerpts = [...new Set(mergedExcerpts.filter(Boolean))].slice(0, 2);
- existing.excerpt = existing.excerpts[0] ?? existing.excerpt;
- existing.score = Math.max(existing.score, source.score);
- }
- return [...grouped.values()];
- }
- type ChatMessage = {
- role: "user" | "assistant";
- text: string;
- html?: string;
- products?: Product[];
- sources?: KnowledgeSource[];
- actions?: AgentAction[];
- enrollmentContext?: {
- age?: number;
- relationship?: Relationship;
- };
- };
- type AgentThreadSummary = {
- id: string;
- title: string;
- status: string;
- message_count: number;
- last_message: {
- role: "USER" | "ASSISTANT";
- content: string;
- created_at: string;
- } | null;
- created_at: string;
- updated_at: string;
- };
- type MainTab = "advisor" | "plans" | "coverage" | "services";
- 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;
- };
- type MaterialRecognition = {
- recognition_id: string;
- policy_id: string;
- material_type: string;
- source_name: string;
- fields: Record<string, string>;
- warnings: string[];
- status: string;
- created_at: string;
- };
- type ServiceRequestItem = {
- request_id: string;
- request_no: string;
- policy_id: string;
- request_type: string;
- recognition_id: string;
- confirmed_fields: Record<string, string>;
- status: string;
- submitted_at: string;
- reviewed_by?: string | null;
- review_note?: string | null;
- reviewed_at?: string | null;
- };
- type SurrenderRequestItem = {
- request_id: string;
- request_no: string;
- policy_id: string;
- reason: string;
- status: string;
- refund_amount_cents: number;
- currency: string;
- calculation?: {
- pre_effective?: boolean;
- cooling_off?: boolean;
- elapsed_days?: number;
- remaining_days?: number;
- gross_unearned_cents?: number;
- deduction_cents?: number;
- };
- risk_level?: string | null;
- risk_reasons?: string[];
- created_at: string;
- confirmed_at?: string | null;
- review_note?: string | null;
- };
- type SurrenderPreview = {
- policy_id: string;
- policy_no: string;
- eligible: boolean;
- reason_code: string;
- reason: string;
- refund_amount_cents: number;
- refund_basis?:
- | "PRE_EFFECTIVE_FULL_REFUND"
- | "COOLING_OFF_FULL_REFUND"
- | "UNEARNED_PREMIUM_WITH_DEDUCTION";
- refund_explanation?: string;
- currency: string;
- rule_version: string;
- calculation: {
- premium_cents: number;
- total_days: number;
- elapsed_days: number;
- remaining_days: number;
- gross_unearned_cents: number;
- deduction_cents: number;
- cooling_off: boolean;
- pre_effective: boolean;
- };
- };
- 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 requestingCode = ref(false);
- const loggingIn = ref(false);
- const refreshing = ref(false);
- const restoring = ref(true);
- const agentInput = ref("我想给65岁的父亲买医疗险,常住成都,普通职业。");
- const agentThreadId = ref("");
- const agentMessages = ref<ChatMessage[]>([]);
- const agentThreads = ref<AgentThreadSummary[]>([]);
- const conversationHistoryOpen = ref(false);
- const conversationHistoryLoading = ref(false);
- const agentLoading = ref(false);
- const agentProgress = ref("");
- const serviceNo = ref("");
- const activeTab = ref<MainTab>("advisor");
- const coverageSection = ref<"policies" | "surrenders">("policies");
- 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 serviceRequests = ref<ServiceRequestItem[]>([]);
- const surrenderRequests = ref<SurrenderRequestItem[]>([]);
- const surrenderApplicationOpen = ref(false);
- const surrenderLoading = ref(false);
- const surrenderError = ref("");
- const selectedSurrenderPolicyId = ref("");
- const surrenderPreview = ref<SurrenderPreview | null>(null);
- const surrenderReason = ref("保障计划调整");
- const surrenderReasonDetail = ref("");
- const surrenderConfirmed = ref(false);
- const serviceApplicationOpen = ref(false);
- const serviceLoading = ref(false);
- const serviceError = ref("");
- const selectedServicePolicyId = ref("");
- const serviceFile = ref<File | null>(null);
- const serviceFilePreview = ref("");
- const materialRecognition = ref<MaterialRecognition | null>(null);
- const serviceConfirmed = ref(false);
- const serviceFields = ref({
- invoice_no: "",
- hospital_name: "",
- visit_date: "",
- amount_yuan: "",
- contact_mobile: "",
- });
- 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(
- () => !requestingCode.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,
- ),
- ];
- });
- const selectedServicePolicy = computed(
- () =>
- policies.value.find(
- (item) => item.policy_id === selectedServicePolicyId.value,
- ) ?? null,
- );
- const selectedSurrenderPolicy = computed(
- () =>
- policies.value.find(
- (item) => item.policy_id === selectedSurrenderPolicyId.value,
- ) ?? null,
- );
- function money(cents: number) {
- return `¥${(cents / 100).toFixed(2)}`;
- }
- function escapeHtml(value: string) {
- return value
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
- }
- 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 surrenderStatus(status: string) {
- return {
- PENDING_CONFIRMATION: "待确认",
- WAITING_APPROVAL: "等待审核",
- REJECTED: "审核未通过",
- REFUND_PENDING: "退款处理中",
- REFUND_RETRYING: "退款重试中",
- MANUAL_INTERVENTION: "人工处理中",
- REFUNDED: "退款完成",
- COMPENSATED: "已恢复保障",
- }[status] ?? status;
- }
- function surrenderStatusTone(status: string) {
- if (status === "REFUNDED" || status === "COMPENSATED") return "success";
- if (status === "REJECTED") return "danger";
- if (status === "MANUAL_INTERVENTION") return "warning";
- return "pending";
- }
- function serviceRequestStatus(status: string) {
- return {
- SUBMITTED: "待审核",
- ACCEPTED: "已受理",
- REJECTED: "未受理",
- }[status] ?? status;
- }
- function serviceRequestStatusTone(status: string) {
- if (status === "ACCEPTED") return "success";
- if (status === "REJECTED") return "danger";
- return "pending";
- }
- function policyById(policyId: string) {
- return policies.value.find((item) => item.policy_id === policyId);
- }
- 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;
- }
- 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() {
- if (requestingCode.value) return;
- error.value = "";
- codeMessage.value = "";
- if (!validateMobile()) return;
- if (
- imageCodeInput.value.trim().toUpperCase() !== imageCode.value.toUpperCase()
- ) {
- error.value = "图形验证码不正确,请重新输入";
- createImageCode();
- return;
- }
- requestingCode.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 : "请求失败";
- } finally {
- requestingCode.value = false;
- }
- }
- async function login() {
- if (loggingIn.value) return;
- 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;
- }
- loggingIn.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();
- await loadAgentThreads();
- } catch (reason) {
- error.value = reason instanceof Error ? reason.message : "请求失败";
- } finally {
- loggingIn.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;
- }
- async function loadAgentThreads() {
- if (!token.value) return;
- conversationHistoryLoading.value = true;
- try {
- const response = await authorizedFetch("/agent/threads?limit=30");
- if (!response.ok) {
- throw new Error(await responseError(response, "历史会话加载失败"));
- }
- agentThreads.value = (await response.json()).data.items;
- } finally {
- conversationHistoryLoading.value = false;
- }
- }
- async function openConversationHistory() {
- conversationHistoryOpen.value = true;
- error.value = "";
- try {
- await loadAgentThreads();
- } catch (reason) {
- error.value = reason instanceof Error ? reason.message : "历史会话加载失败";
- }
- }
- function startNewConversation() {
- agentThreadId.value = "";
- agentMessages.value = [];
- serviceNo.value = "";
- agentInput.value = "";
- conversationHistoryOpen.value = false;
- error.value = "";
- }
- async function restoreConversation(thread: AgentThreadSummary) {
- if (agentLoading.value) return;
- conversationHistoryLoading.value = true;
- error.value = "";
- try {
- const response = await authorizedFetch(
- `/agent/threads/${thread.id}/messages`,
- );
- if (!response.ok) {
- throw new Error(await responseError(response, "会话内容加载失败"));
- }
- const records = (await response.json()).data.items as Array<{
- role: "USER" | "ASSISTANT";
- content: string;
- metadata?: Record<string, any>;
- }>;
- let previousUserText = "";
- let restoredServiceNo = "";
- agentMessages.value = records.map((record) => {
- if (record.role === "USER") {
- previousUserText = record.content;
- return { role: "user", text: record.content };
- }
- const metadata = record.metadata ?? {};
- const cards = (metadata.cards ?? []) as Array<{
- type: string;
- items?: unknown[];
- }>;
- const products = cards
- .filter((card) => card.type === "product_recommendations")
- .flatMap((card) => card.items ?? []) as Product[];
- const sources = normalizeKnowledgeSources(
- cards
- .filter((card) => card.type === "knowledge_sources")
- .flatMap((card) => card.items ?? []) as KnowledgeSource[],
- );
- restoredServiceNo = String(metadata.service_no ?? restoredServiceNo);
- return {
- role: "assistant",
- text: record.content,
- html: renderAgentMarkdown(record.content),
- products: products.slice(0, 2),
- sources: sources.slice(0, 5),
- actions: (metadata.actions ?? []) as AgentAction[],
- enrollmentContext: inferEnrollmentContext(previousUserText),
- };
- });
- agentThreadId.value = thread.id;
- serviceNo.value = restoredServiceNo;
- conversationHistoryOpen.value = false;
- await nextTick();
- document
- .querySelector<HTMLElement>(".messages")
- ?.scrollTo({ top: 999999, behavior: "smooth" });
- } catch (reason) {
- error.value = reason instanceof Error ? reason.message : "会话内容加载失败";
- } finally {
- conversationHistoryLoading.value = false;
- }
- }
- async function sendAgentMessage() {
- const text = agentInput.value.trim();
- if (!text || agentLoading.value) return;
- agentLoading.value = true;
- agentProgress.value = "正在理解保障需求";
- error.value = "";
- const optimisticMessage = { role: "user" as const, text };
- agentMessages.value.push(optimisticMessage);
- agentInput.value = "";
- try {
- if (!agentThreadId.value) {
- const threadResponse = await authorizedFetch("/agent/threads", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: text.slice(0, 28) }),
- });
- 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?: unknown[];
- }>;
- const cardItems = cards
- .filter((card) => card.type === "product_recommendations")
- .flatMap((card) => card.items ?? []) as Product[];
- const knowledgeSources = normalizeKnowledgeSources(
- cards
- .filter((card) => card.type === "knowledge_sources")
- .flatMap((card) => card.items ?? []) as KnowledgeSource[],
- );
- 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),
- sources: knowledgeSources.slice(0, 5),
- actions,
- });
- serviceNo.value = data.service_no ?? "";
- await loadAgentThreads();
- } catch (reason) {
- const optimisticIndex = agentMessages.value.lastIndexOf(optimisticMessage);
- if (optimisticIndex >= 0) agentMessages.value.splice(optimisticIndex, 1);
- 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;
- }
- async function executeAgentAction(
- action: AgentAction,
- context?: ChatMessage["enrollmentContext"],
- ) {
- if (
- action.type === "open_orders" ||
- action.type === "open_policies"
- ) {
- activeTab.value = "coverage";
- coverageSection.value = "policies";
- await loadBusiness();
- return;
- }
- if (action.type === "open_surrenders") {
- activeTab.value = "coverage";
- coverageSection.value = "surrenders";
- 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";
- coverageSection.value = "policies";
- await loadBusiness();
- window.scrollTo({ top: 0, behavior: "smooth" });
- }
- async function openServiceCenter() {
- activeTab.value = "services";
- serviceError.value = "";
- await loadBusiness();
- window.scrollTo({ top: 0, behavior: "smooth" });
- }
- function startPolicyService(item: Policy) {
- if (item.status !== "ACTIVE") {
- error.value = "只有保障中的保单可以发起服务";
- return;
- }
- selectedServicePolicyId.value = item.policy_id;
- clearServiceMaterial();
- serviceError.value = "";
- activeTab.value = "services";
- serviceApplicationOpen.value = true;
- window.scrollTo({ top: 0, behavior: "smooth" });
- }
- // 退保前先向后端获取确定性试算;前端不自行计算退款金额。
- async function startSurrender(item: Policy) {
- if (item.status !== "ACTIVE") {
- error.value = "只有保障中的保单可以申请退保";
- return;
- }
- selectedSurrenderPolicyId.value = item.policy_id;
- surrenderPreview.value = null;
- surrenderReason.value = "保障计划调整";
- surrenderReasonDetail.value = "";
- surrenderConfirmed.value = false;
- surrenderError.value = "";
- surrenderApplicationOpen.value = true;
- surrenderLoading.value = true;
- try {
- const response = await authorizedFetch(
- `/h5/policies/${item.policy_id}/surrender-preview`,
- );
- if (!response.ok) {
- throw new Error(await responseError(response, "退保试算失败"));
- }
- surrenderPreview.value = (await response.json()).data as SurrenderPreview;
- } catch (reason) {
- surrenderError.value =
- reason instanceof Error ? reason.message : "退保试算失败";
- } finally {
- surrenderLoading.value = false;
- }
- }
- function closeSurrenderApplication() {
- surrenderApplicationOpen.value = false;
- surrenderError.value = "";
- surrenderPreview.value = null;
- surrenderReasonDetail.value = "";
- surrenderConfirmed.value = false;
- }
- async function submitSurrender() {
- if (!surrenderPreview.value?.eligible || !selectedSurrenderPolicy.value) {
- surrenderError.value = surrenderPreview.value?.reason ?? "当前保单暂不能申请退保";
- return;
- }
- if (!surrenderReason.value.trim()) {
- surrenderError.value = "请选择退保原因";
- return;
- }
- if (
- ["扣费或服务问题", "其他原因"].includes(surrenderReason.value) &&
- surrenderReasonDetail.value.trim().length < 2
- ) {
- surrenderError.value = "请填写具体的退保原因说明";
- return;
- }
- if (!surrenderConfirmed.value) {
- surrenderError.value = "请确认退款试算与保障终止提示";
- return;
- }
- surrenderLoading.value = true;
- surrenderError.value = "";
- try {
- // 第一步创建待确认申请;随机幂等键可防止本次重复点击产生两笔申请。
- const createdResponse = await authorizedFetch("/h5/surrenders", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "Idempotency-Key": crypto.randomUUID(),
- },
- body: JSON.stringify({
- policy_id: selectedSurrenderPolicy.value.policy_id,
- reason_category: surrenderReason.value.trim(),
- reason_detail: surrenderReasonDetail.value.trim() || null,
- }),
- });
- if (!createdResponse.ok) {
- throw new Error(await responseError(createdResponse, "退保申请提交失败"));
- }
- const created = (await createdResponse.json()).data as SurrenderRequestItem;
- // 第二步显式确认,后端此时才启动 LangGraph 风险评估和自动/人工路由。
- const confirmResponse = await authorizedFetch(
- `/h5/surrenders/${created.request_id}/confirm`,
- { method: "POST" },
- );
- if (!confirmResponse.ok) {
- throw new Error(await responseError(confirmResponse, "退保信息确认失败"));
- }
- await loadBusiness();
- closeSurrenderApplication();
- coverageSection.value = "surrenders";
- window.scrollTo({ top: 0, behavior: "smooth" });
- } catch (reason) {
- surrenderError.value =
- reason instanceof Error ? reason.message : "退保申请提交失败";
- } finally {
- surrenderLoading.value = false;
- }
- }
- function closeServiceApplication() {
- serviceApplicationOpen.value = false;
- serviceError.value = "";
- clearServiceMaterial();
- }
- function clearServiceMaterial() {
- if (serviceFilePreview.value) URL.revokeObjectURL(serviceFilePreview.value);
- serviceFile.value = null;
- serviceFilePreview.value = "";
- materialRecognition.value = null;
- serviceConfirmed.value = false;
- serviceFields.value = {
- invoice_no: "",
- hospital_name: "",
- visit_date: "",
- amount_yuan: "",
- contact_mobile: mobile.value,
- };
- }
- function handleServiceFile(event: Event) {
- serviceError.value = "";
- const input = event.target as HTMLInputElement;
- const file = input.files?.[0] ?? null;
- if (!file) return;
- if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
- serviceError.value = "请上传 JPG、PNG 或 WebP 格式的医疗费用发票";
- input.value = "";
- return;
- }
- if (file.size > 10 * 1024 * 1024) {
- serviceError.value = "单张材料不能超过 10MB";
- input.value = "";
- return;
- }
- clearServiceMaterial();
- serviceFile.value = file;
- serviceFilePreview.value = URL.createObjectURL(file);
- }
- async function recognizeServiceMaterial() {
- if (!selectedServicePolicyId.value) {
- serviceError.value = "请先选择需要申请服务的有效保单";
- return;
- }
- if (!serviceFile.value) {
- serviceError.value = "请先上传医疗费用发票";
- return;
- }
- serviceLoading.value = true;
- serviceError.value = "";
- try {
- const form = new FormData();
- form.append("policy_id", selectedServicePolicyId.value);
- form.append("material_type", "MEDICAL_INVOICE");
- form.append("file", serviceFile.value);
- const response = await authorizedFetch("/h5/service-materials/recognize", {
- method: "POST",
- body: form,
- });
- if (!response.ok) {
- throw new Error(await responseError(response, "医疗票据识别失败"));
- }
- materialRecognition.value = (await response.json()).data;
- const fields = materialRecognition.value?.fields ?? {};
- serviceFields.value = {
- invoice_no: fields.invoice_no ?? "",
- hospital_name: fields.hospital_name ?? "",
- visit_date: fields.visit_date ?? "",
- amount_yuan: fields.amount_yuan ?? "",
- contact_mobile: mobile.value,
- };
- } catch (reason) {
- serviceError.value =
- reason instanceof Error ? reason.message : "医疗票据识别失败";
- } finally {
- serviceLoading.value = false;
- }
- }
- async function submitServiceRequest() {
- if (!materialRecognition.value) {
- serviceError.value = "请先完成医疗票据识别";
- return;
- }
- const requiredFields = [
- serviceFields.value.invoice_no,
- serviceFields.value.hospital_name,
- serviceFields.value.amount_yuan,
- serviceFields.value.contact_mobile,
- ];
- if (requiredFields.some((value) => !value.trim())) {
- serviceError.value = "请完整填写票据号码、医疗机构、费用金额和联系电话";
- return;
- }
- if (!/^1\d{10}$/.test(serviceFields.value.contact_mobile)) {
- serviceError.value = "请输入正确的11位联系电话";
- return;
- }
- if (!serviceConfirmed.value) {
- serviceError.value = "请确认识别内容已经人工核对";
- return;
- }
- serviceLoading.value = true;
- serviceError.value = "";
- try {
- const response = await authorizedFetch("/h5/service-requests", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- policy_id: selectedServicePolicyId.value,
- request_type: "MEDICAL_REIMBURSEMENT_ASSISTANCE",
- recognition_id: materialRecognition.value.recognition_id,
- confirmed_fields: serviceFields.value,
- }),
- });
- if (!response.ok) {
- throw new Error(await responseError(response, "服务申请提交失败"));
- }
- clearServiceMaterial();
- await loadBusiness();
- serviceApplicationOpen.value = false;
- activeTab.value = "services";
- window.scrollTo({ top: 0, behavior: "smooth" });
- } catch (reason) {
- serviceError.value =
- reason instanceof Error ? reason.message : "服务申请提交失败";
- } finally {
- serviceLoading.value = false;
- }
- }
- async function loadBusiness() {
- if (!token.value) return;
- const [
- ordersResponse,
- policiesResponse,
- serviceRequestsResponse,
- surrenderRequestsResponse,
- ] = await Promise.all([
- authorizedFetch("/h5/orders"),
- authorizedFetch("/h5/policies"),
- authorizedFetch("/h5/service-requests"),
- authorizedFetch("/h5/surrenders"),
- ]);
- 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;
- if (
- !selectedServicePolicyId.value ||
- !items.some(
- (item) =>
- item.policy_id === selectedServicePolicyId.value &&
- item.status === "ACTIVE",
- )
- ) {
- selectedServicePolicyId.value =
- items.find((item) => item.status === "ACTIVE")?.policy_id ?? "";
- }
- }
- if (serviceRequestsResponse.ok) {
- serviceRequests.value = (
- await serviceRequestsResponse.json()
- ).data.items as ServiceRequestItem[];
- }
- if (surrenderRequestsResponse.ok) {
- surrenderRequests.value = (
- await surrenderRequestsResponse.json()
- ).data.items as SurrenderRequestItem[];
- }
- }
- 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 = [];
- agentThreads.value = [];
- conversationHistoryOpen.value = false;
- serviceNo.value = "";
- activeTab.value = "advisor";
- coverageSection.value = "policies";
- selectedProduct.value = null;
- quote.value = null;
- order.value = null;
- payment.value = null;
- policy.value = null;
- policies.value = [];
- openedPolicy.value = null;
- serviceRequests.value = [];
- surrenderRequests.value = [];
- surrenderApplicationOpen.value = false;
- surrenderError.value = "";
- selectedSurrenderPolicyId.value = "";
- surrenderPreview.value = null;
- surrenderConfirmed.value = false;
- serviceApplicationOpen.value = false;
- serviceError.value = "";
- selectedServicePolicyId.value = "";
- clearServiceMaterial();
- 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 Promise.all([loadBusiness(), loadAgentThreads()]);
- }
- } catch {
- token.value = "";
- products.value = [];
- } finally {
- restoring.value = false;
- }
- });
- onBeforeUnmount(() => {
- if (countdownTimer) clearInterval(countdownTimer);
- if (serviceFilePreview.value) URL.revokeObjectURL(serviceFilePreview.value);
- });
- </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"
- >
- {{
- requestingCode
- ? "发送中…"
- : 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="loggingIn" type="submit">
- {{ loggingIn ? "正在安全登录…" : "登录" }}
- <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>
- <div class="advisor-heading-actions">
- <button type="button" class="conversation-action" @click="startNewConversation">
- <span>+</span>新会话
- </button>
- <button type="button" class="conversation-action history" @click="openConversationHistory">
- 历史<em v-if="agentThreads.length">{{ agentThreads.length }}</em>
- </button>
- </div>
- </div>
- <p class="page-lead">可以咨询保障方案,也可以查询保险条款和服务材料;知识回答会标明来源。</p>
- <div class="quick-prompts">
- <button @click="agentInput = '我想给65岁的父亲买医疗险,常住成都,普通职业。'">父母医疗</button>
- <button @click="agentInput = '我想给一家三口配置意外保障,请帮我推荐。'">家庭意外</button>
- <button @click="agentInput = '我想了解基础医疗保障,年龄32岁。'">成人医疗</button>
- <button @click="agentInput = '医疗险等待期是多少天?请给出条款来源。'">条款问答</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.sources?.length" class="knowledge-source-list">
- <div class="knowledge-source-title">
- <span>依据</span>
- <strong>本次回答引用</strong>
- </div>
- <article
- v-for="source in message.sources"
- :key="`${source.document_id}-${source.document_version}-${source.source_name}`"
- >
- <span>V{{ source.document_version }}</span>
- <div>
- <strong>{{ source.title }}</strong>
- <ul
- v-if="source.excerpts && source.excerpts.length > 1"
- class="knowledge-source-evidence"
- >
- <li
- v-for="(excerpt, excerptIndex) in source.excerpts"
- :key="`${source.document_id}-${source.document_version}-${excerptIndex}`"
- >
- {{ excerpt }}
- </li>
- </ul>
- <p v-else>{{ source.excerpts?.[0] ?? source.excerpt }}</p>
- <small>{{ source.source_name }}</small>
- </div>
- </article>
- </div>
- <div
- v-if="
- message.actions?.length &&
- !message.products?.length &&
- !message.sources?.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-if="activeTab === 'coverage'" class="tab-page coverage-page">
- <div class="page-heading">
- <div><p class="step">MY PROTECTION</p><h2>我的保障</h2></div>
- <span class="record-count">
- {{
- coverageSection === "policies"
- ? (policies.length > 0 ? `${policies.length}张保单` : "暂无保单")
- : `${surrenderRequests.length}笔记录`
- }}
- </span>
- </div>
- <p class="page-lead">
- {{
- coverageSection === "policies"
- ? "集中查看已经生效的保障,并从保单发起后续服务。"
- : "查看退保审核、退款处理与保障终止进度。"
- }}
- </p>
- <nav class="coverage-switch" aria-label="我的保障分类">
- <button
- type="button"
- :class="{ active: coverageSection === 'policies' }"
- @click="coverageSection = 'policies'"
- >
- <span>01</span>
- <div><strong>保障保单</strong><small>当前持有的保障</small></div>
- <em>{{ policies.length }}</em>
- </button>
- <button
- type="button"
- :class="{ active: coverageSection === 'surrenders' }"
- @click="coverageSection = 'surrenders'"
- >
- <span>02</span>
- <div><strong>退保记录</strong><small>审核与退款进度</small></div>
- <em>{{ surrenderRequests.length }}</em>
- </button>
- </nav>
- <div
- v-if="coverageSection === 'policies' && policies.length > 0"
- class="policy-list coverage-section-enter"
- >
- <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>
- <div class="policy-actions">
- <button
- type="button"
- class="policy-document-action"
- @click="openPolicyDocument(item)"
- >
- <span class="policy-action-icon" aria-hidden="true">
- <svg viewBox="0 0 24 24">
- <path d="M7 3.5h7l3 3V20.5H7z" />
- <path d="M14 3.5v4h4M9.5 11h5M9.5 14.5h5" />
- </svg>
- </span>
- <span class="policy-action-copy">
- <strong>电子保单</strong>
- <small>查看保障凭证</small>
- </span>
- <span class="policy-action-arrow" aria-hidden="true">→</span>
- </button>
- <button
- v-if="item.status === 'ACTIVE'"
- type="button"
- class="policy-service-action"
- @click="startPolicyService(item)"
- >
- <span class="policy-action-icon" aria-hidden="true">
- <svg viewBox="0 0 24 24">
- <path d="M5 5.5h14v13H5z" />
- <path d="M9 5.5V4h6v1.5M12 9v6M9 12h6" />
- </svg>
- </span>
- <span class="policy-action-copy">
- <strong>保单服务</strong>
- <small>提交服务申请</small>
- </span>
- <span class="policy-action-arrow" aria-hidden="true">→</span>
- </button>
- <button
- v-if="item.status === 'ACTIVE'"
- type="button"
- class="policy-surrender-action"
- @click="startSurrender(item)"
- >
- <span class="policy-surrender-copy">
- <small>保障计划需要调整?</small>
- <strong>申请退保</strong>
- </span>
- <span class="policy-surrender-arrow" aria-hidden="true">→</span>
- </button>
- </div>
- </article>
- </div>
- <Teleport to="body">
- <div
- v-if="surrenderApplicationOpen"
- class="surrender-layer"
- role="dialog"
- aria-modal="true"
- aria-label="退保申请确认"
- @click.self="closeSurrenderApplication"
- >
- <article class="surrender-sheet">
- <header>
- <div>
- <p>SURRENDER REVIEW</p>
- <h2>退保申请确认</h2>
- </div>
- <button type="button" aria-label="关闭退保申请" @click="closeSurrenderApplication">×</button>
- </header>
- <div v-if="surrenderLoading && !surrenderPreview" class="surrender-loading">
- <i></i>
- <strong>正在核对保单与计算退款金额</strong>
- <span>资格与金额由业务规则确定,AI 不参与决策</span>
- </div>
- <template v-else-if="surrenderPreview && selectedSurrenderPolicy">
- <section class="surrender-policy-card">
- <span>关联保单</span>
- <h3>{{ selectedSurrenderPolicy.product_name }}</h3>
- <p>{{ selectedSurrenderPolicy.policy_no }}</p>
- <em>{{ selectedSurrenderPolicy.plan_name }}</em>
- </section>
- <section :class="['surrender-quote', { denied: !surrenderPreview.eligible }]">
- <div>
- <span>{{ surrenderPreview.eligible ? "预计退还" : "暂不可退保" }}</span>
- <strong v-if="surrenderPreview.eligible">{{ money(surrenderPreview.refund_amount_cents) }}</strong>
- <strong v-else>—</strong>
- </div>
- <p>{{ surrenderPreview.reason }}</p>
- </section>
- <section
- v-if="surrenderPreview.eligible"
- :class="[
- 'surrender-refund-basis',
- {
- cooling:
- surrenderPreview.calculation.pre_effective ||
- surrenderPreview.calculation.cooling_off,
- },
- ]"
- >
- <span>{{
- surrenderPreview.calculation.pre_effective
- ? "保障生效前"
- : surrenderPreview.calculation.cooling_off
- ? "犹豫期内"
- : "犹豫期外"
- }}</span>
- <div>
- <strong>{{
- surrenderPreview.calculation.pre_effective ||
- surrenderPreview.calculation.cooling_off
- ? "按已交保费全额退还"
- : "按剩余保障期限折算"
- }}</strong>
- <p>
- {{
- surrenderPreview.refund_explanation ??
- (surrenderPreview.calculation.pre_effective
- ? "保单保障尚未开始,本次撤销不收取退保扣减。"
- : surrenderPreview.calculation.cooling_off
- ? "当前申请仍在犹豫期内,退保扣减为0元。"
- : "当前申请按未满期保费折算,并按照规则扣减。")
- }}
- </p>
- </div>
- </section>
- <dl v-if="surrenderPreview.eligible" class="surrender-calculation">
- <div><dt>已交保费</dt><dd>{{ money(surrenderPreview.calculation.premium_cents) }}</dd></div>
- <div><dt>未满期保费</dt><dd>{{ money(surrenderPreview.calculation.gross_unearned_cents) }}</dd></div>
- <div><dt>退保扣减</dt><dd>-{{ money(surrenderPreview.calculation.deduction_cents) }}</dd></div>
- <div><dt>剩余保障天数</dt><dd>{{ surrenderPreview.calculation.remaining_days }}天</dd></div>
- </dl>
- <section v-if="surrenderPreview.eligible" class="surrender-form">
- <label>
- <span>退保原因</span>
- <select v-model="surrenderReason">
- <option>保障计划调整</option>
- <option>已有其他保障</option>
- <option>暂时不需要该保障</option>
- <option>扣费或服务问题</option>
- <option>其他原因</option>
- </select>
- </label>
- <label class="surrender-reason-detail">
- <span>
- 具体说明
- <em v-if="['扣费或服务问题', '其他原因'].includes(surrenderReason)">
- 必填
- </em>
- <small v-else>选填</small>
- </span>
- <textarea
- v-model="surrenderReasonDetail"
- rows="3"
- maxlength="160"
- placeholder="请描述具体情况,例如扣费、服务争议或身份异常"
- ></textarea>
- <small>{{ surrenderReasonDetail.length }}/160</small>
- </label>
- <label class="surrender-confirm">
- <input v-model="surrenderConfirmed" type="checkbox" />
- <span>我已确认退款试算;流程通过风险分流并退款成功后,本保单保障将终止。</span>
- </label>
- </section>
- </template>
- <p v-if="surrenderError" class="error status-message" role="alert"><span>!</span>{{ surrenderError }}</p>
- <footer>
- <button type="button" class="surrender-cancel" @click="closeSurrenderApplication">暂不申请</button>
- <button
- type="button"
- class="surrender-submit"
- :disabled="surrenderLoading || !surrenderPreview?.eligible"
- @click="submitSurrender"
- >
- {{ surrenderLoading ? "正在提交…" : "确认提交退保申请" }}
- </button>
- </footer>
- </article>
- </div>
- </Teleport>
- <section
- v-if="coverageSection === 'surrenders' && surrenderRequests.length"
- class="surrender-records coverage-section-enter"
- >
- <div class="surrender-records-heading">
- <div><span>POLICY LIFECYCLE</span><h3>退保进度</h3></div>
- <em>{{ surrenderRequests.length }}笔</em>
- </div>
- <article v-for="item in surrenderRequests" :key="item.request_id">
- <div class="surrender-record-mark">退</div>
- <div>
- <strong>{{ item.request_no }}</strong>
- <p>{{ policyById(item.policy_id)?.product_name ?? "关联保单" }} · {{ item.reason }}</p>
- <small>{{ dateTime(item.created_at) }}</small>
- </div>
- <aside>
- <span :class="surrenderStatusTone(item.status)">{{ surrenderStatus(item.status) }}</span>
- <strong>{{ money(item.refund_amount_cents) }}</strong>
- <small class="surrender-record-basis">
- {{
- item.calculation?.pre_effective
- ? "保障生效前全额退还"
- : item.calculation?.cooling_off
- ? "犹豫期内全额退还"
- : "按剩余保障期折算"
- }}
- </small>
- </aside>
- </article>
- </section>
- <div
- v-if="coverageSection === 'policies' && !order && policies.length === 0"
- class="coverage-empty"
- >
- <span class="empty-shield">◇</span>
- <h3>还没有保障记录</h3>
- <p>先和智能顾问聊聊,找到适合你的保障方案。</p>
- <button @click="activeTab = 'advisor'">咨询智能顾问</button>
- </div>
- <div
- v-if="coverageSection === 'surrenders' && surrenderRequests.length === 0"
- class="surrender-empty-records coverage-section-enter"
- >
- <span>00</span>
- <h3>还没有退保记录</h3>
- <p>当前保障仍在正常生效。如需调整保障计划,请从有效保单中发起申请。</p>
- <button type="button" @click="coverageSection = 'policies'">查看保障保单</button>
- </div>
- <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
- </section>
- <section v-else class="tab-page service-page">
- <div class="page-heading">
- <div><p class="step">POLICY SERVICE DESK</p><h2>保单服务</h2></div>
- <span class="record-count">{{ serviceRequests.length }}笔申请</span>
- </div>
- <p class="page-lead">查看从有效保单发起的服务申请,以及运营人员反馈的最新处理进度。</p>
- <Teleport to="body">
- <div v-if="serviceApplicationOpen" class="service-application-sheet">
- <div class="service-sheet-header">
- <button type="button" aria-label="关闭服务申请" @click="closeServiceApplication">←</button>
- <div><small>POLICY SERVICE APPLICATION</small><strong>发起保单服务</strong></div>
- <span>关联保单</span>
- </div>
- <div class="service-intro-card">
- <span class="service-intro-mark">AI</span>
- <div>
- <strong>医疗费用报销协助</strong>
- <p>识别结果只作为候选内容,不代表保险公司作出赔付承诺。</p>
- </div>
- </div>
- <div class="service-progress-rail">
- <div :class="{ active: true }"><span>01</span><small>选择保单</small></div>
- <i></i>
- <div :class="{ active: !!serviceFile }"><span>02</span><small>识别材料</small></div>
- <i></i>
- <div :class="{ active: !!materialRecognition }"><span>03</span><small>确认提交</small></div>
- </div>
- <div v-if="selectedServicePolicy" class="service-section">
- <div class="service-section-heading">
- <span>01</span>
- <div><strong>已关联保障中的保单</strong><small>服务申请将绑定到该保单</small></div>
- </div>
- <div class="service-linked-policy">
- <span class="policy-choice-mark"></span>
- <div>
- <strong>{{ selectedServicePolicy.product_name }}</strong>
- <small>{{ selectedServicePolicy.plan_name }} · {{ selectedServicePolicy.policy_no }}</small>
- </div>
- <em>{{ policyStatus(selectedServicePolicy.status) }}</em>
- </div>
- </div>
- <div v-if="selectedServicePolicy" class="service-section">
- <div class="service-section-heading">
- <span>02</span>
- <div><strong>上传医疗费用发票</strong><small>支持 JPG、PNG、WebP,单张不超过 10MB</small></div>
- </div>
- <label v-if="!serviceFilePreview" class="service-upload">
- <input
- type="file"
- accept="image/jpeg,image/png,image/webp"
- capture="environment"
- @change="handleServiceFile"
- />
- <span class="upload-symbol">
- <svg viewBox="0 0 24 24" aria-hidden="true">
- <path d="M12 16V4m0 0L7 9m5-5 5 5M5 14v5h14v-5" />
- </svg>
- </span>
- <strong>拍照或选择票据图片</strong>
- <small>请保持票据完整、平整,文字和金额清晰可见</small>
- </label>
- <div v-else class="service-file-preview">
- <img :src="serviceFilePreview" alt="待识别的医疗费用发票" />
- <div>
- <span>待识别材料</span>
- <strong>{{ serviceFile?.name }}</strong>
- <small>{{ serviceFile ? `${(serviceFile.size / 1024 / 1024).toFixed(2)} MB` : "" }}</small>
- <button type="button" :disabled="serviceLoading" @click="clearServiceMaterial">重新选择</button>
- </div>
- </div>
- <button
- v-if="serviceFile && !materialRecognition"
- type="button"
- class="service-primary"
- :disabled="serviceLoading"
- @click="recognizeServiceMaterial"
- >
- <span class="ai-button-mark">AI</span>
- {{ serviceLoading ? "正在识别票据内容…" : "开始智能识别" }}
- <em>→</em>
- </button>
- <p v-if="serviceError" class="error status-message service-recognition-error" role="alert">
- <span>!</span>{{ serviceError }}
- </p>
- </div>
- <div v-if="materialRecognition" class="service-section recognition-section">
- <div class="service-section-heading">
- <span>03</span>
- <div><strong>核对识别结果</strong><small>所有字段都可以修改,请以票据原件为准</small></div>
- </div>
- <div v-if="materialRecognition.warnings.length" class="recognition-warnings">
- <strong>请重点核对</strong>
- <p v-for="warning in materialRecognition.warnings" :key="warning">{{ warning }}</p>
- </div>
- <div class="service-confirm-form">
- <label>票据号码<input v-model.trim="serviceFields.invoice_no" placeholder="请输入票据号码" /></label>
- <label>医疗机构<input v-model.trim="serviceFields.hospital_name" placeholder="请输入医疗机构名称" /></label>
- <label>就诊日期<input v-model.trim="serviceFields.visit_date" type="date" /></label>
- <label>费用金额(元)<input v-model.trim="serviceFields.amount_yuan" inputmode="decimal" placeholder="0.00" /></label>
- <label>联系电话<input v-model.trim="serviceFields.contact_mobile" inputmode="tel" maxlength="11" /></label>
- </div>
- <div class="service-material-note">
- <span>材料提醒</span>
- <p>后续审核可能需要费用清单、诊断证明、入出院记录等材料,运营人员会在申请记录中反馈。</p>
- </div>
- <label class="service-confirm-check">
- <input v-model="serviceConfirmed" type="checkbox" />
- <i aria-hidden="true">✓</i>
- <span>我已对照原始票据核对以上信息,并确认内容真实、完整。</span>
- </label>
- <button
- type="button"
- class="service-primary submit"
- :disabled="serviceLoading"
- @click="submitServiceRequest"
- >
- {{ serviceLoading ? "正在提交申请…" : "确认并提交服务申请" }}
- <em>→</em>
- </button>
- </div>
- </div>
- </Teleport>
- <template v-if="!serviceApplicationOpen">
- <div v-if="serviceRequests.length" class="service-request-list">
- <article v-for="item in serviceRequests" :key="item.request_id" class="service-request-card">
- <div class="service-request-topline">
- <span>{{ item.request_no }}</span>
- <em :class="serviceRequestStatusTone(item.status)">
- {{ serviceRequestStatus(item.status) }}
- </em>
- </div>
- <h3>{{ policyById(item.policy_id)?.product_name ?? "医疗费用报销协助" }}</h3>
- <p>{{ policyById(item.policy_id)?.policy_no ?? item.policy_id }}</p>
- <dl>
- <div><dt>医疗机构</dt><dd>{{ item.confirmed_fields.hospital_name }}</dd></div>
- <div><dt>申请金额</dt><dd>¥{{ item.confirmed_fields.amount_yuan }}</dd></div>
- <div><dt>提交时间</dt><dd>{{ dateTime(item.submitted_at) }}</dd></div>
- </dl>
- <div v-if="item.review_note" class="service-review-note">
- <span>处理意见</span>
- <p>{{ item.review_note }}</p>
- </div>
- </article>
- </div>
- <div v-else class="service-empty-records">
- <span>00</span>
- <h3>还没有服务申请</h3>
- <p>请前往“我的保障”,从保障中的保单发起服务申请。</p>
- <button type="button" @click="activeTab = 'coverage'">前往我的保障</button>
- </div>
- </template>
- <p v-if="serviceError && !serviceApplicationOpen" class="error status-message" role="alert"><span>!</span>{{ serviceError }}</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>
- <button :class="{ active: activeTab === 'services' }" @click="openServiceCenter">
- <svg viewBox="0 0 24 24" aria-hidden="true">
- <path d="M4 6h16v13H4V6Zm4-3h8v3H8V3Zm1 9h6M12 9v6" />
- </svg>
- <span>保单服务</span>
- </button>
- </nav>
- <div
- v-if="conversationHistoryOpen"
- class="conversation-layer"
- role="dialog"
- aria-modal="true"
- aria-label="历史会话"
- @click.self="conversationHistoryOpen = false"
- >
- <section class="conversation-drawer">
- <header>
- <div>
- <p class="step">CONVERSATION ARCHIVE</p>
- <h2>历史会话</h2>
- </div>
- <button type="button" aria-label="关闭历史会话" @click="conversationHistoryOpen = false">×</button>
- </header>
- <div class="conversation-summary">
- <span>会话已安全保存在你的账户中</span>
- <strong>{{ agentThreads.length }}<small>次咨询</small></strong>
- </div>
- <button type="button" class="new-conversation-card" @click="startNewConversation">
- <span>+</span>
- <div><strong>开始新会话</strong><small>创建一个独立的咨询上下文</small></div>
- <em>→</em>
- </button>
- <div v-if="conversationHistoryLoading" class="conversation-loading">
- <i></i><span>正在读取历史会话…</span>
- </div>
- <div v-else-if="agentThreads.length" class="conversation-list">
- <button
- v-for="thread in agentThreads"
- :key="thread.id"
- type="button"
- :class="{ current: thread.id === agentThreadId }"
- @click="restoreConversation(thread)"
- >
- <div class="conversation-index">
- <span>{{ String(agentThreads.indexOf(thread) + 1).padStart(2, "0") }}</span>
- <i v-if="thread.id === agentThreadId"></i>
- </div>
- <div>
- <strong>{{ thread.title }}</strong>
- <p>{{ thread.last_message?.content ?? "尚未发送消息" }}</p>
- <small>{{ dateTime(thread.updated_at) }} · {{ thread.message_count }}条消息</small>
- </div>
- <em>›</em>
- </button>
- </div>
- <div v-else class="conversation-empty">
- <span>◇</span>
- <strong>暂无历史会话</strong>
- <p>发送第一条咨询后,会话会自动保存到这里。</p>
- </div>
- </section>
- </div>
- <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>
|