diff --git a/apps/admin-web/src/components/AdminStorePackagesSection.tsx b/apps/admin-web/src/components/AdminStorePackagesSection.tsx index dd1166a..0fb8465 100644 --- a/apps/admin-web/src/components/AdminStorePackagesSection.tsx +++ b/apps/admin-web/src/components/AdminStorePackagesSection.tsx @@ -1,14 +1,14 @@ import { useEffect, useState } from 'react'; -import { Alert, Button, Form, Input, InputNumber, Space, Typography, message } from 'antd'; +import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd'; import { DownOutlined, UpOutlined } from '@ant-design/icons'; import type { StorePackageItemDto } from '@dukang/shared-types'; import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types'; import { request } from '../lib/api'; - +import OssUpload from './OssUpload'; type PackageRow = StorePackageItemDto; function emptyRow(index = 0): PackageRow { - return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', sortOrder: index }; + return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', imageUrl: '', sortOrder: index }; } export default function AdminStorePackagesSection({ storeId }: { storeId: string }) { @@ -41,18 +41,33 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string } function removeAt(index: number) { - setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }))); - setCollapsed((prev) => { - const next: Record = {}; - Object.entries(prev).forEach(([k, v]) => { - const i = Number(k); - if (i < index) next[i] = v; - else if (i > index) next[i - 1] = v; + const run = () => { + setItems((prev) => { + const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })); + return next.length ? next : []; }); - return next; - }); + setCollapsed((prev) => { + const next: Record = {}; + Object.entries(prev).forEach(([k, v]) => { + const i = Number(k); + if (i < index) next[i] = v; + else if (i > index) next[i - 1] = v; + }); + return next; + }); + }; + if (items.length === 1) { + Modal.confirm({ + title: '清空门店套餐', + content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?', + okText: '确认删除', + cancelText: '取消', + onOk: run, + }); + return; + } + run(); } - function toggleCollapse(index: number) { setCollapsed((prev) => ({ ...prev, [index]: !prev[index] })); } @@ -65,6 +80,7 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string dishes: item.dishes.trim(), usableTime: item.usableTime?.trim() || null, otherNotes: item.otherNotes?.trim() || null, + imageUrl: item.imageUrl?.trim() || null, sortOrder: index, })) .filter((item) => item.name || item.dishes || item.price); @@ -106,8 +122,21 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string return 加载套餐中…; } - return ( -
+ if (!items.length) { + return ( + + + + + + ); + } + + return (
- {items.length > 1 ? ( + {items.length > 0 ? ( - ) : null} - + ) : null} {!isCollapsed ? ( <> @@ -186,8 +214,16 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string /> - - + updateAt(index, { imageUrl: url })} + /> + + + updateAt(index, { otherNotes: e.target.value })} diff --git a/apps/admin-web/src/pages/DevPlanTasksPage.tsx b/apps/admin-web/src/pages/DevPlanTasksPage.tsx index 618b472..73115f8 100644 --- a/apps/admin-web/src/pages/DevPlanTasksPage.tsx +++ b/apps/admin-web/src/pages/DevPlanTasksPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Button, Form, @@ -19,6 +19,7 @@ import { type DevPlanTaskDto, type DevPlanTaskStatusDto, type DevPlanTaskTypeDto, + type DevPlanVersionDto, } from '@dukang/shared-types'; import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; @@ -46,11 +47,22 @@ export default function DevPlanTasksPage() { const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [dispatchOpen, setDispatchOpen] = useState(false); + const [batchEditOpen, setBatchEditOpen] = useState(false); + const [batchSaving, setBatchSaving] = useState(false); + const [versions, setVersions] = useState([]); const [editing, setEditing] = useState(null); const [saving, setSaving] = useState(false); const [dispatching, setDispatching] = useState(false); const [form] = Form.useForm(); const [dispatchForm] = Form.useForm<{ supplement?: string }>(); + const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>(); + + useEffect(() => { + if (!batchEditOpen) return; + request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100') + .then((res) => setVersions(res.items ?? [])) + .catch(() => setVersions([])); + }, [batchEditOpen]); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/dev-plan/tasks', @@ -119,6 +131,38 @@ export default function DevPlanTasksPage() { setDispatchOpen(true); } + function openBatchEdit() { + batchForm.setFieldsValue({ status: undefined, versionId: undefined }); + setBatchEditOpen(true); + } + + async function submitBatchEdit() { + const values = await batchForm.validateFields(); + if (!values.status && !values.versionId) { + message.warning('请至少选择状态或关联版本'); + return; + } + setBatchSaving(true); + try { + await request('/admin/dev-plan/tasks/batch-update', { + method: 'POST', + body: JSON.stringify({ + taskIds: selectedRowKeys, + status: values.status || undefined, + versionId: values.versionId || undefined, + }), + }); + message.success('批量更新成功'); + setBatchEditOpen(false); + setSelectedRowKeys([]); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '批量更新失败'); + } finally { + setBatchSaving(false); + } + } + async function submitDispatch() { const values = await dispatchForm.validateFields(); setDispatching(true); @@ -186,6 +230,9 @@ export default function DevPlanTasksPage() { 开发计划 · 任务列表 + @@ -280,6 +327,29 @@ export default function DevPlanTasksPage() { + + setBatchEditOpen(false)} + onOk={() => void submitBatchEdit()} + confirmLoading={batchSaving} + destroyOnClose + > + 已选 {selectedRowKeys.length} 条任务 +
+ + ({ value: v.id, label: v.versionNo }))} + /> + +
+
); } diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx index b207e02..abe64ff 100644 --- a/apps/admin-web/src/pages/ProductsPage.tsx +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -168,10 +168,10 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp <> - 详情页轮播(CAROUSEL) + 详情页轮播(CAROUSEL,单张最大 10MB) - 详情长图(DETAIL,可逐张修改) + 详情长图(DETAIL,可逐张修改,单张最大 10MB) diff --git a/apps/admin-web/src/pages/SupportTicketsPage.tsx b/apps/admin-web/src/pages/SupportTicketsPage.tsx index 1e6a141..0be5414 100644 --- a/apps/admin-web/src/pages/SupportTicketsPage.tsx +++ b/apps/admin-web/src/pages/SupportTicketsPage.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from 'react'; import { Button, Card, + Checkbox, Drawer, Form, + Image, Input, Modal, Radio, @@ -40,6 +42,10 @@ import { import { request, type HqProfile } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; +import OssUpload from '../components/OssUpload'; + +const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom'; +const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程'; const STATUS_COLOR: Record = { PENDING_REVIEW: 'orange', @@ -88,12 +94,26 @@ export default function SupportTicketsPage() { const [batchPreviewOpen, setBatchPreviewOpen] = useState(false); const [batchPreview, setBatchPreview] = useState([]); const [batchConfirming, setBatchConfirming] = useState(false); + const [batchStatusOpen, setBatchStatusOpen] = useState(false); + const [batchStatusSaving, setBatchStatusSaving] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [editSaving, setEditSaving] = useState(false); const [reviewDecision, setReviewDecision] = useState<'APPROVE' | 'REJECT'>('APPROVE'); const [createForm] = Form.useForm(); + const [editForm] = Form.useForm(); const [reviewForm] = Form.useForm<{ decision: 'APPROVE' | 'REJECT'; rejectReason?: string; note?: string }>(); - const [tasksForm] = Form.useForm<{ tasks: Array<{ content: string; type: DevPlanTaskTypeDto }> }>(); + const [tasksForm] = Form.useForm<{ + tasks: Array<{ content: string; type: DevPlanTaskTypeDto }>; + dispatchToWecom?: boolean; + dispatchSupplement?: string; + }>(); const [batchForm] = Form.useForm<{ items: BatchReviewPreviewItem[] }>(); + const [batchStatusForm] = Form.useForm<{ + status: SupportTicketStatusDto; + rejectReason?: string; + note?: string; + }>(); const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; @@ -117,6 +137,7 @@ export default function SupportTicketsPage() { title: values.title.trim(), content: values.content?.trim() || undefined, remark: values.remark?.trim() || undefined, + attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean), }), }); message.success('技术支持工单已创建,等待最高管理员评审'); @@ -166,13 +187,80 @@ export default function SupportTicketsPage() { const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500); tasksForm.setFieldsValue({ tasks: [{ content: summary || detail!.title, type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType) }], + dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false', + dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT, }); setCreateTasksOpen(true); } + function openEdit() { + if (!detail) return; + editForm.setFieldsValue({ + ticketType: detail.ticketType, + title: detail.title, + content: detail.content || '', + remark: detail.remark || '', + attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''], + }); + setEditOpen(true); + } + + async function submitEdit() { + const values = await editForm.validateFields(); + setEditSaving(true); + try { + await request(`/admin/support-tickets/${detail!.id}`, { + method: 'PATCH', + body: JSON.stringify({ + ticketType: values.ticketType, + title: values.title.trim(), + content: values.content?.trim() || undefined, + remark: values.remark?.trim() || undefined, + attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean), + }), + }); + message.success('已保存'); + setEditOpen(false); + await openDetail(String(detail!.id)); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setEditSaving(false); + } + } + + async function submitBatchStatus() { + const values = await batchStatusForm.validateFields(); + setBatchStatusSaving(true); + try { + const res = await request<{ successCount: number; failCount: number }>( + '/admin/support-tickets/batch-update-status', + { + method: 'POST', + body: JSON.stringify({ + ticketIds: selectedRowKeys, + status: values.status, + rejectReason: values.rejectReason?.trim() || undefined, + note: values.note?.trim() || undefined, + }), + }, + ); + message.success(`成功 ${res.successCount} 条,失败 ${res.failCount} 条`); + setBatchStatusOpen(false); + setSelectedRowKeys([]); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '批量更新失败'); + } finally { + setBatchStatusSaving(false); + } + } + async function submitCreateTasks() { const values = await tasksForm.validateFields(); const note = reviewForm.getFieldValue('note') as string | undefined; + localStorage.setItem(DISPATCH_WECOM_STORAGE_KEY, values.dispatchToWecom ? 'true' : 'false'); setActing(true); try { await request(`/admin/support-tickets/${detail!.id}/review`, { @@ -181,9 +269,11 @@ export default function SupportTicketsPage() { decision: 'APPROVE', note: note?.trim() || undefined, tasks: values.tasks.map((t) => ({ content: t.content.trim(), type: t.type })), + dispatchToWecom: !!values.dispatchToWecom, + dispatchSupplement: values.dispatchSupplement?.trim() || undefined, }), }); - message.success('审批通过,已创建开发任务'); + message.success(values.dispatchToWecom ? '审批通过,已创建任务并派发企微' : '审批通过,已创建开发任务'); setCreateTasksOpen(false); setDrawerOpen(false); reload(); @@ -309,9 +399,12 @@ export default function SupportTicketsPage() { if (!detail) return null; if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) { return ( - + + + + ); } if (detail.status === 'DEVELOPING') { @@ -346,9 +439,20 @@ export default function SupportTicketsPage() { {isSuperAdmin ? ( - + <> + + + ) : null} + + )} + + + + + + setBatchStatusOpen(false)} + onOk={() => void submitBatchStatus()} + confirmLoading={batchStatusSaving} + destroyOnClose + > + 已选 {selectedRowKeys.length} 条工单 +
+ + { + fileRefs.current[index] = el; + }} + type="file" + accept="image/*" + hidden + disabled={disabled} + onChange={(e) => void pickPackageImage(index, e.target.files?.[0])} + /> + + +