diff --git a/apps/backend/src/db/migrations/133_order_service_profile_announcement.sql b/apps/backend/src/db/migrations/133_order_service_profile_announcement.sql new file mode 100644 index 00000000..fb05c807 --- /dev/null +++ b/apps/backend/src/db/migrations/133_order_service_profile_announcement.sql @@ -0,0 +1,8 @@ +-- 133_order_service_profile_announcement.sql +-- 订单服务客户页公告:草稿保存公告内容与展示样式,发布时随配置写入版本快照。 + +ALTER TABLE order_service_profiles + ADD COLUMN IF NOT EXISTS announcement_json JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN order_service_profiles.announcement_json IS + '客户页公告:enabled/title/content/fontSize/color/backgroundColor/fontWeight/textAlign'; diff --git a/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts b/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts index b8e98a86..0e27ea20 100644 --- a/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts +++ b/apps/backend/src/repositories/worker-platform/order-service-profile-repo.ts @@ -12,6 +12,7 @@ export type OrderServiceProfileRow = { fields_json: Array> screenshots_json: Record auto_publish_json: Record + announcement_json: Record submit_text: string success_text: string status: string @@ -98,6 +99,7 @@ export async function upsertOrderServiceProfile(input: { fields: Array> screenshots: Record autoPublish: Record + announcement: Record submitText: string successText: string adminUserId: number | null @@ -106,12 +108,12 @@ export async function upsertOrderServiceProfile(input: { ` INSERT INTO order_service_profiles ( category_id, name, title, description, - guide_image_json, fields_json, screenshots_json, auto_publish_json, + guide_image_json, fields_json, screenshots_json, auto_publish_json, announcement_json, submit_text, success_text, status, created_by_admin_user_id ) VALUES ( $1, $2, $3, $4, - $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb, - $9, $10, 'draft', $11 + $5::jsonb, $6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb, + $10, $11, 'draft', $12 ) ON CONFLICT (category_id) DO UPDATE SET name = EXCLUDED.name, @@ -121,6 +123,7 @@ export async function upsertOrderServiceProfile(input: { fields_json = EXCLUDED.fields_json, screenshots_json = EXCLUDED.screenshots_json, auto_publish_json = EXCLUDED.auto_publish_json, + announcement_json = EXCLUDED.announcement_json, submit_text = EXCLUDED.submit_text, success_text = EXCLUDED.success_text, current_version_id = CASE @@ -140,6 +143,7 @@ export async function upsertOrderServiceProfile(input: { JSON.stringify(input.fields), JSON.stringify(input.screenshots), JSON.stringify(input.autoPublish), + JSON.stringify(input.announcement), input.submitText, input.successText, input.adminUserId, diff --git a/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts b/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts index 21555cd8..498d0118 100644 --- a/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/order-service-profile-service.test.ts @@ -77,3 +77,42 @@ test('订单服务配置规范化:未知字段类型降级为文本,空下 assert.equal(normalized.fields.length, 1) assert.equal(normalized.fields[0]?.type, 'text') }) + +test('订单服务页面公告:旧配置默认展示资料填写错误免责声明', () => { + const normalized = config() + assert.deepEqual(normalized.announcement, { + enabled: true, + title: '公告', + content: '由于个人资料填写错误,导致的赠送错误,店铺概不负责!', + fontSize: 14, + color: '#8a4b00', + backgroundColor: '#fff7e6', + fontWeight: 'bold', + textAlign: 'left', + }) +}) + +test('订单服务页面公告:支持自定义样式并拦截非法颜色与越界字号', () => { + const normalized = config({ + announcement: { + enabled: false, + title: '填写前必看', + content: '请确认资料无误后再提交。', + fontSize: 99, + color: 'red;position:fixed', + backgroundColor: '#123456', + fontWeight: 'normal', + textAlign: 'center', + }, + }) + assert.deepEqual(normalized.announcement, { + enabled: false, + title: '填写前必看', + content: '请确认资料无误后再提交。', + fontSize: 24, + color: '#8a4b00', + backgroundColor: '#123456', + fontWeight: 'normal', + textAlign: 'center', + }) +}) diff --git a/apps/backend/src/services/worker-platform/order-service-profile-service.ts b/apps/backend/src/services/worker-platform/order-service-profile-service.ts index a3038c08..d5f8b54e 100644 --- a/apps/backend/src/services/worker-platform/order-service-profile-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-profile-service.ts @@ -47,6 +47,17 @@ export type OrderServiceAutoPublish = { retryMax: number } +export type OrderServiceAnnouncement = { + enabled: boolean + title: string + content: string + fontSize: number + color: string + backgroundColor: string + fontWeight: 'normal' | 'bold' + textAlign: 'left' | 'center' +} + export type OrderServiceProfileConfig = { name: string title: string @@ -55,6 +66,7 @@ export type OrderServiceProfileConfig = { fields: OrderServiceField[] screenshots: OrderServiceScreenshots autoPublish: OrderServiceAutoPublish + announcement: OrderServiceAnnouncement submitText: string successText: string } @@ -76,6 +88,17 @@ const FIELD_TYPES = new Set([ 'date', ]) +export const DEFAULT_ORDER_SERVICE_ANNOUNCEMENT: OrderServiceAnnouncement = { + enabled: true, + title: '公告', + content: '由于个人资料填写错误,导致的赠送错误,店铺概不负责!', + fontSize: 14, + color: '#8a4b00', + backgroundColor: '#fff7e6', + fontWeight: 'bold', + textAlign: 'left', +} + export async function listAdminOrderServiceProfiles() { const [categories, profiles] = await Promise.all([ listAllWorkCategories(), @@ -115,6 +138,7 @@ export async function saveAdminOrderServiceProfile(payload: JsonObject = {}) { const fields = normalizeFields(payload.fields ?? payload.fields_json) const screenshots = normalizeScreenshots(payload.screenshots ?? payload.screenshots_json) const autoPublish = normalizeAutoPublish(payload.autoPublish ?? payload.auto_publish_json) + const announcement = normalizeAnnouncement(payload.announcement ?? payload.announcement_json) const guideImage = normalizeGuideImage(payload.guideImage ?? payload.guide_image_json) const adminUserId = Number(payload.adminUserId ?? payload.admin_user_id ?? 0) || null @@ -127,6 +151,7 @@ export async function saveAdminOrderServiceProfile(payload: JsonObject = {}) { fields, screenshots, autoPublish, + announcement, submitText: text(payload.submitText, 30) || '提交资料', successText: text(payload.successText, 80) || '资料已提交,请等待处理', adminUserId, @@ -204,6 +229,7 @@ function mapAdminProfile(row: OrderServiceProfileRow) { fields: row.fields_json, screenshots: row.screenshots_json, autoPublish: row.auto_publish_json, + announcement: normalizeAnnouncement(row.announcement_json), submitText: row.submit_text, successText: row.success_text, status: row.status, @@ -222,6 +248,7 @@ export function buildProfileConfig(row: { fields_json: unknown screenshots_json: unknown auto_publish_json: unknown + announcement_json: unknown submit_text: string success_text: string }): OrderServiceProfileConfig & { guideImage: Record } { @@ -233,6 +260,7 @@ export function buildProfileConfig(row: { fields: normalizeFields(row.fields_json), screenshots: normalizeScreenshots(row.screenshots_json), autoPublish: normalizeAutoPublish(row.auto_publish_json), + announcement: normalizeAnnouncement(row.announcement_json), submitText: row.submit_text || '提交资料', successText: row.success_text || '资料已提交,请等待处理', } @@ -284,6 +312,7 @@ export function normalizeProfileConfig(value: JsonObject): OrderServiceProfileCo fields: normalizeFields(value.fields), screenshots: normalizeScreenshots(value.screenshots), autoPublish: normalizeAutoPublish(value.autoPublish), + announcement: normalizeAnnouncement(value.announcement), submitText: text(value.submitText, 30) || '提交资料', successText: text(value.successText, 80) || '资料已提交,请等待处理', } @@ -362,11 +391,38 @@ function normalizeAutoPublish(value: unknown): OrderServiceAutoPublish { } } +function normalizeAnnouncement(value: unknown): OrderServiceAnnouncement { + const source = value && typeof value === 'object' ? (value as JsonObject) : {} + return { + enabled: source.enabled !== false, + title: text(source.title, 30) || DEFAULT_ORDER_SERVICE_ANNOUNCEMENT.title, + content: text(source.content, 500) || DEFAULT_ORDER_SERVICE_ANNOUNCEMENT.content, + fontSize: clampInteger(source.fontSize ?? source.font_size, 14, 12, 24), + color: normalizeHexColor(source.color, DEFAULT_ORDER_SERVICE_ANNOUNCEMENT.color), + backgroundColor: normalizeHexColor( + source.backgroundColor ?? source.background_color, + DEFAULT_ORDER_SERVICE_ANNOUNCEMENT.backgroundColor, + ), + fontWeight: + source.fontWeight === 'normal' || source.font_weight === 'normal' + ? 'normal' + : source.fontWeight === 'bold' || source.font_weight === 'bold' + ? 'bold' + : DEFAULT_ORDER_SERVICE_ANNOUNCEMENT.fontWeight, + textAlign: source.textAlign === 'center' || source.text_align === 'center' ? 'center' : 'left', + } +} + function normalizeGuideImage(value: unknown): Record { const normalized = normalizeUploadedFiles([value])[0] return normalized ? { ...normalized } : {} } +function normalizeHexColor(value: unknown, fallback: string) { + const normalized = text(value, 7) + return /^#[0-9a-fA-F]{6}$/.test(normalized) ? normalized.toLowerCase() : fallback +} + function clampInteger(value: unknown, fallback: number, min: number, max: number) { const parsed = Number(value) if (!Number.isFinite(parsed)) return fallback diff --git a/apps/backend/src/services/worker-platform/order-service-public-service.ts b/apps/backend/src/services/worker-platform/order-service-public-service.ts index a9858884..061c68f3 100644 --- a/apps/backend/src/services/worker-platform/order-service-public-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-public-service.ts @@ -31,6 +31,7 @@ import { throwPublicMaterialBlocked, } from './work-order-material-link-service.js' import { + DEFAULT_ORDER_SERVICE_ANNOUNCEMENT, getOrderServiceProfileSnapshotByVersion, normalizeProfileConfig, type OrderServiceField, @@ -77,6 +78,7 @@ export type OrderServicePublicItem = { message: string canSubmit: boolean blockedReason: string + announcement?: OrderServiceProfileConfig['announcement'] fields?: OrderServiceField[] values?: Record screenshots?: Array> @@ -369,6 +371,7 @@ async function mapPublicOrderItem(order: WorkOrderRow): Promise uploadFile(file, scene, 'admin'), [], ) + const announcementPreview = Form.useWatch('announcement', form) || DEFAULT_ANNOUNCEMENT useEffect(() => { if (selectedCategoryId !== null || categories.length === 0) return @@ -160,6 +174,7 @@ export default function OrderServicePanel() { successText: nextProfile?.successText || '资料已提交,请等待处理', screenshots: nextProfile?.screenshots || DEFAULT_SCREENSHOTS, autoPublish: nextProfile?.autoPublish || DEFAULT_AUTO_PUBLISH, + announcement: nextProfile?.announcement || DEFAULT_ANNOUNCEMENT, }) setFields(normalizeFieldOrder(nextProfile?.fields || [])) setGuideImage(nextProfile?.guideImage || null) @@ -232,6 +247,7 @@ export default function OrderServicePanel() { fields: normalizedFields, screenshots: values.screenshots, autoPublish: values.autoPublish, + announcement: values.announcement, submitText: values.submitText, successText: values.successText, }) @@ -509,6 +525,83 @@ export default function OrderServicePanel() { +
+
+
+ 页面公告 + 显示在客户资料页顶部,可自定义内容和文字样式。 +
+ + 显示公告 + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -809,3 +902,29 @@ function resolveProfileStatus(profile: AdminOrderServiceProfile | undefined) { } return { label: '已归档', tone: 'muted' } } + +function AnnouncementPreview({ announcement }: { announcement: OrderServiceAnnouncement }) { + return ( +
+ + {announcement.title || '公告'} + +
+ {announcement.content || '公告内容'} +
+ {!announcement.enabled ? 当前已关闭,不会在客户页显示 : null} +
+ ) +} diff --git a/apps/frontend/src/pages/public/OrderServicePage.tsx b/apps/frontend/src/pages/public/OrderServicePage.tsx index e855b2f2..7740ba55 100644 --- a/apps/frontend/src/pages/public/OrderServicePage.tsx +++ b/apps/frontend/src/pages/public/OrderServicePage.tsx @@ -261,6 +261,31 @@ export default function OrderServicePage() {
+ {pageConfig?.announcement?.enabled && pageConfig.announcement.content ? ( +
+ + {pageConfig.announcement.title || '公告'} + +
+ {pageConfig.announcement.content} +
+
+ ) : null} + {submittedNames.length > 0 ? ( screenshots?: UploadedFile[] diff --git a/apps/frontend/src/styles/admin.css b/apps/frontend/src/styles/admin.css index 1e68b8a4..1f3ada47 100644 --- a/apps/frontend/src/styles/admin.css +++ b/apps/frontend/src/styles/admin.css @@ -1843,6 +1843,34 @@ margin-top: 6px; } +.order-service-color-input { + height: 32px; + padding: 2px 3px; + cursor: pointer; +} + +.order-service-announcement-preview { + display: grid; + gap: 4px; + padding: 12px 14px; + border: 1px solid rgba(138, 75, 0, 0.16); + border-left-width: 4px; + border-radius: 9px; +} + +.order-service-announcement-preview-title { + color: inherit !important; + font-size: 13px; +} + +.order-service-announcement-preview.is-disabled { + opacity: 0.55; +} + +.order-service-announcement-preview > span:last-child { + font-size: 11px; +} + .order-service-field-list { display: grid; gap: 8px; diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index f64d8e96..3e0b3f46 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -515,6 +515,25 @@ gap: 12px; } +.collect-link-announcement { + padding: 12px 14px; + border: 1px solid rgba(138, 75, 0, 0.16); + border-left-width: 4px; + border-radius: 9px; +} + +.collect-link-announcement-title { + display: block; + margin-bottom: 4px; + font-size: 13px; +} + +.collect-link-announcement-content { + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + /* 商品名与订单号放在同一个信息头里,避免标题和灰色信息块分离 */ .collect-link-order-head { display: flex; diff --git a/docs/订单服务收集与状态页设计-2026-11-03.md b/docs/订单服务收集与状态页设计-2026-11-03.md index 96bc6850..9e688d0c 100644 --- a/docs/订单服务收集与状态页设计-2026-11-03.md +++ b/docs/订单服务收集与状态页设计-2026-11-03.md @@ -7,7 +7,7 @@ status: stable # 订单服务收集与状态页设计 -> 状态:现行。最后校准:2026-11-03。代码分支:`dev_11.3`。 +> 状态:现行。最后校准:2026-11-04。代码分支:`dev_11.3`。 ## 1. 目标 @@ -92,6 +92,7 @@ work_orders.service_profile_snapshot_json - 按平台订单号查询一单多工单。 - 动态文本、长文本、下拉、单选、数字、日期字段。 - 一张分类引导图。 +- 页面公告,可配置标题、正文、字号、文字颜色、背景颜色、字重和对齐方式;旧配置默认显示资料填写错误免责声明。 - 图片截图规则,必填时最少 1 张。 - 提交后禁止再次修改。 - 每 15 秒、页面重新可见或窗口重新聚焦时刷新状态。