TuringToken/web/src/pages/DistributorApply.jsx

519 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
Card,
Form,
Typography,
Spin,
Modal,
Popover,
Radio,
} from '@douyinfe/semi-ui';
import {
IconUserGroup,
IconAlertTriangle,
IconInfoCircle,
IconTick,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess, userIsDistributorUser } from '../helpers';
import { StatusContext } from '../context/Status';
import { UserContext } from '../context/User';
import DOMPurify from 'dompurify';
import DistributorApplyFileUpload from '../components/distributor/DistributorApplyFileUpload';
const { Text } = Typography;
/** 代理申请:必填项标题(红 * + 加粗),与资格证书区块一致 */
function ApplyRequiredLabel({ children, className = '' }) {
return (
<Text
strong
className={`text-[var(--semi-color-text-0)] ${className}`.trim()}
>
<span
className='text-[var(--semi-color-danger)] mr-1 font-normal'
aria-hidden
>
*
</span>
{children}
</Text>
);
}
const applyTrimmedRequiredRule = (t) => ({
validator: (rule, value) => {
const v = value == null ? '' : String(value).trim();
if (!v) return Promise.reject(t('必填'));
return Promise.resolve();
},
});
const iconLg = 22;
/**
* 自研圆角提示条(与主卡片 `rounded-2xl` 一致,替代 Semi Banner 直角/风格差异问题)
* @param {'danger' | 'info' | 'success'} variant
*/
function ApplyStatusNotice({
variant = 'info',
title,
className = '',
children,
}) {
const tone =
variant === 'danger'
? {
frame:
'border-[var(--semi-color-danger-light-hover)] bg-[var(--semi-color-danger-light-default)]',
icon: (
<IconAlertTriangle
size={iconLg}
style={{ color: 'var(--semi-color-danger)' }}
/>
),
}
: variant === 'success'
? {
frame:
'border-[var(--semi-color-success-light-hover)] bg-[var(--semi-color-success-light-default)]',
icon: (
<IconTick
size={iconLg}
style={{ color: 'var(--semi-color-success)' }}
/>
),
}
: {
frame:
'border-[var(--semi-color-info-light-hover)] bg-[var(--semi-color-info-light-default)]',
icon: (
<IconInfoCircle
size={iconLg}
style={{ color: 'var(--semi-color-primary)' }}
/>
),
};
return (
<div
role={variant === 'danger' ? 'alert' : 'status'}
className={[
'distributor-apply-notice flex gap-3 rounded-2xl border border-solid p-3.5 shadow-sm md:p-4',
tone.frame,
className,
]
.filter(Boolean)
.join(' ')}
>
<span className='shrink-0 pt-0.5' aria-hidden>
{tone.icon}
</span>
<div className='min-w-0 flex-1 text-[15px] leading-relaxed [overflow-wrap:anywhere] text-[var(--semi-color-text-0)]'>
{title ? (
<div className='mb-1.5 font-semibold text-[var(--semi-color-text-0)]'>
{title}
</div>
) : null}
{children}
</div>
</div>
);
}
export default function DistributorApply() {
const { t } = useTranslation();
const formApi = useRef(null);
const [statusState] = useContext(StatusContext);
const [userState] = useContext(UserContext);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [app, setApp] = useState(null);
const [urls, setUrls] = useState([]);
const [previewUrl, setPreviewUrl] = useState(null);
const [applyType, setApplyType] = useState(1);
const csImage = (
statusState?.status?.distributor_apply_cs_image_url || ''
).trim();
const showCsColumn = Boolean(csImage);
const applyIntroHtmlRaw = (
statusState?.status?.distributor_apply_intro_html || ''
).trim();
const applyIntroHtml = applyIntroHtmlRaw
? DOMPurify.sanitize(applyIntroHtmlRaw, { USE_PROFILES: { html: true } })
: '';
const isPdfUrl = (u) => /\.pdf(\?|$)/i.test(u || '');
const load = async () => {
setLoading(true);
try {
const res = await API.get('/api/distributor/my_application');
const { success, message, data } = res.data;
if (!success) {
showError(message);
return;
}
setApp(data || null);
} catch {
showError(t('加载失败'));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const isDist = useMemo(() => {
const u = userState?.user;
if (u) return userIsDistributorUser(u);
try {
const raw = localStorage.getItem('user');
if (raw) return userIsDistributorUser(JSON.parse(raw));
} catch {
// ignore
}
return false;
}, [userState?.user]);
/** 申请记录为已通过,且当前账号仍是代理:显示「去代理分销」 */
const showApprovedForActiveDistributor = app?.status === 2 && isDist;
/** 记录曾为已通过,但账号已非代理(如被降级):应允许重新提交 */
const showReapplyAfterRevoked = app?.status === 2 && !isDist;
const shouldPrefillFormFromApp = showReapplyAfterRevoked || app?.status === 3;
useEffect(() => {
if (!shouldPrefillFormFromApp || !app || loading) return;
const tid = window.setTimeout(() => {
try {
formApi.current?.setValues({
real_name: app.real_name || '',
id_card_no: app.id_card_no || '',
contact: app.contact || '',
});
const at = Number(app.apply_type);
setApplyType(at === 2 ? 2 : 1);
const raw = app.qualification_urls;
if (raw) {
const j = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (Array.isArray(j) && j.length) setUrls(j.filter(Boolean));
}
} catch {
// ignore
}
}, 0);
return () => window.clearTimeout(tid);
}, [shouldPrefillFormFromApp, app, loading]);
const onSubmit = async () => {
const api = formApi.current;
if (!api) return;
try {
await api.validate();
} catch {
return;
}
const values = api.getValues();
if (!urls.length) {
showError(t('请上传资格证书'));
return;
}
setSubmitting(true);
try {
const res = await API.post('/api/distributor/application', {
apply_type: applyType,
real_name: values.real_name,
id_card_no: values.id_card_no,
contact: values.contact,
qualification_urls: urls,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('提交成功'));
await load();
} else {
showError(message);
}
} catch {
showError(t('提交失败'));
} finally {
setSubmitting(false);
}
};
if (isDist) {
return (
<div className='distributor-apply-page-root text-slate-900 dark:text-white'>
<div className='w-full max-w-3xl px-1'>
<ApplyStatusNotice variant='success' className='w-full'>
{t('您已是代理,无需再次申请')}
</ApplyStatusNotice>
</div>
</div>
);
}
const csPopoverContent = showCsColumn ? (
<div className='distributor-apply-cs-popover w-[min(100vw-3rem,260px)] max-w-[260px] p-1'>
<button
type='button'
className='w-full cursor-zoom-in rounded-lg border-0 bg-transparent p-0'
onClick={() => setPreviewUrl(csImage)}
>
<img
src={csImage}
alt=''
className='h-auto w-full max-w-full rounded-md object-contain'
/>
</button>
</div>
) : null;
const rightBody =
app?.status === 1 ? (
<div className='flex min-h-0 flex-1 flex-col items-center justify-center py-8 text-center'>
<Text className='!text-lg md:!text-xl !leading-relaxed !font-medium text-[var(--semi-color-text-0)]'>
{t('您的申请正在审核中,请耐心等待。')}
</Text>
</div>
) : showApprovedForActiveDistributor ? (
<div className='flex min-h-0 flex-1 flex-col items-center justify-center py-8 text-center'>
<Text className='!text-lg md:!text-xl !leading-relaxed !font-medium text-[var(--semi-color-text-0)]'>
{t('申请已通过,请从侧栏进入「代理分销」。')}
</Text>
</div>
) : (
<Spin spinning={loading} className='w-full min-w-0'>
{app?.status === 3 ? (
<ApplyStatusNotice
variant='danger'
className='mb-4 shrink-0'
title={t('申请已被驳回')}
>
{app?.reject_reason ? (
<div
className='whitespace-pre-wrap font-normal'
style={{ lineHeight: 1.6 }}
>
{app.reject_reason}
</div>
) : (
<div className='font-normal'>
{t('请根据管理员说明修改资料后重新提交。')}
</div>
)}
</ApplyStatusNotice>
) : null}
{showReapplyAfterRevoked ? (
<ApplyStatusNotice variant='info' className='mb-4 shrink-0'>
{t(
'您的代理资格已失效(例如已被管理员调整),可修改资料后重新提交审核。',
)}
</ApplyStatusNotice>
) : null}
<Form getFormApi={(f) => (formApi.current = f)} layout='vertical'>
<div className='mb-4'>
<Text strong className='mb-2 block'>
{t('申请类型')}
</Text>
<Radio.Group
type='button'
value={applyType}
onChange={(val) => {
const v =
val && typeof val === 'object' && 'target' in val
? val.target.value
: val;
setApplyType(Number(v));
}}
>
<Radio value={1}>{t('个人申请')}</Radio>
<Radio value={2}>{t('企业申请')}</Radio>
</Radio.Group>
</div>
<Form.Input
field='real_name'
label={
<ApplyRequiredLabel>
{applyType === 2 ? t('企业名称') : t('姓名')}
</ApplyRequiredLabel>
}
rules={[applyTrimmedRequiredRule(t)]}
/>
<Form.Input
field='id_card_no'
label={
<ApplyRequiredLabel>
{applyType === 2 ? t('统一社会信用代码') : t('身份证')}
</ApplyRequiredLabel>
}
rules={[applyTrimmedRequiredRule(t)]}
/>
<Form.Input
field='contact'
label={<ApplyRequiredLabel>{t('联系方式')}</ApplyRequiredLabel>}
rules={[applyTrimmedRequiredRule(t)]}
/>
<DistributorApplyFileUpload
label={t('资格证书')}
required
urls={urls}
onUrlsChange={setUrls}
maxCount={5}
multiple
onPreview={setPreviewUrl}
hint={t('支持图片或 PDF最多 5 个;点击图片可大图预览')}
/>
<div className='flex justify-center pt-1'>
<Button
theme='solid'
type='primary'
loading={submitting}
onClick={onSubmit}
>
{showReapplyAfterRevoked ? t('重新提交审核') : t('提交申请')}
</Button>
</div>
</Form>
</Spin>
);
return (
<div className='distributor-apply-page-root'>
<div className='mx-auto flex h-full min-h-0 w-full min-w-0 max-w-6xl flex-1 flex-col'>
<Card className='distributor-apply-main-card !rounded-2xl flex h-full min-h-0 w-full min-w-0 flex-1 flex-col !overflow-hidden !shadow-none'>
<div className='distributor-apply-card-stacked-scroll flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto overflow-x-hidden overscroll-y-contain lg:flex-row lg:overflow-hidden'>
<section className='distributor-apply-panels-divider flex min-w-0 max-lg:shrink-0 flex-col border-b border-solid lg:min-h-0 lg:min-w-0 lg:flex-[3] lg:border-b-0 lg:border-r'>
<div className='shrink-0 p-4 pb-2 md:px-5 md:pt-5'>
<header className='distributor-apply-hero relative rounded-xl p-[12px]'>
<span
className='distributor-apply-hero-orb distributor-apply-hero-orb--a'
aria-hidden
/>
<span
className='distributor-apply-hero-orb distributor-apply-hero-orb--b'
aria-hidden
/>
<div className='relative z-[1] min-w-0'>
<div className='flex min-w-0 items-start justify-between gap-2 sm:gap-3'>
<div className='flex min-w-0 flex-1 items-start gap-3'>
<div
className='distributor-apply-hero-icon flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-solid bg-[var(--semi-color-bg-0)] text-[var(--semi-color-primary)] shadow-sm'
aria-hidden
>
<IconUserGroup size={28} />
</div>
<Typography.Title
heading={3}
className='distributor-apply-hero-title !mb-0 !mt-0 min-w-0 !font-semibold !tracking-tight !leading-snug sm:!leading-tight'
>
{t('代理伙伴招募')}
</Typography.Title>
</div>
{showCsColumn ? (
<Popover
content={csPopoverContent}
trigger='click'
position='leftTop'
showArrow
>
<Button
theme='outline'
type='primary'
size='small'
className='shrink-0 !font-medium'
>
{t('联系客服')}
</Button>
</Popover>
) : null}
</div>
</div>
</header>
</div>
<div className='distributor-apply-intro-scroll min-w-0 px-4 pb-4 pt-0 md:px-5 md:pb-5 max-lg:overflow-visible lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:overflow-x-hidden lg:overscroll-y-contain'>
{applyIntroHtml ? (
<div
className='distributor-apply-intro-html max-w-full break-words text-[15px] leading-relaxed text-[var(--semi-color-text-0)] [overflow-wrap:anywhere] [&_p]:mb-2 [&_p]:last:mb-0 [&_img]:!max-w-full [&_img]:h-auto [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_table]:!max-w-full [&_table]:table-fixed [&_ul]:mb-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:mb-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_a]:break-all [&_a]:text-[var(--semi-color-primary)] [&_blockquote]:border-l-2 [&_blockquote]:border-[var(--semi-color-border)] [&_blockquote]:pl-3'
dangerouslySetInnerHTML={{ __html: applyIntroHtml }}
/>
) : null}
<Text
type='tertiary'
className='distributor-apply-hero-desc mt-2 !block !text-[15px] !leading-relaxed [overflow-wrap:anywhere]'
>
{t(
'请如实填写以下信息,审核通过后即可获得代理资格与邀请分成。',
)}
</Text>
</div>
</section>
<section className='flex min-w-0 max-lg:shrink-0 flex-col p-4 md:p-5 max-lg:overflow-visible lg:min-h-0 lg:min-w-0 lg:flex-[2] lg:overflow-y-auto lg:overscroll-y-contain [scrollbar-gutter:stable]'>
{rightBody}
</section>
</div>
</Card>
</div>
<Modal
title={t('预览')}
visible={Boolean(previewUrl)}
onCancel={() => setPreviewUrl(null)}
footer={null}
width={Math.min(
960,
typeof window !== 'undefined' ? window.innerWidth - 48 : 960,
)}
>
{previewUrl &&
(isPdfUrl(previewUrl) ? (
<div className='py-6 text-center'>
<Button
type='primary'
onClick={() =>
window.open(previewUrl, '_blank', 'noopener,noreferrer')
}
>
{t('在新窗口打开 PDF')}
</Button>
</div>
) : (
<div className='flex max-h-[85vh] justify-center overflow-auto p-2'>
<img
src={previewUrl}
alt=''
className='max-h-[85vh] max-w-full object-contain'
/>
</div>
))}
</Modal>
</div>
);
}