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
+2
View File
@@ -25,6 +25,7 @@ import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage';
import StoreCategoriesPage from './pages/StoreCategoriesPage';
import PromoCodesPage from './pages/PromoCodesPage';
import ActivityPostersPage from './pages/ActivityPostersPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage';
@@ -86,6 +87,7 @@ export default function App() {
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/promo-codes" element={<PromoCodesPage />} />
<Route path="/activity-posters" element={<ActivityPostersPage />} />
<Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}>
<Route index element={<PromoCodeDetailPage />} />
<Route path="users" element={<PromoCodeUsersPage />} />
@@ -0,0 +1,110 @@
import { useCallback, useRef } from 'react';
import { DEFAULT_ACTIVITY_POSTER_QR_SLOT, type ActivityPosterQrSlot } from '@dukang/shared-types';
type Props = {
imageUrl?: string;
value?: ActivityPosterQrSlot;
onChange?: (slot: ActivityPosterQrSlot) => void;
};
export default function ActivityPosterQrSlotEditor({ imageUrl, value, onChange }: Props) {
const boxRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
mode: 'move' | 'resize';
startX: number;
startY: number;
start: ActivityPosterQrSlot;
} | null>(null);
const slot = value ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT;
const apply = useCallback((next: ActivityPosterQrSlot) => {
const size = Math.min(50, Math.max(5, next.qrSizePct));
const box = boxRef.current;
const ratio = box && box.clientHeight > 0 ? box.clientWidth / box.clientHeight : 1;
const heightPct = size * ratio;
const x = Math.min(100 - size, Math.max(0, next.qrXPct));
const y = Math.min(Math.max(0, 100 - heightPct), Math.max(0, next.qrYPct));
onChange?.({
qrXPct: Number(x.toFixed(2)),
qrYPct: Number(y.toFixed(2)),
qrSizePct: Number(size.toFixed(2)),
});
}, [onChange]);
function onPointerDown(mode: 'move' | 'resize', e: React.PointerEvent) {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = { mode, startX: e.clientX, startY: e.clientY, start: { ...slot } };
}
function onPointerMove(e: React.PointerEvent) {
const drag = dragRef.current;
const box = boxRef.current;
if (!drag || !box) return;
const dx = ((e.clientX - drag.startX) / box.clientWidth) * 100;
const dy = ((e.clientY - drag.startY) / box.clientHeight) * 100;
if (drag.mode === 'move') {
apply({
...drag.start,
qrXPct: drag.start.qrXPct + dx,
qrYPct: drag.start.qrYPct + dy,
});
} else {
apply({ ...drag.start, qrSizePct: drag.start.qrSizePct + dx });
}
}
function endDrag() {
dragRef.current = null;
}
if (!imageUrl) {
return <div style={{ color: 'rgba(0,0,0,0.45)' }}></div>;
}
return (
<div>
<div
ref={boxRef}
style={{ position: 'relative', width: '100%', maxWidth: 420, userSelect: 'none', touchAction: 'none' }}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<img src={imageUrl} alt="活动图预览" style={{ width: '100%', display: 'block' }} draggable={false} />
<div
style={{
position: 'absolute',
left: `${slot.qrXPct}%`,
top: `${slot.qrYPct}%`,
width: `${slot.qrSizePct}%`,
aspectRatio: '1',
border: '2px dashed #1677ff',
background: 'rgba(22,119,255,0.14)',
cursor: 'move',
boxSizing: 'border-box',
}}
onPointerDown={(e) => onPointerDown('move', e)}
>
<div
style={{
position: 'absolute',
right: -6,
bottom: -6,
width: 14,
height: 14,
background: '#1677ff',
borderRadius: 2,
cursor: 'nwse-resize',
}}
onPointerDown={(e) => onPointerDown('resize', e)}
/>
</div>
</div>
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.45)', fontSize: 12 }}>
{slot.qrSizePct}%
</div>
</div>
);
}
@@ -19,6 +19,7 @@ import {
SettingOutlined,
AccountBookOutlined,
ProjectOutlined,
PictureOutlined,
} from '@ant-design/icons';
import {
hasAnySystemSettingsPermission,
@@ -118,6 +119,7 @@ const MENU_ITEMS: MenuProps['items'] = [
},
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票' },
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
{ key: '/activity-posters', icon: <PictureOutlined />, label: '活动图' },
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
{
key: 'wecom-group',
@@ -180,6 +182,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/product-detail-templates': 'products',
'/orders': 'orders',
'/promo-codes': 'promo_codes',
'/activity-posters': 'activity_posters',
'/wecom/bots': 'wecom_bots',
'/wecom/pushes': 'wecom_bots',
'wecom-group': 'wecom_bots',
+1
View File
@@ -120,6 +120,7 @@ export const RESOURCE_BIZ_TYPE_LABELS: Record<string, string> = {
QRCODE: '二维码',
SIGN_PHOTO: '签收照',
VIDEO: '视频',
ACTIVITY_POSTER: '活动图',
};
export const RESOURCE_STATUS_LABELS: Record<string, string> = {
+1
View File
@@ -43,6 +43,7 @@ export const REF_TYPE_LABELS: Record<string, string> = {
INVOICE: '发票',
PROMO: '推广码',
PROMO_CODE: '推广码',
ACTIVITY_POSTER: '活动图',
HQ: '总部',
HQ_ACCOUNT: 'HQ 账号',
STORE_ACCOUNT: '门店账号',
+4
View File
@@ -61,6 +61,10 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' },
{ value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' },
{ value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' },
{ value: 'ACTIVITY_POSTER_CREATE', label: '新增活动图' },
{ value: 'ACTIVITY_POSTER_UPDATE', label: '编辑活动图' },
{ value: 'ACTIVITY_POSTER_UPDATE_STATUS', label: '活动图上下架' },
{ value: 'ACTIVITY_POSTER_DELETE', label: '删除活动图' },
] as const;
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
@@ -0,0 +1,242 @@
import { useState } from 'react';
import {
Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ACTIVITY_POSTER_STATUS_LABELS,
DEFAULT_ACTIVITY_POSTER_QR_SLOT,
type ActivityPosterItem,
type ActivityPosterQrSlot,
type ActivityPosterStatus,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import OssUpload from '../components/OssUpload';
import ActivityPosterQrSlotEditor from '../components/ActivityPosterQrSlotEditor';
type FormValues = {
title: string;
copyText?: string;
imageUrl: string;
qrXPct: number;
qrYPct: number;
qrSizePct: number;
sortOrder?: number;
status?: ActivityPosterStatus;
};
export default function ActivityPostersPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<ActivityPosterItem>(
'/admin/activity-posters',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ActivityPosterItem | null>(null);
const imageUrl = Form.useWatch('imageUrl', editForm);
const qrXPct = Form.useWatch('qrXPct', editForm);
const qrYPct = Form.useWatch('qrYPct', editForm);
const qrSizePct = Form.useWatch('qrSizePct', editForm);
const slot: ActivityPosterQrSlot = {
qrXPct: qrXPct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrXPct,
qrYPct: qrYPct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrYPct,
qrSizePct: qrSizePct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrSizePct,
};
function openCreate() {
setEditing(null);
editForm.setFieldsValue({
title: '',
copyText: '',
imageUrl: '',
...DEFAULT_ACTIVITY_POSTER_QR_SLOT,
sortOrder: 0,
status: 'ACTIVE',
});
setModalOpen(true);
}
function openEdit(row: ActivityPosterItem) {
setEditing(row);
editForm.setFieldsValue({
title: row.title,
copyText: row.copyText,
imageUrl: row.imageUrl,
qrXPct: row.qrXPct,
qrYPct: row.qrYPct,
qrSizePct: row.qrSizePct,
sortOrder: row.sortOrder,
status: row.status,
});
setModalOpen(true);
}
async function save() {
const v = await editForm.validateFields();
const body = JSON.stringify(v);
if (editing) {
await request(`/admin/activity-posters/${editing.id}`, { method: 'PUT', body });
message.success('已保存');
} else {
await request('/admin/activity-posters', { method: 'POST', body });
message.success('已创建');
}
setModalOpen(false);
void reload();
}
const baseColumns: ColumnsType<ActivityPosterItem> = [
{
title: '标题',
dataIndex: 'title',
width: 180,
render: (v, row) => <AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>,
},
{
title: '封面',
dataIndex: 'imageUrl',
width: 90,
render: (url: string) => <Image src={url} width={56} height={56} style={{ objectFit: 'cover' }} />,
},
{
title: '文案',
dataIndex: 'copyText',
width: 240,
render: (v: string) => v || '—',
},
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: ActivityPosterStatus) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{ACTIVITY_POSTER_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作',
width: 200,
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => openEdit(row)}></Button>
<Button
type="link"
size="small"
onClick={async () => {
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE';
await request(`/admin/activity-posters/${row.id}/status`, {
method: 'PUT',
body: JSON.stringify({ status: next }),
});
message.success(next === 'ACTIVE' ? '已上架' : '已下架');
void reload();
}}
>
{row.status === 'ACTIVE' ? '下架' : '上架'}
</Button>
<Popconfirm
title="确认删除该活动图?"
onConfirm={async () => {
await request(`/admin/activity-posters/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void reload();
}}
>
<Button type="link" size="small" danger></Button>
</Popconfirm>
</Space>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('activity-posters', baseColumns, {
page,
pageSize,
});
return (
<div>
{settingsModal}
<AdminListHeader
title="活动图"
settings={settingsButton}
actions={<Button type="primary" onClick={openCreate}></Button>}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(ACTIVITY_POSTER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
<Modal
title={editing ? '编辑活动图' : '新建活动图'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void save()}
width={720}
destroyOnClose
>
<Form form={editForm} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请填写标题' }]}>
<Input maxLength={128} />
</Form.Item>
<Form.Item name="imageUrl" label="活动图" rules={[{ required: true, message: '请上传活动图' }]}>
<OssUpload bizType="ACTIVITY_POSTER" />
</Form.Item>
<Form.Item name="qrXPct" hidden><InputNumber /></Form.Item>
<Form.Item name="qrYPct" hidden><InputNumber /></Form.Item>
<Form.Item name="qrSizePct" hidden><InputNumber /></Form.Item>
<Form.Item label="二维码栏">
<ActivityPosterQrSlotEditor
imageUrl={imageUrl}
value={slot}
onChange={(next) => editForm.setFieldsValue(next)}
/>
</Form.Item>
<Form.Item name="copyText" label="推广文案">
<Input.TextArea rows={4} maxLength={4000} placeholder="合伙人可一键复制" />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="ACTIVE">
<Select options={Object.entries(ACTIVITY_POSTER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+2
View File
@@ -25,6 +25,7 @@ import LeaderboardPage from './pages/LeaderboardPage';
import UsersManagePage from './pages/UsersManagePage';
import AssocOrdersPage from './pages/AssocOrdersPage';
import CommissionOrdersPage from './pages/CommissionOrdersPage';
import ActivityPostersPage from './pages/ActivityPostersPage';
function PrimaryRoutes() {
return (
@@ -41,6 +42,7 @@ function PrimaryRoutes() {
<Route path="/center/settlement" element={<SettlementPage />} />
<Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/center/activity-posters" element={<ActivityPostersPage />} />
<Route path="/center/assoc" element={<Navigate to="/users" replace />} />
<Route path="/center/assoc/users" element={<Navigate to="/users" replace />} />
<Route path="/users/orders" element={<AssocOrdersPage />} />
@@ -0,0 +1,246 @@
import { useEffect, useRef, useState } from 'react';
import type { ActivityPosterItem, PartnerAssocSummary } from '@dukang/shared-types';
import { request } from '../lib/api';
import {
copyActivityCopy,
downloadBlob,
fetchActivityPosterImage,
fetchAssocQrcodeImage,
getActivityPosterSelection,
listActivityPosters,
saveActivityPosterSelection,
} from '../lib/activity-posters';
import { toastError, toastSuccess } from '../lib/toast';
import { isWechatEnv } from '../lib/weixin';
const NONE_ID = 'none';
type Props = {
initialId?: string | null;
};
export default function ActivityPosterPicker({ initialId }: Props) {
const [items, setItems] = useState<ActivityPosterItem[]>([]);
const [qrcodeUrl, setQrcodeUrl] = useState<string | null>(null);
const [partnerId, setPartnerId] = useState<string>('');
const [selectedId, setSelectedId] = useState<string>(NONE_ID);
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
const [listLoading, setListLoading] = useState(true);
const [previewLoading, setPreviewLoading] = useState(false);
const [downloading, setDownloading] = useState(false);
const blobsRef = useRef(new Map<string, Blob>());
const urlsRef = useRef<string[]>([]);
useEffect(() => () => {
urlsRef.current.forEach((url) => URL.revokeObjectURL(url));
}, []);
useEffect(() => {
let cancelled = false;
Promise.all([
listActivityPosters(),
request<PartnerAssocSummary>('PARTNER_H5', '/partner/assoc', { silent: true }),
getActivityPosterSelection().catch(() => ({ posterId: null })),
])
.then(([list, assoc, selection]) => {
if (cancelled) return;
setItems(list);
setQrcodeUrl(assoc.qrcodeUrl);
setPartnerId(assoc.partnerId);
const saved = selection.posterId && list.some((item) => item.id === selection.posterId)
? selection.posterId
: null;
const fromQuery = initialId && list.some((item) => item.id === initialId) ? initialId : null;
const next = saved || fromQuery || NONE_ID;
setSelectedId(next);
if (!saved && fromQuery) {
void saveActivityPosterSelection(fromQuery).catch((err) => {
toastError(err instanceof Error ? err.message : '保存失败');
});
}
})
.catch((e) => toastError(e instanceof Error ? e.message : '加载活动图失败'))
.finally(() => {
if (!cancelled) setListLoading(false);
});
return () => {
cancelled = true;
};
}, [initialId]);
useEffect(() => {
if (selectedId === NONE_ID || previewUrls[selectedId] || blobsRef.current.has(selectedId)) return;
let cancelled = false;
setPreviewLoading(true);
fetchActivityPosterImage(selectedId)
.then((blob) => {
if (cancelled) return;
blobsRef.current.set(selectedId, blob);
const url = URL.createObjectURL(blob);
urlsRef.current.push(url);
setPreviewUrls((prev) => ({ ...prev, [selectedId]: url }));
})
.catch((e) => {
if (!cancelled) toastError(e instanceof Error ? e.message : '预览失败');
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
return () => {
cancelled = true;
};
}, [selectedId, previewUrls]);
const selected = items.find((item) => item.id === selectedId) ?? null;
const previewUrl = selectedId !== NONE_ID ? previewUrls[selectedId] : undefined;
async function downloadSelected() {
setDownloading(true);
try {
if (selectedId === NONE_ID) {
const blob = await fetchAssocQrcodeImage();
if (isWechatEnv()) {
const url = URL.createObjectURL(blob);
urlsRef.current.push(url);
setQrcodeUrl(url);
toastSuccess('请长按上方图片保存到相册');
return;
}
downloadBlob(blob, `partner-assoc-${partnerId || 'qr'}.png`);
toastSuccess('已开始下载');
return;
}
if (!selected) return;
let blob = blobsRef.current.get(selected.id);
if (!blob) {
blob = await fetchActivityPosterImage(selected.id);
blobsRef.current.set(selected.id, blob);
}
if (isWechatEnv()) {
let url = previewUrls[selected.id];
if (!url) {
url = URL.createObjectURL(blob);
urlsRef.current.push(url);
setPreviewUrls((prev) => ({ ...prev, [selected.id]: url }));
}
toastSuccess('请长按上方图片保存到相册');
return;
}
downloadBlob(blob, `activity-poster-${selected.id}.png`);
toastSuccess('已开始下载');
} catch (e) {
toastError(e instanceof Error ? e.message : '下载失败');
} finally {
setDownloading(false);
}
}
async function selectPoster(id: string) {
const prev = selectedId;
setSelectedId(id);
try {
await saveActivityPosterSelection(id === NONE_ID ? null : id);
} catch (e) {
setSelectedId(prev);
toastError(e instanceof Error ? e.message : '保存失败');
}
}
function renderRadioRow(id: string, label: string) {
return (
<label className="partner-activity-radio-row">
<span className="headline-md">{label}</span>
<input
type="radio"
name="activity-poster"
checked={selectedId === id}
onChange={() => void selectPoster(id)}
/>
</label>
);
}
if (listLoading) {
return <div className="empty"></div>;
}
return (
<div className="partner-activity-picker">
<section className="partner-bill-card" style={{ overflow: 'hidden' }}>
{renderRadioRow(NONE_ID, '无')}
{selectedId === NONE_ID && (
<div className="partner-activity-panel">
{qrcodeUrl ? (
<div className="partner-activity-preview partner-activity-preview--qr">
<img src={qrcodeUrl} alt="关联码" />
</div>
) : (
<p className="body-md text-muted" style={{ marginBottom: 12 }}></p>
)}
<button
type="button"
className="partner-btn-primary"
disabled={downloading || !qrcodeUrl}
onClick={() => void downloadSelected()}
>
{downloading ? '下载中…' : '下载二维码'}
</button>
{isWechatEnv() && (
<p className="label-md text-muted" style={{ marginTop: 12 }}>
</p>
)}
</div>
)}
</section>
{items.map((item) => (
<section key={item.id} className="partner-bill-card" style={{ marginTop: 12, overflow: 'hidden' }}>
{renderRadioRow(item.id, item.title)}
{selectedId === item.id && (
<div className="partner-activity-panel">
<div className="partner-activity-preview">
<img src={previewUrl || item.imageUrl} alt={item.title} />
{previewLoading && !previewUrl && (
<div className="partner-activity-preview-mask"></div>
)}
</div>
{item.copyText ? (
<p className="body-md" style={{ whiteSpace: 'pre-wrap', margin: '12px 0' }}>{item.copyText}</p>
) : (
<p className="body-md text-muted" style={{ margin: '12px 0' }}>广</p>
)}
<div style={{ display: 'flex', gap: 12 }}>
<button
type="button"
className="partner-btn-outline"
onClick={() => {
void copyActivityCopy(item.copyText)
.then(() => toastSuccess('文案已复制'))
.catch((e) => toastError(e instanceof Error ? e.message : '复制失败'));
}}
>
</button>
<button
type="button"
className="partner-btn-primary"
style={{ flex: 1, width: 'auto', padding: 0, height: 48 }}
disabled={downloading || previewLoading}
onClick={() => void downloadSelected()}
>
{downloading || previewLoading ? '生成中…' : '下载活动图'}
</button>
</div>
{previewUrl && isWechatEnv() && (
<p className="label-md text-muted" style={{ marginTop: 12 }}>
</p>
)}
</div>
)}
</section>
))}
</div>
);
}
@@ -0,0 +1,74 @@
import type { ActivityPosterItem, ActivityPosterSelection } from '@dukang/shared-types';
import { request } from './api';
export async function listActivityPosters() {
const data = await request<ActivityPosterItem[]>('PARTNER_H5', '/partner/activity-posters', {
silent: true,
});
return Array.isArray(data) ? data : [];
}
export async function fetchAssocQrcodeImage(): Promise<Blob> {
const token = localStorage.getItem('accessToken');
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('下载二维码失败');
return res.blob();
}
export async function fetchActivityPosterImage(id: string): Promise<Blob> {
const token = localStorage.getItem('accessToken');
const res = await fetch(`/api/v1/partner/activity-posters/${id}/image`, {
headers: {
Authorization: token ? `Bearer ${token}` : '',
'X-Client-App': 'PARTNER_H5',
},
});
if (!res.ok) {
const json = await res.json().catch(() => null) as { message?: string } | null;
throw new Error(json?.message || '生成活动图失败');
}
return res.blob();
}
export function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
export async function getActivityPosterSelection() {
return request<ActivityPosterSelection>('PARTNER_H5', '/partner/activity-posters/selection', {
silent: true,
});
}
export async function saveActivityPosterSelection(posterId: string | null) {
return request<ActivityPosterSelection>('PARTNER_H5', '/partner/activity-posters/selection', {
method: 'PUT',
body: JSON.stringify({ posterId }),
silent: true,
});
}
export async function copyActivityCopy(text: string) {
const value = text.trim();
if (!value) throw new Error('暂无文案');
try {
await navigator.clipboard.writeText(value);
} catch {
const ta = document.createElement('textarea');
ta.value = value;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
}
@@ -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>
+116
View File
@@ -3971,3 +3971,119 @@ header:has(> .app-page-title:only-child),
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.partner-activity-scroll {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
-webkit-overflow-scrolling: touch;
}
.partner-activity-chip {
flex: 0 0 92px;
border: 1px solid var(--color-outline-variant, #eee);
border-radius: 12px;
background: #fff;
padding: 6px;
text-align: left;
cursor: pointer;
}
.partner-activity-chip img {
width: 100%;
height: 92px;
object-fit: cover;
border-radius: 8px;
display: block;
background: #f5f5f5;
}
.partner-activity-chip span {
display: block;
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.partner-activity-chip.is-active {
border-color: var(--color-heritage-red);
box-shadow: 0 0 0 1px var(--color-heritage-red);
}
.partner-activity-preview {
position: relative;
background: #f5f5f5;
}
.partner-activity-preview img {
width: 100%;
display: block;
}
.partner-activity-preview-mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.62);
color: var(--color-on-surface-variant, #666);
font-size: 14px;
}
.partner-activity-center-card {
flex: 0 0 120px;
text-decoration: none;
color: inherit;
}
.partner-activity-center-card img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 12px;
display: block;
background: #f5f5f5;
}
.partner-activity-center-card span {
display: block;
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.partner-activity-radio-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
cursor: pointer;
}
.partner-activity-radio-row input[type='radio'] {
width: 20px;
height: 20px;
flex-shrink: 0;
accent-color: var(--color-heritage-red);
}
.partner-activity-panel {
padding: 0 16px 16px;
}
.partner-activity-preview--qr {
display: flex;
justify-content: center;
background: #fff;
margin-bottom: 12px;
}
.partner-activity-preview--qr img {
width: 200px;
height: 200px;
}