add.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742
  1. import UIButton from "@/components/ui/UIButton";
  2. import { Colors } from "@/constants/theme";
  3. import api from "@/utils/api";
  4. import { DatePicker, Input, Modal, Radio, Toast } from "@ant-design/react-native";
  5. import { Ionicons } from "@expo/vector-icons";
  6. import { usePreventRemove, useRoute } from "@react-navigation/native";
  7. import { NavigationAction } from "@react-navigation/routers";
  8. import { Link, Stack, useNavigation } from "expo-router";
  9. import React, { useCallback, useRef, useState } from "react";
  10. import {
  11. KeyboardAvoidingView,
  12. Platform,
  13. Pressable,
  14. ScrollView,
  15. StyleSheet,
  16. Text,
  17. TextInput,
  18. View,
  19. } from "react-native";
  20. import { useSafeAreaInsets } from "react-native-safe-area-context";
  21. import { UploadScreen } from "../credit/upload";
  22. type CustomerForm = {
  23. name: string;
  24. mobile: string;
  25. gender: string;
  26. birthday?: Date;
  27. idcard: string;
  28. nation: string;
  29. registered: string;
  30. residential: string;
  31. photo: string;
  32. occupation: string;
  33. other: string;
  34. };
  35. type FieldKey = "name" | "mobile" | "idcard";
  36. type FieldErrors = Partial<Record<FieldKey, string>>;
  37. type BirthDateSource = "unset" | "manual" | "id-card";
  38. type Option = {
  39. label: string;
  40. value: string;
  41. };
  42. const GENDER_OPTIONS: Option[] = [
  43. { label: "男", value: "1" },
  44. { label: "女", value: "2" },
  45. { label: "其它", value: "3" }
  46. ];
  47. const MOBILE_PATTERN = /^1\d{10}$/;
  48. const IDCARD_PATTERN = /^(?:\d{15}|\d{17}[\dX])$/;
  49. const EMPTY_CUSTOMER_FORM: CustomerForm = {
  50. name: "",
  51. mobile: "",
  52. gender: "",
  53. birthday: undefined,
  54. idcard: "",
  55. nation: "",
  56. registered: "",
  57. residential: "",
  58. photo: "",
  59. occupation: "",
  60. other: "",
  61. };
  62. type CustomerExtPayload = {
  63. nation: string;
  64. registered: string | null;
  65. residential: string | null;
  66. photo: string | null;
  67. occupation: string | null;
  68. other: string | null;
  69. };
  70. type CustomerPayload = {
  71. name: string;
  72. mobile: string;
  73. gender: number;
  74. birthday: string | null;
  75. idcard: string;
  76. ext: CustomerExtPayload;
  77. };
  78. function sanitizePhone(value: string) {
  79. return value.replace(/\D/g, "").slice(0, 11);
  80. }
  81. function sanitizeIdCard(value: string) {
  82. return value.replace(/[^0-9xX]/g, "").toUpperCase().slice(0, 18);
  83. }
  84. function padZero(value: number) {
  85. return String(value).padStart(2, "0");
  86. }
  87. function formatDate(date?: Date) {
  88. if (!date) {
  89. return "";
  90. }
  91. return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())}`;
  92. }
  93. function isValidDatePart(year: number, month: number, day: number) {
  94. if (year < 1900 || year > new Date().getFullYear()) {
  95. return false;
  96. }
  97. const date = new Date(year, month - 1, day);
  98. return (
  99. date.getFullYear() === year &&
  100. date.getMonth() === month - 1 &&
  101. date.getDate() === day &&
  102. date.getTime() <= Date.now()
  103. );
  104. }
  105. function inferBirthDateFromIdCard(idCard: string) {
  106. if (idCard.length < 14) {
  107. return undefined;
  108. }
  109. const birthText = idCard.slice(6, 14);
  110. if (!/^\d{8}$/.test(birthText)) {
  111. return undefined;
  112. }
  113. const year = Number(birthText.slice(0, 4));
  114. const month = Number(birthText.slice(4, 6));
  115. const day = Number(birthText.slice(6, 8));
  116. if (!isValidDatePart(year, month, day)) {
  117. return undefined;
  118. }
  119. return new Date(year, month - 1, day);
  120. }
  121. function nullableText(value: string) {
  122. const trimmed = value.trim();
  123. return trimmed || null;
  124. }
  125. function validateForm(form: CustomerForm): FieldErrors {
  126. const nextErrors: FieldErrors = {};
  127. const mobile = form.mobile.trim();
  128. const idcard = form.idcard.trim();
  129. if (!form.name.trim()) {
  130. nextErrors.name = "请输入客户姓名";
  131. }
  132. if (!mobile && !idcard) {
  133. nextErrors.mobile = "手机号和身份证号至少填写一项";
  134. nextErrors.idcard = "手机号和身份证号至少填写一项";
  135. return nextErrors;
  136. }
  137. if (mobile && !MOBILE_PATTERN.test(mobile)) {
  138. nextErrors.mobile = "请输入 11 位手机号";
  139. }
  140. if (idcard && !IDCARD_PATTERN.test(idcard)) {
  141. nextErrors.idcard = "请输入正确的身份证号";
  142. }
  143. return nextErrors;
  144. }
  145. function buildCustomerPayload(form: CustomerForm): CustomerPayload {
  146. return {
  147. name: form.name.trim(),
  148. mobile: form.mobile.trim(),
  149. gender: Number(form.gender) || 0,
  150. birthday: form.birthday ? formatDate(form.birthday) : null,
  151. idcard: form.idcard.trim(),
  152. ext: {
  153. nation: form.nation.trim(),
  154. registered: nullableText(form.registered),
  155. residential: nullableText(form.residential),
  156. photo: nullableText(form.photo),
  157. occupation: nullableText(form.occupation),
  158. other: nullableText(form.other),
  159. },
  160. };
  161. }
  162. function FieldLabel({
  163. label,
  164. required = false,
  165. }: {
  166. label: string;
  167. required?: boolean;
  168. }) {
  169. return (
  170. <View className="mb-2 flex-row items-center">
  171. <Text className="text-sm font-semibold text-on-surface">{label}</Text>
  172. {required ? <Text className="ml-1 text-sm font-semibold text-red-500">*</Text> : null}
  173. </View>
  174. );
  175. }
  176. function FieldMessage({
  177. error,
  178. helper,
  179. }: {
  180. error?: string;
  181. helper?: string;
  182. }) {
  183. if (error) {
  184. return <Text className="mt-2 text-xs leading-5 text-red-500">{error}</Text>;
  185. }
  186. if (helper) {
  187. return <Text className="mt-2 text-xs leading-5 text-on-surface-variant">{helper}</Text>;
  188. }
  189. return null;
  190. }
  191. function PickerField({
  192. label,
  193. required = false,
  194. value,
  195. placeholder,
  196. error,
  197. helper,
  198. disabled = false,
  199. onPress,
  200. }: {
  201. label: string;
  202. required?: boolean;
  203. value?: string;
  204. placeholder: string;
  205. error?: string;
  206. helper?: string;
  207. disabled?: boolean;
  208. onPress: () => void;
  209. }) {
  210. const hasValue = Boolean(value);
  211. return (
  212. <View className="mb-4">
  213. <FieldLabel label={label} required={required} />
  214. <Pressable
  215. disabled={disabled}
  216. onPress={onPress}
  217. className={`flex-row items-center justify-between rounded-2xl border px-4 py-4 ${error
  218. ? "border-red-300 bg-red-50"
  219. : "border-outline-variant bg-surface-container-low"
  220. }`}
  221. style={({ pressed }) => ({
  222. opacity: disabled ? 0.55 : pressed ? 0.9 : 1,
  223. })}
  224. >
  225. <Text
  226. className={`flex-1 text-base ${hasValue ? "text-on-surface" : "text-outline"}`}
  227. numberOfLines={1}
  228. >
  229. {hasValue ? value : placeholder}
  230. </Text>
  231. <Ionicons
  232. name="chevron-down"
  233. size={18}
  234. color={error ? "#ef4444" : Colors.color_text_paragraph}
  235. />
  236. </Pressable>
  237. <FieldMessage error={error} helper={helper} />
  238. </View>
  239. );
  240. }
  241. export default function AddCustomerScreen() {
  242. const insets = useSafeAreaInsets();
  243. const navigation = useNavigation();
  244. const [form, setForm] = useState<CustomerForm>(EMPTY_CUSTOMER_FORM);
  245. const [errors, setErrors] = useState<FieldErrors>({});
  246. const [birthDateSource, setBirthDateSource] = useState<BirthDateSource>("unset");
  247. const [saving, setSaving] = useState(false);
  248. const leaveConfirmVisibleRef = useRef(false);
  249. const savedLeaveRef = useRef(false);
  250. const route = useRoute();
  251. const scrollViewRef = useRef<ScrollView>(null!);
  252. const hasDraft = Boolean(
  253. form.name.trim() ||
  254. form.mobile.trim() ||
  255. form.gender ||
  256. form.birthday ||
  257. form.idcard.trim() ||
  258. form.nation.trim() ||
  259. form.registered.trim() ||
  260. form.residential.trim() ||
  261. form.photo.trim() ||
  262. form.occupation.trim() ||
  263. form.other.trim()
  264. );
  265. const openLeaveConfirm = useCallback(
  266. (action: NavigationAction) => {
  267. if (leaveConfirmVisibleRef.current) {
  268. return;
  269. }
  270. leaveConfirmVisibleRef.current = true;
  271. Modal.alert(
  272. "放弃当前编辑",
  273. "当前页面还有未保存的客户资料,确定要返回吗?",
  274. [
  275. {
  276. text: "继续编辑",
  277. style: "cancel",
  278. onPress: () => {
  279. leaveConfirmVisibleRef.current = false;
  280. },
  281. },
  282. {
  283. text: "确认返回",
  284. style: "destructive",
  285. onPress: () => {
  286. leaveConfirmVisibleRef.current = false;
  287. navigation.dispatch(action);
  288. },
  289. },
  290. ],
  291. () => {
  292. leaveConfirmVisibleRef.current = false;
  293. return true;
  294. }
  295. );
  296. },
  297. [navigation]
  298. );
  299. usePreventRemove(hasDraft, ({ data }) => {
  300. if (savedLeaveRef.current) {
  301. navigation.dispatch(data.action);
  302. return;
  303. }
  304. openLeaveConfirm(data.action);
  305. });
  306. const clearErrors = (keys: FieldKey[]) => {
  307. setErrors((prev) => {
  308. const next = { ...prev };
  309. keys.forEach((key) => {
  310. delete next[key];
  311. });
  312. return next;
  313. });
  314. };
  315. const handleNameChange = (value: string) => {
  316. setForm((prev) => ({ ...prev, name: value }));
  317. clearErrors(["name"]);
  318. };
  319. const handleMobileChange = (value: string) => {
  320. setForm((prev) => ({ ...prev, mobile: sanitizePhone(value) }));
  321. clearErrors(["mobile", "idcard"]);
  322. };
  323. const handleIdCardChange = (value: string) => {
  324. const nextIdCard = sanitizeIdCard(value);
  325. const inferredBirthDate = inferBirthDateFromIdCard(nextIdCard);
  326. const canAutofillBirthDate = birthDateSource !== "manual" || !form.birthday;
  327. setForm((prev) => ({
  328. ...prev,
  329. idcard: nextIdCard,
  330. birthday:
  331. inferredBirthDate && canAutofillBirthDate
  332. ? inferredBirthDate
  333. : !inferredBirthDate && birthDateSource === "id-card"
  334. ? undefined
  335. : prev.birthday,
  336. }));
  337. if (inferredBirthDate && canAutofillBirthDate) {
  338. setBirthDateSource("id-card");
  339. } else if (!inferredBirthDate && birthDateSource === "id-card") {
  340. setBirthDateSource("unset");
  341. }
  342. clearErrors(["mobile", "idcard"]);
  343. };
  344. const savedRef = useRef<boolean>(false);
  345. const onSelectCredis = (isCancel: boolean) => {
  346. setSelectCredit(false);
  347. // if (isCancel) {
  348. // return;
  349. // }
  350. if (savedRef.current || !isCancel) {
  351. navigation.goBack();
  352. return;
  353. }
  354. }
  355. const handleSubmit = async () => {
  356. if (saving) {
  357. return;
  358. }
  359. const nextErrors = validateForm(form);
  360. setErrors(nextErrors);
  361. const firstError = Object.values(nextErrors)[0];
  362. if (firstError) {
  363. Toast.fail(firstError);
  364. scrollViewRef.current?.scrollTo({ y: 160, animated: true });
  365. return;
  366. }
  367. setSaving(true);
  368. const toastKey = Toast.loading("正在保存客户...");
  369. try {
  370. const customerId = await api.post("/customer/add", buildCustomerPayload(form));
  371. Toast.success("客户已保存");
  372. savedLeaveRef.current = true;
  373. // setForm(EMPTY_CUSTOMER_FORM);
  374. // setBirthDateSource("unset");
  375. // setErrors({});
  376. // @ts-ignore
  377. if (route.params?.onGoBack) {
  378. // @ts-ignore
  379. route.params.onGoBack({ customerId });
  380. navigation.goBack();
  381. } else {
  382. Modal.alert("客户添加完成", "是否立即上传征信信息", [{
  383. text: '否',
  384. }, {
  385. 'text': '立即上传',
  386. onPress: () => {
  387. setSelectCredit(true);
  388. }
  389. }])
  390. }
  391. } catch (error) {
  392. console.error("保存客户失败:", error);
  393. Toast.fail(error instanceof Error ? error.message : "保存客户失败,请稍后重试");
  394. } finally {
  395. setSaving(false);
  396. Toast.remove(toastKey);
  397. }
  398. };
  399. const birthDateHelper =
  400. birthDateSource === "id-card"
  401. ? "已根据身份证自动识别,可继续手动调整"
  402. : "填写身份证后可自动识别,也可以手动选择";
  403. const [selectCredit, setSelectCredit] = useState(false);
  404. return (
  405. <View className="flex-1 bg-surface">
  406. <Stack.Screen options={{ title: "添加客户" }} />
  407. <KeyboardAvoidingView
  408. className="flex-1"
  409. behavior={Platform.OS === "ios" ? "padding" : undefined}
  410. >
  411. <ScrollView
  412. ref={scrollViewRef}
  413. className="flex-1"
  414. contentContainerClassName="px-5 pb-10"
  415. contentContainerStyle={{
  416. paddingTop: (insets.top ?? 0) + 60,
  417. paddingBottom: (insets.bottom ?? 0) + 28,
  418. }}
  419. keyboardShouldPersistTaps="handled"
  420. showsVerticalScrollIndicator={false}
  421. >
  422. <View className="mb-6">
  423. <Text className="text-3xl font-extrabold tracking-tight text-on-surface">
  424. 新建客户
  425. </Text>
  426. </View>
  427. <View className="mb-5 rounded-3xl border border-blue-100 bg-primary-fixed px-4 py-4">
  428. <View className="flex-row items-start">
  429. <View className="mr-3 mt-0.5 h-10 w-10 items-center justify-center rounded-2xl bg-primary-container">
  430. <Ionicons name="information-circle-outline" size={20} color="#ffffff" />
  431. </View>
  432. <View className="flex-1">
  433. <Text className="text-base font-bold text-on-surface">提示:</Text>
  434. <Text className="mt-1 text-sm leading-6 text-on-surface-variant">
  435. 您可以先<Link href="/credit/select" replace asChild><Text className="text-primary font-bold">上传分析征信</Text></Link>,然后从已分析征信中<Link href="/credit/select" asChild><Text className="text-primary font-bold">选择</Text></Link>一条信息以填充客户资料。
  436. </Text>
  437. </View>
  438. </View>
  439. </View>
  440. <View className="mb-4">
  441. <FieldLabel label="姓名" required />
  442. <Input
  443. value={form.name}
  444. onChangeText={handleNameChange}
  445. placeholder="请输入客户姓名"
  446. allowClear
  447. status={errors.name ? "error" : undefined}
  448. style={[
  449. styles.inputContainer,
  450. errors.name ? styles.inputContainerError : undefined,
  451. ]}
  452. inputStyle={styles.inputText}
  453. />
  454. <FieldMessage error={errors.name} />
  455. </View>
  456. <View className="mb-4">
  457. <FieldLabel label="性别" />
  458. <Radio.Group
  459. value={form.gender}
  460. onChange={(event) => {
  461. const selected = event.target.value;
  462. if (typeof selected !== "string" && typeof selected !== "number") {
  463. return;
  464. }
  465. setForm((prev) => ({
  466. ...prev,
  467. gender: String(selected),
  468. }));
  469. }}
  470. style={styles.radioGroup}
  471. >
  472. {GENDER_OPTIONS.map((option) => (
  473. <Radio key={option.value} value={option.value}>
  474. {option.label}
  475. </Radio>
  476. ))}
  477. </Radio.Group>
  478. </View>
  479. <DatePicker
  480. precision="day"
  481. value={form.birthday}
  482. minDate={new Date(1950, 0, 1)}
  483. maxDate={new Date()}
  484. format={formatDate}
  485. onOk={(value) => {
  486. setForm((prev) => ({ ...prev, birthday: value }));
  487. setBirthDateSource("manual");
  488. }}
  489. >
  490. {({ disabled, toggle }) => (
  491. <PickerField
  492. label="出生日期"
  493. value={formatDate(form.birthday)}
  494. placeholder="请选择出生日期"
  495. helper={birthDateHelper}
  496. disabled={disabled}
  497. onPress={toggle}
  498. />
  499. )}
  500. </DatePicker>
  501. <View className="mb-4">
  502. <FieldLabel label="手机号" />
  503. <TextInput
  504. value={form.mobile}
  505. onChangeText={handleMobileChange}
  506. placeholder="请输入 11 位手机号"
  507. keyboardType="phone-pad"
  508. placeholderTextColor="#9ca3af"
  509. textContentType="telephoneNumber"
  510. underlineColorAndroid="transparent"
  511. maxLength={11}
  512. style={[
  513. styles.inputContainer,
  514. styles.inputText,
  515. errors.mobile ? styles.inputContainerError : undefined,
  516. ]}
  517. />
  518. <FieldMessage
  519. error={errors.mobile}
  520. helper={errors.mobile ? undefined : "手机号和身份证号至少填写一项"}
  521. />
  522. </View>
  523. <View className="mb-4">
  524. <FieldLabel label="身份证号" />
  525. <TextInput
  526. value={form.idcard}
  527. onChangeText={handleIdCardChange}
  528. placeholder="请输入身份证号"
  529. autoCapitalize="characters"
  530. autoCorrect={false}
  531. placeholderTextColor="#9ca3af"
  532. underlineColorAndroid="transparent"
  533. maxLength={18}
  534. style={[
  535. styles.inputContainer,
  536. styles.inputText,
  537. errors.idcard ? styles.inputContainerError : undefined,
  538. ]}
  539. />
  540. <FieldMessage
  541. error={errors.idcard}
  542. helper={errors.idcard ? undefined : "填写 18 位身份证后会自动识别出生日期"}
  543. />
  544. </View>
  545. <View className="mb-4">
  546. <FieldLabel label="民族" />
  547. <Input
  548. value={form.nation}
  549. onChangeText={(value) => setForm((prev) => ({ ...prev, nation: value }))}
  550. placeholder="请输入民族"
  551. allowClear
  552. maxLength={16}
  553. style={styles.inputContainer}
  554. inputStyle={styles.inputText}
  555. />
  556. </View>
  557. <View className="mb-4">
  558. <FieldLabel label="户籍所在地" />
  559. <Input
  560. value={form.registered}
  561. onChangeText={(value) => setForm((prev) => ({ ...prev, registered: value }))}
  562. placeholder="请输入户籍所在地"
  563. allowClear
  564. style={styles.inputContainer}
  565. inputStyle={styles.inputText}
  566. />
  567. </View>
  568. <View className="mb-4">
  569. <FieldLabel label="现居住地" />
  570. <Input
  571. value={form.residential}
  572. onChangeText={(value) => setForm((prev) => ({ ...prev, residential: value }))}
  573. placeholder="请输入现居住地"
  574. allowClear
  575. style={styles.inputContainer}
  576. inputStyle={styles.inputText}
  577. />
  578. </View>
  579. <View className="mb-4">
  580. <FieldLabel label="照片" />
  581. <Input
  582. value={form.photo}
  583. onChangeText={(value) => setForm((prev) => ({ ...prev, photo: value }))}
  584. placeholder="请输入照片地址"
  585. allowClear
  586. style={styles.inputContainer}
  587. inputStyle={styles.inputText}
  588. />
  589. </View>
  590. <View className="mb-4">
  591. <FieldLabel label="职业" />
  592. <Input
  593. value={form.occupation}
  594. onChangeText={(value) => setForm((prev) => ({ ...prev, occupation: value }))}
  595. placeholder="请输入职业"
  596. allowClear
  597. style={styles.inputContainer}
  598. inputStyle={styles.inputText}
  599. />
  600. </View>
  601. <View>
  602. <FieldLabel label="其它" />
  603. <Input.TextArea
  604. value={form.other}
  605. onChangeText={(value) => setForm((prev) => ({ ...prev, other: value }))}
  606. placeholder="补充备注、渠道来源、客户标签等"
  607. autoSize={{ minRows: 4, maxRows: 6 }}
  608. maxLength={200}
  609. showCount
  610. allowClear
  611. style={styles.textAreaContainer}
  612. inputStyle={styles.textAreaText}
  613. />
  614. </View>
  615. <UIButton type="primary" icon="save" onPress={handleSubmit} disabled={saving} loading={saving}>
  616. 保存客户
  617. </UIButton>
  618. </ScrollView>
  619. </KeyboardAvoidingView>
  620. <UploadScreen visible={selectCredit} onClose={onSelectCredis} />
  621. </View>
  622. );
  623. }
  624. const styles = StyleSheet.create({
  625. inputContainer: {
  626. minHeight: 54,
  627. borderRadius: 16,
  628. borderWidth: 1,
  629. borderColor: Colors.border_color_base,
  630. backgroundColor: Colors.fill_body,
  631. paddingHorizontal: 14,
  632. },
  633. inputContainerError: {
  634. borderColor: "#fca5a5",
  635. backgroundColor: "#fef2f2",
  636. },
  637. inputText: {
  638. color: Colors.color_text_base,
  639. fontSize: 16,
  640. paddingVertical: Platform.OS === "web" ? 12 : 10,
  641. },
  642. radioGroup: {
  643. flexDirection: "row",
  644. gap: 24,
  645. minHeight: 44,
  646. alignItems: "center",
  647. },
  648. textAreaContainer: {
  649. borderRadius: 16,
  650. borderWidth: 1,
  651. borderColor: Colors.border_color_base,
  652. backgroundColor: Colors.fill_body,
  653. paddingHorizontal: 14,
  654. paddingTop: 12,
  655. paddingBottom: 12,
  656. },
  657. textAreaText: {
  658. color: Colors.color_text_base,
  659. fontSize: 16,
  660. lineHeight: 22,
  661. minHeight: 96,
  662. textAlignVertical: "top",
  663. },
  664. });