350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||
import type {
|
||
PartnerAssocSummary,
|
||
PartnerAssocUserItem,
|
||
PartnerAssocUserSort,
|
||
} from '@dukang/shared-types';
|
||
import { request } from '../lib/api';
|
||
import {
|
||
downloadBlob,
|
||
fetchActivityPosterImage,
|
||
fetchAssocQrcodeImage,
|
||
} from '../lib/activity-posters';
|
||
import { toastError, toastSuccess } from '../lib/toast';
|
||
import { blobToDataUrl, isHttpImageUrl, previewSaveableImage } from '../lib/wechat-save-image';
|
||
import { isWechatEnv } from '../lib/weixin';
|
||
import { usePartnerPageView } from '../lib/usePageView';
|
||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||
import { isSubAccount } from '../lib/partnerAccess';
|
||
|
||
type ListRes = { items: PartnerAssocUserItem[]; total: number };
|
||
|
||
const SORTS: { key: PartnerAssocUserSort; label: string }[] = [
|
||
{ key: 'boundAt', label: '关联时间' },
|
||
{ key: 'createdAt', label: '注册时间' },
|
||
{ key: 'orderCount', label: '订单数' },
|
||
];
|
||
|
||
function formatUserLabel(u: PartnerAssocUserItem) {
|
||
const name = u.nickname?.trim() || u.userNo || '—';
|
||
const remark = u.partnerRemark?.trim();
|
||
return remark ? `${name}(${remark})` : name;
|
||
}
|
||
|
||
function fmtTime(iso?: string | null) {
|
||
if (!iso) return '—';
|
||
return iso.slice(0, 16).replace('T', ' ');
|
||
}
|
||
|
||
export default function UsersManagePage() {
|
||
usePartnerPageView('partner_users_manage_view');
|
||
const { account } = usePartnerSession();
|
||
const hidePoster = isSubAccount(account);
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
|
||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||
const [heroUrl, setHeroUrl] = useState<string | null>(null);
|
||
const [heroLoading, setHeroLoading] = useState(false);
|
||
const [items, setItems] = useState<PartnerAssocUserItem[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [loading, setLoading] = useState(true);
|
||
const [keyword, setKeyword] = useState('');
|
||
const [appliedKeyword, setAppliedKeyword] = useState('');
|
||
const [sort, setSort] = useState<PartnerAssocUserSort>('boundAt');
|
||
const [editing, setEditing] = useState<PartnerAssocUserItem | null>(null);
|
||
const [remarkDraft, setRemarkDraft] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
document.title = '用户管理';
|
||
}, []);
|
||
|
||
useEffect(() => () => {
|
||
if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
|
||
}, [previewUrl]);
|
||
|
||
useEffect(() => {
|
||
const posterId = hidePoster ? null : summary?.activityPosterId;
|
||
if (!posterId) {
|
||
setHeroUrl(null);
|
||
setHeroLoading(false);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
const acc = { url: null as string | null };
|
||
setHeroLoading(true);
|
||
fetchActivityPosterImage(posterId)
|
||
.then((blob) => {
|
||
if (cancelled) return;
|
||
acc.url = URL.createObjectURL(blob);
|
||
setHeroUrl(acc.url);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setHeroUrl(null);
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setHeroLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
if (acc.url) URL.revokeObjectURL(acc.url);
|
||
};
|
||
}, [hidePoster, summary?.activityPosterId]);
|
||
|
||
const loadSummary = useCallback(async () => {
|
||
const data = await request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc');
|
||
setSummary(data);
|
||
}, []);
|
||
|
||
const loadUsers = useCallback(async () => {
|
||
const qs = new URLSearchParams({ page: '1', pageSize: '50', sort });
|
||
const q = appliedKeyword.trim();
|
||
if (q) qs.set('keyword', q);
|
||
const res = await request<ListRes>('PARTNER_H5', `/partner/assoc/users?${qs}`);
|
||
setItems(res.items ?? []);
|
||
setTotal(res.total ?? 0);
|
||
}, [appliedKeyword, sort]);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
await Promise.all([loadSummary(), loadUsers()]);
|
||
} catch (e) {
|
||
toastError(e instanceof Error ? e.message : '加载失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [loadSummary, loadUsers]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
useEffect(() => {
|
||
const t = window.setTimeout(() => setAppliedKeyword(keyword.trim()), 400);
|
||
return () => window.clearTimeout(t);
|
||
}, [keyword]);
|
||
|
||
useEffect(() => {
|
||
const uid = searchParams.get('userId');
|
||
if (uid) navigate(`/users/${uid}/orders`, { replace: true });
|
||
}, [searchParams, navigate]);
|
||
|
||
async function downloadMainImage() {
|
||
const posterId = hidePoster ? null : summary?.activityPosterId;
|
||
try {
|
||
if (isWechatEnv() && !posterId && isHttpImageUrl(summary?.qrcodeUrl)) {
|
||
await previewSaveableImage(summary.qrcodeUrl);
|
||
return;
|
||
}
|
||
const blob = posterId
|
||
? await fetchActivityPosterImage(posterId)
|
||
: await fetchAssocQrcodeImage();
|
||
if (isWechatEnv()) {
|
||
if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
|
||
setPreviewUrl(await blobToDataUrl(blob));
|
||
toastSuccess('请长按上方图片保存到相册');
|
||
return;
|
||
}
|
||
downloadBlob(blob, posterId
|
||
? `activity-poster-${posterId}.png`
|
||
: `partner-assoc-${summary?.partnerId || 'qr'}.png`);
|
||
toastSuccess('已开始下载');
|
||
} catch (e) {
|
||
if (!posterId && isHttpImageUrl(summary?.qrcodeUrl)) {
|
||
if (isWechatEnv()) {
|
||
await previewSaveableImage(summary.qrcodeUrl);
|
||
return;
|
||
}
|
||
setPreviewUrl(summary.qrcodeUrl);
|
||
toastSuccess('请长按图片保存到相册');
|
||
return;
|
||
}
|
||
toastError(e instanceof Error ? e.message : '下载失败');
|
||
}
|
||
}
|
||
|
||
async function saveRemark() {
|
||
if (!editing) return;
|
||
setSaving(true);
|
||
try {
|
||
await request(`PARTNER_H5`, `/partner/assoc/users/${editing.id}/remark`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ remark: remarkDraft }),
|
||
});
|
||
toastSuccess(remarkDraft.trim() ? '备注已保存' : '已清除备注');
|
||
setEditing(null);
|
||
await loadUsers();
|
||
} catch (e) {
|
||
toastError(e instanceof Error ? e.message : '保存失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<PullToRefresh onRefresh={load} className="page partner-users-page">
|
||
<div style={{ padding: '16px 20px 24px' }}>
|
||
<h1 className="headline-lg" style={{ marginBottom: 16 }}>用户管理</h1>
|
||
|
||
<section className="partner-bill-card" style={{ marginBottom: 20, textAlign: 'center', padding: 20 }}>
|
||
<p className="body-md text-muted" style={{ marginBottom: 12 }}>
|
||
用户扫码后首次锁定,后续购酒计入关联订单
|
||
</p>
|
||
{summary?.activityPosterId && !hidePoster && (previewUrl || heroUrl) ? (
|
||
<img
|
||
className="partner-longpress-img"
|
||
src={previewUrl || heroUrl || ''}
|
||
alt="活动图"
|
||
style={{ width: '100%', maxWidth: 360, background: '#f5f5f5' }}
|
||
/>
|
||
) : summary?.activityPosterId && !hidePoster && heroLoading ? (
|
||
<p className="body-md text-muted">正在生成活动图…</p>
|
||
) : previewUrl || summary?.qrcodeUrl ? (
|
||
<img
|
||
className="partner-longpress-img"
|
||
src={previewUrl || summary?.qrcodeUrl || ''}
|
||
alt="关联码"
|
||
style={{ width: 200, height: 200, background: '#fff' }}
|
||
/>
|
||
) : (
|
||
<p className="body-md text-muted">{loading ? '加载中…' : '关联码尚未生成'}</p>
|
||
)}
|
||
{(() => {
|
||
const scanCount = summary?.scanCount ?? 0;
|
||
const userCount = summary?.userCount ?? total;
|
||
if (scanCount <= 0 && userCount <= 0) return null;
|
||
return (
|
||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||
已扫码 {scanCount} 人 · 已关联 {userCount} 人
|
||
</p>
|
||
);
|
||
})()}
|
||
<button type="button" className="partner-btn-primary" style={{ marginTop: 16 }} onClick={() => void downloadMainImage()}>
|
||
{summary?.activityPosterId && !hidePoster ? '下载活动图' : '下载二维码'}
|
||
</button>
|
||
{isWechatEnv() && (previewUrl || summary?.qrcodeUrl) ? (
|
||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||
微信内请点「{summary?.activityPosterId && !hidePoster ? '下载活动图' : '下载二维码'}」后长按保存
|
||
</p>
|
||
) : null}
|
||
</section>
|
||
|
||
{!hidePoster && (
|
||
<Link to="/center/activity-posters" className="partner-menu-card" style={{ display: 'block', marginBottom: 20, textDecoration: 'none', color: 'inherit' }}>
|
||
<div className="partner-menu-item">
|
||
<div className="partner-menu-item-left">
|
||
<div className="partner-menu-icon">
|
||
<span className="material-symbols-outlined">image</span>
|
||
</div>
|
||
<span className="body-md" style={{ fontSize: 16 }}>活动图</span>
|
||
</div>
|
||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||
</div>
|
||
</Link>
|
||
)}
|
||
|
||
<h2 className="headline-md" style={{ marginBottom: 12 }}>已关联用户</h2>
|
||
<div className="partner-search">
|
||
<span className="material-symbols-outlined">search</span>
|
||
<input
|
||
value={keyword}
|
||
placeholder="搜索昵称 / 手机 / 备注 / 编号"
|
||
onChange={(e) => setKeyword(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') void loadUsers();
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="partner-filter-row" style={{ marginBottom: 12, paddingLeft: 0, paddingRight: 0 }}>
|
||
{SORTS.map((s) => (
|
||
<button
|
||
key={s.key}
|
||
type="button"
|
||
className={`partner-filter-tab${sort === s.key ? ' active' : ''}`}
|
||
onClick={() => setSort(s.key)}
|
||
>
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="label-md text-muted" style={{ marginBottom: 12 }}>
|
||
{loading ? '加载中…' : `共 ${total} 人`}
|
||
</p>
|
||
{!loading && items.length === 0 && <div className="empty">暂无关联用户</div>}
|
||
{items.map((u) => (
|
||
<div key={u.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||
<div className="partner-store-card-header" style={{ marginBottom: 4 }}>
|
||
<p className="body-md">{formatUserLabel(u)}</p>
|
||
<button
|
||
type="button"
|
||
className="label-md text-primary"
|
||
style={{ background: 'none', border: 0, padding: 0, flexShrink: 0 }}
|
||
onClick={() => navigate(`/users/${u.id}/orders`)}
|
||
>
|
||
{u.orderCount} 单
|
||
</button>
|
||
</div>
|
||
<p className="label-md text-muted">{u.phone || '未绑定手机'}</p>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
marginTop: 4,
|
||
}}
|
||
>
|
||
<p className="label-md text-muted">注册 {fmtTime(u.createdAt)}</p>
|
||
<button
|
||
type="button"
|
||
className="label-md text-primary"
|
||
style={{ background: 'none', border: 0, padding: 0, flexShrink: 0 }}
|
||
onClick={() => {
|
||
setEditing(u);
|
||
setRemarkDraft(u.partnerRemark ?? '');
|
||
}}
|
||
>
|
||
{u.partnerRemark ? '改备注' : '添加备注'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{editing && (
|
||
<div className="partner-remark-mask" onClick={() => setEditing(null)}>
|
||
<div className="partner-remark-sheet" onClick={(e) => e.stopPropagation()}>
|
||
<p className="headline-md" style={{ marginBottom: 8 }}>用户备注</p>
|
||
<p className="label-md text-muted" style={{ marginBottom: 12 }}>仅自己可见,总部看不到</p>
|
||
<textarea
|
||
value={remarkDraft}
|
||
maxLength={128}
|
||
rows={3}
|
||
placeholder="最多 128 字"
|
||
style={{ width: '100%', padding: 12, borderRadius: 8, border: '1px solid #eee' }}
|
||
onChange={(e) => setRemarkDraft(e.target.value)}
|
||
/>
|
||
<div className="partner-ship-actions">
|
||
<button type="button" className="partner-btn-secondary" onClick={() => setEditing(null)}>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="partner-btn-primary"
|
||
disabled={saving}
|
||
onClick={() => void saveRemark()}
|
||
>
|
||
保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</PullToRefresh>
|
||
);
|
||
}
|