register.page.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. <script setup lang="ts">
  2. import NavHomeSelect from '@/assets/images/nav-home.select.png?url';
  3. import { Toast } from '@/platform';
  4. import { getCaptchaMethod, registerAccountMethod, registerFieldsMethod, dictionariesMethod, searchAccountMethod } from '@/request/api';
  5. import type { CascaderOption, Fields, Option, RegisterModel } from '@/request/model';
  6. import { useRouteQuery } from '@vueuse/router';
  7. import { useCaptcha, useRequest, useSerialRequest } from 'alova/client';
  8. import type { FormInstance } from 'vant';
  9. import { RadioGroup as vanRadioGroup } from 'vant';
  10. import PickerDialog from '@/components/PickerDialog.vue';
  11. import CascaderDialog from '@/components/CascaderDialog.vue';
  12. import { useFlowStore } from '@/stores';
  13. const formRef = useTemplateRef<FormInstance>('register-form');
  14. const modelRef = ref<Partial<RegisterModel>>({ code: '' });
  15. const modelValueRef = ref<Partial<RegisterModel>>({});
  16. const model = computed(() => ({ ...modelRef.value, ...modelValueRef.value }));
  17. const { data: fields, loading } = useSerialRequest([dictionariesMethod, (dictionaries) => registerFieldsMethod(dictionaries)]).onSuccess(({ data }) => {
  18. const sex = data.find((field) => field.name === 'sex');
  19. if (sex) {
  20. const unknown = (<any>sex).component?.options?.find((option: any) => option.value === '2');
  21. modelRef.value.sex = unknown?.value;
  22. }
  23. });
  24. const flow = useFlowStore();
  25. const { loading: submitting, send: submit } = useRequest(registerAccountMethod, { immediate: false }).onSuccess(({ data }) => {
  26. flow.router.push();
  27. });
  28. const forbiddenFields = shallowRef<Record<string, boolean>>({});
  29. const { loading: searching, send: search } = useRequest((data) => searchAccountMethod(data), {
  30. immediate: false,
  31. }).onSuccess(({ data }) => {
  32. if (!fields.value.some(field => field.name === 'phone')) Reflect.deleteProperty(data, 'phone');
  33. const modelLabel = {} as Record<string, any>;
  34. const modelValue = {} as Record<string, any>;
  35. const forbidden = {} as Record<string, boolean>;
  36. for (const [key, value] of Object.entries(data)) {
  37. const field = fields.value?.find((field) => field.name === key);
  38. if (field) forbidden[key] = !!value && !['phone', 'cardno'].includes(key);
  39. if (typeof value === 'string' && field?.component?.name === 'picker') {
  40. const result = value.split(',').map((value) => {
  41. const [v, l] = value.split(':');
  42. return { value, label: l ?? (field.component as { options: Option[] })?.options?.find((option) => option.value === v)?.label ?? v };
  43. });
  44. modelValue[key] = result.map((t) => t.value).join(',');
  45. modelLabel[key] = result.map((t) => t.label).join(',');
  46. } else if (typeof value === 'object' && field?.component?.name === 'cascader') {
  47. modelLabel[key] = value.map((option) => option.label).join(' / ');
  48. modelValue[key] = value;
  49. } else {
  50. modelLabel[key] = value;
  51. }
  52. }
  53. forbiddenFields.value = forbidden;
  54. modelRef.value = { ...modelRef.value, ...modelLabel };
  55. modelValueRef.value = { ...modelValueRef.value, ...modelValue };
  56. });
  57. let captchaLoaded = false;
  58. const { loading: captchaLoading, countdown, send: getCaptcha } = useCaptcha(
  59. () => getCaptchaMethod(modelRef.value.phone!),
  60. { initialCountdown: 60 },
  61. ).onSuccess(({ data }) => {
  62. captchaLoaded = true;
  63. Toast.success(data ?? '获取成功');
  64. });
  65. const getCaptchaHandle = async () => {
  66. try {
  67. await formRef.value?.validate('phone');
  68. if ( !modelRef.value.phone ) throw { message: `请输入手机号码` };
  69. await getCaptcha();
  70. const field = fields.value.find(field => field.name === 'code');
  71. if ( field?.keyboard ) { field.keyboard.show = true; }
  72. } catch ( e: any ) {
  73. Toast.warning(e?.message);
  74. }
  75. };
  76. const searchHandle = async (key: 'cardno' | 'code') => {
  77. const forbidden = { cardno: 'phone', code: 'cardno' }[key];
  78. try {
  79. await formRef.value?.validate(key);
  80. forbiddenFields.value = {};
  81. const { cardno, phone, code } = modelRef.value;
  82. await search({ cardno, phone, code })
  83. .then((data) => {
  84. forbiddenFields.value[forbidden] = !!(data as any)[forbidden];
  85. triggerRef(forbiddenFields);
  86. })
  87. .catch();
  88. } catch (e: any) {
  89. Toast.warning(e?.message);
  90. }
  91. };
  92. function onKeyboardBlur(field: Fields[number]) {
  93. if ( field?.name === 'phone' && !captchaLoaded ) { getCaptchaHandle(); }
  94. if ( field?.name === 'cardno' && modelRef.value.cardno ) { searchHandle('cardno'); }
  95. if ( field?.name === 'code' && modelRef.value.phone ) { searchHandle('code'); }
  96. }
  97. function onSubmitHandle() {
  98. submit(model.value);
  99. }
  100. function fix(key: string) {
  101. for ( const field of fields.value ) {
  102. if (field.keyboard?.show && field.name !== key ) field.keyboard.show = false;
  103. }
  104. }
  105. const scan = useRouteQuery<string>('scan');
  106. watch(scan, key => {
  107. if ( key ) {
  108. try {
  109. const { model } = JSON.parse(sessionStorage.getItem(`scan_${ key }`) ?? '');
  110. modelRef.value = { ...modelRef.value, ...model };
  111. } catch ( e: any ) {}
  112. }
  113. }, { immediate: true });
  114. onBeforeUnmount(() => {
  115. for ( let i = 0; i < sessionStorage.length; i++ ) {
  116. const key = sessionStorage.key(i);
  117. if ( key?.startsWith('scan_') ) sessionStorage.removeItem(key);
  118. }
  119. });
  120. const keyboardProps = reactive({
  121. key: '',
  122. props: {},
  123. show: false,
  124. });
  125. const pickerProps = reactive({
  126. key: '',
  127. props: { options: [], selected: [] },
  128. show: false,
  129. handle(options: Option[]) {
  130. const key = (this ?? pickerProps).key;
  131. (modelRef.value as Record<string, any>)[key] = options.map(option => option.label).join(',');
  132. (modelValueRef.value as Record<string, any>)[key] = options.map(option => option.value).join(',');
  133. }
  134. });
  135. const cascaderProps = reactive({
  136. key: '',
  137. props: { options: [], loading: false, },
  138. show: false,
  139. handle(options: CascaderOption[]) {
  140. const key = (this ?? cascaderProps).key;
  141. (modelRef.value as Record<string, any>)[key] = options.map((option) => option.label).join(' / ');
  142. (modelValueRef.value as Record<string, any>)[key] = options;
  143. },
  144. });
  145. function onFieldFocus(field: any) {
  146. if (forbiddenFields.value[field.name]) return;
  147. if (field.keyboard) {
  148. keyboardProps.key = field.name;
  149. keyboardProps.show = true;
  150. keyboardProps.props = {
  151. ...field.keyboard,
  152. maxlength: field.control?.maxlength ?? Number.POSITIVE_INFINITY,
  153. onBlur() {
  154. keyboardProps.show = false;
  155. onKeyboardBlur(field);
  156. },
  157. }
  158. } else if (field.component?.name === 'picker') {
  159. pickerProps.key = field.name;
  160. pickerProps.show = true;
  161. pickerProps.props = {
  162. ...field.component.props,
  163. title: field.control.label,
  164. options: field.component.options,
  165. selected: (modelValueRef.value as Record<string, string>)[field.name]?.split(','),
  166. };
  167. } else if (field.component?.name === 'cascader') {
  168. cascaderProps.key = field.name;
  169. cascaderProps.show = true;
  170. if (typeof field.component.options === 'function') {
  171. cascaderProps.props = {
  172. ...field.component.props,
  173. title: field.control.label,
  174. options: [],
  175. loading: true,
  176. };
  177. (async function () {
  178. field.component.options = await field.component.options();
  179. cascaderProps.props = {
  180. ...field.component.props,
  181. title: field.control.label,
  182. options: field.component.options,
  183. loading: false,
  184. selected: (modelValueRef.value as Record<string, { value: string }[]>)[field.name]?.map((option) => option.value),
  185. };
  186. })();
  187. } else {
  188. cascaderProps.props = {
  189. ...field.component.props,
  190. title: field.control.label,
  191. options: field.component.options,
  192. };
  193. }
  194. }
  195. }
  196. function onFieldBlur(field: any) {
  197. keyboardProps.show = false;
  198. pickerProps.show = false;
  199. }
  200. </script>
  201. <template>
  202. <div>
  203. <div class="page-header flex py-4 px-4 overflow-hidden">
  204. <div class="grow shrink-0 h-full min-w-16"></div>
  205. <div class="grow-[3] shrink mx-2 flex flex-col justify-center overflow-hidden">
  206. <div class="flex justify-center font-bold text-3xl text-nowrap text-center tracking-wide overflow-ellipsis overflow-hidden">
  207. 建档 <van-loading v-if="searching" style="margin-left: 4px; color: #38ff6e;"></van-loading>
  208. </div>
  209. </div>
  210. <div class="grow shrink-0 h-full min-w-16 flex items-center justify-end overflow-hidden">
  211. <router-link :to="{ path: '/screen' }" replace>
  212. <img class="size-8 object-scale-down" :src="NavHomeSelect" alt="返回首页">
  213. </router-link>
  214. </div>
  215. </div>
  216. <div class="page-content p-6 overflow-auto">
  217. <van-form class="register-form" ref="register-form" colon required="auto"
  218. scroll-to-error scroll-to-error-position="center"
  219. @submit="onSubmitHandle()"
  220. >
  221. <van-cell-group :border="false">
  222. <template v-for="field in fields" :key="field.name">
  223. <template v-if="!field.control?.hide || (typeof field.control?.hide === 'function' && !field.control.hide(model))">
  224. <van-field v-model="modelRef[field.name]" :name="field.name" :id="field.name"
  225. :rules="field.rules" v-bind="field.control"
  226. :class="{'no-border': field.control?.border === false}"
  227. @focus="onFieldFocus(field)" @blur="onFieldBlur(field)"
  228. :readonly="field.control?.readonly" @click="onFieldFocus(field)"
  229. :disabled="forbiddenFields[field.name]"
  230. >
  231. <template #input v-if="field.component?.name === 'radio'">
  232. <van-radio-group v-model="modelRef[field.name]" direction="horizontal" shape="dot" :disabled="forbiddenFields[field.name]">
  233. <van-radio v-for="option in field.component?.options" :key="option.value" :name="option.value">
  234. {{ option.label }}
  235. </van-radio>
  236. </van-radio-group>
  237. </template>
  238. <template #input v-else-if="field.component?.name === 'code'">
  239. <van-password-input
  240. style="width: 100%;"
  241. v-model:value="modelRef[field.name]" v-bind="(field.component as any)!.props"
  242. :focused="field.keyboard?.show" @focus="field.keyboard && (field.keyboard.show = true);fix('code')"
  243. />
  244. </template>
  245. <template #button>
  246. <div class="text-primary cursor-pointer">
  247. <template v-if="field.component?.name === 'code'">
  248. <div class="text-primary cursor-pointer" @click="getCaptchaHandle()">
  249. {{ captchaLoading ? '发送中...' : countdown > 0 ? `${ countdown }后可重发` : '获取验证码' }}
  250. </div>
  251. </template>
  252. <template v-else>{{ field.suffix }}</template>
  253. </div>
  254. </template>
  255. </van-field>
  256. </template>
  257. </template>
  258. </van-cell-group>
  259. </van-form>
  260. <div class="m-4">
  261. <div class="m-auto size-16 cursor-pointer">
  262. <van-loading v-if="submitting || loading" type="spinner" size="64" color="#38ff6e" />
  263. <img v-else class="size-full" src="@/assets/images/next-step.svg" alt="提交" @click="formRef?.submit()">
  264. </div>
  265. </div>
  266. <van-number-keyboard v-bind="keyboardProps.props" :show="keyboardProps.show" v-model="modelRef[keyboardProps.key]"></van-number-keyboard>
  267. <PickerDialog v-bind="pickerProps.props" v-model:show="pickerProps.show" @selected="pickerProps.handle($event)"></PickerDialog>
  268. <CascaderDialog v-bind="cascaderProps.props" v-model:show="cascaderProps.show" @selected="cascaderProps.handle($event)"></CascaderDialog>
  269. </div>
  270. </div>
  271. </template>
  272. <style scoped lang="scss">
  273. .register-form {
  274. .van-field {
  275. margin: 0;
  276. padding: 0;
  277. }
  278. .van-field.no-border {
  279. :deep(.van-field__control) {
  280. padding: 0;
  281. border: none;
  282. border-radius: 8px;
  283. text-align: center;
  284. }
  285. }
  286. :deep(.van-field--disabled) {
  287. --tw-text-opacity: 0.5;
  288. --van-radio-checked-icon-color: rgba(56, 255, 110, var(--tw-text-opacity, 1));
  289. .text-primary {
  290. --tw-text-opacity: 0.5;
  291. }
  292. }
  293. :deep(.van-field__label) {
  294. margin-bottom: 24px;
  295. padding: 8px 0;
  296. min-width: 100px;
  297. font-size: 18px;
  298. }
  299. :deep(.van-field__control) {
  300. margin-bottom: 24px;
  301. padding: 8px;
  302. border: 1px solid var(--van-radio-checked-icon-color);
  303. border-radius: 8px;
  304. text-align: center;
  305. }
  306. :deep(.van-field__clear) {
  307. align-self: flex-start;
  308. display: flex;
  309. align-items: center;
  310. height: 42px;
  311. }
  312. :deep(.van-field__button) {
  313. margin-bottom: 24px;
  314. padding: 8px var(--van-padding-xs);
  315. min-width: 100px;
  316. font-size: 18px;
  317. text-align: left;
  318. }
  319. :deep(.van-field__error-message) {
  320. position: absolute;
  321. top: 40px + 2px;
  322. }
  323. :deep(.van-password-input) {
  324. margin: 0;
  325. }
  326. :deep(.van-password-input__security) {
  327. justify-content: space-between;
  328. align-items: center;
  329. text-align: center;
  330. $size: 40px;
  331. height: $size + 2px;
  332. &::after {
  333. display: none;
  334. }
  335. li {
  336. height: $size;
  337. width: $size;
  338. flex: none;
  339. border: 1px solid #38ff6e;
  340. border-radius: 8px;
  341. }
  342. }
  343. }
  344. .van-radio-group {
  345. height: 40px + 2px;
  346. }
  347. .sub-option.checked {
  348. color: #fff;
  349. background-color: var(--primary-color);
  350. }
  351. </style>