feat(ops): v4.0.7 HQ 活动图快链与勾选导出
HQ 指定一张活动图为勾选主合伙人合成 PNG/zip;城市合伙人页增加快链与单下/导出。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user