| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742 |
- import UIButton from "@/components/ui/UIButton";
- import { Colors } from "@/constants/theme";
- import api from "@/utils/api";
- import { DatePicker, Input, Modal, Radio, Toast } from "@ant-design/react-native";
- import { Ionicons } from "@expo/vector-icons";
- import { usePreventRemove, useRoute } from "@react-navigation/native";
- import { NavigationAction } from "@react-navigation/routers";
- import { Link, Stack, useNavigation } from "expo-router";
- import React, { useCallback, useRef, useState } from "react";
- import {
- KeyboardAvoidingView,
- Platform,
- Pressable,
- ScrollView,
- StyleSheet,
- Text,
- TextInput,
- View,
- } from "react-native";
- import { useSafeAreaInsets } from "react-native-safe-area-context";
- import { UploadScreen } from "../credit/upload";
- type CustomerForm = {
- name: string;
- mobile: string;
- gender: string;
- birthday?: Date;
- idcard: string;
- nation: string;
- registered: string;
- residential: string;
- photo: string;
- occupation: string;
- other: string;
- };
- type FieldKey = "name" | "mobile" | "idcard";
- type FieldErrors = Partial<Record<FieldKey, string>>;
- type BirthDateSource = "unset" | "manual" | "id-card";
- type Option = {
- label: string;
- value: string;
- };
- const GENDER_OPTIONS: Option[] = [
- { label: "男", value: "1" },
- { label: "女", value: "2" },
- { label: "其它", value: "3" }
- ];
- const MOBILE_PATTERN = /^1\d{10}$/;
- const IDCARD_PATTERN = /^(?:\d{15}|\d{17}[\dX])$/;
- const EMPTY_CUSTOMER_FORM: CustomerForm = {
- name: "",
- mobile: "",
- gender: "",
- birthday: undefined,
- idcard: "",
- nation: "",
- registered: "",
- residential: "",
- photo: "",
- occupation: "",
- other: "",
- };
- type CustomerExtPayload = {
- nation: string;
- registered: string | null;
- residential: string | null;
- photo: string | null;
- occupation: string | null;
- other: string | null;
- };
- type CustomerPayload = {
- name: string;
- mobile: string;
- gender: number;
- birthday: string | null;
- idcard: string;
- ext: CustomerExtPayload;
- };
- function sanitizePhone(value: string) {
- return value.replace(/\D/g, "").slice(0, 11);
- }
- function sanitizeIdCard(value: string) {
- return value.replace(/[^0-9xX]/g, "").toUpperCase().slice(0, 18);
- }
- function padZero(value: number) {
- return String(value).padStart(2, "0");
- }
- function formatDate(date?: Date) {
- if (!date) {
- return "";
- }
- return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())}`;
- }
- function isValidDatePart(year: number, month: number, day: number) {
- if (year < 1900 || year > new Date().getFullYear()) {
- return false;
- }
- const date = new Date(year, month - 1, day);
- return (
- date.getFullYear() === year &&
- date.getMonth() === month - 1 &&
- date.getDate() === day &&
- date.getTime() <= Date.now()
- );
- }
- function inferBirthDateFromIdCard(idCard: string) {
- if (idCard.length < 14) {
- return undefined;
- }
- const birthText = idCard.slice(6, 14);
- if (!/^\d{8}$/.test(birthText)) {
- return undefined;
- }
- const year = Number(birthText.slice(0, 4));
- const month = Number(birthText.slice(4, 6));
- const day = Number(birthText.slice(6, 8));
- if (!isValidDatePart(year, month, day)) {
- return undefined;
- }
- return new Date(year, month - 1, day);
- }
- function nullableText(value: string) {
- const trimmed = value.trim();
- return trimmed || null;
- }
- function validateForm(form: CustomerForm): FieldErrors {
- const nextErrors: FieldErrors = {};
- const mobile = form.mobile.trim();
- const idcard = form.idcard.trim();
- if (!form.name.trim()) {
- nextErrors.name = "请输入客户姓名";
- }
- if (!mobile && !idcard) {
- nextErrors.mobile = "手机号和身份证号至少填写一项";
- nextErrors.idcard = "手机号和身份证号至少填写一项";
- return nextErrors;
- }
- if (mobile && !MOBILE_PATTERN.test(mobile)) {
- nextErrors.mobile = "请输入 11 位手机号";
- }
- if (idcard && !IDCARD_PATTERN.test(idcard)) {
- nextErrors.idcard = "请输入正确的身份证号";
- }
- return nextErrors;
- }
- function buildCustomerPayload(form: CustomerForm): CustomerPayload {
- return {
- name: form.name.trim(),
- mobile: form.mobile.trim(),
- gender: Number(form.gender) || 0,
- birthday: form.birthday ? formatDate(form.birthday) : null,
- idcard: form.idcard.trim(),
- ext: {
- nation: form.nation.trim(),
- registered: nullableText(form.registered),
- residential: nullableText(form.residential),
- photo: nullableText(form.photo),
- occupation: nullableText(form.occupation),
- other: nullableText(form.other),
- },
- };
- }
- function FieldLabel({
- label,
- required = false,
- }: {
- label: string;
- required?: boolean;
- }) {
- return (
- <View className="mb-2 flex-row items-center">
- <Text className="text-sm font-semibold text-on-surface">{label}</Text>
- {required ? <Text className="ml-1 text-sm font-semibold text-red-500">*</Text> : null}
- </View>
- );
- }
- function FieldMessage({
- error,
- helper,
- }: {
- error?: string;
- helper?: string;
- }) {
- if (error) {
- return <Text className="mt-2 text-xs leading-5 text-red-500">{error}</Text>;
- }
- if (helper) {
- return <Text className="mt-2 text-xs leading-5 text-on-surface-variant">{helper}</Text>;
- }
- return null;
- }
- function PickerField({
- label,
- required = false,
- value,
- placeholder,
- error,
- helper,
- disabled = false,
- onPress,
- }: {
- label: string;
- required?: boolean;
- value?: string;
- placeholder: string;
- error?: string;
- helper?: string;
- disabled?: boolean;
- onPress: () => void;
- }) {
- const hasValue = Boolean(value);
- return (
- <View className="mb-4">
- <FieldLabel label={label} required={required} />
- <Pressable
- disabled={disabled}
- onPress={onPress}
- className={`flex-row items-center justify-between rounded-2xl border px-4 py-4 ${error
- ? "border-red-300 bg-red-50"
- : "border-outline-variant bg-surface-container-low"
- }`}
- style={({ pressed }) => ({
- opacity: disabled ? 0.55 : pressed ? 0.9 : 1,
- })}
- >
- <Text
- className={`flex-1 text-base ${hasValue ? "text-on-surface" : "text-outline"}`}
- numberOfLines={1}
- >
- {hasValue ? value : placeholder}
- </Text>
- <Ionicons
- name="chevron-down"
- size={18}
- color={error ? "#ef4444" : Colors.color_text_paragraph}
- />
- </Pressable>
- <FieldMessage error={error} helper={helper} />
- </View>
- );
- }
- export default function AddCustomerScreen() {
- const insets = useSafeAreaInsets();
- const navigation = useNavigation();
- const [form, setForm] = useState<CustomerForm>(EMPTY_CUSTOMER_FORM);
- const [errors, setErrors] = useState<FieldErrors>({});
- const [birthDateSource, setBirthDateSource] = useState<BirthDateSource>("unset");
- const [saving, setSaving] = useState(false);
- const leaveConfirmVisibleRef = useRef(false);
- const savedLeaveRef = useRef(false);
- const route = useRoute();
- const scrollViewRef = useRef<ScrollView>(null!);
- const hasDraft = Boolean(
- form.name.trim() ||
- form.mobile.trim() ||
- form.gender ||
- form.birthday ||
- form.idcard.trim() ||
- form.nation.trim() ||
- form.registered.trim() ||
- form.residential.trim() ||
- form.photo.trim() ||
- form.occupation.trim() ||
- form.other.trim()
- );
- const openLeaveConfirm = useCallback(
- (action: NavigationAction) => {
- if (leaveConfirmVisibleRef.current) {
- return;
- }
- leaveConfirmVisibleRef.current = true;
- Modal.alert(
- "放弃当前编辑",
- "当前页面还有未保存的客户资料,确定要返回吗?",
- [
- {
- text: "继续编辑",
- style: "cancel",
- onPress: () => {
- leaveConfirmVisibleRef.current = false;
- },
- },
- {
- text: "确认返回",
- style: "destructive",
- onPress: () => {
- leaveConfirmVisibleRef.current = false;
- navigation.dispatch(action);
- },
- },
- ],
- () => {
- leaveConfirmVisibleRef.current = false;
- return true;
- }
- );
- },
- [navigation]
- );
- usePreventRemove(hasDraft, ({ data }) => {
- if (savedLeaveRef.current) {
- navigation.dispatch(data.action);
- return;
- }
- openLeaveConfirm(data.action);
- });
- const clearErrors = (keys: FieldKey[]) => {
- setErrors((prev) => {
- const next = { ...prev };
- keys.forEach((key) => {
- delete next[key];
- });
- return next;
- });
- };
- const handleNameChange = (value: string) => {
- setForm((prev) => ({ ...prev, name: value }));
- clearErrors(["name"]);
- };
- const handleMobileChange = (value: string) => {
- setForm((prev) => ({ ...prev, mobile: sanitizePhone(value) }));
- clearErrors(["mobile", "idcard"]);
- };
- const handleIdCardChange = (value: string) => {
- const nextIdCard = sanitizeIdCard(value);
- const inferredBirthDate = inferBirthDateFromIdCard(nextIdCard);
- const canAutofillBirthDate = birthDateSource !== "manual" || !form.birthday;
- setForm((prev) => ({
- ...prev,
- idcard: nextIdCard,
- birthday:
- inferredBirthDate && canAutofillBirthDate
- ? inferredBirthDate
- : !inferredBirthDate && birthDateSource === "id-card"
- ? undefined
- : prev.birthday,
- }));
- if (inferredBirthDate && canAutofillBirthDate) {
- setBirthDateSource("id-card");
- } else if (!inferredBirthDate && birthDateSource === "id-card") {
- setBirthDateSource("unset");
- }
- clearErrors(["mobile", "idcard"]);
- };
- const savedRef = useRef<boolean>(false);
- const onSelectCredis = (isCancel: boolean) => {
- setSelectCredit(false);
- // if (isCancel) {
- // return;
- // }
- if (savedRef.current || !isCancel) {
- navigation.goBack();
- return;
- }
- }
- const handleSubmit = async () => {
- if (saving) {
- return;
- }
- const nextErrors = validateForm(form);
- setErrors(nextErrors);
- const firstError = Object.values(nextErrors)[0];
- if (firstError) {
- Toast.fail(firstError);
- scrollViewRef.current?.scrollTo({ y: 160, animated: true });
- return;
- }
- setSaving(true);
- const toastKey = Toast.loading("正在保存客户...");
- try {
- const customerId = await api.post("/customer/add", buildCustomerPayload(form));
- Toast.success("客户已保存");
- savedLeaveRef.current = true;
- // setForm(EMPTY_CUSTOMER_FORM);
- // setBirthDateSource("unset");
- // setErrors({});
- // @ts-ignore
- if (route.params?.onGoBack) {
- // @ts-ignore
- route.params.onGoBack({ customerId });
- navigation.goBack();
- } else {
- Modal.alert("客户添加完成", "是否立即上传征信信息", [{
- text: '否',
- }, {
- 'text': '立即上传',
- onPress: () => {
- setSelectCredit(true);
- }
- }])
- }
- } catch (error) {
- console.error("保存客户失败:", error);
- Toast.fail(error instanceof Error ? error.message : "保存客户失败,请稍后重试");
- } finally {
- setSaving(false);
- Toast.remove(toastKey);
- }
- };
- const birthDateHelper =
- birthDateSource === "id-card"
- ? "已根据身份证自动识别,可继续手动调整"
- : "填写身份证后可自动识别,也可以手动选择";
- const [selectCredit, setSelectCredit] = useState(false);
- return (
- <View className="flex-1 bg-surface">
- <Stack.Screen options={{ title: "添加客户" }} />
- <KeyboardAvoidingView
- className="flex-1"
- behavior={Platform.OS === "ios" ? "padding" : undefined}
- >
- <ScrollView
- ref={scrollViewRef}
- className="flex-1"
- contentContainerClassName="px-5 pb-10"
- contentContainerStyle={{
- paddingTop: (insets.top ?? 0) + 60,
- paddingBottom: (insets.bottom ?? 0) + 28,
- }}
- keyboardShouldPersistTaps="handled"
- showsVerticalScrollIndicator={false}
- >
- <View className="mb-6">
- <Text className="text-3xl font-extrabold tracking-tight text-on-surface">
- 新建客户
- </Text>
- </View>
- <View className="mb-5 rounded-3xl border border-blue-100 bg-primary-fixed px-4 py-4">
- <View className="flex-row items-start">
- <View className="mr-3 mt-0.5 h-10 w-10 items-center justify-center rounded-2xl bg-primary-container">
- <Ionicons name="information-circle-outline" size={20} color="#ffffff" />
- </View>
- <View className="flex-1">
- <Text className="text-base font-bold text-on-surface">提示:</Text>
- <Text className="mt-1 text-sm leading-6 text-on-surface-variant">
- 您可以先<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>一条信息以填充客户资料。
- </Text>
- </View>
- </View>
- </View>
- <View className="mb-4">
- <FieldLabel label="姓名" required />
- <Input
- value={form.name}
- onChangeText={handleNameChange}
- placeholder="请输入客户姓名"
- allowClear
- status={errors.name ? "error" : undefined}
- style={[
- styles.inputContainer,
- errors.name ? styles.inputContainerError : undefined,
- ]}
- inputStyle={styles.inputText}
- />
- <FieldMessage error={errors.name} />
- </View>
- <View className="mb-4">
- <FieldLabel label="性别" />
- <Radio.Group
- value={form.gender}
- onChange={(event) => {
- const selected = event.target.value;
- if (typeof selected !== "string" && typeof selected !== "number") {
- return;
- }
- setForm((prev) => ({
- ...prev,
- gender: String(selected),
- }));
- }}
- style={styles.radioGroup}
- >
- {GENDER_OPTIONS.map((option) => (
- <Radio key={option.value} value={option.value}>
- {option.label}
- </Radio>
- ))}
- </Radio.Group>
- </View>
- <DatePicker
- precision="day"
- value={form.birthday}
- minDate={new Date(1950, 0, 1)}
- maxDate={new Date()}
- format={formatDate}
- onOk={(value) => {
- setForm((prev) => ({ ...prev, birthday: value }));
- setBirthDateSource("manual");
- }}
- >
- {({ disabled, toggle }) => (
- <PickerField
- label="出生日期"
- value={formatDate(form.birthday)}
- placeholder="请选择出生日期"
- helper={birthDateHelper}
- disabled={disabled}
- onPress={toggle}
- />
- )}
- </DatePicker>
- <View className="mb-4">
- <FieldLabel label="手机号" />
- <TextInput
- value={form.mobile}
- onChangeText={handleMobileChange}
- placeholder="请输入 11 位手机号"
- keyboardType="phone-pad"
- placeholderTextColor="#9ca3af"
- textContentType="telephoneNumber"
- underlineColorAndroid="transparent"
- maxLength={11}
- style={[
- styles.inputContainer,
- styles.inputText,
- errors.mobile ? styles.inputContainerError : undefined,
- ]}
- />
- <FieldMessage
- error={errors.mobile}
- helper={errors.mobile ? undefined : "手机号和身份证号至少填写一项"}
- />
- </View>
- <View className="mb-4">
- <FieldLabel label="身份证号" />
- <TextInput
- value={form.idcard}
- onChangeText={handleIdCardChange}
- placeholder="请输入身份证号"
- autoCapitalize="characters"
- autoCorrect={false}
- placeholderTextColor="#9ca3af"
- underlineColorAndroid="transparent"
- maxLength={18}
- style={[
- styles.inputContainer,
- styles.inputText,
- errors.idcard ? styles.inputContainerError : undefined,
- ]}
- />
- <FieldMessage
- error={errors.idcard}
- helper={errors.idcard ? undefined : "填写 18 位身份证后会自动识别出生日期"}
- />
- </View>
- <View className="mb-4">
- <FieldLabel label="民族" />
- <Input
- value={form.nation}
- onChangeText={(value) => setForm((prev) => ({ ...prev, nation: value }))}
- placeholder="请输入民族"
- allowClear
- maxLength={16}
- style={styles.inputContainer}
- inputStyle={styles.inputText}
- />
- </View>
- <View className="mb-4">
- <FieldLabel label="户籍所在地" />
- <Input
- value={form.registered}
- onChangeText={(value) => setForm((prev) => ({ ...prev, registered: value }))}
- placeholder="请输入户籍所在地"
- allowClear
- style={styles.inputContainer}
- inputStyle={styles.inputText}
- />
- </View>
- <View className="mb-4">
- <FieldLabel label="现居住地" />
- <Input
- value={form.residential}
- onChangeText={(value) => setForm((prev) => ({ ...prev, residential: value }))}
- placeholder="请输入现居住地"
- allowClear
- style={styles.inputContainer}
- inputStyle={styles.inputText}
- />
- </View>
- <View className="mb-4">
- <FieldLabel label="照片" />
- <Input
- value={form.photo}
- onChangeText={(value) => setForm((prev) => ({ ...prev, photo: value }))}
- placeholder="请输入照片地址"
- allowClear
- style={styles.inputContainer}
- inputStyle={styles.inputText}
- />
- </View>
- <View className="mb-4">
- <FieldLabel label="职业" />
- <Input
- value={form.occupation}
- onChangeText={(value) => setForm((prev) => ({ ...prev, occupation: value }))}
- placeholder="请输入职业"
- allowClear
- style={styles.inputContainer}
- inputStyle={styles.inputText}
- />
- </View>
- <View>
- <FieldLabel label="其它" />
- <Input.TextArea
- value={form.other}
- onChangeText={(value) => setForm((prev) => ({ ...prev, other: value }))}
- placeholder="补充备注、渠道来源、客户标签等"
- autoSize={{ minRows: 4, maxRows: 6 }}
- maxLength={200}
- showCount
- allowClear
- style={styles.textAreaContainer}
- inputStyle={styles.textAreaText}
- />
- </View>
- <UIButton type="primary" icon="save" onPress={handleSubmit} disabled={saving} loading={saving}>
- 保存客户
- </UIButton>
- </ScrollView>
- </KeyboardAvoidingView>
- <UploadScreen visible={selectCredit} onClose={onSelectCredis} />
- </View>
- );
- }
- const styles = StyleSheet.create({
- inputContainer: {
- minHeight: 54,
- borderRadius: 16,
- borderWidth: 1,
- borderColor: Colors.border_color_base,
- backgroundColor: Colors.fill_body,
- paddingHorizontal: 14,
- },
- inputContainerError: {
- borderColor: "#fca5a5",
- backgroundColor: "#fef2f2",
- },
- inputText: {
- color: Colors.color_text_base,
- fontSize: 16,
- paddingVertical: Platform.OS === "web" ? 12 : 10,
- },
- radioGroup: {
- flexDirection: "row",
- gap: 24,
- minHeight: 44,
- alignItems: "center",
- },
- textAreaContainer: {
- borderRadius: 16,
- borderWidth: 1,
- borderColor: Colors.border_color_base,
- backgroundColor: Colors.fill_body,
- paddingHorizontal: 14,
- paddingTop: 12,
- paddingBottom: 12,
- },
- textAreaText: {
- color: Colors.color_text_base,
- fontSize: 16,
- lineHeight: 22,
- minHeight: 96,
- textAlignVertical: "top",
- },
- });
|