cdc4b82931
HQ 指定一张活动图为勾选主合伙人合成 PNG/zip;城市合伙人页增加快链与单下/导出。 Co-authored-by: Cursor <cursoragent@cursor.com>
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Link, useSearchParams } from 'react-router-dom';
|
|
import {
|
|
Alert, 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, requestDownload } 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;
|
|
};
|
|
|
|
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>>({});
|
|
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 [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);
|
|
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,
|
|
};
|
|
|
|
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({
|
|
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();
|
|
}
|
|
|
|
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: '标题',
|
|
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: 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"
|
|
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>}
|
|
/>
|
|
{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
|
|
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>
|
|
);
|
|
}
|