diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 31b8415..6b56e63 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -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() { } /> } /> } /> + } /> }> } /> } /> diff --git a/apps/admin-web/src/components/ActivityPosterQrSlotEditor.tsx b/apps/admin-web/src/components/ActivityPosterQrSlotEditor.tsx new file mode 100644 index 0000000..73c1cda --- /dev/null +++ b/apps/admin-web/src/components/ActivityPosterQrSlotEditor.tsx @@ -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(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
请先上传活动图,再拖拽定位方形码栏
; + } + + return ( +
+
+ 活动图预览 +
onPointerDown('move', e)} + > +
onPointerDown('resize', e)} + /> +
+
+
+ 拖拽移动码栏,拉右下角调整大小(边长相对图宽 {slot.qrSizePct}%) +
+
+ ); +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index f610a89..36a4d30 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -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: , label: '发票' }, { key: '/promo-codes', icon: , label: '推广码' }, + { key: '/activity-posters', icon: , label: '活动图' }, { key: '/wechat-bindings', icon: , 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', diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index 490bd3c..57e4f01 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -120,6 +120,7 @@ export const RESOURCE_BIZ_TYPE_LABELS: Record = { QRCODE: '二维码', SIGN_PHOTO: '签收照', VIDEO: '视频', + ACTIVITY_POSTER: '活动图', }; export const RESOURCE_STATUS_LABELS: Record = { diff --git a/apps/admin-web/src/lib/display-labels.ts b/apps/admin-web/src/lib/display-labels.ts index d238c1e..a41f472 100644 --- a/apps/admin-web/src/lib/display-labels.ts +++ b/apps/admin-web/src/lib/display-labels.ts @@ -43,6 +43,7 @@ export const REF_TYPE_LABELS: Record = { INVOICE: '发票', PROMO: '推广码', PROMO_CODE: '推广码', + ACTIVITY_POSTER: '活动图', HQ: '总部', HQ_ACCOUNT: 'HQ 账号', STORE_ACCOUNT: '门店账号', diff --git a/apps/admin-web/src/lib/hq-log.ts b/apps/admin-web/src/lib/hq-log.ts index d61b340..843ecd5 100644 --- a/apps/admin-web/src/lib/hq-log.ts +++ b/apps/admin-web/src/lib/hq-log.ts @@ -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 = Object.fromEntries( diff --git a/apps/admin-web/src/pages/ActivityPostersPage.tsx b/apps/admin-web/src/pages/ActivityPostersPage.tsx new file mode 100644 index 0000000..d0887cc --- /dev/null +++ b/apps/admin-web/src/pages/ActivityPostersPage.tsx @@ -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(); + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/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(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 = [ + { + title: '标题', + dataIndex: 'title', + width: 180, + render: (v, row) => openEdit(row)}>{v}, + }, + { + title: '封面', + dataIndex: 'imageUrl', + width: 90, + render: (url: string) => , + }, + { + title: '文案', + dataIndex: 'copyText', + width: 240, + render: (v: string) => v || '—', + }, + { title: '排序', dataIndex: 'sortOrder', width: 70 }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s: ActivityPosterStatus) => ( + {ACTIVITY_POSTER_STATUS_LABELS[s] || s} + ), + }, + { title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime }, + { + title: '操作', + width: 200, + render: (_, row) => ( + + + + { + await request(`/admin/activity-posters/${row.id}`, { method: 'DELETE' }); + message.success('已删除'); + void reload(); + }} + > + + + + ), + }, + ]; + + const { columns, settingsButton, settingsModal } = useAdminListColumns('activity-posters', baseColumns, { + page, + pageSize, + }); + + return ( +
+ {settingsModal} + 新建活动图} + /> +
{ setFilters(v); setPage(1); }}> + + + + + + + + + + + editForm.setFieldsValue(next)} + /> + + + + + + + + + void selectPoster(id)} + /> + + ); + } + + if (listLoading) { + return
加载活动图…
; + } + + return ( +
+
+ {renderRadioRow(NONE_ID, '无')} + {selectedId === NONE_ID && ( +
+ {qrcodeUrl ? ( +
+ 关联码 +
+ ) : ( +

关联码尚未生成

+ )} + + {isWechatEnv() && ( +

+ 微信内请长按上方图片保存 +

+ )} +
+ )} +
+ + {items.map((item) => ( +
+ {renderRadioRow(item.id, item.title)} + {selectedId === item.id && ( +
+
+ {item.title} + {previewLoading && !previewUrl && ( +
正在贴入二维码…
+ )} +
+ {item.copyText ? ( +

{item.copyText}

+ ) : ( +

暂无推广文案

+ )} +
+ + +
+ {previewUrl && isWechatEnv() && ( +

+ 微信内请长按上方图片保存 +

+ )} +
+ )} +
+ ))} +
+ ); +} diff --git a/apps/h5-partner/src/lib/activity-posters.ts b/apps/h5-partner/src/lib/activity-posters.ts new file mode 100644 index 0000000..31f555a --- /dev/null +++ b/apps/h5-partner/src/lib/activity-posters.ts @@ -0,0 +1,74 @@ +import type { ActivityPosterItem, ActivityPosterSelection } from '@dukang/shared-types'; +import { request } from './api'; + +export async function listActivityPosters() { + const data = await request('PARTNER_H5', '/partner/activity-posters', { + silent: true, + }); + return Array.isArray(data) ? data : []; +} + +export async function fetchAssocQrcodeImage(): Promise { + 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 { + 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('PARTNER_H5', '/partner/activity-posters/selection', { + silent: true, + }); +} + +export async function saveActivityPosterSelection(posterId: string | null) { + return request('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); + } +} diff --git a/apps/h5-partner/src/pages/ActivityPostersPage.tsx b/apps/h5-partner/src/pages/ActivityPostersPage.tsx new file mode 100644 index 0000000..8fc6eff --- /dev/null +++ b/apps/h5-partner/src/pages/ActivityPostersPage.tsx @@ -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 ( +
+ navigate(-1)} /> +
+ +
+
+ ); +} diff --git a/apps/h5-partner/src/pages/CenterPage.tsx b/apps/h5-partner/src/pages/CenterPage.tsx index 2c5f672..a2067ae 100644 --- a/apps/h5-partner/src/pages/CenterPage.tsx +++ b/apps/h5-partner/src/pages/CenterPage.tsx @@ -219,6 +219,15 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag chevron_right
+ +
+
+ image +
+ 活动图 +
+ chevron_right +
diff --git a/apps/h5-partner/src/pages/UsersManagePage.tsx b/apps/h5-partner/src/pages/UsersManagePage.tsx index 2efe6de..f893893 100644 --- a/apps/h5-partner/src/pages/UsersManagePage.tsx +++ b/apps/h5-partner/src/pages/UsersManagePage.tsx @@ -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(null); const [previewUrl, setPreviewUrl] = useState(null); + const [heroUrl, setHeroUrl] = useState(null); + const [heroLoading, setHeroLoading] = useState(false); const [items, setItems] = useState([]); 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('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() {

用户扫码后首次锁定,后续购酒计入关联订单

- {summary?.qrcodeUrl ? ( + {summary?.activityPosterId && (previewUrl || heroUrl) ? ( 活动图 + ) : summary?.activityPosterId && heroLoading ? ( +

正在生成活动图…

+ ) : previewUrl || summary?.qrcodeUrl ? ( + 关联码 @@ -165,14 +200,26 @@ export default function UsersManagePage() {

已关联 {summary?.userCount ?? total} 人

- {previewUrl && isWechatEnv() && (

微信内请长按上方图片保存

)} + +
+
+
+ image +
+ 活动图 +
+ chevron_right +
+ +

已关联用户

search diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index 68898b2..914374a 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -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; +} diff --git a/docs/杜康好客-v3-PRD.md b/docs/杜康好客-v3-PRD.md index 41f8e0a..12e122f 100644 --- a/docs/杜康好客-v3-PRD.md +++ b/docs/杜康好客-v3-PRD.md @@ -1,7 +1,7 @@ # 杜康好客 · V3.0 PRD > **v3.0**(2026-07-10)· 3.x 产品事实源 · 冲突时 **V3 > V2** -> **4.0 起**(关联码 / 订单佣金归属 / 合伙人账单明细)见 [`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。 +> **4.0 起**(关联码 / 订单佣金归属 / 合伙人账单明细 / 活动图)见 [`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。 > 实现:[`v3编码手册`](./杜康好客-v3编码手册.md) · 审计:[`v3-现状对照`](./杜康好客-v3-现状对照.md) ## 0. 说明 diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index d4a4b92..9da39d3 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -1,7 +1,7 @@ # 杜康好客 · V3 编码手册(交付业务版) > **事实源**:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · **审计**:[`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) -> **佣金归属 / 关联码 / 合伙人账单明细(v4.0.1)**:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。 +> **佣金归属 / 关联码 / 合伙人账单明细(v4.0.1)· 活动图(v4.0.2)**:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。 > V2/preV1 **非需求依据**。总部交付 = **`apps/admin-web`**(非 H5)。 ## 1. 交付目标(六条) @@ -56,6 +56,8 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd **HQ 门店账单打款凭证**:确认打款 / 提现通过可填 `paymentRef`,并可上传照片(`paymentProofUrls`,OSS `PAYMENT_PROOF`,最多 9 张)。详情与 T+1 导出展示。 +**活动图(v4.0.2)**:规则见 v4-PRD §6。表 `activity_poster`;HQ `GET/POST/PUT/DELETE /admin/activity-posters`(权限 `activity_posters`);合伙人 `GET /partner/activity-posters` · `GET/PUT /partner/activity-posters/selection`(写入 `partner_account.activity_poster_id`)· `GET /partner/activity-posters/:id/image`(合成本人关联码)。`GET /partner/assoc` 返回 `activityPosterId`。码栏百分比相对图宽。子账号无入口。 + **合伙人关联与订单佣金(v4.0.1)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `GET /partner/assoc` · `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。 ## 5. 验收用例(必过) diff --git a/docs/杜康好客-v4-PRD.md b/docs/杜康好客-v4-PRD.md index f854e1d..e1ecc9b 100644 --- a/docs/杜康好客-v4-PRD.md +++ b/docs/杜康好客-v4-PRD.md @@ -1,14 +1,15 @@ # 杜康好客 · V4 PRD > **v4.0**(2026-08-29)· 关联码与分佣事实源 -> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(仅本主题:订单佣金归属、关联码、合伙人账单明细)。 -> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md) +> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图)。 +> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md) ## 0. 版本 | 版 | 日期 | 要点 | 开发文档 | |----|------|------|----------| | 4.0.1 | 08-29 / 08-30 | 合伙人关联码;订单佣金只认关联;账单酒单/核销分列;去掉区县酒单佣金;HQ 关联筛选与快链;合伙人 H5 用户管理与首页统计 | [`v4.0.1`](./杜康好客-v4.0.1-开发文档.md) | +| 4.0.2 | 08-30 | HQ 活动图模板(底图 + 方形码栏 + 文案);合伙人下载合成关联码;所选图写入库并作为用户管理主图 | [`v4.0.2`](./杜康好客-v4.0.2-开发文档.md) | ## 1. 锚点(沿用 V3,佣金归属改写) @@ -63,6 +64,17 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照则全城已付单 × 当前费率」。 -## 6. 不做 +## 6. 活动图 -改推广码体系;改核销归属;子账号自己的码;回刷已打款账单;区县佣金双轨。 +- 一套活动图 = 底图 + 方形码栏(相对坐标)+ 标题 + 推广文案 + 排序 + 上架状态。文案由 HQ 手填,不自动生成。 +- 码栏用相对百分比存储,与底图像素无关:`qrXPct` / `qrYPct` 为左上角,`qrSizePct` 为边长(相对**图宽**,保证正方形)。HQ 上传后在预览图上拖拽定位、拉角改尺寸。 +- 合入的码固定为 **主合伙人关联码**(v4.0.1,`getwxacodeunlimit`,scene=`pa_{partnerId}`)。不复用推广码。 +- 合伙人 H5(仅主账号)可浏览已上架活动图、一键复制文案、下载合成图。下载时服务端把本人关联码 PNG 贴进码栏后返回整图。 +- 列表第一项「无」= 只用关联码。单选立即写入 `partner_account.activity_poster_id`(空=无);用户管理主图按该选择展示,下次登录仍有效。下架/删除后回退为关联码。 +- 微信内下载失败则预览 + 长按保存(与关联码下载一致)。 +- 合伙人只看 `ACTIVE`;下架后列表不再出现。无关联码则不可下载并明确报错。 +- HQ 持权限 `activity_posters`(默认超管 + 运营)可增删改、上下架。 + +## 7. 不做 + +改推广码体系;改核销归属;子账号自己的码;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。 diff --git a/docs/杜康好客-v4-现状对照.md b/docs/杜康好客-v4-现状对照.md index 662645a..2ca152e 100644 --- a/docs/杜康好客-v4-现状对照.md +++ b/docs/杜康好客-v4-现状对照.md @@ -3,22 +3,25 @@ > 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) > V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。 -## 0. 总览(2026-08-29) +## 0. 总览(2026-08-30) | 维度 | 结论 | |------|------| -| 版本线 | **v4.0.1** 关联码 + 分佣账单 | +| 版本线 | **v4.0.2** 活动图模板 + 关联码合成 | | 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 | | 账单 | 酒订单 / 核销订单分列 | +| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;用户管理主图跟选择走 | ## 1. 版本交付 | 版本 | 文档 | 状态 | |------|------|------| | 4.0.1 | [`关联码与分佣账单`](./杜康好客-v4.0.1-开发文档.md) | ✅ 已实现 | +| 4.0.2 | [`活动图模板与关联码合成`](./杜康好客-v4.0.2-开发文档.md) | ✅ 已实现 | | 日期 | 说明 | |------|------| | 2026-08-29 | v4.0.1:合伙人关联码、订单佣金只认关联、账单两段明细、去掉区县酒单佣金、HQ 改费率修复 | | 2026-08-30 | HQ 订单/用户详情展示关联合伙人;改绑权限 `users_partner_assoc`;用户/订单筛选关联合伙人;开城关联用户快链 | | 2026-08-30 | 合伙人 H5:首页关联用户/关联用户订单统计;用户管理 Tab(关联码+列表+备注);`partner_user_note` 与 HQ `hqRemark` 隔离 | +| 2026-08-30 | v4.0.2:HQ 活动图(底图 + 拖拽码栏 + 文案);合伙人复制文案、下载合成关联码;选择写入库,用户管理主图下次登录仍显示 | diff --git a/docs/杜康好客-v4.0.2-开发文档.md b/docs/杜康好客-v4.0.2-开发文档.md new file mode 100644 index 0000000..c87c852 --- /dev/null +++ b/docs/杜康好客-v4.0.2-开发文档.md @@ -0,0 +1,49 @@ +# 杜康好客 · v4.0.2 开发文档 + +> **2026-08-30** · ops / common / store / admin-web / h5-partner +> **主题**:HQ 活动图模板(底图 + 方形码栏 + 文案);合伙人下载时合成本人关联码;所选图写入库并作为用户管理主图 + +需求事实源:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) §6 + +--- + +## 1. 版本目标 + +| # | 任务 | 类型 | 交付 | +|---|------|------|------| +| 1 | HQ 活动图管理 | 需求 | 上传底图、拖拽方形码栏、手填文案、排序、上下架、删除 | +| 2 | 合伙人下载 | 需求 | 主账号列表已上架图;复制文案;服务端把关联码贴进码栏后返回 PNG | +| 3 | 微信保存 | 需求 | 下载失败则预览 + 长按保存(复用关联码下载) | +| 4 | 选择持久化 | 需求 | 单选立即写入 `partner_account.activity_poster_id`;用户管理主图跟选择走,下次登录仍显示 | + +**不做**:AI 出图/出文案;子账号码;C 端/门店端;预生成每人缓存图。 + +--- + +## 2. 规则 + +见 v4-PRD §6。码栏百分比相对图宽;合入码 = 主合伙人关联码。列表第一项「无」= 只用关联码。下架/删除后回退为关联码。 + +--- + +## 3. 变更面 + +- 表 [`activity_poster`](../server/dukang-api/prisma/schema.prisma);SQL [`migrate-activity-poster-v402.sql`](../server/dukang-api/prisma/migrate-activity-poster-v402.sql) · [`migrate-partner-activity-poster-pref-v402.sql`](../server/dukang-api/prisma/migrate-partner-activity-poster-pref-v402.sql) +- `ResourceBizType` 增 `ACTIVITY_POSTER` +- HQ:`/admin/activity-posters` CRUD + 状态;权限 `activity_posters`(超管全量;运营默认含此项,存量 OPS 角色需执行 SQL 或在权限分配中勾选) +- 合伙人:`GET /partner/activity-posters` · `GET/PUT /partner/activity-posters/selection` · `GET /partner/activity-posters/:id/image` +- `partner_account.activity_poster_id` 记所选活动图;`GET /partner/assoc` 返回 `activityPosterId` +- admin-web:`/activity-posters` 列表 + 拖拽码栏编辑器 +- h5-partner:中心 / 用户管理入口 → `/center/activity-posters`;用户管理主图按选择展示合成图或关联码 + +--- + +## 4. 验收 + +- HQ 上传后可拖出方形栏并保存;改位置/尺寸后合伙人下载落点一致 +- 文案保存后合伙人可一键复制 +- 下架图不出现在合伙人列表 +- 合成图为本人关联码;无码时明确报错 +- 浏览器可下载 PNG;微信内可长按保存 +- 子账号无入口 +- 合伙人单选活动图后立即写入 `partner_account.activity_poster_id`;用户管理主图跟选择走,重新登录仍显示 diff --git a/packages/shared-types/src/activity-poster.test.ts b/packages/shared-types/src/activity-poster.test.ts new file mode 100644 index 0000000..3fb0e70 --- /dev/null +++ b/packages/shared-types/src/activity-poster.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { activityPosterQrSlotPx } from './activity-poster'; + +describe('activityPosterQrSlotPx', () => { + it('uses width for square size', () => { + expect(activityPosterQrSlotPx(1000, 2000, 10, 20, 18)).toEqual({ + left: 100, + top: 400, + size: 180, + }); + }); + + it('clamps slot inside the canvas', () => { + expect(activityPosterQrSlotPx(100, 80, 90, 90, 30)).toEqual({ + left: 70, + top: 50, + size: 30, + }); + }); +}); diff --git a/packages/shared-types/src/activity-poster.ts b/packages/shared-types/src/activity-poster.ts new file mode 100644 index 0000000..da3bddf --- /dev/null +++ b/packages/shared-types/src/activity-poster.ts @@ -0,0 +1,72 @@ +export const ACTIVITY_POSTER_STATUSES = ['ACTIVE', 'DISABLED'] as const; +export type ActivityPosterStatus = (typeof ACTIVITY_POSTER_STATUSES)[number]; + +export const ACTIVITY_POSTER_STATUS_LABELS: Record = { + ACTIVE: '上架', + DISABLED: '下架', +}; + +export const DEFAULT_ACTIVITY_POSTER_QR_SLOT = { + qrXPct: 78, + qrYPct: 78, + qrSizePct: 18, +} as const; + +export type ActivityPosterQrSlot = { + qrXPct: number; + qrYPct: number; + qrSizePct: number; +}; + +export type ActivityPosterItem = { + id: string; + title: string; + copyText: string; + imageUrl: string; + qrXPct: number; + qrYPct: number; + qrSizePct: number; + sortOrder: number; + status: ActivityPosterStatus; + createdAt: string; + updatedAt: string; +}; + +export type ActivityPosterUpsertRequest = { + title: string; + copyText?: string; + imageUrl: string; + qrXPct: number; + qrYPct: number; + qrSizePct: number; + sortOrder?: number; + status?: ActivityPosterStatus; +}; + +export type ActivityPosterStatusRequest = { + status: ActivityPosterStatus; +}; + +export type ActivityPosterSelection = { + posterId: string | null; +}; + +export type ActivityPosterSelectionRequest = { + posterId?: string | null; +}; + +/** 码栏百分比 → 像素。size 相对图宽,保证正方形;落点夹在画布内。 */ +export function activityPosterQrSlotPx( + width: number, + height: number, + qrXPct: number, + qrYPct: number, + qrSizePct: number, +): { left: number; top: number; size: number } { + const w = Math.max(1, Math.round(width)); + const h = Math.max(1, Math.round(height)); + const size = Math.max(1, Math.min(w, Math.round((qrSizePct / 100) * w))); + const left = Math.max(0, Math.min(w - size, Math.round((qrXPct / 100) * w))); + const top = Math.max(0, Math.min(h - size, Math.round((qrYPct / 100) * h))); + return { left, top, size }; +} diff --git a/packages/shared-types/src/hq-list-columns.ts b/packages/shared-types/src/hq-list-columns.ts index 8ce45ae..a44d7cc 100644 --- a/packages/shared-types/src/hq-list-columns.ts +++ b/packages/shared-types/src/hq-list-columns.ts @@ -8,6 +8,7 @@ export const HQ_LIST_COLUMN_KEYS = [ 'orders', 'promo-codes', 'promo-code-users', + 'activity-posters', 'wecom-bots', 'wecom-pushes', 'llm-configs', diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts index 1d984d2..1473abf 100644 --- a/packages/shared-types/src/hq-permissions.ts +++ b/packages/shared-types/src/hq-permissions.ts @@ -6,6 +6,7 @@ export const HQ_PERMISSION_CATALOG = [ { key: 'products', label: '商品管理', group: '业务' }, { key: 'orders', label: '订单管理', group: '业务' }, { key: 'promo_codes', label: '推广码', group: '业务' }, + { key: 'activity_posters', label: '活动图', group: '业务' }, { key: 'stores', label: '门店列表', group: '业务' }, { key: 'store_audits', label: '门店审核', group: '业务' }, { key: 'store_ratings', label: '门店评价', group: '业务' }, @@ -178,6 +179,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record=18.18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@ioredis/commands@1.10.0': resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} @@ -6072,6 +6231,10 @@ packages: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} engines: {node: '>=8'} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -8007,6 +8170,11 @@ snapshots: '@dual-bundle/import-meta-resolve@4.2.1': {} + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emotion/hash@0.8.0': {} '@emotion/unitless@0.7.5': {} @@ -8198,6 +8366,102 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@ioredis/commands@1.10.0': {} '@ioredis/commands@1.5.1': {} @@ -10685,8 +10949,7 @@ snapshots: destroy@1.2.0: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} dfa@1.2.0: {} @@ -13367,6 +13630,37 @@ snapshots: dependencies: kind-of: 6.0.3 + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c39c378..4b2683c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,3 +16,4 @@ allowBuilds: esbuild: true msgpackr-extract: true prisma: true + sharp: true diff --git a/server/dukang-api/AGENTS.md b/server/dukang-api/AGENTS.md index a6214a6..00d094f 100644 --- a/server/dukang-api/AGENTS.md +++ b/server/dukang-api/AGENTS.md @@ -17,7 +17,7 @@ | **benefit** | BenefitCoupon | jacy-dukang | | **redeem** | RedeemRecord, StoreRating | 刘景尧 | | **settlement** | StorePayout, PartnerBill | jacy-dukang | -| **ops** | 只读聚合 | jacy-dukang | +| **ops** | 只读聚合、ActivityPoster | jacy-dukang | | **analytics** | LogUserAnalytics | jacy-dukang | | **common** | CommonResource, CommonEvent, CommonTicket | jacy-dukang | | **integrations** | 无表 | jacy-dukang | diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index 231a858..2d86d8b 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -54,7 +54,8 @@ "pdfkit": "^0.19.1", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "sharp": "^0.34.5" }, "devDependencies": { "@nestjs/cli": "^10.4.0", diff --git a/server/dukang-api/prisma/migrate-activity-poster-v402.sql b/server/dukang-api/prisma/migrate-activity-poster-v402.sql new file mode 100644 index 0000000..6e801dd --- /dev/null +++ b/server/dukang-api/prisma/migrate-activity-poster-v402.sql @@ -0,0 +1,37 @@ +-- v4.0.2:活动图模板(底图 + 方形码栏 + 文案) + +ALTER TABLE `common_resource` + MODIFY COLUMN `biz_type` ENUM( + 'COVER', + 'ENV', + 'CONTRACT', + 'CAROUSEL', + 'DETAIL', + 'AVATAR', + 'QRCODE', + 'SIGN_PHOTO', + 'VIDEO', + 'REDEEM_PENDING_PHOTO', + 'ACTIVITY_POSTER' + ) NOT NULL; + +CREATE TABLE IF NOT EXISTS `activity_poster` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(128) NOT NULL, + `copy_text` TEXT NOT NULL, + `image_url` VARCHAR(512) NOT NULL, + `qr_x_pct` DECIMAL(5,2) NOT NULL, + `qr_y_pct` DECIMAL(5,2) NOT NULL, + `qr_size_pct` DECIMAL(5,2) NOT NULL, + `sort_order` INT NOT NULL DEFAULT 0, + `status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_activity_poster_status_sort` (`status`, `sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HQ 活动图模板'; + +INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`) +SELECT 'OPS', 'activity_posters' +FROM DUAL +WHERE EXISTS (SELECT 1 FROM `hq_role_permission` WHERE `admin_role` = 'OPS'); diff --git a/server/dukang-api/prisma/migrate-partner-activity-poster-pref-v402.sql b/server/dukang-api/prisma/migrate-partner-activity-poster-pref-v402.sql new file mode 100644 index 0000000..94a8e42 --- /dev/null +++ b/server/dukang-api/prisma/migrate-partner-activity-poster-pref-v402.sql @@ -0,0 +1,9 @@ +-- v4.0.2:主合伙人记住所选活动图(空=仅二维码) + +ALTER TABLE `partner_account` + ADD COLUMN `activity_poster_id` BIGINT UNSIGNED DEFAULT NULL AFTER `assoc_qrcode_resource_id`, + ADD KEY `idx_partner_account_activity_poster` (`activity_poster_id`); + +ALTER TABLE `partner_account` + ADD CONSTRAINT `fk_partner_account_activity_poster` + FOREIGN KEY (`activity_poster_id`) REFERENCES `activity_poster`(`id`) ON DELETE SET NULL; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index e55e4f5..53bfdd5 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -44,6 +44,7 @@ enum ResourceBizType { SIGN_PHOTO VIDEO REDEEM_PENDING_PHOTO + ACTIVITY_POSTER } enum RedeemPendingStatus { @@ -1204,6 +1205,7 @@ model PartnerAccount { managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt assocQrcodeId String? @unique @map("assoc_qrcode_id") @db.VarChar(64) assocQrcodeResourceId BigInt? @map("assoc_qrcode_resource_id") @db.UnsignedBigInt + activityPosterId BigInt? @map("activity_poster_id") @db.UnsignedBigInt /// 测试合伙人账号 isTest Boolean @default(false) @map("is_test") createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) @@ -1219,6 +1221,7 @@ model PartnerAccount { assocUsers User[] @relation("UserPartnerAssoc") userNotes PartnerUserNote[] assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], references: [id], onDelete: SetNull) + activityPoster ActivityPoster? @relation(fields: [activityPosterId], references: [id], onDelete: SetNull) @@index([cityId, scopeType]) @@index([cityId, isPrimary]) @@ -1226,6 +1229,7 @@ model PartnerAccount { @@index([wxOpenId]) @@index([contactPhone]) @@index([isTest]) + @@index([activityPosterId]) @@map("partner_account") } @@ -1246,6 +1250,26 @@ model PartnerUserNote { @@map("partner_user_note") } +/// HQ 活动图模板:底图 + 方形码栏(相对百分比)+ 文案 +model ActivityPoster { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + title String @db.VarChar(128) + copyText String @map("copy_text") @db.Text + imageUrl String @map("image_url") @db.VarChar(512) + qrXPct Decimal @map("qr_x_pct") @db.Decimal(5, 2) + qrYPct Decimal @map("qr_y_pct") @db.Decimal(5, 2) + qrSizePct Decimal @map("qr_size_pct") @db.Decimal(5, 2) + sortOrder Int @default(0) @map("sort_order") + status String @default("ACTIVE") @db.VarChar(16) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + selectedByPartners PartnerAccount[] + + @@index([status, sortOrder]) + @@map("activity_poster") +} + model PartnerBill { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt billNo String @unique @map("bill_no") @db.VarChar(32) diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 4ac9700..e1e1b78 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -122,6 +122,10 @@ export const HqOperationAction = { DEV_PLAN_DISPATCH_TEST: 'DEV_PLAN_DISPATCH_TEST', SUPPORT_TICKET_REVIEW: 'SUPPORT_TICKET_REVIEW', SUPPORT_TICKET_BATCH_REVIEW: 'SUPPORT_TICKET_BATCH_REVIEW', + ACTIVITY_POSTER_CREATE: 'ACTIVITY_POSTER_CREATE', + ACTIVITY_POSTER_UPDATE: 'ACTIVITY_POSTER_UPDATE', + ACTIVITY_POSTER_UPDATE_STATUS: 'ACTIVITY_POSTER_UPDATE_STATUS', + ACTIVITY_POSTER_DELETE: 'ACTIVITY_POSTER_DELETE', } as const; export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction]; @@ -249,6 +253,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.DEV_PLAN_DISPATCH_TEST]: '测试任务派发助手', [HqOperationAction.SUPPORT_TICKET_REVIEW]: '技术支持工单审批', [HqOperationAction.SUPPORT_TICKET_BATCH_REVIEW]: '技术支持批量审批', + [HqOperationAction.ACTIVITY_POSTER_CREATE]: '新增活动图', + [HqOperationAction.ACTIVITY_POSTER_UPDATE]: '编辑活动图', + [HqOperationAction.ACTIVITY_POSTER_UPDATE_STATUS]: '活动图上下架', + [HqOperationAction.ACTIVITY_POSTER_DELETE]: '删除活动图', STORE_PAYOUT: '门店打款确认', }; diff --git a/server/dukang-api/src/modules/ops/activity-poster.service.ts b/server/dukang-api/src/modules/ops/activity-poster.service.ts new file mode 100644 index 0000000..b1db723 --- /dev/null +++ b/server/dukang-api/src/modules/ops/activity-poster.service.ts @@ -0,0 +1,188 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { activityPosterQrSlotPx } from '@dukang/shared-types'; +import sharp from 'sharp'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { PartnerAssocService } from '../store/partner-assoc.service'; +import type { + ActivityPosterQueryDto, + UpdateActivityPosterStatusDto, + UpsertActivityPosterDto, +} from './dto/activity-poster.dto'; + +type PosterRow = { + id: bigint; + title: string; + copyText: string; + imageUrl: string; + qrXPct: Prisma.Decimal; + qrYPct: Prisma.Decimal; + qrSizePct: Prisma.Decimal; + sortOrder: number; + status: string; + createdAt: Date; + updatedAt: Date; +}; + +@Injectable() +export class ActivityPosterService { + constructor( + private readonly prisma: PrismaService, + private readonly partnerAssoc: PartnerAssocService, + ) {} + + async adminList(query: ActivityPosterQueryDto) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where: Prisma.ActivityPosterWhereInput = {}; + if (query.status) where.status = query.status; + + const [items, total] = await Promise.all([ + this.prisma.activityPoster.findMany({ + where, + orderBy: [{ sortOrder: 'asc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.activityPoster.count({ where }), + ]); + + return serializeBigInt({ + items: items.map((row) => this.format(row)), + total, + page, + pageSize, + }); + } + + async adminDetail(id: bigint) { + return serializeBigInt(this.format(await this.require(id))); + } + + async create(dto: UpsertActivityPosterDto) { + const row = await this.prisma.activityPoster.create({ + data: this.toCreateData(dto), + }); + return serializeBigInt(this.format(row)); + } + + async update(id: bigint, dto: UpsertActivityPosterDto) { + await this.require(id); + const row = await this.prisma.activityPoster.update({ + where: { id }, + data: this.toCreateData(dto), + }); + return serializeBigInt(this.format(row)); + } + + async updateStatus(id: bigint, dto: UpdateActivityPosterStatusDto) { + await this.require(id); + const row = await this.prisma.activityPoster.update({ + where: { id }, + data: { status: dto.status }, + }); + return serializeBigInt(this.format(row)); + } + + async remove(id: bigint) { + await this.require(id); + await this.prisma.activityPoster.delete({ where: { id } }); + return { ok: true }; + } + + async listForPartner() { + const items = await this.prisma.activityPoster.findMany({ + where: { status: 'ACTIVE' }, + orderBy: [{ sortOrder: 'asc' }, { id: 'desc' }], + }); + return serializeBigInt(items.map((row) => this.format(row))); + } + + getSelection(partnerAccountId: bigint) { + return this.partnerAssoc.getSelectedActivityPosterId(partnerAccountId).then((posterId) => ({ posterId })); + } + + setSelection(partnerAccountId: bigint, posterId: bigint | null) { + return this.partnerAssoc.setSelectedActivityPoster(partnerAccountId, posterId); + } + + async composeForPartner(partnerAccountId: bigint, posterId: bigint) { + const poster = await this.require(posterId); + if (poster.status !== 'ACTIVE') { + throw new NotFoundException('活动图不存在或已下架'); + } + + let qr: { buffer: Buffer; fileName: string }; + try { + qr = await this.partnerAssoc.getQrcodeBuffer(partnerAccountId); + } catch (e) { + if (e instanceof NotFoundException) { + throw new BadRequestException('关联码尚未生成,无法合成活动图'); + } + throw e; + } + const template = await this.fetchPngLike(poster.imageUrl, '活动图底图下载失败'); + + const buffer = await this.compose(template, qr.buffer, poster); + return { buffer, fileName: `activity-poster-${poster.id}.png` }; + } + + private async compose(template: Buffer, qrPng: Buffer, poster: PosterRow) { + const base = sharp(template); + const meta = await base.metadata(); + if (!meta.width || !meta.height) { + throw new BadRequestException('活动图底图无法读取尺寸'); + } + const { left, top, size } = activityPosterQrSlotPx( + meta.width, + meta.height, + Number(poster.qrXPct), + Number(poster.qrYPct), + Number(poster.qrSizePct), + ); + const qr = await sharp(qrPng).resize(size, size, { fit: 'fill' }).png().toBuffer(); + return base.composite([{ input: qr, left, top }]).png().toBuffer(); + } + + private async fetchPngLike(url: string, failMessage: string) { + const res = await fetch(url); + if (!res.ok) throw new BadRequestException(failMessage); + return Buffer.from(await res.arrayBuffer()); + } + + private async require(id: bigint) { + const row = await this.prisma.activityPoster.findUnique({ where: { id } }); + if (!row) throw new NotFoundException('活动图不存在'); + return row; + } + + private toCreateData(dto: UpsertActivityPosterDto): Prisma.ActivityPosterCreateInput { + return { + title: dto.title.trim(), + copyText: (dto.copyText ?? '').trim(), + imageUrl: dto.imageUrl.trim(), + qrXPct: new Prisma.Decimal(dto.qrXPct.toFixed(2)), + qrYPct: new Prisma.Decimal(dto.qrYPct.toFixed(2)), + qrSizePct: new Prisma.Decimal(dto.qrSizePct.toFixed(2)), + sortOrder: dto.sortOrder ?? 0, + status: dto.status ?? 'ACTIVE', + }; + } + + private format(row: PosterRow) { + return { + id: row.id.toString(), + title: row.title, + copyText: row.copyText, + imageUrl: row.imageUrl, + qrXPct: Number(row.qrXPct), + qrYPct: Number(row.qrYPct), + qrSizePct: Number(row.qrSizePct), + sortOrder: row.sortOrder, + status: row.status, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } +} diff --git a/server/dukang-api/src/modules/ops/admin-activity-posters.controller.ts b/server/dukang-api/src/modules/ops/admin-activity-posters.controller.ts new file mode 100644 index 0000000..b2c3d29 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-activity-posters.controller.ts @@ -0,0 +1,71 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { HqPermissionGuard, RequireHqPermissions } from '../../common/guards/hq-permission.guard'; +import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; +import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; +import { ActivityPosterService } from './activity-poster.service'; +import { + ActivityPosterQueryDto, + UpdateActivityPosterStatusDto, + UpsertActivityPosterDto, +} from './dto/activity-poster.dto'; + +@Controller('admin/activity-posters') +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('activity_posters') +export class AdminActivityPostersController { + constructor(private readonly service: ActivityPosterService) {} + + @Get() + list(@Query() query: ActivityPosterQueryDto) { + return this.service.adminList(query); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.service.adminDetail(BigInt(id)); + } + + @Post() + @HqOperation({ + action: HqOperationAction.ACTIVITY_POSTER_CREATE, + refType: 'ACTIVITY_POSTER', + refIdField: 'id', + includeBody: true, + }) + create(@Body() dto: UpsertActivityPosterDto) { + return this.service.create(dto); + } + + @Put(':id') + @HqOperation({ + action: HqOperationAction.ACTIVITY_POSTER_UPDATE, + refType: 'ACTIVITY_POSTER', + refIdParam: 'id', + includeBody: true, + }) + update(@Param('id') id: string, @Body() dto: UpsertActivityPosterDto) { + return this.service.update(BigInt(id), dto); + } + + @Put(':id/status') + @HqOperation({ + action: HqOperationAction.ACTIVITY_POSTER_UPDATE_STATUS, + refType: 'ACTIVITY_POSTER', + refIdParam: 'id', + includeBody: true, + }) + updateStatus(@Param('id') id: string, @Body() dto: UpdateActivityPosterStatusDto) { + return this.service.updateStatus(BigInt(id), dto); + } + + @Delete(':id') + @HqOperation({ + action: HqOperationAction.ACTIVITY_POSTER_DELETE, + refType: 'ACTIVITY_POSTER', + refIdParam: 'id', + }) + remove(@Param('id') id: string) { + return this.service.remove(BigInt(id)); + } +} diff --git a/server/dukang-api/src/modules/ops/dto/activity-poster.dto.ts b/server/dukang-api/src/modules/ops/dto/activity-poster.dto.ts new file mode 100644 index 0000000..cfd2683 --- /dev/null +++ b/server/dukang-api/src/modules/ops/dto/activity-poster.dto.ts @@ -0,0 +1,78 @@ +import { Type } from 'class-transformer'; +import { + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; +import { ACTIVITY_POSTER_STATUSES } from '@dukang/shared-types'; +import { PaginationQueryDto } from './admin-query.dto'; + +export class ActivityPosterQueryDto extends PaginationQueryDto { + @IsOptional() + @IsIn([...ACTIVITY_POSTER_STATUSES]) + status?: string; +} + +export class UpsertActivityPosterDto { + @IsString() + @MinLength(1) + @MaxLength(128) + title!: string; + + @IsOptional() + @IsString() + @MaxLength(4000) + copyText?: string; + + @IsString() + @MinLength(1) + @MaxLength(512) + imageUrl!: string; + + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + qrXPct!: number; + + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + qrYPct!: number; + + @Type(() => Number) + @IsNumber() + @Min(5) + @Max(50) + qrSizePct!: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + sortOrder?: number; + + @IsOptional() + @IsIn([...ACTIVITY_POSTER_STATUSES]) + status?: string; +} + +export class UpdateActivityPosterStatusDto { + @IsIn([...ACTIVITY_POSTER_STATUSES]) + status!: string; +} + +export class ActivityPosterSelectionDto { + @IsOptional() + @ValidateIf((_, value) => value != null) + @IsString() + posterId?: string | null; +} diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 8e312c4..1565b18 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -82,6 +82,9 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide import { AdminDomainEventsController } from './admin-domain-events.controller'; import { AdminDomainEventsService } from './admin-domain-events.service'; import { AdminTestWhitelistController } from './admin-test-whitelist.controller'; +import { ActivityPosterService } from './activity-poster.service'; +import { AdminActivityPostersController } from './admin-activity-posters.controller'; +import { PartnerActivityPostersController } from './partner-activity-posters.controller'; @Module({ imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule, DevPlanModule], @@ -132,6 +135,8 @@ import { AdminTestWhitelistController } from './admin-test-whitelist.controller' AdminDevPlanController, AdminFulfillmentProvidersController, AdminTestWhitelistController, + AdminActivityPostersController, + PartnerActivityPostersController, ], providers: [ AdminDashboardService, @@ -165,6 +170,7 @@ import { AdminTestWhitelistController } from './admin-test-whitelist.controller' AdminLlmConfigsService, AdminKnowledgeBasesService, SuperAdminGuard, + ActivityPosterService, ], exports: [CityScopeModule], }) diff --git a/server/dukang-api/src/modules/ops/partner-activity-posters.controller.ts b/server/dukang-api/src/modules/ops/partner-activity-posters.controller.ts new file mode 100644 index 0000000..703fe1a --- /dev/null +++ b/server/dukang-api/src/modules/ops/partner-activity-posters.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Get, Param, Put, Res, UseGuards } from '@nestjs/common'; +import type { Response } from 'express'; +import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; +import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { ActivityPosterService } from './activity-poster.service'; +import { ActivityPosterSelectionDto } from './dto/activity-poster.dto'; + +@Controller('partner/activity-posters') +@UseGuards(JwtAuthGuard, PartnerPrimaryGuard) +export class PartnerActivityPostersController { + constructor(private readonly service: ActivityPosterService) {} + + @Get() + list() { + return this.service.listForPartner(); + } + + @Get('selection') + getSelection(@CurrentUser() user: AuthUser) { + return this.service.getSelection(user.actorId); + } + + @Put('selection') + setSelection(@CurrentUser() user: AuthUser, @Body() dto: ActivityPosterSelectionDto) { + const raw = dto.posterId?.trim(); + const posterId = raw && /^\d+$/.test(raw) ? BigInt(raw) : null; + return this.service.setSelection(user.actorId, posterId); + } + + @Get(':id/image') + async image(@CurrentUser() user: AuthUser, @Param('id') id: string, @Res() res: Response) { + const { buffer, fileName } = await this.service.composeForPartner(user.actorId, BigInt(id)); + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + res.send(buffer); + } +} diff --git a/server/dukang-api/src/modules/store/partner-assoc.service.ts b/server/dukang-api/src/modules/store/partner-assoc.service.ts index 9e0fa17..4fc1f57 100644 --- a/server/dukang-api/src/modules/store/partner-assoc.service.ts +++ b/server/dukang-api/src/modules/store/partner-assoc.service.ts @@ -136,15 +136,51 @@ export class PartnerAssocService { const userCount = await this.prisma.user.count({ where: { assocPartnerAccountId: primary.id }, }); + const selectedPoster = primary.activityPosterId + ? await this.prisma.activityPoster.findUnique({ + where: { id: primary.activityPosterId }, + select: { id: true, status: true }, + }) + : null; + const activityPosterId = + selectedPoster?.status === 'ACTIVE' ? selectedPoster.id.toString() : null; + return { partnerId: primary.id.toString(), qrcodeUrl: ensured.qrcodeUrl, userCount, companyName: primary.companyName, name: primary.name, + activityPosterId, }; } + async getSelectedActivityPosterId(partnerAccountId: bigint): Promise { + const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + if (!primary.activityPosterId) return null; + const poster = await this.prisma.activityPoster.findUnique({ + where: { id: primary.activityPosterId }, + select: { id: true, status: true }, + }); + return poster?.status === 'ACTIVE' ? poster.id.toString() : null; + } + + async setSelectedActivityPoster(partnerAccountId: bigint, posterId: bigint | null) { + const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + if (posterId) { + const poster = await this.prisma.activityPoster.findFirst({ + where: { id: posterId, status: 'ACTIVE' }, + select: { id: true }, + }); + if (!poster) throw new BadRequestException('活动图不存在或已下架'); + } + await this.prisma.partnerAccount.update({ + where: { id: primary.id }, + data: { activityPosterId: posterId }, + }); + return { posterId: posterId?.toString() ?? null }; + } + async getStats(partnerAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const { todayStart, monthStart } = dayBounds();