form-page.component.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. import { DropdownDirective } from '../../shared/dropdown.directive';
  2. import { CommonModule } from '@angular/common';
  3. import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
  4. import { FormsModule } from '@angular/forms';
  5. import { ActivatedRoute } from '@angular/router';
  6. import { AlertCircle, ImagePlus, LucideAngularModule, Save, Send, X } from 'lucide-angular';
  7. import { finalize, firstValueFrom } from 'rxjs';
  8. import { ApiError, ApiService } from '../../core/api.service';
  9. import { buildFormSubmission, FormValue } from '../../core/form-payload';
  10. import { FormField, PageMeta } from '../../core/models';
  11. import { PlatformService } from '../../core/platform.service';
  12. import { SessionService } from '../../core/session.service';
  13. @Component({
  14. selector: 'app-form-page',
  15. standalone: true,
  16. imports: [DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
  17. templateUrl: './form-page.component.html',
  18. styleUrl: './form-page.component.scss',
  19. changeDetection: ChangeDetectionStrategy.OnPush,
  20. })
  21. export class FormPageComponent implements OnInit {
  22. readonly meta = inject(ActivatedRoute).snapshot.data['meta'] as PageMeta;
  23. readonly loading = signal(false);
  24. readonly uploadProgress = signal<Record<string, boolean>>({});
  25. readonly nodeOptions = signal<Array<{ label: string; value: string | number }>>([]);
  26. readonly productOptions = signal<Array<{ label: string; value: string | number }>>([]);
  27. readonly appealAudit = signal<Record<string, unknown>>({});
  28. readonly loadError = signal('');
  29. readonly icons = { AlertCircle, ImagePlus, Save, Send, X };
  30. values: Record<string, FormValue> = {};
  31. private loadedRecord: Record<string, unknown> = {};
  32. private loadedAddon: unknown;
  33. private readonly route = inject(ActivatedRoute);
  34. private readonly api = inject(ApiService);
  35. private readonly session = inject(SessionService);
  36. private readonly platform = inject(PlatformService);
  37. ngOnInit(): void {
  38. for (const field of this.meta.fields ?? []) {
  39. this.values[field.key] = field.type === 'checkbox' ? false : field.options?.[0]?.value ?? '';
  40. }
  41. if (this.meta.path === 'pages/shop/order_appeal') this.values['concat'] = this.session.user()?.mobile ?? '';
  42. if (this.meta.path === 'pages/member/myshop_add') this.loadProductNodes();
  43. if (this.meta.api) this.loadWhenApplicable();
  44. }
  45. private loadWhenApplicable(): void {
  46. const action = this.meta.api?.action;
  47. const id = this.route.snapshot.queryParamMap.get('id');
  48. if (!action) return;
  49. if (['receaddr_get', 'invoice_get', 'user_bank_get', 'product_get'].includes(action) && !id) return;
  50. if (action === 'order_get' && !id) {
  51. this.loadError.set('缺少订单编号,请从订单页面进入');
  52. return;
  53. }
  54. this.load();
  55. }
  56. load(): void {
  57. const binding = this.meta.api;
  58. if (!binding) return;
  59. this.loadError.set('');
  60. const payload = { ...binding.params, ...this.route.snapshot.queryParams, uid: this.session.user()?.userId };
  61. this.loading.set(true);
  62. const request = binding.method === 'post' ? this.api.post<Record<string, unknown>>(binding.action, payload) : this.api.get<Record<string, unknown>>(binding.action, payload);
  63. request.pipe(finalize(() => this.loading.set(false))).subscribe({
  64. next: ({ result, addon }) => {
  65. const record = this.asRecord(Array.isArray(result) ? result[0] : result);
  66. this.loadedRecord = record;
  67. this.loadedAddon = addon;
  68. if (this.isOrderForm()) this.prepareOrderProducts(addon);
  69. for (const field of this.meta.fields ?? []) {
  70. const value = this.loadedValue(field.key, record, addon);
  71. if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') this.values[field.key] = value;
  72. }
  73. if (this.meta.path === 'pages/shop/order_appeal') this.loadExistingAppeal(record);
  74. },
  75. error: (error: ApiError) => this.loadError.set(error.message),
  76. });
  77. }
  78. async chooseFile(field: FormField, event: Event): Promise<void> {
  79. const input = event.target as HTMLInputElement;
  80. const files = Array.from(input.files ?? []);
  81. input.value = '';
  82. if (!files.length) return;
  83. const current = this.fileUrls(field);
  84. const available = Math.max(0, this.fileLimit(field) - current.length);
  85. const selected = files.slice(0, this.isMultiFile(field) ? available : 1);
  86. if (!selected.length) {
  87. this.platform.toast(`最多上传${this.fileLimit(field)}张图片`, 'error');
  88. return;
  89. }
  90. this.uploadProgress.update((value) => ({ ...value, [field.key]: true }));
  91. try {
  92. const uploaded: string[] = [];
  93. for (const file of selected) {
  94. const { result } = await firstValueFrom(this.api.upload(file));
  95. uploaded.push(String(result));
  96. }
  97. if (this.meta.path === 'pages/member/myshop_add_procon' && field.key === 'image') {
  98. const html = uploaded.map((url) => `<p><img src="${url}" /></p>`).join('');
  99. this.values['Procontent'] = `${String(this.values['Procontent'] ?? '')}${html}`;
  100. this.values[field.key] = '';
  101. } else if (this.isMultiFile(field)) {
  102. this.values[field.key] = JSON.stringify([...current, ...uploaded].map((url) => ({ url, name: '', desc: '', link: '' })));
  103. } else {
  104. this.values[field.key] = uploaded[0];
  105. }
  106. this.platform.toast('上传成功', 'success');
  107. } catch (error) {
  108. this.platform.toast(error instanceof ApiError ? error.message : '上传失败', 'error');
  109. } finally {
  110. this.uploadProgress.update((value) => ({ ...value, [field.key]: false }));
  111. }
  112. }
  113. fileUrls(field: FormField): string[] {
  114. const raw = String(this.values[field.key] ?? '').trim();
  115. if (!raw) return [];
  116. try {
  117. const parsed = JSON.parse(raw) as Array<{ url?: string }>;
  118. if (Array.isArray(parsed)) return parsed.map((item) => String(item?.url ?? '')).filter(Boolean);
  119. } catch { /* Single paths and legacy pipe-separated image values are valid. */ }
  120. return raw.split('|').map((url) => url.trim()).filter(Boolean);
  121. }
  122. removeFile(field: FormField, index: number): void {
  123. const urls = this.fileUrls(field).filter((_, itemIndex) => itemIndex !== index);
  124. this.values[field.key] = this.isMultiFile(field)
  125. ? JSON.stringify(urls.map((url) => ({ url, name: '', desc: '', link: '' })))
  126. : '';
  127. }
  128. imageUrl(path: string): string {
  129. return this.api.assetUrl(path);
  130. }
  131. isMultiFile(field: FormField): boolean {
  132. return ['images', 'pics', 'license'].includes(field.key);
  133. }
  134. fileLimit(field: FormField): number {
  135. if (field.key === 'images') return 5;
  136. if (field.key === 'pics') return 20;
  137. if (field.key === 'license') return 20;
  138. return 1;
  139. }
  140. visibleField(field: FormField): boolean {
  141. if (this.meta.path === 'pages/member/invoice_add' && ['UserMobile', 'UserAddress', 'UserBankName', 'UserBankAccount'].includes(field.key)) {
  142. return Number(this.values['InvType']) === 1;
  143. }
  144. if (this.meta.path === 'pages/member/group_join') {
  145. const mode = String(this.values['mode'] ?? 'supplier');
  146. if (['name', 'bankacc', 'license', 'remark'].includes(field.key)) return mode === 'supplier';
  147. if (['mobile', 'call', 'tips'].includes(field.key)) return mode === 'invite';
  148. }
  149. if (this.meta.path === 'pages/member/myshop_add' && field.key === 'Stock' && this.loadedRecord['ID']) return false;
  150. return true;
  151. }
  152. fieldOptions(field: FormField): Array<{ label: string; value: string | number }> {
  153. if (this.meta.path === 'pages/member/myshop_add' && field.key === 'Nodeid') return this.nodeOptions();
  154. if (this.meta.path === 'pages/shop/order_comment_add' && field.key === 'proId') return this.productOptions();
  155. return field.options ?? [];
  156. }
  157. fieldDisabled(field: FormField): boolean {
  158. if (this.appealAudit()['ID']) return true;
  159. return this.meta.path === 'pages/member/myshop_add' && field.key === 'Nodeid' && Boolean(this.loadedRecord['ID']);
  160. }
  161. isOrderForm(): boolean { return ['pages/shop/order_appeal', 'pages/shop/order_comment_add'].includes(this.meta.path); }
  162. orderNumber(): string { return String(this.loadedRecord['OrderNo'] ?? '-'); }
  163. orderProducts(): Array<Record<string, unknown>> {
  164. const addon = this.asRecord(this.loadedAddon);
  165. return Array.isArray(addon['cart']) ? addon['cart'] as Array<Record<string, unknown>> : [];
  166. }
  167. productName(item: Record<string, unknown>): string { return String(item['Proname'] ?? this.parseObject(item['ProInfo'])['Proname'] ?? '商品'); }
  168. productImage(item: Record<string, unknown>): string { return this.imageUrl(String(item['proimg'] ?? this.parseObject(item['ProInfo'])['Thumbnails'] ?? '')); }
  169. auditText(): string {
  170. const audit = this.appealAudit();
  171. if (!audit['ID']) return '';
  172. return Number(audit['Pubstart'] ?? 0) === 0 ? '处理中:平台正在处理,请耐心等待' : `已处理:${audit['PubRemind'] || '暂无处理说明'}`;
  173. }
  174. fieldRequired(field: FormField): boolean {
  175. if (this.meta.path === 'pages/member/invoice_add' && field.key === 'UserMobile') return Number(this.values['InvType']) === 1;
  176. if (this.meta.path === 'pages/member/group_join') {
  177. const mode = String(this.values['mode'] ?? 'supplier');
  178. return mode === 'supplier'
  179. ? ['name', 'bankacc', 'license'].includes(field.key)
  180. : field.key === 'mobile';
  181. }
  182. return Boolean(field.required);
  183. }
  184. fieldLabel(field: FormField): string {
  185. if (this.meta.path !== 'pages/member/invoice_add') return field.label;
  186. const company = Number(this.values['InvType']) === 1;
  187. const labels: Record<string, string> = company
  188. ? { InvHead: '企业名称', UserCode: '统一信用代码', UserEmail: '企业注册邮箱' }
  189. : { InvHead: '姓名', UserCode: '身份证号码', UserEmail: '联系邮箱' };
  190. return labels[field.key] ?? field.label;
  191. }
  192. statusText(): string {
  193. if (this.meta.path !== 'pages/member/rnauth') return '';
  194. if (!Number(this.loadedRecord['ID'] ?? 0)) return '未提交实名信息';
  195. return Number(this.loadedRecord['Status'] ?? 0) === 99 ? '已实名认证' : '正在审核';
  196. }
  197. sendCode(): void {
  198. const mobile = String(this.values['mobile'] ?? this.values['MobileNum'] ?? this.session.user()?.mobile ?? '').trim();
  199. if (!/^1\d{10}$/.test(mobile)) {
  200. this.platform.toast('请先填写正确的手机号码', 'error');
  201. return;
  202. }
  203. this.platform.loading.set(true);
  204. const source = this.meta.path.endsWith('passwd2') ? 'reset_paypwd' : this.meta.path.endsWith('passwd') ? 'reset_loginpwd' : 'update';
  205. this.api.post('mcode_send', { mobile, va: 1, source, uid: this.session.user()?.userId }).pipe(finalize(() => this.platform.loading.set(false))).subscribe({
  206. next: () => this.platform.toast('验证码已发送', 'success'),
  207. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  208. });
  209. }
  210. async submit(): Promise<void> {
  211. if (this.loadError()) {
  212. this.platform.toast('当前页面数据未正确加载,无法保存', 'error');
  213. return;
  214. }
  215. if (this.appealAudit()['ID']) { this.platform.toast('该订单已有退换货申请', 'info'); return; }
  216. for (const field of this.meta.fields ?? []) {
  217. if (!this.visibleField(field)) continue;
  218. if (this.fieldRequired(field) && !String(this.values[field.key] ?? '').trim()) {
  219. this.platform.toast(`${field.type === 'file' ? '请上传' : '请填写'}${this.fieldLabel(field)}`, 'error');
  220. return;
  221. }
  222. const value = String(this.values[field.key] ?? '').trim();
  223. if (field.type === 'tel' && value && !/^1\d{10}$/.test(value)) {
  224. this.platform.toast(`请填写正确的${field.label}`, 'error');
  225. return;
  226. }
  227. if (field.type === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
  228. this.platform.toast(`请填写正确的${field.label}`, 'error');
  229. return;
  230. }
  231. }
  232. if (!this.validatePairs()) return;
  233. const user = this.session.user();
  234. if (this.meta.path === 'pages/member/myshop_add' && !this.loadedRecord['ID']) {
  235. if (Number(user?.point ?? 0) < 1000) {
  236. this.platform.toast('您的弘法令不足,发布新商品需要1000弘法令', 'error');
  237. return;
  238. }
  239. if (!await this.platform.confirm('发布新商品需要1000弘法令,确定继续?')) return;
  240. }
  241. if (this.meta.path === 'pages/member/rnauth' && this.loadedRecord['ID']) {
  242. if (!await this.platform.confirm('确定放弃当前实名信息并重新提交?')) return;
  243. }
  244. const submission = buildFormSubmission(this.meta, this.values, {
  245. uid: user?.userId,
  246. id: this.route.snapshot.queryParamMap.get('id') ?? undefined,
  247. user,
  248. loaded: this.loadedRecord,
  249. addon: this.loadedAddon,
  250. });
  251. if (!submission) return;
  252. this.platform.loading.set(true);
  253. this.api.post(submission.action, submission.payload).pipe(finalize(() => this.platform.loading.set(false))).subscribe({
  254. next: () => {
  255. this.platform.toast('保存成功', 'success');
  256. if (this.meta.path === 'pages/member/settings') {
  257. this.session.patchUser(this.values as Partial<import('../../core/models').AppUser>);
  258. return;
  259. }
  260. if (this.meta.path === 'pages/member/rnauth') {
  261. this.load();
  262. return;
  263. }
  264. setTimeout(() => history.back(), 450);
  265. },
  266. error: (error: ApiError) => this.platform.toast(error.message, 'error'),
  267. });
  268. }
  269. private validatePairs(): boolean {
  270. const pairs: Array<[string, string, string]> = [
  271. ['passwd', 'passwd2', '两次输入的登录密码不一致'],
  272. ['paypwd', 'paypwd2', '两次输入的支付密码不一致'],
  273. ];
  274. for (const [first, second, message] of pairs) {
  275. if (first in this.values && second in this.values && this.values[first] !== this.values[second]) {
  276. this.platform.toast(message, 'error');
  277. return false;
  278. }
  279. }
  280. if (this.meta.path === 'pages/member/receaddr_add') {
  281. const zipcode = String(this.values['Zipcode'] ?? '');
  282. if (zipcode && !/^\d{6}$/.test(zipcode)) {
  283. this.platform.toast('请填写正确的邮政编码', 'error');
  284. return false;
  285. }
  286. }
  287. if (this.meta.path === 'pages/member/wxauth_after') {
  288. const password = String(this.values['passwd'] ?? '');
  289. const payPassword = String(this.values['paypwd'] ?? '');
  290. if (password.length < 6 || password.length > 18) {
  291. this.platform.toast('登录密码应为6至18位', 'error');
  292. return false;
  293. }
  294. if (!/^\d{6}$/.test(payPassword)) {
  295. this.platform.toast('支付密码必须为6位数字', 'error');
  296. return false;
  297. }
  298. }
  299. if (['pages/member/passwd', 'pages/member/passwd2'].includes(this.meta.path)) {
  300. const isPay = this.meta.path.endsWith('passwd2');
  301. const password = String(this.values[isPay ? 'paypwd' : 'passwd'] ?? '');
  302. if (isPay ? !/^\d{6}$/.test(password) : password.length < 6 || password.length > 18) {
  303. this.platform.toast(isPay ? '支付密码必须为6位数字' : '登录密码应为6至18位', 'error');
  304. return false;
  305. }
  306. }
  307. if (this.meta.path === 'pages/member/invoice_add') {
  308. const company = Number(this.values['InvType']) === 1;
  309. const code = String(this.values['UserCode'] ?? '').trim();
  310. if (!company && !this.isIdCard(code)) {
  311. this.platform.toast('请输入正确的身份证号码', 'error');
  312. return false;
  313. }
  314. if (company && !/^1\d{10}$/.test(String(this.values['UserMobile'] ?? ''))) {
  315. this.platform.toast('请输入正确的企业注册电话', 'error');
  316. return false;
  317. }
  318. }
  319. if (this.meta.path === 'pages/member/rnauth') {
  320. const idCard = String(this.values['idCard'] ?? '').trim();
  321. if (!this.isIdCard(idCard)) {
  322. this.platform.toast('请填写正确的身份证件号', 'error');
  323. return false;
  324. }
  325. const age = new Date().getFullYear() - Number(idCard.slice(6, 10));
  326. if (age < 18 || age > 65) {
  327. this.platform.toast(age < 18 ? '最小注册年龄为18岁' : '最大注册年龄为65岁', 'error');
  328. return false;
  329. }
  330. }
  331. if (this.meta.path === 'pages/shop/order_comment_add') {
  332. const score = Number(this.values['score'] ?? 0);
  333. if (score < 1 || score > 5) {
  334. this.platform.toast('评分应为1至5分', 'error');
  335. return false;
  336. }
  337. }
  338. if (this.meta.path === 'pages/member/myshop_add') {
  339. if (Number(this.values['LinPrice']) <= 0 || Number(this.values['ShiPrice']) <= 0) {
  340. this.platform.toast('商品价格必须大于0', 'error');
  341. return false;
  342. }
  343. if (!this.loadedRecord['ID'] && (!Number.isInteger(Number(this.values['Stock'])) || Number(this.values['Stock']) <= 0)) {
  344. this.platform.toast('初始库存必须是大于0的整数', 'error');
  345. return false;
  346. }
  347. }
  348. if (this.meta.path === 'pages/member/group_join') {
  349. const addon = this.asRecord(this.loadedAddon);
  350. if (Number(addon['vip'] ?? 0) < 3) {
  351. this.platform.toast('您必须成为初级合伙人后才可进行操作', 'error');
  352. return false;
  353. }
  354. }
  355. return true;
  356. }
  357. private isIdCard(value: string): boolean {
  358. if (!/^\d{17}[\dXx]$/.test(value)) return false;
  359. const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
  360. const checks = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
  361. const sum = weights.reduce((total, weight, index) => total + Number(value[index]) * weight, 0);
  362. return checks[sum % 11] === value[17].toUpperCase();
  363. }
  364. private loadedValue(key: string, record: Record<string, unknown>, addon: unknown): unknown {
  365. if (key in record) return record[key];
  366. const aliases: Record<string, string> = {
  367. Nodeid: 'NodeID',
  368. realName: 'RealName',
  369. idCard: 'IdCard',
  370. mobile: 'Mobile',
  371. address: 'Address',
  372. };
  373. if (aliases[key] && aliases[key] in record) return record[aliases[key]];
  374. if (['front', 'back', 'handheld'].includes(key)) {
  375. try {
  376. const pictures = JSON.parse(String(record['IdCardPics'] ?? '[]')) as Array<{ url?: string }>;
  377. return pictures[['front', 'back', 'handheld'].indexOf(key)]?.url ?? '';
  378. } catch { return ''; }
  379. }
  380. if (key === 'proId') {
  381. const extra = this.asRecord(addon);
  382. const carts = Array.isArray(extra['cart']) ? extra['cart'] as Record<string, unknown>[] : [];
  383. return carts[0]?.['ProID'] ?? '';
  384. }
  385. if (this.meta.path === 'pages/member/acc_destory') {
  386. if (key === 'concat') return String(record['PubContent'] ?? '').replace(/^联系方式[::]/, '');
  387. if (key === 'remark') return record['PubRemind'] ?? '';
  388. }
  389. if (key === 'pics' && record['pics']) {
  390. try {
  391. const pictures = JSON.parse(String(record['pics'])) as Array<{ url?: string; name?: string; desc?: string; link?: string }>;
  392. return JSON.stringify(pictures);
  393. } catch { return ''; }
  394. }
  395. return undefined;
  396. }
  397. private loadProductNodes(): void {
  398. this.api.get<unknown>('node_list', { pid: 2 }).subscribe({
  399. next: ({ result }) => this.nodeOptions.set((Array.isArray(result) ? result : [])
  400. .filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && Number((item as Record<string, unknown>)['NodeID']) !== 19)
  401. .map((item) => ({ label: String(item['NodeName'] ?? item['NodeID']), value: Number(item['NodeID']) }))),
  402. error: (error: ApiError) => this.platform.toast(`商品类别加载失败:${error.message}`, 'error'),
  403. });
  404. }
  405. private prepareOrderProducts(addon: unknown): void {
  406. if (this.meta.path !== 'pages/shop/order_comment_add') return;
  407. const extra = this.asRecord(addon);
  408. const carts = Array.isArray(extra['cart']) ? extra['cart'] as Array<Record<string, unknown>> : [];
  409. this.productOptions.set(carts.map((item) => ({ label: this.productName(item), value: String(item['ProID'] ?? '') })).filter((item) => item.value));
  410. if (carts.length === 1) this.values['proId'] = String(carts[0]['ProID'] ?? '');
  411. }
  412. private loadExistingAppeal(order: Record<string, unknown>): void {
  413. const params = { cid: order['id'] ?? order['ID'], uid: this.session.user()?.userId };
  414. const apply = (item: Record<string, unknown>, pubid: number) => {
  415. this.appealAudit.set(item);
  416. this.values['type'] = pubid;
  417. this.values['concat'] = String(item['Concat'] ?? '');
  418. this.values['reason'] = String(item['PubContent'] ?? '');
  419. this.values['images'] = String(item['Pics'] ?? '');
  420. };
  421. this.api.get<unknown>('pub_list', { ...params, pubid: 10 }).subscribe({
  422. next: ({ result }) => {
  423. const returned = Array.isArray(result) ? this.asRecord(result[0]) : {};
  424. if (returned['ID']) { apply(returned, 10); return; }
  425. this.api.get<unknown>('pub_list', { ...params, pubid: 11 }).subscribe({
  426. next: ({ result: exchange }) => { const item = Array.isArray(exchange) ? this.asRecord(exchange[0]) : {}; if (item['ID']) apply(item, 11); },
  427. error: () => undefined,
  428. });
  429. },
  430. error: () => undefined,
  431. });
  432. }
  433. private parseObject(value: unknown): Record<string, unknown> {
  434. try { return this.asRecord(JSON.parse(String(value ?? '{}'))); } catch { return {}; }
  435. }
  436. private asRecord(value: unknown): Record<string, unknown> {
  437. return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
  438. }
  439. }