feat(ops): v4.0.2 活动图模板、合伙人选择持久化与用户管理主图

HQ 上传底图与码栏;合伙人单选写入 partner_account.activity_poster_id,用户管理下次登录仍显示同一张图。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 21:36:43 +08:00
parent 0ff61c2cd1
commit 3166467518
39 changed files with 1872 additions and 35 deletions
@@ -0,0 +1,25 @@
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import ActivityPosterPicker from '../components/ActivityPosterPicker';
import { usePartnerPageView } from '../lib/usePageView';
export default function ActivityPostersPage() {
usePartnerPageView('partner_activity_posters_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialId = searchParams.get('id');
useEffect(() => {
document.title = '活动图';
}, []);
return (
<div>
<PageHeader title="活动图" onBack={() => navigate(-1)} />
<div style={{ padding: 16 }}>
<ActivityPosterPicker initialId={initialId} />
</div>
</div>
);
}
+9
View File
@@ -219,6 +219,15 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/center/activity-posters" 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>
</Link>
<Link to="/users" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
+70 -23
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type {
PartnerAssocSummary,
@@ -7,6 +7,11 @@ import type {
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 { isWechatEnv } from '../lib/weixin';
import { usePartnerPageView } from '../lib/usePageView';
@@ -36,6 +41,8 @@ export default function UsersManagePage() {
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);
@@ -54,6 +61,34 @@ export default function UsersManagePage() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
}, [previewUrl]);
useEffect(() => {
const posterId = 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);
};
}, [summary?.activityPosterId]);
const loadSummary = useCallback(async () => {
const data = await request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc');
setSummary(data);
@@ -93,31 +128,23 @@ export default function UsersManagePage() {
if (uid) navigate(`/users/${uid}/orders`, { replace: true });
}, [searchParams, navigate]);
async function downloadQr() {
const token = localStorage.getItem('accessToken');
async function downloadMainImage() {
const posterId = summary?.activityPosterId;
try {
const res = await fetch('/api/v1/partner/assoc/qrcode', {
headers: {
Authorization: token ? `Bearer ${token}` : '',
'X-Client-App': 'PARTNER_H5',
},
});
if (!res.ok) throw new Error('下载失败');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const blob = posterId
? await fetchActivityPosterImage(posterId)
: await fetchAssocQrcodeImage();
if (isWechatEnv()) {
setPreviewUrl(url);
setPreviewUrl(URL.createObjectURL(blob));
toastSuccess('请长按图片保存到相册');
return;
}
const a = document.createElement('a');
a.href = url;
a.download = `partner-assoc-${summary?.partnerId || 'qr'}.png`;
a.click();
URL.revokeObjectURL(url);
downloadBlob(blob, posterId
? `activity-poster-${posterId}.png`
: `partner-assoc-${summary?.partnerId || 'qr'}.png`);
toastSuccess('已开始下载');
} catch (e) {
if (summary?.qrcodeUrl) {
if (!posterId && summary?.qrcodeUrl) {
setPreviewUrl(summary.qrcodeUrl);
toastSuccess('请长按图片保存到相册');
return;
@@ -153,9 +180,17 @@ export default function UsersManagePage() {
<p className="body-md text-muted" style={{ marginBottom: 12 }}>
</p>
{summary?.qrcodeUrl ? (
{summary?.activityPosterId && (previewUrl || heroUrl) ? (
<img
src={previewUrl || summary.qrcodeUrl}
src={previewUrl || heroUrl || ''}
alt="活动图"
style={{ width: '100%', maxWidth: 360, background: '#f5f5f5' }}
/>
) : summary?.activityPosterId && heroLoading ? (
<p className="body-md text-muted"></p>
) : previewUrl || summary?.qrcodeUrl ? (
<img
src={previewUrl || summary?.qrcodeUrl || ''}
alt="关联码"
style={{ width: 200, height: 200, background: '#fff' }}
/>
@@ -165,14 +200,26 @@ export default function UsersManagePage() {
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{summary?.userCount ?? total}
</p>
<button type="button" className="partner-btn-primary" style={{ marginTop: 16 }} onClick={() => void downloadQr()}>
<button type="button" className="partner-btn-primary" style={{ marginTop: 16 }} onClick={() => void downloadMainImage()}>
{summary?.activityPosterId ? '下载活动图' : '下载二维码'}
</button>
{previewUrl && isWechatEnv() && (
<p className="label-md text-muted" style={{ marginTop: 8 }}></p>
)}
</section>
<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>