App.vue 99 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696
  1. <script setup lang="ts">
  2. import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
  3. type Product = {
  4. product_id: string;
  5. product_version_id: string;
  6. product_code: string;
  7. name: string;
  8. summary: string;
  9. version_no?: string;
  10. plans: Array<{ id: string; code: string; name: string }>;
  11. };
  12. type AgentAction = {
  13. type:
  14. | "open_enrollment"
  15. | "open_orders"
  16. | "open_policies"
  17. | "open_surrenders"
  18. | "none";
  19. label: string;
  20. payload: Record<string, unknown>;
  21. confirmation_required: boolean;
  22. };
  23. type RuntimeEvent = {
  24. event:
  25. | "run.started"
  26. | "tool.completed"
  27. | "ui.ready"
  28. | "run.completed"
  29. | "run.failed";
  30. run_id: string;
  31. sequence: number;
  32. data: Record<string, unknown>;
  33. };
  34. type KnowledgeSource = {
  35. document_id: string;
  36. document_version: number;
  37. title: string;
  38. source_name: string;
  39. excerpt: string;
  40. excerpts?: string[];
  41. score: number;
  42. };
  43. function normalizeKnowledgeSources(sources: KnowledgeSource[]): KnowledgeSource[] {
  44. const grouped = new Map<string, KnowledgeSource>();
  45. for (const source of sources) {
  46. const key = `${source.document_id}:${source.document_version}`;
  47. const excerpts = source.excerpts?.length
  48. ? source.excerpts
  49. : [source.excerpt];
  50. const existing = grouped.get(key);
  51. if (!existing) {
  52. const normalizedExcerpts = [...new Set(excerpts.filter(Boolean))].slice(0, 2);
  53. grouped.set(key, {
  54. ...source,
  55. excerpt: normalizedExcerpts[0] ?? source.excerpt,
  56. excerpts: normalizedExcerpts,
  57. });
  58. continue;
  59. }
  60. const mergedExcerpts = [
  61. ...(existing.excerpts ?? [existing.excerpt]),
  62. ...excerpts,
  63. ];
  64. existing.excerpts = [...new Set(mergedExcerpts.filter(Boolean))].slice(0, 2);
  65. existing.excerpt = existing.excerpts[0] ?? existing.excerpt;
  66. existing.score = Math.max(existing.score, source.score);
  67. }
  68. return [...grouped.values()];
  69. }
  70. type ChatMessage = {
  71. role: "user" | "assistant";
  72. text: string;
  73. html?: string;
  74. products?: Product[];
  75. sources?: KnowledgeSource[];
  76. actions?: AgentAction[];
  77. enrollmentContext?: {
  78. age?: number;
  79. relationship?: Relationship;
  80. };
  81. };
  82. type AgentThreadSummary = {
  83. id: string;
  84. title: string;
  85. status: string;
  86. message_count: number;
  87. last_message: {
  88. role: "USER" | "ASSISTANT";
  89. content: string;
  90. created_at: string;
  91. } | null;
  92. created_at: string;
  93. updated_at: string;
  94. };
  95. type MainTab = "advisor" | "plans" | "coverage" | "services";
  96. type Relationship = "SELF" | "PARENT" | "SPOUSE" | "CHILD";
  97. type Quote = {
  98. quote_id: string;
  99. product_id: string;
  100. plan_id: string;
  101. premium_cents: number;
  102. currency: string;
  103. expires_at: string;
  104. };
  105. type Order = {
  106. order_id: string;
  107. order_no: string;
  108. status: string;
  109. amount_cents: number;
  110. };
  111. type Payment = {
  112. payment_id: string;
  113. payment_no: string;
  114. status: string;
  115. };
  116. type Policy = {
  117. policy_id: string;
  118. policy_no: string;
  119. order_id: string;
  120. order_no: string;
  121. product_name: string;
  122. plan_name: string;
  123. applicant: { name?: string; id_no?: string };
  124. insured: { name?: string; id_no?: string };
  125. status: string;
  126. premium_cents: number;
  127. coverage_start: string;
  128. coverage_end: string;
  129. issued_at: string;
  130. };
  131. type MaterialRecognition = {
  132. recognition_id: string;
  133. policy_id: string;
  134. material_type: string;
  135. source_name: string;
  136. fields: Record<string, string>;
  137. warnings: string[];
  138. status: string;
  139. created_at: string;
  140. };
  141. type ServiceRequestItem = {
  142. request_id: string;
  143. request_no: string;
  144. policy_id: string;
  145. request_type: string;
  146. recognition_id: string;
  147. confirmed_fields: Record<string, string>;
  148. status: string;
  149. submitted_at: string;
  150. reviewed_by?: string | null;
  151. review_note?: string | null;
  152. reviewed_at?: string | null;
  153. };
  154. type SurrenderRequestItem = {
  155. request_id: string;
  156. request_no: string;
  157. policy_id: string;
  158. reason: string;
  159. status: string;
  160. refund_amount_cents: number;
  161. currency: string;
  162. calculation?: {
  163. pre_effective?: boolean;
  164. cooling_off?: boolean;
  165. elapsed_days?: number;
  166. remaining_days?: number;
  167. gross_unearned_cents?: number;
  168. deduction_cents?: number;
  169. };
  170. risk_level?: string | null;
  171. risk_reasons?: string[];
  172. created_at: string;
  173. confirmed_at?: string | null;
  174. review_note?: string | null;
  175. };
  176. type SurrenderPreview = {
  177. policy_id: string;
  178. policy_no: string;
  179. eligible: boolean;
  180. reason_code: string;
  181. reason: string;
  182. refund_amount_cents: number;
  183. refund_basis?:
  184. | "PRE_EFFECTIVE_FULL_REFUND"
  185. | "COOLING_OFF_FULL_REFUND"
  186. | "UNEARNED_PREMIUM_WITH_DEDUCTION";
  187. refund_explanation?: string;
  188. currency: string;
  189. rule_version: string;
  190. calculation: {
  191. premium_cents: number;
  192. total_days: number;
  193. elapsed_days: number;
  194. remaining_days: number;
  195. gross_unearned_cents: number;
  196. deduction_cents: number;
  197. cooling_off: boolean;
  198. pre_effective: boolean;
  199. };
  200. };
  201. const apiBase =
  202. import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8000/api/v1";
  203. const mobile = ref("");
  204. const code = ref("");
  205. const imageCode = ref("");
  206. const imageCodeInput = ref("");
  207. const agreementAccepted = ref(false);
  208. const codeCountdown = ref(0);
  209. const codeRequested = ref(false);
  210. let countdownTimer: ReturnType<typeof setInterval> | undefined;
  211. const token = ref("");
  212. const referralCode = new URLSearchParams(window.location.search)
  213. .get("ref")
  214. ?.trim()
  215. .toUpperCase() ?? "";
  216. const contactMobile = ref("");
  217. const products = ref<Product[]>([]);
  218. const error = ref("");
  219. const codeMessage = ref("");
  220. const requestingCode = ref(false);
  221. const loggingIn = ref(false);
  222. const refreshing = ref(false);
  223. const restoring = ref(true);
  224. const agentInput = ref("我想给65岁的父亲买医疗险,常住成都,普通职业。");
  225. const agentThreadId = ref("");
  226. const agentMessages = ref<ChatMessage[]>([]);
  227. const agentThreads = ref<AgentThreadSummary[]>([]);
  228. const conversationHistoryOpen = ref(false);
  229. const conversationHistoryLoading = ref(false);
  230. const agentLoading = ref(false);
  231. const agentProgress = ref("");
  232. const serviceNo = ref("");
  233. const activeTab = ref<MainTab>("advisor");
  234. const coverageSection = ref<"policies" | "surrenders">("policies");
  235. const selectedProduct = ref<Product | null>(null);
  236. const quote = ref<Quote | null>(null);
  237. const order = ref<Order | null>(null);
  238. const payment = ref<Payment | null>(null);
  239. const policy = ref<Policy | null>(null);
  240. const policies = ref<Policy[]>([]);
  241. const openedPolicy = ref<Policy | null>(null);
  242. const serviceRequests = ref<ServiceRequestItem[]>([]);
  243. const surrenderRequests = ref<SurrenderRequestItem[]>([]);
  244. const surrenderApplicationOpen = ref(false);
  245. const surrenderLoading = ref(false);
  246. const surrenderError = ref("");
  247. const selectedSurrenderPolicyId = ref("");
  248. const surrenderPreview = ref<SurrenderPreview | null>(null);
  249. const surrenderReason = ref("保障计划调整");
  250. const surrenderReasonDetail = ref("");
  251. const surrenderConfirmed = ref(false);
  252. const serviceApplicationOpen = ref(false);
  253. const serviceLoading = ref(false);
  254. const serviceError = ref("");
  255. const selectedServicePolicyId = ref("");
  256. const serviceFile = ref<File | null>(null);
  257. const serviceFilePreview = ref("");
  258. const materialRecognition = ref<MaterialRecognition | null>(null);
  259. const serviceConfirmed = ref(false);
  260. const serviceFields = ref({
  261. invoice_no: "",
  262. hospital_name: "",
  263. visit_date: "",
  264. amount_yuan: "",
  265. contact_mobile: "",
  266. });
  267. const businessLoading = ref(false);
  268. const quotingProductId = ref("");
  269. const quoteFeedback = ref("");
  270. const enrollmentOpen = ref(false);
  271. const preQuoteConfirming = ref(false);
  272. const enrollmentStep = ref(1);
  273. const applicantName = ref("");
  274. const applicantIdNo = ref("");
  275. const insuredName = ref("");
  276. const insuredIdNo = ref("");
  277. const insuredAge = ref(65);
  278. const relationship = ref<Relationship>("PARENT");
  279. const noticeAccepted = ref(false);
  280. const disclosureConfirmed = ref(false);
  281. const canRequestCode = computed(
  282. () => !requestingCode.value && codeCountdown.value === 0,
  283. );
  284. const displayedProducts = computed(() => {
  285. if (!selectedProduct.value) return products.value;
  286. return [
  287. selectedProduct.value,
  288. ...products.value.filter(
  289. (product) => product.product_id !== selectedProduct.value?.product_id,
  290. ),
  291. ];
  292. });
  293. const selectedServicePolicy = computed(
  294. () =>
  295. policies.value.find(
  296. (item) => item.policy_id === selectedServicePolicyId.value,
  297. ) ?? null,
  298. );
  299. const selectedSurrenderPolicy = computed(
  300. () =>
  301. policies.value.find(
  302. (item) => item.policy_id === selectedSurrenderPolicyId.value,
  303. ) ?? null,
  304. );
  305. function money(cents: number) {
  306. return `¥${(cents / 100).toFixed(2)}`;
  307. }
  308. function escapeHtml(value: string) {
  309. return value
  310. .replaceAll("&", "&amp;")
  311. .replaceAll("<", "&lt;")
  312. .replaceAll(">", "&gt;")
  313. .replaceAll('"', "&quot;")
  314. .replaceAll("'", "&#039;");
  315. }
  316. function renderInlineMarkdown(value: string) {
  317. return escapeHtml(value)
  318. .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
  319. .replace(/`([^`]+)`/g, "<code>$1</code>");
  320. }
  321. function markdownCells(line: string) {
  322. return line
  323. .replace(/^\s*\|/, "")
  324. .replace(/\|\s*$/, "")
  325. .split("|")
  326. .map((cell) => cell.trim());
  327. }
  328. function renderAgentMarkdown(source: string) {
  329. const lines = source.replace(/\r\n?/g, "\n").split("\n");
  330. const output: string[] = [];
  331. let listItems: string[] = [];
  332. const flushList = () => {
  333. if (listItems.length === 0) return;
  334. output.push(`<ul>${listItems.map((item) => `<li>${item}</li>`).join("")}</ul>`);
  335. listItems = [];
  336. };
  337. for (let index = 0; index < lines.length; index += 1) {
  338. const line = lines[index].trim();
  339. if (!line || /^-{3,}$/.test(line)) {
  340. flushList();
  341. continue;
  342. }
  343. const nextLine = lines[index + 1]?.trim() ?? "";
  344. if (
  345. line.includes("|") &&
  346. /^\|?\s*:?-{3,}/.test(nextLine)
  347. ) {
  348. flushList();
  349. const headers = markdownCells(line);
  350. const rows: string[][] = [];
  351. index += 2;
  352. while (index < lines.length && lines[index].includes("|")) {
  353. rows.push(markdownCells(lines[index]));
  354. index += 1;
  355. }
  356. index -= 1;
  357. output.push(
  358. `<div class="response-table-wrap"><table><thead><tr>${headers
  359. .map((cell) => `<th>${renderInlineMarkdown(cell)}</th>`)
  360. .join("")}</tr></thead><tbody>${rows
  361. .map(
  362. (row) =>
  363. `<tr>${row
  364. .map((cell) => `<td>${renderInlineMarkdown(cell)}</td>`)
  365. .join("")}</tr>`,
  366. )
  367. .join("")}</tbody></table></div>`,
  368. );
  369. continue;
  370. }
  371. const heading = line.match(/^#{1,4}\s+(.+)$/);
  372. if (heading) {
  373. flushList();
  374. output.push(`<h4>${renderInlineMarkdown(heading[1])}</h4>`);
  375. continue;
  376. }
  377. const listItem = line.match(/^(?:[-*•]|\d+[.)])\s+(.+)$/);
  378. if (listItem) {
  379. listItems.push(renderInlineMarkdown(listItem[1]));
  380. continue;
  381. }
  382. const quoteLine = line.match(/^>\s*(.+)$/);
  383. if (quoteLine) {
  384. flushList();
  385. output.push(`<blockquote>${renderInlineMarkdown(quoteLine[1])}</blockquote>`);
  386. continue;
  387. }
  388. flushList();
  389. output.push(`<p>${renderInlineMarkdown(line)}</p>`);
  390. }
  391. flushList();
  392. return output.join("");
  393. }
  394. function dateTime(value: string) {
  395. return new Intl.DateTimeFormat("zh-CN", {
  396. year: "numeric",
  397. month: "2-digit",
  398. day: "2-digit",
  399. hour: "2-digit",
  400. minute: "2-digit",
  401. hour12: false,
  402. }).format(new Date(value));
  403. }
  404. function policyStatus(status: string) {
  405. return {
  406. ACTIVE: "保障中",
  407. PENDING: "待生效",
  408. EXPIRED: "已到期",
  409. SURRENDERING: "退保处理中",
  410. SURRENDERED: "已退保",
  411. }[status] ?? status;
  412. }
  413. function surrenderStatus(status: string) {
  414. return {
  415. PENDING_CONFIRMATION: "待确认",
  416. WAITING_APPROVAL: "等待审核",
  417. REJECTED: "审核未通过",
  418. REFUND_PENDING: "退款处理中",
  419. REFUND_RETRYING: "退款重试中",
  420. MANUAL_INTERVENTION: "人工处理中",
  421. REFUNDED: "退款完成",
  422. COMPENSATED: "已恢复保障",
  423. }[status] ?? status;
  424. }
  425. function surrenderStatusTone(status: string) {
  426. if (status === "REFUNDED" || status === "COMPENSATED") return "success";
  427. if (status === "REJECTED") return "danger";
  428. if (status === "MANUAL_INTERVENTION") return "warning";
  429. return "pending";
  430. }
  431. function serviceRequestStatus(status: string) {
  432. return {
  433. SUBMITTED: "待审核",
  434. ACCEPTED: "已受理",
  435. REJECTED: "未受理",
  436. }[status] ?? status;
  437. }
  438. function serviceRequestStatusTone(status: string) {
  439. if (status === "ACCEPTED") return "success";
  440. if (status === "REJECTED") return "danger";
  441. return "pending";
  442. }
  443. function policyById(policyId: string) {
  444. return policies.value.find((item) => item.policy_id === policyId);
  445. }
  446. function relationshipName(value: Relationship) {
  447. return {
  448. SELF: "本人",
  449. PARENT: "父母",
  450. SPOUSE: "配偶",
  451. CHILD: "子女",
  452. }[value];
  453. }
  454. function inferEnrollmentContext(text: string) {
  455. const ageMatch = text.match(/(\d{1,3})\s*岁/);
  456. const age = ageMatch ? Number(ageMatch[1]) : undefined;
  457. let inferredRelationship: Relationship | undefined;
  458. if (/(父亲|母亲|父母|爸爸|妈妈)/.test(text)) inferredRelationship = "PARENT";
  459. else if (/(爱人|配偶|丈夫|妻子|老公|老婆)/.test(text)) inferredRelationship = "SPOUSE";
  460. else if (/(孩子|子女|儿子|女儿)/.test(text)) inferredRelationship = "CHILD";
  461. else if (/(本人|自己|我想给我|为自己)/.test(text)) inferredRelationship = "SELF";
  462. return {
  463. age: age !== undefined && age >= 0 && age <= 120 ? age : undefined,
  464. relationship: inferredRelationship,
  465. };
  466. }
  467. function maskIdNo(value: string) {
  468. if (value.length < 10) return value;
  469. return `${value.slice(0, 6)}********${value.slice(-4)}`;
  470. }
  471. function createImageCode() {
  472. const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
  473. const bytes = new Uint8Array(4);
  474. crypto.getRandomValues(bytes);
  475. imageCode.value = Array.from(
  476. bytes,
  477. (value) => alphabet[value % alphabet.length],
  478. ).join("");
  479. imageCodeInput.value = "";
  480. }
  481. function startCodeCountdown() {
  482. if (countdownTimer) clearInterval(countdownTimer);
  483. codeCountdown.value = 60;
  484. countdownTimer = setInterval(() => {
  485. codeCountdown.value -= 1;
  486. if (codeCountdown.value <= 0 && countdownTimer) {
  487. clearInterval(countdownTimer);
  488. countdownTimer = undefined;
  489. }
  490. }, 1000);
  491. }
  492. function validateMobile() {
  493. if (!/^1\d{10}$/.test(mobile.value)) {
  494. error.value = "请输入正确的11位手机号";
  495. return false;
  496. }
  497. return true;
  498. }
  499. async function apiFetch(path: string, init: RequestInit = {}) {
  500. return fetch(`${apiBase}${path}`, {
  501. ...init,
  502. credentials: "include",
  503. });
  504. }
  505. async function responseError(response: Response, fallback: string) {
  506. try {
  507. const body = await response.json();
  508. return body.error?.message ?? fallback;
  509. } catch {
  510. return fallback;
  511. }
  512. }
  513. async function authorizedFetch(path: string, init: RequestInit = {}, allowRefresh = true) {
  514. const headers = new Headers(init.headers);
  515. headers.set("Authorization", `Bearer ${token.value}`);
  516. const response = await apiFetch(path, { ...init, headers });
  517. if (response.status === 401 && allowRefresh && (await refreshAccessToken())) {
  518. return authorizedFetch(path, init, false);
  519. }
  520. return response;
  521. }
  522. async function requestCode() {
  523. if (requestingCode.value) return;
  524. error.value = "";
  525. codeMessage.value = "";
  526. if (!validateMobile()) return;
  527. if (
  528. imageCodeInput.value.trim().toUpperCase() !== imageCode.value.toUpperCase()
  529. ) {
  530. error.value = "图形验证码不正确,请重新输入";
  531. createImageCode();
  532. return;
  533. }
  534. requestingCode.value = true;
  535. try {
  536. const response = await apiFetch("/h5/auth/request-code", {
  537. method: "POST",
  538. headers: { "Content-Type": "application/json" },
  539. body: JSON.stringify({ mobile: mobile.value }),
  540. });
  541. if (!response.ok) {
  542. throw new Error(await responseError(response, "验证码请求失败"));
  543. }
  544. const body = await response.json();
  545. const validMinutes = Math.max(1, Math.floor(body.data.expires_in / 60));
  546. codeRequested.value = true;
  547. codeMessage.value = `验证码已发送,${validMinutes}分钟内有效`;
  548. startCodeCountdown();
  549. } catch (reason) {
  550. error.value = reason instanceof Error ? reason.message : "请求失败";
  551. } finally {
  552. requestingCode.value = false;
  553. }
  554. }
  555. async function login() {
  556. if (loggingIn.value) return;
  557. error.value = "";
  558. if (!validateMobile()) return;
  559. if (!codeRequested.value) {
  560. error.value = "请先获取短信验证码";
  561. return;
  562. }
  563. if (!/^\d{6}$/.test(code.value)) {
  564. error.value = "请输入6位短信验证码";
  565. return;
  566. }
  567. if (!agreementAccepted.value) {
  568. error.value = "请阅读并同意服务协议与隐私政策";
  569. return;
  570. }
  571. loggingIn.value = true;
  572. try {
  573. const response = await apiFetch("/h5/auth/login", {
  574. method: "POST",
  575. headers: { "Content-Type": "application/json" },
  576. body: JSON.stringify({
  577. mobile: mobile.value,
  578. code: code.value,
  579. referral_code: referralCode || undefined,
  580. }),
  581. });
  582. if (!response.ok) {
  583. throw new Error(await responseError(response, "登录失败"));
  584. }
  585. const body = await response.json();
  586. token.value = body.data.tokens.access_token;
  587. mobile.value = body.data.user.mobile;
  588. contactMobile.value = body.data.user.mobile;
  589. code.value = "";
  590. codeMessage.value = "";
  591. await loadProducts();
  592. await loadBusiness();
  593. await loadAgentThreads();
  594. } catch (reason) {
  595. error.value = reason instanceof Error ? reason.message : "请求失败";
  596. } finally {
  597. loggingIn.value = false;
  598. }
  599. }
  600. async function refreshAccessToken() {
  601. const response = await apiFetch("/h5/auth/refresh", { method: "POST" });
  602. if (!response.ok) {
  603. token.value = "";
  604. products.value = [];
  605. return false;
  606. }
  607. const body = await response.json();
  608. token.value = body.data.tokens.access_token;
  609. mobile.value = body.data.user.mobile;
  610. contactMobile.value = body.data.user.mobile;
  611. return true;
  612. }
  613. async function loadProducts(allowRefresh = true) {
  614. const response = await apiFetch("/h5/products", {
  615. headers: { Authorization: `Bearer ${token.value}` },
  616. });
  617. if (response.status === 401 && allowRefresh && (await refreshAccessToken())) {
  618. return loadProducts(false);
  619. }
  620. if (!response.ok) {
  621. if (response.status === 401) token.value = "";
  622. throw new Error(await responseError(response, "产品加载失败"));
  623. }
  624. const body = await response.json();
  625. products.value = body.data.items;
  626. }
  627. async function loadAgentThreads() {
  628. if (!token.value) return;
  629. conversationHistoryLoading.value = true;
  630. try {
  631. const response = await authorizedFetch("/agent/threads?limit=30");
  632. if (!response.ok) {
  633. throw new Error(await responseError(response, "历史会话加载失败"));
  634. }
  635. agentThreads.value = (await response.json()).data.items;
  636. } finally {
  637. conversationHistoryLoading.value = false;
  638. }
  639. }
  640. async function openConversationHistory() {
  641. conversationHistoryOpen.value = true;
  642. error.value = "";
  643. try {
  644. await loadAgentThreads();
  645. } catch (reason) {
  646. error.value = reason instanceof Error ? reason.message : "历史会话加载失败";
  647. }
  648. }
  649. function startNewConversation() {
  650. agentThreadId.value = "";
  651. agentMessages.value = [];
  652. serviceNo.value = "";
  653. agentInput.value = "";
  654. conversationHistoryOpen.value = false;
  655. error.value = "";
  656. }
  657. async function restoreConversation(thread: AgentThreadSummary) {
  658. if (agentLoading.value) return;
  659. conversationHistoryLoading.value = true;
  660. error.value = "";
  661. try {
  662. const response = await authorizedFetch(
  663. `/agent/threads/${thread.id}/messages`,
  664. );
  665. if (!response.ok) {
  666. throw new Error(await responseError(response, "会话内容加载失败"));
  667. }
  668. const records = (await response.json()).data.items as Array<{
  669. role: "USER" | "ASSISTANT";
  670. content: string;
  671. metadata?: Record<string, any>;
  672. }>;
  673. let previousUserText = "";
  674. let restoredServiceNo = "";
  675. agentMessages.value = records.map((record) => {
  676. if (record.role === "USER") {
  677. previousUserText = record.content;
  678. return { role: "user", text: record.content };
  679. }
  680. const metadata = record.metadata ?? {};
  681. const cards = (metadata.cards ?? []) as Array<{
  682. type: string;
  683. items?: unknown[];
  684. }>;
  685. const products = cards
  686. .filter((card) => card.type === "product_recommendations")
  687. .flatMap((card) => card.items ?? []) as Product[];
  688. const sources = normalizeKnowledgeSources(
  689. cards
  690. .filter((card) => card.type === "knowledge_sources")
  691. .flatMap((card) => card.items ?? []) as KnowledgeSource[],
  692. );
  693. restoredServiceNo = String(metadata.service_no ?? restoredServiceNo);
  694. return {
  695. role: "assistant",
  696. text: record.content,
  697. html: renderAgentMarkdown(record.content),
  698. products: products.slice(0, 2),
  699. sources: sources.slice(0, 5),
  700. actions: (metadata.actions ?? []) as AgentAction[],
  701. enrollmentContext: inferEnrollmentContext(previousUserText),
  702. };
  703. });
  704. agentThreadId.value = thread.id;
  705. serviceNo.value = restoredServiceNo;
  706. conversationHistoryOpen.value = false;
  707. await nextTick();
  708. document
  709. .querySelector<HTMLElement>(".messages")
  710. ?.scrollTo({ top: 999999, behavior: "smooth" });
  711. } catch (reason) {
  712. error.value = reason instanceof Error ? reason.message : "会话内容加载失败";
  713. } finally {
  714. conversationHistoryLoading.value = false;
  715. }
  716. }
  717. async function sendAgentMessage() {
  718. const text = agentInput.value.trim();
  719. if (!text || agentLoading.value) return;
  720. agentLoading.value = true;
  721. agentProgress.value = "正在理解保障需求";
  722. error.value = "";
  723. const optimisticMessage = { role: "user" as const, text };
  724. agentMessages.value.push(optimisticMessage);
  725. agentInput.value = "";
  726. try {
  727. if (!agentThreadId.value) {
  728. const threadResponse = await authorizedFetch("/agent/threads", {
  729. method: "POST",
  730. headers: { "Content-Type": "application/json" },
  731. body: JSON.stringify({ title: text.slice(0, 28) }),
  732. });
  733. if (!threadResponse.ok) {
  734. throw new Error(await responseError(threadResponse, "创建咨询会话失败"));
  735. }
  736. agentThreadId.value = (await threadResponse.json()).data.id;
  737. }
  738. const response = await authorizedFetch(
  739. `/agent/threads/${agentThreadId.value}/messages/stream`,
  740. {
  741. method: "POST",
  742. headers: { "Content-Type": "application/json" },
  743. body: JSON.stringify({
  744. content: { type: "text", text },
  745. client_message_id: crypto.randomUUID(),
  746. }),
  747. },
  748. );
  749. if (!response.ok) {
  750. throw new Error(await responseError(response, "智能体响应失败"));
  751. }
  752. const data = await consumeAgentStream(response);
  753. const assistantText = data.assistant_message.text as string;
  754. const cards = (data.assistant_message.cards ?? []) as Array<{
  755. type: string;
  756. items?: unknown[];
  757. }>;
  758. const cardItems = cards
  759. .filter((card) => card.type === "product_recommendations")
  760. .flatMap((card) => card.items ?? []) as Product[];
  761. const knowledgeSources = normalizeKnowledgeSources(
  762. cards
  763. .filter((card) => card.type === "knowledge_sources")
  764. .flatMap((card) => card.items ?? []) as KnowledgeSource[],
  765. );
  766. const actions = (data.assistant_message.actions ?? []) as AgentAction[];
  767. agentMessages.value.push({
  768. role: "assistant",
  769. text: assistantText,
  770. html: renderAgentMarkdown(assistantText),
  771. enrollmentContext: inferEnrollmentContext(text),
  772. products: cardItems.slice(0, 2),
  773. sources: knowledgeSources.slice(0, 5),
  774. actions,
  775. });
  776. serviceNo.value = data.service_no ?? "";
  777. await loadAgentThreads();
  778. } catch (reason) {
  779. const optimisticIndex = agentMessages.value.lastIndexOf(optimisticMessage);
  780. if (optimisticIndex >= 0) agentMessages.value.splice(optimisticIndex, 1);
  781. if (!agentInput.value.trim()) agentInput.value = text;
  782. error.value = reason instanceof Error ? reason.message : "智能体响应失败";
  783. } finally {
  784. agentLoading.value = false;
  785. agentProgress.value = "";
  786. }
  787. }
  788. async function consumeAgentStream(response: Response) {
  789. if (!response.body) throw new Error("浏览器不支持流式响应");
  790. const reader = response.body.getReader();
  791. const decoder = new TextDecoder();
  792. let buffer = "";
  793. let completed: Record<string, any> | null = null;
  794. while (true) {
  795. const { done, value } = await reader.read();
  796. buffer += decoder.decode(value, { stream: !done });
  797. const packets = buffer.split("\n\n");
  798. buffer = packets.pop() ?? "";
  799. for (const packet of packets) {
  800. const dataLine = packet
  801. .split("\n")
  802. .find((line) => line.startsWith("data: "));
  803. if (!dataLine) continue;
  804. const event = JSON.parse(dataLine.slice(6)) as RuntimeEvent;
  805. if (event.event === "tool.completed") {
  806. agentProgress.value = "正在检索已发布知识与业务规则";
  807. } else if (event.event === "ui.ready") {
  808. agentProgress.value = "正在整理可核验的答复";
  809. } else if (event.event === "run.failed") {
  810. throw new Error(String(event.data.message ?? "智能体响应失败"));
  811. } else if (event.event === "run.completed") {
  812. completed = event.data.result as Record<string, any>;
  813. }
  814. }
  815. if (done) break;
  816. }
  817. if (!completed) throw new Error("智能体运行未正常结束");
  818. return completed;
  819. }
  820. async function executeAgentAction(
  821. action: AgentAction,
  822. context?: ChatMessage["enrollmentContext"],
  823. ) {
  824. if (
  825. action.type === "open_orders" ||
  826. action.type === "open_policies"
  827. ) {
  828. activeTab.value = "coverage";
  829. coverageSection.value = "policies";
  830. await loadBusiness();
  831. return;
  832. }
  833. if (action.type === "open_surrenders") {
  834. activeTab.value = "coverage";
  835. coverageSection.value = "surrenders";
  836. await loadBusiness();
  837. return;
  838. }
  839. if (action.type !== "open_enrollment") return;
  840. const productCode =
  841. typeof action.payload.product_code === "string"
  842. ? action.payload.product_code
  843. : "";
  844. const product = products.value.find(
  845. (item) => item.product_code === productCode,
  846. );
  847. if (!product) {
  848. error.value = "推荐产品已不在当前在售目录,请重新咨询";
  849. return;
  850. }
  851. const age =
  852. typeof action.payload.age === "number"
  853. ? action.payload.age
  854. : context?.age;
  855. const rawRelationship = action.payload.relationship;
  856. const actionRelationship = (
  857. ["SELF", "PARENT", "SPOUSE", "CHILD"] as const
  858. ).find((item) => item === rawRelationship);
  859. await startDirectEnrollment(product, {
  860. age,
  861. relationship: actionRelationship ?? context?.relationship,
  862. });
  863. }
  864. function viewProduct(product: Product) {
  865. selectedProduct.value = product;
  866. activeTab.value = "plans";
  867. window.scrollTo({ top: 0, behavior: "smooth" });
  868. }
  869. async function startDirectEnrollment(
  870. product: Product,
  871. context?: ChatMessage["enrollmentContext"],
  872. ) {
  873. selectedProduct.value = product;
  874. if (context?.age !== undefined) insuredAge.value = context.age;
  875. if (context?.relationship) relationship.value = context.relationship;
  876. quote.value = null;
  877. resetEnrollmentFlow();
  878. preQuoteConfirming.value = true;
  879. enrollmentOpen.value = true;
  880. error.value = "";
  881. await nextTick();
  882. document
  883. .querySelector<HTMLElement>(".enrollment-body")
  884. ?.scrollTo({ top: 0 });
  885. }
  886. async function confirmDirectEnrollment() {
  887. if (!selectedProduct.value) return;
  888. if (
  889. !Number.isInteger(insuredAge.value) ||
  890. insuredAge.value < 0 ||
  891. insuredAge.value > 120
  892. ) {
  893. error.value = "请输入0至120之间的有效年龄";
  894. return;
  895. }
  896. await createQuote(selectedProduct.value);
  897. }
  898. function openPolicyDocument(item: Policy) {
  899. openedPolicy.value = item;
  900. }
  901. function closePolicyDocument() {
  902. openedPolicy.value = null;
  903. }
  904. function printPolicyDocument() {
  905. window.print();
  906. }
  907. function resetEnrollmentFlow() {
  908. enrollmentStep.value = 1;
  909. noticeAccepted.value = false;
  910. disclosureConfirmed.value = false;
  911. order.value = null;
  912. payment.value = null;
  913. policy.value = null;
  914. }
  915. async function setEnrollmentStep(step: number) {
  916. enrollmentStep.value = step;
  917. await nextTick();
  918. document
  919. .querySelector<HTMLElement>(".enrollment-body")
  920. ?.scrollTo({ top: 0, behavior: "smooth" });
  921. }
  922. async function createQuote(product: Product) {
  923. businessLoading.value = true;
  924. quotingProductId.value = product.product_id;
  925. quoteFeedback.value = "正在校验投保资格并试算保费…";
  926. error.value = "";
  927. try {
  928. const response = await authorizedFetch("/h5/quotes", {
  929. method: "POST",
  930. headers: { "Content-Type": "application/json" },
  931. body: JSON.stringify({
  932. product_id: product.product_id,
  933. plan_id: product.plans[0].id,
  934. insured: {
  935. age: insuredAge.value,
  936. region_code: "510100",
  937. occupation_code: "GENERAL",
  938. },
  939. relationship: relationship.value,
  940. }),
  941. });
  942. if (!response.ok) throw new Error(await responseError(response, "报价失败"));
  943. selectedProduct.value = product;
  944. quote.value = (await response.json()).data;
  945. resetEnrollmentFlow();
  946. preQuoteConfirming.value = false;
  947. enrollmentOpen.value = true;
  948. quoteFeedback.value = "";
  949. await setEnrollmentStep(1);
  950. } catch (reason) {
  951. error.value = reason instanceof Error ? reason.message : "报价失败";
  952. quoteFeedback.value = error.value;
  953. } finally {
  954. businessLoading.value = false;
  955. quotingProductId.value = "";
  956. }
  957. }
  958. function validateEnrollmentPeople() {
  959. error.value = "";
  960. if (!applicantName.value.trim() || !insuredName.value.trim()) {
  961. error.value = "请填写投保人和被保人姓名";
  962. return false;
  963. }
  964. const idPattern = /^\d{17}[\dXx]$/;
  965. if (
  966. !idPattern.test(applicantIdNo.value) ||
  967. !idPattern.test(insuredIdNo.value)
  968. ) {
  969. error.value = "请输入正确的18位身份证号码";
  970. return false;
  971. }
  972. if (!/^1\d{10}$/.test(contactMobile.value)) {
  973. error.value = "请输入正确的11位联系手机号";
  974. return false;
  975. }
  976. return true;
  977. }
  978. async function goToNotice() {
  979. if (!validateEnrollmentPeople()) return;
  980. error.value = "";
  981. await setEnrollmentStep(2);
  982. }
  983. async function goToConfirmation() {
  984. error.value = "";
  985. if (!noticeAccepted.value || !disclosureConfirmed.value) {
  986. error.value = "请阅读投保须知并完成两项确认";
  987. return;
  988. }
  989. await setEnrollmentStep(3);
  990. }
  991. async function confirmAndCreateOrder() {
  992. if (!quote.value) return;
  993. businessLoading.value = true;
  994. error.value = "";
  995. try {
  996. const draftResponse = await authorizedFetch("/h5/enrollment-drafts", {
  997. method: "POST",
  998. headers: { "Content-Type": "application/json" },
  999. body: JSON.stringify({
  1000. quote_id: quote.value.quote_id,
  1001. applicant: {
  1002. name: applicantName.value.trim(),
  1003. id_no: applicantIdNo.value.toUpperCase(),
  1004. },
  1005. insured: {
  1006. name: insuredName.value.trim(),
  1007. id_no: insuredIdNo.value.toUpperCase(),
  1008. },
  1009. contact: { mobile: contactMobile.value },
  1010. }),
  1011. });
  1012. if (!draftResponse.ok) {
  1013. throw new Error(await responseError(draftResponse, "投保草稿创建失败"));
  1014. }
  1015. const draft = (await draftResponse.json()).data;
  1016. const confirmationResponse = await authorizedFetch(
  1017. `/h5/enrollment-drafts/${draft.draft_id}/confirmation`,
  1018. { method: "POST" },
  1019. );
  1020. if (!confirmationResponse.ok) {
  1021. throw new Error(await responseError(confirmationResponse, "确认失败"));
  1022. }
  1023. const confirmation = (await confirmationResponse.json()).data;
  1024. const orderResponse = await authorizedFetch("/h5/orders", {
  1025. method: "POST",
  1026. headers: {
  1027. "Content-Type": "application/json",
  1028. "Idempotency-Key": crypto.randomUUID(),
  1029. },
  1030. body: JSON.stringify({
  1031. draft_id: draft.draft_id,
  1032. confirmation_token: confirmation.confirmation_token,
  1033. }),
  1034. });
  1035. if (!orderResponse.ok) {
  1036. throw new Error(await responseError(orderResponse, "订单创建失败"));
  1037. }
  1038. order.value = (await orderResponse.json()).data;
  1039. const paymentCreated = await startPayment();
  1040. if (paymentCreated) await setEnrollmentStep(4);
  1041. } catch (reason) {
  1042. error.value = reason instanceof Error ? reason.message : "订单创建失败";
  1043. } finally {
  1044. businessLoading.value = false;
  1045. }
  1046. }
  1047. async function startPayment() {
  1048. if (!order.value) return false;
  1049. businessLoading.value = true;
  1050. error.value = "";
  1051. try {
  1052. const response = await authorizedFetch(
  1053. `/h5/orders/${order.value.order_id}/payments`,
  1054. {
  1055. method: "POST",
  1056. headers: { "Idempotency-Key": crypto.randomUUID() },
  1057. },
  1058. );
  1059. if (!response.ok) throw new Error(await responseError(response, "支付发起失败"));
  1060. payment.value = (await response.json()).data;
  1061. return true;
  1062. } catch (reason) {
  1063. error.value = reason instanceof Error ? reason.message : "支付发起失败";
  1064. return false;
  1065. } finally {
  1066. businessLoading.value = false;
  1067. }
  1068. }
  1069. async function completePayment() {
  1070. if (!payment.value) return;
  1071. businessLoading.value = true;
  1072. error.value = "";
  1073. try {
  1074. const response = await authorizedFetch(
  1075. `/dev/mock-payments/${payment.value.payment_id}/complete`,
  1076. { method: "POST" },
  1077. );
  1078. if (!response.ok) throw new Error(await responseError(response, "支付回调失败"));
  1079. const body = await response.json();
  1080. payment.value.status = body.data.payment_status;
  1081. if (order.value) order.value.status = body.data.order_status;
  1082. policy.value = body.data.policy;
  1083. await loadBusiness();
  1084. } catch (reason) {
  1085. error.value = reason instanceof Error ? reason.message : "支付回调失败";
  1086. } finally {
  1087. businessLoading.value = false;
  1088. }
  1089. }
  1090. async function closeEnrollment() {
  1091. enrollmentOpen.value = false;
  1092. preQuoteConfirming.value = false;
  1093. error.value = "";
  1094. await loadBusiness();
  1095. }
  1096. async function viewCoverageAfterPayment() {
  1097. enrollmentOpen.value = false;
  1098. activeTab.value = "coverage";
  1099. coverageSection.value = "policies";
  1100. await loadBusiness();
  1101. window.scrollTo({ top: 0, behavior: "smooth" });
  1102. }
  1103. async function openServiceCenter() {
  1104. activeTab.value = "services";
  1105. serviceError.value = "";
  1106. await loadBusiness();
  1107. window.scrollTo({ top: 0, behavior: "smooth" });
  1108. }
  1109. function startPolicyService(item: Policy) {
  1110. if (item.status !== "ACTIVE") {
  1111. error.value = "只有保障中的保单可以发起服务";
  1112. return;
  1113. }
  1114. selectedServicePolicyId.value = item.policy_id;
  1115. clearServiceMaterial();
  1116. serviceError.value = "";
  1117. activeTab.value = "services";
  1118. serviceApplicationOpen.value = true;
  1119. window.scrollTo({ top: 0, behavior: "smooth" });
  1120. }
  1121. // 退保前先向后端获取确定性试算;前端不自行计算退款金额。
  1122. async function startSurrender(item: Policy) {
  1123. if (item.status !== "ACTIVE") {
  1124. error.value = "只有保障中的保单可以申请退保";
  1125. return;
  1126. }
  1127. selectedSurrenderPolicyId.value = item.policy_id;
  1128. surrenderPreview.value = null;
  1129. surrenderReason.value = "保障计划调整";
  1130. surrenderReasonDetail.value = "";
  1131. surrenderConfirmed.value = false;
  1132. surrenderError.value = "";
  1133. surrenderApplicationOpen.value = true;
  1134. surrenderLoading.value = true;
  1135. try {
  1136. const response = await authorizedFetch(
  1137. `/h5/policies/${item.policy_id}/surrender-preview`,
  1138. );
  1139. if (!response.ok) {
  1140. throw new Error(await responseError(response, "退保试算失败"));
  1141. }
  1142. surrenderPreview.value = (await response.json()).data as SurrenderPreview;
  1143. } catch (reason) {
  1144. surrenderError.value =
  1145. reason instanceof Error ? reason.message : "退保试算失败";
  1146. } finally {
  1147. surrenderLoading.value = false;
  1148. }
  1149. }
  1150. function closeSurrenderApplication() {
  1151. surrenderApplicationOpen.value = false;
  1152. surrenderError.value = "";
  1153. surrenderPreview.value = null;
  1154. surrenderReasonDetail.value = "";
  1155. surrenderConfirmed.value = false;
  1156. }
  1157. async function submitSurrender() {
  1158. if (!surrenderPreview.value?.eligible || !selectedSurrenderPolicy.value) {
  1159. surrenderError.value = surrenderPreview.value?.reason ?? "当前保单暂不能申请退保";
  1160. return;
  1161. }
  1162. if (!surrenderReason.value.trim()) {
  1163. surrenderError.value = "请选择退保原因";
  1164. return;
  1165. }
  1166. if (
  1167. ["扣费或服务问题", "其他原因"].includes(surrenderReason.value) &&
  1168. surrenderReasonDetail.value.trim().length < 2
  1169. ) {
  1170. surrenderError.value = "请填写具体的退保原因说明";
  1171. return;
  1172. }
  1173. if (!surrenderConfirmed.value) {
  1174. surrenderError.value = "请确认退款试算与保障终止提示";
  1175. return;
  1176. }
  1177. surrenderLoading.value = true;
  1178. surrenderError.value = "";
  1179. try {
  1180. // 第一步创建待确认申请;随机幂等键可防止本次重复点击产生两笔申请。
  1181. const createdResponse = await authorizedFetch("/h5/surrenders", {
  1182. method: "POST",
  1183. headers: {
  1184. "Content-Type": "application/json",
  1185. "Idempotency-Key": crypto.randomUUID(),
  1186. },
  1187. body: JSON.stringify({
  1188. policy_id: selectedSurrenderPolicy.value.policy_id,
  1189. reason_category: surrenderReason.value.trim(),
  1190. reason_detail: surrenderReasonDetail.value.trim() || null,
  1191. }),
  1192. });
  1193. if (!createdResponse.ok) {
  1194. throw new Error(await responseError(createdResponse, "退保申请提交失败"));
  1195. }
  1196. const created = (await createdResponse.json()).data as SurrenderRequestItem;
  1197. // 第二步显式确认,后端此时才启动 LangGraph 风险评估和自动/人工路由。
  1198. const confirmResponse = await authorizedFetch(
  1199. `/h5/surrenders/${created.request_id}/confirm`,
  1200. { method: "POST" },
  1201. );
  1202. if (!confirmResponse.ok) {
  1203. throw new Error(await responseError(confirmResponse, "退保信息确认失败"));
  1204. }
  1205. await loadBusiness();
  1206. closeSurrenderApplication();
  1207. coverageSection.value = "surrenders";
  1208. window.scrollTo({ top: 0, behavior: "smooth" });
  1209. } catch (reason) {
  1210. surrenderError.value =
  1211. reason instanceof Error ? reason.message : "退保申请提交失败";
  1212. } finally {
  1213. surrenderLoading.value = false;
  1214. }
  1215. }
  1216. function closeServiceApplication() {
  1217. serviceApplicationOpen.value = false;
  1218. serviceError.value = "";
  1219. clearServiceMaterial();
  1220. }
  1221. function clearServiceMaterial() {
  1222. if (serviceFilePreview.value) URL.revokeObjectURL(serviceFilePreview.value);
  1223. serviceFile.value = null;
  1224. serviceFilePreview.value = "";
  1225. materialRecognition.value = null;
  1226. serviceConfirmed.value = false;
  1227. serviceFields.value = {
  1228. invoice_no: "",
  1229. hospital_name: "",
  1230. visit_date: "",
  1231. amount_yuan: "",
  1232. contact_mobile: mobile.value,
  1233. };
  1234. }
  1235. function handleServiceFile(event: Event) {
  1236. serviceError.value = "";
  1237. const input = event.target as HTMLInputElement;
  1238. const file = input.files?.[0] ?? null;
  1239. if (!file) return;
  1240. if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
  1241. serviceError.value = "请上传 JPG、PNG 或 WebP 格式的医疗费用发票";
  1242. input.value = "";
  1243. return;
  1244. }
  1245. if (file.size > 10 * 1024 * 1024) {
  1246. serviceError.value = "单张材料不能超过 10MB";
  1247. input.value = "";
  1248. return;
  1249. }
  1250. clearServiceMaterial();
  1251. serviceFile.value = file;
  1252. serviceFilePreview.value = URL.createObjectURL(file);
  1253. }
  1254. async function recognizeServiceMaterial() {
  1255. if (!selectedServicePolicyId.value) {
  1256. serviceError.value = "请先选择需要申请服务的有效保单";
  1257. return;
  1258. }
  1259. if (!serviceFile.value) {
  1260. serviceError.value = "请先上传医疗费用发票";
  1261. return;
  1262. }
  1263. serviceLoading.value = true;
  1264. serviceError.value = "";
  1265. try {
  1266. const form = new FormData();
  1267. form.append("policy_id", selectedServicePolicyId.value);
  1268. form.append("material_type", "MEDICAL_INVOICE");
  1269. form.append("file", serviceFile.value);
  1270. const response = await authorizedFetch("/h5/service-materials/recognize", {
  1271. method: "POST",
  1272. body: form,
  1273. });
  1274. if (!response.ok) {
  1275. throw new Error(await responseError(response, "医疗票据识别失败"));
  1276. }
  1277. materialRecognition.value = (await response.json()).data;
  1278. const fields = materialRecognition.value?.fields ?? {};
  1279. serviceFields.value = {
  1280. invoice_no: fields.invoice_no ?? "",
  1281. hospital_name: fields.hospital_name ?? "",
  1282. visit_date: fields.visit_date ?? "",
  1283. amount_yuan: fields.amount_yuan ?? "",
  1284. contact_mobile: mobile.value,
  1285. };
  1286. } catch (reason) {
  1287. serviceError.value =
  1288. reason instanceof Error ? reason.message : "医疗票据识别失败";
  1289. } finally {
  1290. serviceLoading.value = false;
  1291. }
  1292. }
  1293. async function submitServiceRequest() {
  1294. if (!materialRecognition.value) {
  1295. serviceError.value = "请先完成医疗票据识别";
  1296. return;
  1297. }
  1298. const requiredFields = [
  1299. serviceFields.value.invoice_no,
  1300. serviceFields.value.hospital_name,
  1301. serviceFields.value.amount_yuan,
  1302. serviceFields.value.contact_mobile,
  1303. ];
  1304. if (requiredFields.some((value) => !value.trim())) {
  1305. serviceError.value = "请完整填写票据号码、医疗机构、费用金额和联系电话";
  1306. return;
  1307. }
  1308. if (!/^1\d{10}$/.test(serviceFields.value.contact_mobile)) {
  1309. serviceError.value = "请输入正确的11位联系电话";
  1310. return;
  1311. }
  1312. if (!serviceConfirmed.value) {
  1313. serviceError.value = "请确认识别内容已经人工核对";
  1314. return;
  1315. }
  1316. serviceLoading.value = true;
  1317. serviceError.value = "";
  1318. try {
  1319. const response = await authorizedFetch("/h5/service-requests", {
  1320. method: "POST",
  1321. headers: { "Content-Type": "application/json" },
  1322. body: JSON.stringify({
  1323. policy_id: selectedServicePolicyId.value,
  1324. request_type: "MEDICAL_REIMBURSEMENT_ASSISTANCE",
  1325. recognition_id: materialRecognition.value.recognition_id,
  1326. confirmed_fields: serviceFields.value,
  1327. }),
  1328. });
  1329. if (!response.ok) {
  1330. throw new Error(await responseError(response, "服务申请提交失败"));
  1331. }
  1332. clearServiceMaterial();
  1333. await loadBusiness();
  1334. serviceApplicationOpen.value = false;
  1335. activeTab.value = "services";
  1336. window.scrollTo({ top: 0, behavior: "smooth" });
  1337. } catch (reason) {
  1338. serviceError.value =
  1339. reason instanceof Error ? reason.message : "服务申请提交失败";
  1340. } finally {
  1341. serviceLoading.value = false;
  1342. }
  1343. }
  1344. async function loadBusiness() {
  1345. if (!token.value) return;
  1346. const [
  1347. ordersResponse,
  1348. policiesResponse,
  1349. serviceRequestsResponse,
  1350. surrenderRequestsResponse,
  1351. ] = await Promise.all([
  1352. authorizedFetch("/h5/orders"),
  1353. authorizedFetch("/h5/policies"),
  1354. authorizedFetch("/h5/service-requests"),
  1355. authorizedFetch("/h5/surrenders"),
  1356. ]);
  1357. if (ordersResponse.ok) {
  1358. const items = (await ordersResponse.json()).data.items as Order[];
  1359. order.value = items[0] ?? null;
  1360. }
  1361. if (policiesResponse.ok) {
  1362. const items = (await policiesResponse.json()).data.items as Policy[];
  1363. policies.value = items;
  1364. if (
  1365. !selectedServicePolicyId.value ||
  1366. !items.some(
  1367. (item) =>
  1368. item.policy_id === selectedServicePolicyId.value &&
  1369. item.status === "ACTIVE",
  1370. )
  1371. ) {
  1372. selectedServicePolicyId.value =
  1373. items.find((item) => item.status === "ACTIVE")?.policy_id ?? "";
  1374. }
  1375. }
  1376. if (serviceRequestsResponse.ok) {
  1377. serviceRequests.value = (
  1378. await serviceRequestsResponse.json()
  1379. ).data.items as ServiceRequestItem[];
  1380. }
  1381. if (surrenderRequestsResponse.ok) {
  1382. surrenderRequests.value = (
  1383. await surrenderRequestsResponse.json()
  1384. ).data.items as SurrenderRequestItem[];
  1385. }
  1386. }
  1387. async function refreshProducts() {
  1388. refreshing.value = true;
  1389. error.value = "";
  1390. try {
  1391. await loadProducts();
  1392. } catch (reason) {
  1393. error.value = reason instanceof Error ? reason.message : "产品加载失败";
  1394. } finally {
  1395. refreshing.value = false;
  1396. }
  1397. }
  1398. async function logout() {
  1399. try {
  1400. if (token.value) {
  1401. await apiFetch("/h5/auth/logout", {
  1402. method: "POST",
  1403. headers: { Authorization: `Bearer ${token.value}` },
  1404. });
  1405. }
  1406. } finally {
  1407. token.value = "";
  1408. contactMobile.value = "";
  1409. products.value = [];
  1410. error.value = "";
  1411. agentThreadId.value = "";
  1412. agentMessages.value = [];
  1413. agentThreads.value = [];
  1414. conversationHistoryOpen.value = false;
  1415. serviceNo.value = "";
  1416. activeTab.value = "advisor";
  1417. coverageSection.value = "policies";
  1418. selectedProduct.value = null;
  1419. quote.value = null;
  1420. order.value = null;
  1421. payment.value = null;
  1422. policy.value = null;
  1423. policies.value = [];
  1424. openedPolicy.value = null;
  1425. serviceRequests.value = [];
  1426. surrenderRequests.value = [];
  1427. surrenderApplicationOpen.value = false;
  1428. surrenderError.value = "";
  1429. selectedSurrenderPolicyId.value = "";
  1430. surrenderPreview.value = null;
  1431. surrenderConfirmed.value = false;
  1432. serviceApplicationOpen.value = false;
  1433. serviceError.value = "";
  1434. selectedServicePolicyId.value = "";
  1435. clearServiceMaterial();
  1436. quotingProductId.value = "";
  1437. quoteFeedback.value = "";
  1438. enrollmentOpen.value = false;
  1439. preQuoteConfirming.value = false;
  1440. enrollmentStep.value = 1;
  1441. applicantName.value = "";
  1442. applicantIdNo.value = "";
  1443. insuredName.value = "";
  1444. insuredIdNo.value = "";
  1445. relationship.value = "PARENT";
  1446. noticeAccepted.value = false;
  1447. disclosureConfirmed.value = false;
  1448. codeRequested.value = false;
  1449. codeCountdown.value = 0;
  1450. agreementAccepted.value = false;
  1451. if (countdownTimer) {
  1452. clearInterval(countdownTimer);
  1453. countdownTimer = undefined;
  1454. }
  1455. createImageCode();
  1456. }
  1457. }
  1458. onMounted(async () => {
  1459. createImageCode();
  1460. // 清理早期版本遗留的持久化Access Token,当前版本只在内存中保存。
  1461. localStorage.removeItem("zbt_h5_token");
  1462. try {
  1463. if (await refreshAccessToken()) await loadProducts(false);
  1464. if (token.value) {
  1465. await Promise.all([loadBusiness(), loadAgentThreads()]);
  1466. }
  1467. } catch {
  1468. token.value = "";
  1469. products.value = [];
  1470. } finally {
  1471. restoring.value = false;
  1472. }
  1473. });
  1474. onBeforeUnmount(() => {
  1475. if (countdownTimer) clearInterval(countdownTimer);
  1476. if (serviceFilePreview.value) URL.revokeObjectURL(serviceFilePreview.value);
  1477. });
  1478. </script>
  1479. <template>
  1480. <main class="phone-shell">
  1481. <section v-if="restoring || !token" class="hero">
  1482. <div class="brand-line"><span class="seal">智</span> 智保通</div>
  1483. <p class="eyebrow">AI 智能保险服务平台</p>
  1484. <h1>把复杂条款,<br /><em>说成一句明白话。</em></h1>
  1485. <div class="hero-orbit" aria-hidden="true"></div>
  1486. </section>
  1487. <section v-if="restoring" class="login-card session-loading" aria-live="polite">
  1488. <p class="step">00 / 会话检查</p>
  1489. <h2>正在恢复安全会话…</h2>
  1490. <p class="hint">仅从安全 Cookie 轮换登录凭证。</p>
  1491. </section>
  1492. <section v-else-if="!token" class="login-card">
  1493. <div class="login-heading">
  1494. <div>
  1495. <p class="step">01 / 安全登录</p>
  1496. <h2>欢迎使用智保通</h2>
  1497. </div>
  1498. <span class="secure-badge"><i></i>安全连接</span>
  1499. </div>
  1500. <p class="login-intro">登录后继续咨询方案、查询订单与管理个人保单。</p>
  1501. <p v-if="referralCode" class="referral-banner">
  1502. <span>邀</span> 已识别专属服务邀请 · {{ referralCode }}
  1503. </p>
  1504. <form class="login-form" @submit.prevent="login">
  1505. <label>
  1506. 手机号
  1507. <span class="field-shell">
  1508. <span class="field-prefix">+86</span>
  1509. <input
  1510. v-model.trim="mobile"
  1511. inputmode="tel"
  1512. autocomplete="tel"
  1513. maxlength="11"
  1514. placeholder="请输入手机号"
  1515. aria-label="手机号"
  1516. />
  1517. </span>
  1518. </label>
  1519. <label>
  1520. 图形验证码
  1521. <span class="field-row">
  1522. <input
  1523. v-model.trim="imageCodeInput"
  1524. class="image-code-input"
  1525. maxlength="4"
  1526. autocomplete="off"
  1527. placeholder="请输入图中字符"
  1528. aria-label="图形验证码"
  1529. />
  1530. <button
  1531. type="button"
  1532. class="captcha"
  1533. aria-label="刷新图形验证码"
  1534. title="看不清?点击换一张"
  1535. @click="createImageCode"
  1536. >
  1537. <span
  1538. v-for="(letter, index) in imageCode"
  1539. :key="`${letter}-${index}`"
  1540. :style="{ transform: `rotate(${index % 2 === 0 ? -8 : 7}deg) translateY(${index % 3 - 1}px)` }"
  1541. >{{ letter }}</span>
  1542. <i></i><i></i>
  1543. </button>
  1544. </span>
  1545. <small class="field-help">看不清?点击图片更换</small>
  1546. </label>
  1547. <label>
  1548. 短信验证码
  1549. <span class="field-row">
  1550. <input
  1551. v-model.trim="code"
  1552. inputmode="numeric"
  1553. autocomplete="one-time-code"
  1554. maxlength="6"
  1555. placeholder="请输入6位验证码"
  1556. aria-label="短信验证码"
  1557. />
  1558. <button
  1559. type="button"
  1560. class="send-code"
  1561. :disabled="!canRequestCode"
  1562. @click="requestCode"
  1563. >
  1564. {{
  1565. requestingCode
  1566. ? "发送中…"
  1567. : codeCountdown > 0
  1568. ? `${codeCountdown}s 后重发`
  1569. : "获取验证码"
  1570. }}
  1571. </button>
  1572. </span>
  1573. </label>
  1574. <label class="agreement">
  1575. <input v-model="agreementAccepted" type="checkbox" />
  1576. <span>我已阅读并同意<a href="#service-agreement">《用户服务协议》</a>和<a href="#privacy-policy">《隐私政策》</a></span>
  1577. </label>
  1578. <p v-if="codeMessage" class="success status-message" aria-live="polite">
  1579. <span>✓</span>{{ codeMessage }}
  1580. </p>
  1581. <p v-if="error" class="error status-message" role="alert">
  1582. <span>!</span>{{ error }}
  1583. </p>
  1584. <button class="login-submit" :disabled="loggingIn" type="submit">
  1585. {{ loggingIn ? "正在安全登录…" : "登录" }}
  1586. <span aria-hidden="true">→</span>
  1587. </button>
  1588. </form>
  1589. <p class="privacy-note"><span>◇</span> 你的个人信息将被加密保护</p>
  1590. </section>
  1591. <div v-else class="app-shell">
  1592. <header class="app-header">
  1593. <div class="compact-brand">
  1594. <span class="seal">智</span>
  1595. <div><strong>智保通</strong><small>懂你的保险顾问</small></div>
  1596. </div>
  1597. <button class="header-action" aria-label="退出登录" @click="logout">
  1598. <svg viewBox="0 0 24 24" aria-hidden="true">
  1599. <path d="M10 4H5v16h5M14 8l4 4-4 4M18 12H9" />
  1600. </svg>
  1601. </button>
  1602. </header>
  1603. <div class="tab-content">
  1604. <section v-if="activeTab === 'advisor'" class="tab-page advisor-page">
  1605. <div class="page-heading">
  1606. <div><p class="step">AI INSURANCE ADVISOR</p><h2>智能顾问</h2></div>
  1607. <div class="advisor-heading-actions">
  1608. <button type="button" class="conversation-action" @click="startNewConversation">
  1609. <span>+</span>新会话
  1610. </button>
  1611. <button type="button" class="conversation-action history" @click="openConversationHistory">
  1612. 历史<em v-if="agentThreads.length">{{ agentThreads.length }}</em>
  1613. </button>
  1614. </div>
  1615. </div>
  1616. <p class="page-lead">可以咨询保障方案,也可以查询保险条款和服务材料;知识回答会标明来源。</p>
  1617. <div class="quick-prompts">
  1618. <button @click="agentInput = '我想给65岁的父亲买医疗险,常住成都,普通职业。'">父母医疗</button>
  1619. <button @click="agentInput = '我想给一家三口配置意外保障,请帮我推荐。'">家庭意外</button>
  1620. <button @click="agentInput = '我想了解基础医疗保障,年龄32岁。'">成人医疗</button>
  1621. <button @click="agentInput = '医疗险等待期是多少天?请给出条款来源。'">条款问答</button>
  1622. </div>
  1623. <div class="agent-panel">
  1624. <div class="agent-head">
  1625. <span class="agent-mark">AI</span>
  1626. <div><strong>你好,我是智保通顾问</strong><small>在线服务 · 安全连接</small></div>
  1627. </div>
  1628. <div class="messages" aria-live="polite">
  1629. <div v-if="agentMessages.length === 0" class="welcome-message">
  1630. <strong>我可以帮你做什么?</strong>
  1631. <p>告诉我为谁投保、年龄、常住地区和职业,我会先理解需求,再推荐方案。</p>
  1632. </div>
  1633. <div
  1634. v-for="(message, index) in agentMessages"
  1635. :key="index"
  1636. :class="['message', message.role]"
  1637. >
  1638. <div class="message-meta">
  1639. <span v-if="message.role === 'assistant'">AI</span>
  1640. <small>{{ message.role === "user" ? "你" : "智保通顾问" }}</small>
  1641. </div>
  1642. <div
  1643. v-if="message.role === 'assistant'"
  1644. class="assistant-content"
  1645. v-html="message.html"
  1646. ></div>
  1647. <p v-else>{{ message.text }}</p>
  1648. <div v-if="message.products?.length" class="recommendation-list">
  1649. <div
  1650. v-for="(product, productIndex) in message.products"
  1651. :key="product.product_id"
  1652. class="recommendation-card"
  1653. >
  1654. <div>
  1655. <span>{{ productIndex === 0 ? "优先推荐" : "备选方案" }}</span>
  1656. <strong>{{ product.name }}</strong>
  1657. <small>{{ product.summary }}</small>
  1658. </div>
  1659. <button @click="startDirectEnrollment(product, message.enrollmentContext)">
  1660. 立即投保 →
  1661. </button>
  1662. </div>
  1663. </div>
  1664. <div v-if="message.sources?.length" class="knowledge-source-list">
  1665. <div class="knowledge-source-title">
  1666. <span>依据</span>
  1667. <strong>本次回答引用</strong>
  1668. </div>
  1669. <article
  1670. v-for="source in message.sources"
  1671. :key="`${source.document_id}-${source.document_version}-${source.source_name}`"
  1672. >
  1673. <span>V{{ source.document_version }}</span>
  1674. <div>
  1675. <strong>{{ source.title }}</strong>
  1676. <ul
  1677. v-if="source.excerpts && source.excerpts.length > 1"
  1678. class="knowledge-source-evidence"
  1679. >
  1680. <li
  1681. v-for="(excerpt, excerptIndex) in source.excerpts"
  1682. :key="`${source.document_id}-${source.document_version}-${excerptIndex}`"
  1683. >
  1684. {{ excerpt }}
  1685. </li>
  1686. </ul>
  1687. <p v-else>{{ source.excerpts?.[0] ?? source.excerpt }}</p>
  1688. <small>{{ source.source_name }}</small>
  1689. </div>
  1690. </article>
  1691. </div>
  1692. <div
  1693. v-if="
  1694. message.actions?.length &&
  1695. !message.products?.length &&
  1696. !message.sources?.length
  1697. "
  1698. class="agent-actions"
  1699. >
  1700. <button
  1701. v-for="action in message.actions.filter((item) => item.type !== 'none')"
  1702. :key="`${action.type}-${action.label}`"
  1703. type="button"
  1704. @click="executeAgentAction(action, message.enrollmentContext)"
  1705. >
  1706. {{ action.label }} <span>→</span>
  1707. </button>
  1708. </div>
  1709. </div>
  1710. </div>
  1711. <div class="agent-composer">
  1712. <textarea
  1713. v-model="agentInput"
  1714. rows="3"
  1715. aria-label="投保需求"
  1716. placeholder="请描述你的投保需求…"
  1717. ></textarea>
  1718. <button
  1719. aria-label="发送消息"
  1720. :disabled="agentLoading || !agentInput.trim()"
  1721. @click="sendAgentMessage"
  1722. >
  1723. <svg viewBox="0 0 24 24" aria-hidden="true">
  1724. <path d="m4 5 16 7-16 7 3-7-3-7Zm3 7h13" />
  1725. </svg>
  1726. </button>
  1727. </div>
  1728. <p v-if="agentLoading" class="thinking"><i></i><i></i><i></i> {{ agentProgress }}</p>
  1729. <p v-if="serviceNo" class="trace">服务流水号 · {{ serviceNo }}</p>
  1730. </div>
  1731. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  1732. </section>
  1733. <section v-else-if="activeTab === 'plans'" class="tab-page plans-page">
  1734. <div class="page-heading">
  1735. <div><p class="step">PROTECTION PLANS</p><h2>保障方案</h2></div>
  1736. <button class="refresh-button" :disabled="refreshing" @click="refreshProducts">
  1737. {{ refreshing ? "刷新中" : "刷新" }}
  1738. </button>
  1739. </div>
  1740. <p class="page-lead">先填写被保人的基本情况,再查看确定性保费。</p>
  1741. <div class="quote-conditions">
  1742. <div class="condition-title"><span>01</span><strong>被保人信息</strong></div>
  1743. <label>
  1744. 与投保人关系
  1745. <span class="relation-options">
  1746. <button
  1747. v-for="item in (['SELF', 'PARENT', 'SPOUSE', 'CHILD'] as Relationship[])"
  1748. :key="item"
  1749. :class="{ active: relationship === item }"
  1750. @click="relationship = item"
  1751. >
  1752. {{ relationshipName(item) }}
  1753. </button>
  1754. </span>
  1755. </label>
  1756. <label>
  1757. 被保人年龄
  1758. <input v-model.number="insuredAge" type="number" min="0" max="120" />
  1759. </label>
  1760. <div class="fixed-conditions">
  1761. <span>常住地区:成都市</span><span>职业类别:普通职业</span>
  1762. </div>
  1763. </div>
  1764. <div class="product-list">
  1765. <div
  1766. v-for="(product, index) in displayedProducts"
  1767. :key="product.product_code"
  1768. :class="['product-card', { recommended: selectedProduct?.product_id === product.product_id }]"
  1769. >
  1770. <div class="product-topline">
  1771. <span>{{ String(index + 1).padStart(2, "0") }}</span>
  1772. <em v-if="selectedProduct?.product_id === product.product_id">顾问推荐</em>
  1773. </div>
  1774. <h3>{{ product.name }}</h3>
  1775. <p>{{ product.summary }}</p>
  1776. <div class="plans">
  1777. <span v-for="plan in product.plans" :key="plan.code">{{ plan.name }}</span>
  1778. </div>
  1779. <ul class="benefit-list">
  1780. <li>电子保单即时查询</li>
  1781. <li>支持家庭成员投保</li>
  1782. <li>保费由规则引擎确定</li>
  1783. </ul>
  1784. <button
  1785. class="product-action"
  1786. :disabled="businessLoading"
  1787. :aria-busy="quotingProductId === product.product_id"
  1788. @click="createQuote(product)"
  1789. >
  1790. {{ quotingProductId === product.product_id ? "正在试算…" : "测算保费并投保" }} <span>→</span>
  1791. </button>
  1792. </div>
  1793. </div>
  1794. <div v-if="quoteFeedback" :class="['quote-feedback', { failed: !businessLoading }]">
  1795. <span>{{ businessLoading ? "···" : "!" }}</span>{{ quoteFeedback }}
  1796. </div>
  1797. <p v-if="products.length === 0" class="empty-state">暂时没有可投保产品。</p>
  1798. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  1799. </section>
  1800. <section v-else-if="activeTab === 'coverage'" class="tab-page coverage-page">
  1801. <div class="page-heading">
  1802. <div><p class="step">MY PROTECTION</p><h2>我的保障</h2></div>
  1803. <span class="record-count">
  1804. {{
  1805. coverageSection === "policies"
  1806. ? (policies.length > 0 ? `${policies.length}张保单` : "暂无保单")
  1807. : `${surrenderRequests.length}笔记录`
  1808. }}
  1809. </span>
  1810. </div>
  1811. <p class="page-lead">
  1812. {{
  1813. coverageSection === "policies"
  1814. ? "集中查看已经生效的保障,并从保单发起后续服务。"
  1815. : "查看退保审核、退款处理与保障终止进度。"
  1816. }}
  1817. </p>
  1818. <nav class="coverage-switch" aria-label="我的保障分类">
  1819. <button
  1820. type="button"
  1821. :class="{ active: coverageSection === 'policies' }"
  1822. @click="coverageSection = 'policies'"
  1823. >
  1824. <span>01</span>
  1825. <div><strong>保障保单</strong><small>当前持有的保障</small></div>
  1826. <em>{{ policies.length }}</em>
  1827. </button>
  1828. <button
  1829. type="button"
  1830. :class="{ active: coverageSection === 'surrenders' }"
  1831. @click="coverageSection = 'surrenders'"
  1832. >
  1833. <span>02</span>
  1834. <div><strong>退保记录</strong><small>审核与退款进度</small></div>
  1835. <em>{{ surrenderRequests.length }}</em>
  1836. </button>
  1837. </nav>
  1838. <div
  1839. v-if="coverageSection === 'policies' && policies.length > 0"
  1840. class="policy-list coverage-section-enter"
  1841. >
  1842. <article v-for="(item, index) in policies" :key="item.policy_id" class="policy-summary">
  1843. <div class="policy-glow"></div>
  1844. <div class="policy-heading">
  1845. <span>电子保单 · {{ String(index + 1).padStart(2, "0") }}</span>
  1846. <em>{{ policyStatus(item.status) }}</em>
  1847. </div>
  1848. <h3>{{ item.product_name }}</h3>
  1849. <p class="policy-plan">{{ item.plan_name }}</p>
  1850. <p class="policy-number">{{ item.policy_no }}</p>
  1851. <div class="policy-data">
  1852. <div><small>年度保费</small><strong>{{ money(item.premium_cents) }}</strong></div>
  1853. <div><small>保障期限</small><strong>{{ item.coverage_start }} 至 {{ item.coverage_end }}</strong></div>
  1854. </div>
  1855. <div class="policy-actions">
  1856. <button
  1857. type="button"
  1858. class="policy-document-action"
  1859. @click="openPolicyDocument(item)"
  1860. >
  1861. <span class="policy-action-icon" aria-hidden="true">
  1862. <svg viewBox="0 0 24 24">
  1863. <path d="M7 3.5h7l3 3V20.5H7z" />
  1864. <path d="M14 3.5v4h4M9.5 11h5M9.5 14.5h5" />
  1865. </svg>
  1866. </span>
  1867. <span class="policy-action-copy">
  1868. <strong>电子保单</strong>
  1869. <small>查看保障凭证</small>
  1870. </span>
  1871. <span class="policy-action-arrow" aria-hidden="true">→</span>
  1872. </button>
  1873. <button
  1874. v-if="item.status === 'ACTIVE'"
  1875. type="button"
  1876. class="policy-service-action"
  1877. @click="startPolicyService(item)"
  1878. >
  1879. <span class="policy-action-icon" aria-hidden="true">
  1880. <svg viewBox="0 0 24 24">
  1881. <path d="M5 5.5h14v13H5z" />
  1882. <path d="M9 5.5V4h6v1.5M12 9v6M9 12h6" />
  1883. </svg>
  1884. </span>
  1885. <span class="policy-action-copy">
  1886. <strong>保单服务</strong>
  1887. <small>提交服务申请</small>
  1888. </span>
  1889. <span class="policy-action-arrow" aria-hidden="true">→</span>
  1890. </button>
  1891. <button
  1892. v-if="item.status === 'ACTIVE'"
  1893. type="button"
  1894. class="policy-surrender-action"
  1895. @click="startSurrender(item)"
  1896. >
  1897. <span class="policy-surrender-copy">
  1898. <small>保障计划需要调整?</small>
  1899. <strong>申请退保</strong>
  1900. </span>
  1901. <span class="policy-surrender-arrow" aria-hidden="true">→</span>
  1902. </button>
  1903. </div>
  1904. </article>
  1905. </div>
  1906. <Teleport to="body">
  1907. <div
  1908. v-if="surrenderApplicationOpen"
  1909. class="surrender-layer"
  1910. role="dialog"
  1911. aria-modal="true"
  1912. aria-label="退保申请确认"
  1913. @click.self="closeSurrenderApplication"
  1914. >
  1915. <article class="surrender-sheet">
  1916. <header>
  1917. <div>
  1918. <p>SURRENDER REVIEW</p>
  1919. <h2>退保申请确认</h2>
  1920. </div>
  1921. <button type="button" aria-label="关闭退保申请" @click="closeSurrenderApplication">×</button>
  1922. </header>
  1923. <div v-if="surrenderLoading && !surrenderPreview" class="surrender-loading">
  1924. <i></i>
  1925. <strong>正在核对保单与计算退款金额</strong>
  1926. <span>资格与金额由业务规则确定,AI 不参与决策</span>
  1927. </div>
  1928. <template v-else-if="surrenderPreview && selectedSurrenderPolicy">
  1929. <section class="surrender-policy-card">
  1930. <span>关联保单</span>
  1931. <h3>{{ selectedSurrenderPolicy.product_name }}</h3>
  1932. <p>{{ selectedSurrenderPolicy.policy_no }}</p>
  1933. <em>{{ selectedSurrenderPolicy.plan_name }}</em>
  1934. </section>
  1935. <section :class="['surrender-quote', { denied: !surrenderPreview.eligible }]">
  1936. <div>
  1937. <span>{{ surrenderPreview.eligible ? "预计退还" : "暂不可退保" }}</span>
  1938. <strong v-if="surrenderPreview.eligible">{{ money(surrenderPreview.refund_amount_cents) }}</strong>
  1939. <strong v-else>—</strong>
  1940. </div>
  1941. <p>{{ surrenderPreview.reason }}</p>
  1942. </section>
  1943. <section
  1944. v-if="surrenderPreview.eligible"
  1945. :class="[
  1946. 'surrender-refund-basis',
  1947. {
  1948. cooling:
  1949. surrenderPreview.calculation.pre_effective ||
  1950. surrenderPreview.calculation.cooling_off,
  1951. },
  1952. ]"
  1953. >
  1954. <span>{{
  1955. surrenderPreview.calculation.pre_effective
  1956. ? "保障生效前"
  1957. : surrenderPreview.calculation.cooling_off
  1958. ? "犹豫期内"
  1959. : "犹豫期外"
  1960. }}</span>
  1961. <div>
  1962. <strong>{{
  1963. surrenderPreview.calculation.pre_effective ||
  1964. surrenderPreview.calculation.cooling_off
  1965. ? "按已交保费全额退还"
  1966. : "按剩余保障期限折算"
  1967. }}</strong>
  1968. <p>
  1969. {{
  1970. surrenderPreview.refund_explanation ??
  1971. (surrenderPreview.calculation.pre_effective
  1972. ? "保单保障尚未开始,本次撤销不收取退保扣减。"
  1973. : surrenderPreview.calculation.cooling_off
  1974. ? "当前申请仍在犹豫期内,退保扣减为0元。"
  1975. : "当前申请按未满期保费折算,并按照规则扣减。")
  1976. }}
  1977. </p>
  1978. </div>
  1979. </section>
  1980. <dl v-if="surrenderPreview.eligible" class="surrender-calculation">
  1981. <div><dt>已交保费</dt><dd>{{ money(surrenderPreview.calculation.premium_cents) }}</dd></div>
  1982. <div><dt>未满期保费</dt><dd>{{ money(surrenderPreview.calculation.gross_unearned_cents) }}</dd></div>
  1983. <div><dt>退保扣减</dt><dd>-{{ money(surrenderPreview.calculation.deduction_cents) }}</dd></div>
  1984. <div><dt>剩余保障天数</dt><dd>{{ surrenderPreview.calculation.remaining_days }}天</dd></div>
  1985. </dl>
  1986. <section v-if="surrenderPreview.eligible" class="surrender-form">
  1987. <label>
  1988. <span>退保原因</span>
  1989. <select v-model="surrenderReason">
  1990. <option>保障计划调整</option>
  1991. <option>已有其他保障</option>
  1992. <option>暂时不需要该保障</option>
  1993. <option>扣费或服务问题</option>
  1994. <option>其他原因</option>
  1995. </select>
  1996. </label>
  1997. <label class="surrender-reason-detail">
  1998. <span>
  1999. 具体说明
  2000. <em v-if="['扣费或服务问题', '其他原因'].includes(surrenderReason)">
  2001. 必填
  2002. </em>
  2003. <small v-else>选填</small>
  2004. </span>
  2005. <textarea
  2006. v-model="surrenderReasonDetail"
  2007. rows="3"
  2008. maxlength="160"
  2009. placeholder="请描述具体情况,例如扣费、服务争议或身份异常"
  2010. ></textarea>
  2011. <small>{{ surrenderReasonDetail.length }}/160</small>
  2012. </label>
  2013. <label class="surrender-confirm">
  2014. <input v-model="surrenderConfirmed" type="checkbox" />
  2015. <span>我已确认退款试算;流程通过风险分流并退款成功后,本保单保障将终止。</span>
  2016. </label>
  2017. </section>
  2018. </template>
  2019. <p v-if="surrenderError" class="error status-message" role="alert"><span>!</span>{{ surrenderError }}</p>
  2020. <footer>
  2021. <button type="button" class="surrender-cancel" @click="closeSurrenderApplication">暂不申请</button>
  2022. <button
  2023. type="button"
  2024. class="surrender-submit"
  2025. :disabled="surrenderLoading || !surrenderPreview?.eligible"
  2026. @click="submitSurrender"
  2027. >
  2028. {{ surrenderLoading ? "正在提交…" : "确认提交退保申请" }}
  2029. </button>
  2030. </footer>
  2031. </article>
  2032. </div>
  2033. </Teleport>
  2034. <section
  2035. v-if="coverageSection === 'surrenders' && surrenderRequests.length"
  2036. class="surrender-records coverage-section-enter"
  2037. >
  2038. <div class="surrender-records-heading">
  2039. <div><span>POLICY LIFECYCLE</span><h3>退保进度</h3></div>
  2040. <em>{{ surrenderRequests.length }}笔</em>
  2041. </div>
  2042. <article v-for="item in surrenderRequests" :key="item.request_id">
  2043. <div class="surrender-record-mark">退</div>
  2044. <div>
  2045. <strong>{{ item.request_no }}</strong>
  2046. <p>{{ policyById(item.policy_id)?.product_name ?? "关联保单" }} · {{ item.reason }}</p>
  2047. <small>{{ dateTime(item.created_at) }}</small>
  2048. </div>
  2049. <aside>
  2050. <span :class="surrenderStatusTone(item.status)">{{ surrenderStatus(item.status) }}</span>
  2051. <strong>{{ money(item.refund_amount_cents) }}</strong>
  2052. <small class="surrender-record-basis">
  2053. {{
  2054. item.calculation?.pre_effective
  2055. ? "保障生效前全额退还"
  2056. : item.calculation?.cooling_off
  2057. ? "犹豫期内全额退还"
  2058. : "按剩余保障期折算"
  2059. }}
  2060. </small>
  2061. </aside>
  2062. </article>
  2063. </section>
  2064. <div
  2065. v-if="coverageSection === 'policies' && !order && policies.length === 0"
  2066. class="coverage-empty"
  2067. >
  2068. <span class="empty-shield">◇</span>
  2069. <h3>还没有保障记录</h3>
  2070. <p>先和智能顾问聊聊,找到适合你的保障方案。</p>
  2071. <button @click="activeTab = 'advisor'">咨询智能顾问</button>
  2072. </div>
  2073. <div
  2074. v-if="coverageSection === 'surrenders' && surrenderRequests.length === 0"
  2075. class="surrender-empty-records coverage-section-enter"
  2076. >
  2077. <span>00</span>
  2078. <h3>还没有退保记录</h3>
  2079. <p>当前保障仍在正常生效。如需调整保障计划,请从有效保单中发起申请。</p>
  2080. <button type="button" @click="coverageSection = 'policies'">查看保障保单</button>
  2081. </div>
  2082. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  2083. </section>
  2084. <section v-else class="tab-page service-page">
  2085. <div class="page-heading">
  2086. <div><p class="step">POLICY SERVICE DESK</p><h2>保单服务</h2></div>
  2087. <span class="record-count">{{ serviceRequests.length }}笔申请</span>
  2088. </div>
  2089. <p class="page-lead">查看从有效保单发起的服务申请,以及运营人员反馈的最新处理进度。</p>
  2090. <Teleport to="body">
  2091. <div v-if="serviceApplicationOpen" class="service-application-sheet">
  2092. <div class="service-sheet-header">
  2093. <button type="button" aria-label="关闭服务申请" @click="closeServiceApplication">←</button>
  2094. <div><small>POLICY SERVICE APPLICATION</small><strong>发起保单服务</strong></div>
  2095. <span>关联保单</span>
  2096. </div>
  2097. <div class="service-intro-card">
  2098. <span class="service-intro-mark">AI</span>
  2099. <div>
  2100. <strong>医疗费用报销协助</strong>
  2101. <p>识别结果只作为候选内容,不代表保险公司作出赔付承诺。</p>
  2102. </div>
  2103. </div>
  2104. <div class="service-progress-rail">
  2105. <div :class="{ active: true }"><span>01</span><small>选择保单</small></div>
  2106. <i></i>
  2107. <div :class="{ active: !!serviceFile }"><span>02</span><small>识别材料</small></div>
  2108. <i></i>
  2109. <div :class="{ active: !!materialRecognition }"><span>03</span><small>确认提交</small></div>
  2110. </div>
  2111. <div v-if="selectedServicePolicy" class="service-section">
  2112. <div class="service-section-heading">
  2113. <span>01</span>
  2114. <div><strong>已关联保障中的保单</strong><small>服务申请将绑定到该保单</small></div>
  2115. </div>
  2116. <div class="service-linked-policy">
  2117. <span class="policy-choice-mark"></span>
  2118. <div>
  2119. <strong>{{ selectedServicePolicy.product_name }}</strong>
  2120. <small>{{ selectedServicePolicy.plan_name }} · {{ selectedServicePolicy.policy_no }}</small>
  2121. </div>
  2122. <em>{{ policyStatus(selectedServicePolicy.status) }}</em>
  2123. </div>
  2124. </div>
  2125. <div v-if="selectedServicePolicy" class="service-section">
  2126. <div class="service-section-heading">
  2127. <span>02</span>
  2128. <div><strong>上传医疗费用发票</strong><small>支持 JPG、PNG、WebP,单张不超过 10MB</small></div>
  2129. </div>
  2130. <label v-if="!serviceFilePreview" class="service-upload">
  2131. <input
  2132. type="file"
  2133. accept="image/jpeg,image/png,image/webp"
  2134. capture="environment"
  2135. @change="handleServiceFile"
  2136. />
  2137. <span class="upload-symbol">
  2138. <svg viewBox="0 0 24 24" aria-hidden="true">
  2139. <path d="M12 16V4m0 0L7 9m5-5 5 5M5 14v5h14v-5" />
  2140. </svg>
  2141. </span>
  2142. <strong>拍照或选择票据图片</strong>
  2143. <small>请保持票据完整、平整,文字和金额清晰可见</small>
  2144. </label>
  2145. <div v-else class="service-file-preview">
  2146. <img :src="serviceFilePreview" alt="待识别的医疗费用发票" />
  2147. <div>
  2148. <span>待识别材料</span>
  2149. <strong>{{ serviceFile?.name }}</strong>
  2150. <small>{{ serviceFile ? `${(serviceFile.size / 1024 / 1024).toFixed(2)} MB` : "" }}</small>
  2151. <button type="button" :disabled="serviceLoading" @click="clearServiceMaterial">重新选择</button>
  2152. </div>
  2153. </div>
  2154. <button
  2155. v-if="serviceFile && !materialRecognition"
  2156. type="button"
  2157. class="service-primary"
  2158. :disabled="serviceLoading"
  2159. @click="recognizeServiceMaterial"
  2160. >
  2161. <span class="ai-button-mark">AI</span>
  2162. {{ serviceLoading ? "正在识别票据内容…" : "开始智能识别" }}
  2163. <em>→</em>
  2164. </button>
  2165. <p v-if="serviceError" class="error status-message service-recognition-error" role="alert">
  2166. <span>!</span>{{ serviceError }}
  2167. </p>
  2168. </div>
  2169. <div v-if="materialRecognition" class="service-section recognition-section">
  2170. <div class="service-section-heading">
  2171. <span>03</span>
  2172. <div><strong>核对识别结果</strong><small>所有字段都可以修改,请以票据原件为准</small></div>
  2173. </div>
  2174. <div v-if="materialRecognition.warnings.length" class="recognition-warnings">
  2175. <strong>请重点核对</strong>
  2176. <p v-for="warning in materialRecognition.warnings" :key="warning">{{ warning }}</p>
  2177. </div>
  2178. <div class="service-confirm-form">
  2179. <label>票据号码<input v-model.trim="serviceFields.invoice_no" placeholder="请输入票据号码" /></label>
  2180. <label>医疗机构<input v-model.trim="serviceFields.hospital_name" placeholder="请输入医疗机构名称" /></label>
  2181. <label>就诊日期<input v-model.trim="serviceFields.visit_date" type="date" /></label>
  2182. <label>费用金额(元)<input v-model.trim="serviceFields.amount_yuan" inputmode="decimal" placeholder="0.00" /></label>
  2183. <label>联系电话<input v-model.trim="serviceFields.contact_mobile" inputmode="tel" maxlength="11" /></label>
  2184. </div>
  2185. <div class="service-material-note">
  2186. <span>材料提醒</span>
  2187. <p>后续审核可能需要费用清单、诊断证明、入出院记录等材料,运营人员会在申请记录中反馈。</p>
  2188. </div>
  2189. <label class="service-confirm-check">
  2190. <input v-model="serviceConfirmed" type="checkbox" />
  2191. <i aria-hidden="true">✓</i>
  2192. <span>我已对照原始票据核对以上信息,并确认内容真实、完整。</span>
  2193. </label>
  2194. <button
  2195. type="button"
  2196. class="service-primary submit"
  2197. :disabled="serviceLoading"
  2198. @click="submitServiceRequest"
  2199. >
  2200. {{ serviceLoading ? "正在提交申请…" : "确认并提交服务申请" }}
  2201. <em>→</em>
  2202. </button>
  2203. </div>
  2204. </div>
  2205. </Teleport>
  2206. <template v-if="!serviceApplicationOpen">
  2207. <div v-if="serviceRequests.length" class="service-request-list">
  2208. <article v-for="item in serviceRequests" :key="item.request_id" class="service-request-card">
  2209. <div class="service-request-topline">
  2210. <span>{{ item.request_no }}</span>
  2211. <em :class="serviceRequestStatusTone(item.status)">
  2212. {{ serviceRequestStatus(item.status) }}
  2213. </em>
  2214. </div>
  2215. <h3>{{ policyById(item.policy_id)?.product_name ?? "医疗费用报销协助" }}</h3>
  2216. <p>{{ policyById(item.policy_id)?.policy_no ?? item.policy_id }}</p>
  2217. <dl>
  2218. <div><dt>医疗机构</dt><dd>{{ item.confirmed_fields.hospital_name }}</dd></div>
  2219. <div><dt>申请金额</dt><dd>¥{{ item.confirmed_fields.amount_yuan }}</dd></div>
  2220. <div><dt>提交时间</dt><dd>{{ dateTime(item.submitted_at) }}</dd></div>
  2221. </dl>
  2222. <div v-if="item.review_note" class="service-review-note">
  2223. <span>处理意见</span>
  2224. <p>{{ item.review_note }}</p>
  2225. </div>
  2226. </article>
  2227. </div>
  2228. <div v-else class="service-empty-records">
  2229. <span>00</span>
  2230. <h3>还没有服务申请</h3>
  2231. <p>请前往“我的保障”,从保障中的保单发起服务申请。</p>
  2232. <button type="button" @click="activeTab = 'coverage'">前往我的保障</button>
  2233. </div>
  2234. </template>
  2235. <p v-if="serviceError && !serviceApplicationOpen" class="error status-message" role="alert"><span>!</span>{{ serviceError }}</p>
  2236. </section>
  2237. </div>
  2238. <nav class="bottom-nav" aria-label="主导航">
  2239. <button :class="{ active: activeTab === 'advisor' }" @click="activeTab = 'advisor'">
  2240. <svg viewBox="0 0 24 24" aria-hidden="true">
  2241. <path d="M7 8h10M7 12h7M8 18l-4 2 1-4a8 8 0 1 1 3 2Z" />
  2242. </svg>
  2243. <span>智能顾问</span>
  2244. </button>
  2245. <button :class="{ active: activeTab === 'plans' }" @click="activeTab = 'plans'">
  2246. <svg viewBox="0 0 24 24" aria-hidden="true">
  2247. <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" />
  2248. </svg>
  2249. <span>保障方案</span>
  2250. </button>
  2251. <button :class="{ active: activeTab === 'coverage' }" @click="activeTab = 'coverage'">
  2252. <svg viewBox="0 0 24 24" aria-hidden="true">
  2253. <path d="M7 3h8l4 4v14H7V3Zm8 0v5h4M10 12h6M10 16h6" />
  2254. </svg>
  2255. <span>我的保障</span>
  2256. </button>
  2257. <button :class="{ active: activeTab === 'services' }" @click="openServiceCenter">
  2258. <svg viewBox="0 0 24 24" aria-hidden="true">
  2259. <path d="M4 6h16v13H4V6Zm4-3h8v3H8V3Zm1 9h6M12 9v6" />
  2260. </svg>
  2261. <span>保单服务</span>
  2262. </button>
  2263. </nav>
  2264. <div
  2265. v-if="conversationHistoryOpen"
  2266. class="conversation-layer"
  2267. role="dialog"
  2268. aria-modal="true"
  2269. aria-label="历史会话"
  2270. @click.self="conversationHistoryOpen = false"
  2271. >
  2272. <section class="conversation-drawer">
  2273. <header>
  2274. <div>
  2275. <p class="step">CONVERSATION ARCHIVE</p>
  2276. <h2>历史会话</h2>
  2277. </div>
  2278. <button type="button" aria-label="关闭历史会话" @click="conversationHistoryOpen = false">×</button>
  2279. </header>
  2280. <div class="conversation-summary">
  2281. <span>会话已安全保存在你的账户中</span>
  2282. <strong>{{ agentThreads.length }}<small>次咨询</small></strong>
  2283. </div>
  2284. <button type="button" class="new-conversation-card" @click="startNewConversation">
  2285. <span>+</span>
  2286. <div><strong>开始新会话</strong><small>创建一个独立的咨询上下文</small></div>
  2287. <em>→</em>
  2288. </button>
  2289. <div v-if="conversationHistoryLoading" class="conversation-loading">
  2290. <i></i><span>正在读取历史会话…</span>
  2291. </div>
  2292. <div v-else-if="agentThreads.length" class="conversation-list">
  2293. <button
  2294. v-for="thread in agentThreads"
  2295. :key="thread.id"
  2296. type="button"
  2297. :class="{ current: thread.id === agentThreadId }"
  2298. @click="restoreConversation(thread)"
  2299. >
  2300. <div class="conversation-index">
  2301. <span>{{ String(agentThreads.indexOf(thread) + 1).padStart(2, "0") }}</span>
  2302. <i v-if="thread.id === agentThreadId"></i>
  2303. </div>
  2304. <div>
  2305. <strong>{{ thread.title }}</strong>
  2306. <p>{{ thread.last_message?.content ?? "尚未发送消息" }}</p>
  2307. <small>{{ dateTime(thread.updated_at) }} · {{ thread.message_count }}条消息</small>
  2308. </div>
  2309. <em>›</em>
  2310. </button>
  2311. </div>
  2312. <div v-else class="conversation-empty">
  2313. <span>◇</span>
  2314. <strong>暂无历史会话</strong>
  2315. <p>发送第一条咨询后,会话会自动保存到这里。</p>
  2316. </div>
  2317. </section>
  2318. </div>
  2319. <div v-if="enrollmentOpen" class="enrollment-layer" role="dialog" aria-modal="true">
  2320. <header class="enrollment-header">
  2321. <button aria-label="关闭投保流程" @click="closeEnrollment">×</button>
  2322. <div>
  2323. <span>{{ preQuoteConfirming ? "投保前确认" : "投保流程" }}</span>
  2324. <strong>{{ selectedProduct?.name ?? "保障计划" }}</strong>
  2325. </div>
  2326. <small>{{ preQuoteConfirming ? "准备投保" : `${enrollmentStep}/4` }}</small>
  2327. </header>
  2328. <div v-if="!preQuoteConfirming" class="progress-track">
  2329. <span v-for="stepNo in 4" :key="stepNo" :class="{ active: stepNo <= enrollmentStep }"></span>
  2330. </div>
  2331. <div v-else class="direct-entry-line"></div>
  2332. <div class="enrollment-body">
  2333. <section v-if="preQuoteConfirming" class="enrollment-step direct-enrollment-step">
  2334. <p class="step">DIRECT ENROLLMENT</p>
  2335. <h2>确认投保条件</h2>
  2336. <p class="step-description">顾问已经为你选好方案,确认以下信息后即可测算保费并继续投保。</p>
  2337. <div class="direct-product">
  2338. <div>
  2339. <span>顾问推荐</span>
  2340. <h3>{{ selectedProduct?.name }}</h3>
  2341. <p>{{ selectedProduct?.summary }}</p>
  2342. </div>
  2343. <em>已选择</em>
  2344. </div>
  2345. <div class="form-section direct-condition-form">
  2346. <div class="form-section-title"><span>被保人情况</span><small>用于资格校验和保费测算</small></div>
  2347. <label>
  2348. 与投保人关系
  2349. <span class="relation-options">
  2350. <button
  2351. v-for="item in (['SELF', 'PARENT', 'SPOUSE', 'CHILD'] as Relationship[])"
  2352. :key="item"
  2353. type="button"
  2354. :class="{ active: relationship === item }"
  2355. @click="relationship = item"
  2356. >
  2357. {{ relationshipName(item) }}
  2358. </button>
  2359. </span>
  2360. </label>
  2361. <label>
  2362. 被保人年龄
  2363. <input v-model.number="insuredAge" type="number" min="0" max="120" inputmode="numeric" />
  2364. </label>
  2365. <div class="fixed-conditions">
  2366. <span>常住地区:成都市</span><span>职业类别:普通职业</span>
  2367. </div>
  2368. </div>
  2369. <div class="direct-entry-note">
  2370. <span>AI</span>
  2371. <p>以上信息已根据你的咨询自动带入,请确认无误。最终是否可投保及保费以本次系统测算结果为准。</p>
  2372. </div>
  2373. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  2374. <button
  2375. class="flow-primary"
  2376. :disabled="businessLoading"
  2377. @click="confirmDirectEnrollment"
  2378. >
  2379. {{ businessLoading ? "正在测算保费…" : "确认并开始投保" }} <span>→</span>
  2380. </button>
  2381. </section>
  2382. <section v-else-if="enrollmentStep === 1" class="enrollment-step">
  2383. <p class="step">STEP 01</p>
  2384. <h2>填写投保信息</h2>
  2385. <p class="step-description">请确认信息真实、准确,这些信息将写入投保订单。</p>
  2386. <div class="quote-mini">
  2387. <div><span>{{ selectedProduct?.name }}</span><small>{{ relationshipName(relationship) }} · {{ insuredAge }}岁</small></div>
  2388. <strong>{{ quote ? money(quote.premium_cents) : "—" }}</strong>
  2389. </div>
  2390. <div class="form-section">
  2391. <div class="form-section-title"><span>投保人</span><small>负责缴费和接收通知</small></div>
  2392. <label>姓名<input v-model.trim="applicantName" autocomplete="name" placeholder="请输入真实姓名" /></label>
  2393. <label>身份证号<input v-model.trim="applicantIdNo" maxlength="18" placeholder="请输入18位身份证号" /></label>
  2394. <label>联系手机<input v-model.trim="contactMobile" inputmode="tel" maxlength="11" placeholder="请输入接收通知的手机号" /><small class="input-help">默认使用登录手机号,可修改</small></label>
  2395. </div>
  2396. <div class="form-section">
  2397. <div class="form-section-title"><span>被保人</span><small>实际享受保障的人</small></div>
  2398. <label>与投保人关系
  2399. <select v-model="relationship">
  2400. <option value="SELF">本人</option><option value="PARENT">父母</option>
  2401. <option value="SPOUSE">配偶</option><option value="CHILD">子女</option>
  2402. </select>
  2403. </label>
  2404. <label>姓名<input v-model.trim="insuredName" placeholder="请输入真实姓名" /></label>
  2405. <label>身份证号<input v-model.trim="insuredIdNo" maxlength="18" placeholder="请输入18位身份证号" /></label>
  2406. </div>
  2407. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  2408. <button class="flow-primary" @click="goToNotice">下一步:阅读投保须知 <span>→</span></button>
  2409. </section>
  2410. <section v-else-if="enrollmentStep === 2" class="enrollment-step notice-step">
  2411. <p class="step">STEP 02</p>
  2412. <h2>投保须知</h2>
  2413. <p class="step-description">这些内容会影响承保和理赔,请完整阅读后再确认。</p>
  2414. <div class="notice-document">
  2415. <header><span>智保通投保须知</span><small>适用于 {{ selectedProduct?.name }}</small></header>
  2416. <div>
  2417. <h3>一、投保资格</h3>
  2418. <p>被保人应符合产品约定的年龄、常住地区和职业范围。本次试算结果仅对当前填写信息有效。</p>
  2419. <h3>二、如实告知</h3>
  2420. <p>投保人应如实填写身份及健康相关信息。故意或因重大过失未履行如实告知义务,可能影响合同效力及理赔结果。</p>
  2421. <h3>三、保障生效</h3>
  2422. <p>支付成功并完成承保后生成电子保单,具体保障责任、等待期、免赔额和责任限额以电子保单及保险条款为准。</p>
  2423. <h3>四、责任免除</h3>
  2424. <p>既往症、违法行为、故意伤害以及条款明确列示的其他情形可能不在保障范围内,请重点阅读责任免除部分。</p>
  2425. <h3>五、信息与退保</h3>
  2426. <p>投保信息将用于承保、保全和服务处理。退保规则、可退金额及生效后的处理方式以保险条款和后续服务流程为准。</p>
  2427. </div>
  2428. </div>
  2429. <label class="notice-check">
  2430. <input v-model="noticeAccepted" type="checkbox" />
  2431. <span>我已阅读并理解《投保须知》《保险条款》和《责任免除》</span>
  2432. </label>
  2433. <label class="notice-check">
  2434. <input v-model="disclosureConfirmed" type="checkbox" />
  2435. <span>我确认投保信息真实、完整,并同意进行电子投保</span>
  2436. </label>
  2437. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  2438. <div class="flow-actions">
  2439. <button class="flow-back" @click="setEnrollmentStep(1)">上一步</button>
  2440. <button class="flow-primary" @click="goToConfirmation">同意并继续 <span>→</span></button>
  2441. </div>
  2442. </section>
  2443. <section v-else-if="enrollmentStep === 3" class="enrollment-step confirmation-step">
  2444. <p class="step">STEP 03</p>
  2445. <h2>确认投保订单</h2>
  2446. <p class="step-description">请最后核对保障方案和人员信息。</p>
  2447. <div class="confirm-product">
  2448. <span>保障方案</span>
  2449. <h3>{{ selectedProduct?.name }}</h3>
  2450. <p>{{ relationshipName(relationship) }} · {{ insuredAge }}岁 · 成都市 · 普通职业</p>
  2451. <strong>{{ quote ? money(quote.premium_cents) : "—" }}<small>/年</small></strong>
  2452. </div>
  2453. <dl class="confirm-list">
  2454. <div><dt>投保人</dt><dd>{{ applicantName }}</dd></div>
  2455. <div><dt>投保人证件</dt><dd>{{ maskIdNo(applicantIdNo) }}</dd></div>
  2456. <div><dt>被保人</dt><dd>{{ insuredName }}</dd></div>
  2457. <div><dt>被保人证件</dt><dd>{{ maskIdNo(insuredIdNo) }}</dd></div>
  2458. <div><dt>联系电话</dt><dd>{{ contactMobile }}</dd></div>
  2459. </dl>
  2460. <div class="confirmed-notice"><span>✓</span>已阅读并同意投保须知与责任免除</div>
  2461. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  2462. <div class="flow-actions">
  2463. <button class="flow-back" @click="setEnrollmentStep(2)">上一步</button>
  2464. <button class="flow-primary" :disabled="businessLoading" @click="confirmAndCreateOrder">
  2465. {{ businessLoading ? "正在创建订单…" : `确认投保并支付 ${quote ? money(quote.premium_cents) : ""}` }}
  2466. </button>
  2467. </div>
  2468. </section>
  2469. <section v-else class="enrollment-step payment-step">
  2470. <template v-if="!policy">
  2471. <p class="step">STEP 04</p>
  2472. <h2>安全支付</h2>
  2473. <p class="step-description">确认支付后将立即完成承保并生成电子保单。</p>
  2474. <div class="payment-amount">
  2475. <span>应付金额</span>
  2476. <strong>{{ order ? money(order.amount_cents) : quote ? money(quote.premium_cents) : "—" }}</strong>
  2477. <small>{{ selectedProduct?.name ?? "智保通保障计划" }}</small>
  2478. </div>
  2479. <div class="payment-channel">
  2480. <span class="channel-icon">¥</span>
  2481. <div><strong>快捷支付</strong><small>支付过程由安全通道保护</small></div>
  2482. <em>已选择</em>
  2483. </div>
  2484. <p v-if="order" class="payment-order">订单号 {{ order.order_no }}</p>
  2485. <p v-if="error" class="error status-message" role="alert"><span>!</span>{{ error }}</p>
  2486. <button
  2487. class="flow-primary pay-button"
  2488. :disabled="businessLoading || !payment"
  2489. @click="completePayment"
  2490. >
  2491. {{ businessLoading ? "正在确认支付…" : payment ? "确认支付" : "正在准备支付…" }}
  2492. </button>
  2493. </template>
  2494. <template v-else>
  2495. <div class="success-symbol"><span>✓</span></div>
  2496. <p class="step success-step">ENROLLMENT COMPLETED</p>
  2497. <h2>投保成功</h2>
  2498. <p class="step-description">电子保单已经生成,保障信息已保存到“我的保障”。</p>
  2499. <div class="success-policy">
  2500. <span>电子保单号</span><strong>{{ policy.policy_no }}</strong>
  2501. <div><small>保障状态</small><em>{{ policyStatus(policy.status) }}</em></div>
  2502. <div><small>保障期限</small><p>{{ policy.coverage_start }} 至 {{ policy.coverage_end }}</p></div>
  2503. </div>
  2504. <button class="flow-primary" @click="viewCoverageAfterPayment">查看我的保障 <span>→</span></button>
  2505. </template>
  2506. </section>
  2507. </div>
  2508. </div>
  2509. <div
  2510. v-if="openedPolicy"
  2511. class="policy-document-layer"
  2512. role="dialog"
  2513. aria-modal="true"
  2514. aria-label="电子保单"
  2515. @click.self="closePolicyDocument"
  2516. >
  2517. <article class="policy-document">
  2518. <header class="policy-document-header">
  2519. <button type="button" aria-label="关闭电子保单" @click="closePolicyDocument">×</button>
  2520. <div><span>智保通</span><small>电子保险凭证</small></div>
  2521. <em>{{ policyStatus(openedPolicy.status) }}</em>
  2522. </header>
  2523. <section class="policy-document-hero">
  2524. <p>POLICY CERTIFICATE</p>
  2525. <h2>{{ openedPolicy.product_name }}</h2>
  2526. <span>{{ openedPolicy.plan_name }}</span>
  2527. <div><small>电子保单号</small><strong>{{ openedPolicy.policy_no }}</strong></div>
  2528. </section>
  2529. <section class="policy-document-body">
  2530. <div class="document-section-title"><span>01</span><h3>合同信息</h3></div>
  2531. <dl class="document-fields">
  2532. <div><dt>保单状态</dt><dd class="active-status">{{ policyStatus(openedPolicy.status) }}</dd></div>
  2533. <div><dt>订单编号</dt><dd>{{ openedPolicy.order_no }}</dd></div>
  2534. <div><dt>签发时间</dt><dd>{{ dateTime(openedPolicy.issued_at) }}</dd></div>
  2535. <div><dt>年度保费</dt><dd>{{ money(openedPolicy.premium_cents) }}</dd></div>
  2536. <div class="wide-field"><dt>保障期限</dt><dd>{{ openedPolicy.coverage_start }} 至 {{ openedPolicy.coverage_end }}</dd></div>
  2537. </dl>
  2538. <div class="document-section-title"><span>02</span><h3>被保险人信息</h3></div>
  2539. <dl class="document-fields">
  2540. <div><dt>投保人</dt><dd>{{ openedPolicy.applicant.name ?? "—" }}</dd></div>
  2541. <div><dt>投保人证件</dt><dd>{{ maskIdNo(openedPolicy.applicant.id_no ?? "") || "—" }}</dd></div>
  2542. <div><dt>被保人</dt><dd>{{ openedPolicy.insured.name ?? "—" }}</dd></div>
  2543. <div><dt>被保人证件</dt><dd>{{ maskIdNo(openedPolicy.insured.id_no ?? "") || "—" }}</dd></div>
  2544. </dl>
  2545. <div class="policy-document-note">
  2546. <span>i</span>
  2547. <p>本电子凭证记录本次承保结果。具体保障责任、责任免除、等待期及赔付限额以保险条款和投保须知为准。</p>
  2548. </div>
  2549. <div class="policy-seal"><span>智保通</span><small>电子承保凭证</small></div>
  2550. <button type="button" class="document-print" @click="printPolicyDocument">打印 / 保存为 PDF</button>
  2551. </section>
  2552. </article>
  2553. </div>
  2554. </div>
  2555. </main>
  2556. </template>