feat(ops): v4.0.7 HQ 活动图快链与勾选导出

HQ 指定一张活动图为勾选主合伙人合成 PNG/zip;城市合伙人页增加快链与单下/导出。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 15:12:10 +08:00
parent eeee55e215
commit cdc4b82931
21 changed files with 1041 additions and 28 deletions
@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import { Form, Modal, Select, Typography, message } from 'antd';
import type { ActivityPosterItem } from '@dukang/shared-types';
import { request, requestDownload, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
type Props = {
open: boolean;
partnerIds: string[];
onClose: () => void;
};
export default function ActivityPosterDownloadModal({ open, partnerIds, onClose }: Props) {
const [posters, setPosters] = useState<ActivityPosterItem[]>([]);
const [posterId, setPosterId] = useState<string>();
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const multi = partnerIds.length > 1;
useEffect(() => {
if (!open) return;
setPosterId(undefined);
setLoading(true);
void request<Paginated<ActivityPosterItem>>(
`/admin/activity-posters?status=ACTIVE&page=1&pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
)
.then((res) => {
const items = res.items ?? [];
setPosters(items);
setPosterId(items[0]?.id);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载活动图失败'))
.finally(() => setLoading(false));
}, [open]);
async function submit() {
if (!posterId) {
message.error('请选择活动图');
return;
}
if (!partnerIds.length) {
message.error('请选择合伙人');
return;
}
setSubmitting(true);
try {
if (partnerIds.length === 1) {
await requestDownload(
`/admin/activity-posters/${posterId}/image?partnerId=${encodeURIComponent(partnerIds[0])}`,
{},
'activity-poster.png',
);
} else {
await requestDownload(
`/admin/activity-posters/${posterId}/partner-pack`,
{ method: 'POST', body: JSON.stringify({ partnerIds }) },
'activity-poster-pack.zip',
);
}
message.success('已开始下载');
onClose();
} catch (e) {
message.error(e instanceof Error ? e.message : '下载失败');
} finally {
setSubmitting(false);
}
}
return (
<Modal
title={multi ? `导出活动图(${partnerIds.length} 人)` : '下载活动图'}
open={open}
onCancel={onClose}
onOk={() => void submit()}
confirmLoading={submitting}
okText="下载"
okButtonProps={{ disabled: loading || !posterId }}
destroyOnClose
>
<Typography.Paragraph type="secondary">
{multi
? '将把所选主合伙人的关联码贴进同一张活动图,打包为 zip。无关联码或测试账号会跳过。'
: '将把该主合伙人的关联码贴进所选活动图后下载 PNG。'}
</Typography.Paragraph>
<Form layout="vertical">
<Form.Item label="活动图" required>
<Select
loading={loading}
placeholder={loading ? '加载中' : posters.length ? '选择已上架活动图' : '暂无上架活动图'}
value={posterId}
onChange={setPosterId}
options={posters.map((row) => ({ value: row.id, label: row.title }))}
showSearch
optionFilterProp="label"
/>
</Form.Item>
</Form>
</Modal>
);
}
@@ -5,6 +5,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
import { request, type HqProfile, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
import ActivityPosterDownloadModal from './ActivityPosterDownloadModal';
function formatNicknameWithRemark(row: PartnerAssocUserItem) {
const name = row.nickname?.trim() || '—';
@@ -24,6 +25,8 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
const [loading, setLoading] = useState(false);
const [issuing, setIssuing] = useState(false);
const [canEditAssoc, setCanEditAssoc] = useState(false);
const [canDownloadPosters, setCanDownloadPosters] = useState(false);
const [posterDownloadOpen, setPosterDownloadOpen] = useState(false);
const loadSummary = useCallback(async () => {
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
@@ -46,8 +49,15 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
useEffect(() => {
void request<HqProfile>('/admin/auth/me')
.then((p) => setCanEditAssoc((p.permissionKeys ?? []).includes('users_partner_assoc')))
.catch(() => setCanEditAssoc(false));
.then((p) => {
const keys = p.permissionKeys ?? [];
setCanEditAssoc(keys.includes('users_partner_assoc'));
setCanDownloadPosters(keys.includes('activity_posters'));
})
.catch(() => {
setCanEditAssoc(false);
setCanDownloadPosters(false);
});
}, []);
useEffect(() => {
@@ -130,6 +140,9 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
<Button loading={issuing} onClick={() => void reissue()}>
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
</Button>
{canDownloadPosters ? (
<Button onClick={() => setPosterDownloadOpen(true)}></Button>
) : null}
</div>
</Space>
<Table
@@ -145,6 +158,11 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
onChange: (p) => void loadUsers(p),
}}
/>
<ActivityPosterDownloadModal
open={posterDownloadOpen}
partnerIds={[partnerId]}
onClose={() => setPosterDownloadOpen(false)}
/>
</div>
);
}
+48
View File
@@ -58,6 +58,54 @@ export async function request<T>(path: string, options: RequestInit = {}): Promi
return json.data as T;
}
function parseDownloadFileName(header: string | null, fallback: string): string {
if (!header) return fallback;
const star = /filename\*=(?:UTF-8''|utf-8'')([^;]+)/i.exec(header);
if (star?.[1]) {
try {
return decodeURIComponent(star[1].trim());
} catch {
/* ignore */
}
}
const quoted = /filename="([^"]+)"/i.exec(header);
if (quoted?.[1]) return quoted[1];
const plain = /filename=([^;]+)/i.exec(header);
return plain?.[1]?.trim() || fallback;
}
/** Binary download (PNG / zip). JSON error bodies are surfaced as Error. */
export async function requestDownload(path: string, options: RequestInit = {}, fallbackName: string) {
const headers: Record<string, string> = {
'X-Client-App': CLIENT_APP,
...(options.headers as Record<string, string>),
};
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const json = (await res.json()) as { code?: number; message?: string };
if (json.code === 401) {
clearAuth();
window.location.href = '/login';
throw new Error('未登录');
}
throw new Error(json.message || '下载失败');
}
if (!res.ok) throw new Error(`下载失败(HTTP ${res.status}`);
const blob = await res.blob();
const fileName = parseDownloadFileName(res.headers.get('content-disposition'), fallbackName);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
export type Paginated<T> = {
items: T[];
total: number;
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message,
Alert, Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -10,7 +11,7 @@ import {
type ActivityPosterQrSlot,
type ActivityPosterStatus,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { request, requestDownload } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
@@ -30,7 +31,16 @@ type FormValues = {
status?: ActivityPosterStatus;
};
type PartnerHint = {
id: string;
companyName?: string | null;
name?: string;
phone?: string;
};
export default function ActivityPostersPage() {
const [searchParams] = useSearchParams();
const partnerId = searchParams.get('partnerId')?.trim() || '';
const [form] = Form.useForm();
const [editForm] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
@@ -45,6 +55,8 @@ export default function ActivityPostersPage() {
);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ActivityPosterItem | null>(null);
const [partnerHint, setPartnerHint] = useState<PartnerHint | null>(null);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const imageUrl = Form.useWatch('imageUrl', editForm);
const qrXPct = Form.useWatch('qrXPct', editForm);
@@ -56,6 +68,20 @@ export default function ActivityPostersPage() {
qrSizePct: qrSizePct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrSizePct,
};
useEffect(() => {
if (!partnerId) {
setPartnerHint(null);
return;
}
void request<PartnerHint>(`/admin/partners/${partnerId}`)
.then(setPartnerHint)
.catch(() => setPartnerHint({ id: partnerId }));
}, [partnerId]);
const partnerLabel = partnerHint
? (partnerHint.companyName || partnerHint.name || partnerHint.phone || partnerHint.id)
: partnerId;
function openCreate() {
setEditing(null);
editForm.setFieldsValue({
@@ -98,6 +124,23 @@ export default function ActivityPostersPage() {
void reload();
}
async function downloadForPartner(posterId: string) {
if (!partnerId) return;
setDownloadingId(posterId);
try {
await requestDownload(
`/admin/activity-posters/${posterId}/image?partnerId=${encodeURIComponent(partnerId)}`,
{},
'activity-poster.png',
);
message.success('已开始下载');
} catch (e) {
message.error(e instanceof Error ? e.message : '下载失败');
} finally {
setDownloadingId(null);
}
}
const baseColumns: ColumnsType<ActivityPosterItem> = [
{
title: '标题',
@@ -129,10 +172,20 @@ export default function ActivityPostersPage() {
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作',
width: 200,
width: partnerId ? 280 : 200,
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => openEdit(row)}></Button>
{partnerId && row.status === 'ACTIVE' ? (
<Button
type="link"
size="small"
loading={downloadingId === row.id}
onClick={() => void downloadForPartner(row.id)}
>
</Button>
) : null}
<Button
type="link"
size="small"
@@ -176,6 +229,19 @@ export default function ActivityPostersPage() {
settings={settingsButton}
actions={<Button type="primary" onClick={openCreate}></Button>}
/>
{partnerId ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={
<>
{partnerLabel}{' '}
<Link to="/city-partners"></Link>
</>
}
/>
) : null}
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="status" label="状态">
<Select
+67 -2
View File
@@ -30,7 +30,6 @@ import {
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { omitNullFields } from '../lib/omit-null-fields';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
@@ -38,9 +37,11 @@ import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
import PartnerAssocPanel from '../components/PartnerAssocPanel';
import ActivityPosterDownloadModal from '../components/ActivityPosterDownloadModal';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { request, type HqProfile, type Paginated } from '../lib/api';
type SubRow = PartnerSubAccountRow;
@@ -159,6 +160,9 @@ export default function CityPartnersPage() {
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
const [createMaxRate, setCreateMaxRate] = useState(0.05);
const [createCityCode, setCreateCityCode] = useState<string | undefined>();
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [posterDownloadIds, setPosterDownloadIds] = useState<string[] | null>(null);
const [canDownloadPosters, setCanDownloadPosters] = useState(false);
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
@@ -169,6 +173,12 @@ export default function CityPartnersPage() {
void loadCities();
}, [loadCities]);
useEffect(() => {
void request<HqProfile>('/admin/auth/me')
.then((p) => setCanDownloadPosters((p.permissionKeys ?? []).includes('activity_posters')))
.catch(() => setCanDownloadPosters(false));
}, []);
const editCityCode = detail?.cityId
? cities.find((c) => c.id === detail.cityId)?.code
: undefined;
@@ -373,17 +383,34 @@ export default function CityPartnersPage() {
</Link>
),
},
{
title: '活动图',
width: 70,
render: (_, row) => (
<Link
to={`/activity-posters?partnerId=${encodeURIComponent(row.id)}`}
title={`为「${row.companyName || row.phone}」下载活动图`}
>
</Link>
),
},
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 140,
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
{canDownloadPosters ? (
<Button type="link" size="small" onClick={() => setPosterDownloadIds([row.id])}>
</Button>
) : null}
<Popconfirm
title="确认删除该城市合伙人?"
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
@@ -410,7 +437,16 @@ export default function CityPartnersPage() {
settings={settingsButton}
actions={
<>
<Link to="/activity-posters"></Link>
<Link to="/users?assocPartnerAccountId=any"></Link>
{canDownloadPosters ? (
<Button
disabled={!selectedRowKeys.length}
onClick={() => setPosterDownloadIds(selectedRowKeys)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
<Button
type="primary"
onClick={() => {
@@ -498,6 +534,14 @@ export default function CityPartnersPage() {
dataSource={toTableRows(data?.items ?? [])}
scroll={{ x: 'max-content' }}
childrenColumnName="__noTreeChildren__"
rowSelection={
canDownloadPosters
? {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys.map(String)),
}
: undefined
}
expandable={{
expandedRowRender: (record) => (
<PartnerSubAccountList
@@ -564,6 +608,21 @@ export default function CityPartnersPage() {
{detail.assocUserCount ?? 0}
</Link>
</Descriptions.Item>
<Descriptions.Item label="活动图">
<Space>
<Link
to={`/activity-posters?partnerId=${encodeURIComponent(detail.id)}`}
title="查看活动图并下载该合伙人合成图"
>
</Link>
{canDownloadPosters ? (
<Button type="link" size="small" onClick={() => setPosterDownloadIds([detail.id])}>
</Button>
) : null}
</Space>
</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? (
<Typography.Text type="secondary">
@@ -812,6 +871,12 @@ export default function CityPartnersPage() {
</Form.Item>
</Form>
</Modal>
<ActivityPosterDownloadModal
open={posterDownloadIds != null && posterDownloadIds.length > 0}
partnerIds={posterDownloadIds ?? []}
onClose={() => setPosterDownloadIds(null)}
/>
</div>
);
}