feat(order-service): 优化用户引导图配置与展示

This commit is contained in:
yml2213
2026-11-04 21:26:26 +08:00
parent e3a66309a0
commit 3a557e8bda
9 changed files with 283 additions and 71 deletions
@@ -94,6 +94,19 @@ test('订单服务提交后说明:旧配置使用默认文案,新配置支
) )
}) })
test('用户引导图:说明文字随图片配置保存,旧配置默认不显示说明', () => {
assert.equal(config().guideImageDescription, '')
const normalized = config({
guideImage: {
objectKey: 'order-service/guide.webp',
url: '/api/v1/files/object?key=order-service%2Fguide.webp',
},
guideImageDescription: '请截图买家个人主页,并确认游戏 ID 清晰可见。',
})
assert.equal(normalized.guideImageDescription, '请截图买家个人主页,并确认游戏 ID 清晰可见。')
assert.equal(normalized.guideImage.description, '请截图买家个人主页,并确认游戏 ID 清晰可见。')
})
test('客户状态进度:四个节点支持独立标题和说明,旧配置使用默认文案', () => { test('客户状态进度:四个节点支持独立标题和说明,旧配置使用默认文案', () => {
assert.deepEqual(config().statusSteps, { assert.deepEqual(config().statusSteps, {
submitted: { title: '提交资料', description: '' }, submitted: { title: '提交资料', description: '' },
@@ -69,6 +69,7 @@ export type OrderServiceProfileConfig = {
title: string title: string
description: string description: string
guideImage: Record<string, unknown> guideImage: Record<string, unknown>
guideImageDescription: string
fields: OrderServiceField[] fields: OrderServiceField[]
screenshots: OrderServiceScreenshots screenshots: OrderServiceScreenshots
autoPublish: OrderServiceAutoPublish autoPublish: OrderServiceAutoPublish
@@ -157,7 +158,14 @@ export async function saveAdminOrderServiceProfile(payload: JsonObject = {}) {
payload.materialConfirmation ?? payload.material_confirmation_json, payload.materialConfirmation ?? payload.material_confirmation_json,
) )
const statusSteps = normalizeStatusSteps(payload.statusSteps ?? payload.status_steps_json) const statusSteps = normalizeStatusSteps(payload.statusSteps ?? payload.status_steps_json)
const guideImage = normalizeGuideImage(payload.guideImage ?? payload.guide_image_json) const guideImageDescription = text(
payload.guideImageDescription ?? payload.guide_image_description,
200,
)
const guideImage = normalizeGuideImage(
payload.guideImage ?? payload.guide_image_json,
guideImageDescription,
)
const adminUserId = Number(payload.adminUserId ?? payload.admin_user_id ?? 0) || null const adminUserId = Number(payload.adminUserId ?? payload.admin_user_id ?? 0) || null
const saved = await upsertOrderServiceProfile({ const saved = await upsertOrderServiceProfile({
@@ -246,6 +254,7 @@ function mapAdminProfile(row: OrderServiceProfileRow) {
title: row.title, title: row.title,
description: row.description, description: row.description,
guideImage: row.guide_image_json, guideImage: row.guide_image_json,
guideImageDescription: buildProfileConfig(row).guideImageDescription,
fields: buildProfileConfig(row).fields, fields: buildProfileConfig(row).fields,
screenshots: row.screenshots_json, screenshots: row.screenshots_json,
autoPublish: row.auto_publish_json, autoPublish: row.auto_publish_json,
@@ -276,11 +285,13 @@ export function buildProfileConfig(row: {
success_text: string success_text: string
post_submit_text: string post_submit_text: string
}): OrderServiceProfileConfig & { guideImage: Record<string, unknown> } { }): OrderServiceProfileConfig & { guideImage: Record<string, unknown> } {
const guideImage = normalizeGuideImage(row.guide_image_json)
return { return {
name: row.name, name: row.name,
title: row.title, title: row.title,
description: row.description, description: row.description,
guideImage: normalizeGuideImage(row.guide_image_json), guideImage,
guideImageDescription: readGuideImageDescription(guideImage),
fields: normalizeFields(row.fields_json), fields: normalizeFields(row.fields_json),
screenshots: normalizeScreenshots(row.screenshots_json), screenshots: normalizeScreenshots(row.screenshots_json),
autoPublish: normalizeAutoPublish(row.auto_publish_json), autoPublish: normalizeAutoPublish(row.auto_publish_json),
@@ -330,11 +341,16 @@ export function isOrderServiceMaterialRequired(config: OrderServiceProfileConfig
} }
export function normalizeProfileConfig(value: JsonObject): OrderServiceProfileConfig { export function normalizeProfileConfig(value: JsonObject): OrderServiceProfileConfig {
const guideImage = normalizeGuideImage(
value.guideImage ?? value.guide_image,
value.guideImageDescription ?? value.guide_image_description,
)
return { return {
name: text(value.name, 80) || '订单服务配置', name: text(value.name, 80) || '订单服务配置',
title: text(value.title, 80), title: text(value.title, 80),
description: text(value.description, 300), description: text(value.description, 300),
guideImage: normalizeGuideImage(value.guideImage ?? value.guide_image), guideImage,
guideImageDescription: readGuideImageDescription(guideImage),
fields: normalizeFields(value.fields), fields: normalizeFields(value.fields),
screenshots: normalizeScreenshots(value.screenshots), screenshots: normalizeScreenshots(value.screenshots),
autoPublish: normalizeAutoPublish(value.autoPublish), autoPublish: normalizeAutoPublish(value.autoPublish),
@@ -459,9 +475,20 @@ function normalizeStatusStep(value: unknown, fallback: { title: string; descript
} }
} }
function normalizeGuideImage(value: unknown): Record<string, unknown> { function normalizeGuideImage(value: unknown, descriptionInput?: unknown): Record<string, unknown> {
const source =
value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
const normalized = normalizeUploadedFiles([value])[0] const normalized = normalizeUploadedFiles([value])[0]
return normalized ? { ...normalized } : {} if (!normalized) return {}
const description = text(descriptionInput ?? source.description ?? source.guideDescription, 200)
return {
...normalized,
...(description ? { description } : {}),
}
}
function readGuideImageDescription(value: Record<string, unknown>) {
return text(value.description, 200)
} }
function clampInteger(value: unknown, fallback: number, min: number, max: number) { function clampInteger(value: unknown, fallback: number, min: number, max: number) {
@@ -93,6 +93,7 @@ export type OrderServicePublicItem = {
screenshots?: Array<Record<string, unknown>> screenshots?: Array<Record<string, unknown>>
screenshotsRule?: OrderServiceProfileConfig['screenshots'] screenshotsRule?: OrderServiceProfileConfig['screenshots']
guideImage?: Record<string, unknown> guideImage?: Record<string, unknown>
guideImageDescription?: string
acceptanceImageUrls?: string[] acceptanceImageUrls?: string[]
acceptanceVideo?: OrderServiceAcceptanceVideo acceptanceVideo?: OrderServiceAcceptanceVideo
submittedAt?: string submittedAt?: string
@@ -537,6 +538,7 @@ async function mapPublicOrderItem(order: WorkOrderRow): Promise<OrderServicePubl
return { return {
...base, ...base,
guideImage: profile?.guideImage || {}, guideImage: profile?.guideImage || {},
guideImageDescription: profile?.guideImageDescription || '',
} }
} }
@@ -565,6 +567,7 @@ async function resolveProfileConfig(
title: '', title: '',
description: '', description: '',
guideImage: {}, guideImage: {},
guideImageDescription: '',
fields: normalizeProfileConfig({ fields: normalizeProfileConfig({
name: '通用资料', name: '通用资料',
fields: legacy.fields, fields: legacy.fields,
@@ -71,6 +71,7 @@ type OrderServiceFormValues = {
name: string name: string
title: string title: string
description: string description: string
guideImageDescription: string
submitText: string submitText: string
successText: string successText: string
postSubmitText: string postSubmitText: string
@@ -230,6 +231,14 @@ function buildPresetFields(keys: string[]) {
}) })
} }
function resolveGuideImage(value: unknown): UploadedFile | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const source = value as Record<string, unknown>
const url = String(source.url || '').trim()
const objectKey = String(source.objectKey || source.object_key || '').trim()
return url || objectKey ? (value as UploadedFile) : null
}
export default function OrderServicePanel() { export default function OrderServicePanel() {
const { message } = App.useApp() const { message } = App.useApp()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -275,7 +284,10 @@ export default function OrderServicePanel() {
).length ).length
const selectedCategory = categories.find((item) => item.categoryId === selectedCategoryId) || null const selectedCategory = categories.find((item) => item.categoryId === selectedCategoryId) || null
const profile = (selectedCategoryId ? profileByCategoryId.get(selectedCategoryId) : null) || null const profile = (selectedCategoryId ? profileByCategoryId.get(selectedCategoryId) : null) || null
const guideFiles = useMemo(() => (guideImage ? [guideImage as UploadedFile] : []), [guideImage]) const guideFiles = useMemo(() => {
const image = resolveGuideImage(guideImage)
return image ? [image] : []
}, [guideImage])
const handleGuideImageChange = useCallback((files: UploadedFile[]) => { const handleGuideImageChange = useCallback((files: UploadedFile[]) => {
setGuideImage(files[0] || null) setGuideImage(files[0] || null)
}, []) }, [])
@@ -302,6 +314,7 @@ export default function OrderServicePanel() {
name: nextProfile?.name || selectedCategory?.name || '', name: nextProfile?.name || selectedCategory?.name || '',
title: nextProfile?.title || '', title: nextProfile?.title || '',
description: nextProfile?.description || '', description: nextProfile?.description || '',
guideImageDescription: nextProfile?.guideImageDescription || '',
submitText: nextProfile?.submitText || '提交资料', submitText: nextProfile?.submitText || '提交资料',
successText: nextProfile?.successText || '资料已提交,请等待处理', successText: nextProfile?.successText || '资料已提交,请等待处理',
postSubmitText: nextProfile?.postSubmitText || DEFAULT_POST_SUBMIT_TEXT, postSubmitText: nextProfile?.postSubmitText || DEFAULT_POST_SUBMIT_TEXT,
@@ -315,7 +328,7 @@ export default function OrderServicePanel() {
nextProfile ? nextProfile.fields : buildPresetFields(GAME_BASIC_PRESET_KEYS), nextProfile ? nextProfile.fields : buildPresetFields(GAME_BASIC_PRESET_KEYS),
), ),
) )
setGuideImage(nextProfile?.guideImage || null) setGuideImage(resolveGuideImage(nextProfile?.guideImage))
} }
async function refresh() { async function refresh() {
@@ -413,6 +426,7 @@ export default function OrderServicePanel() {
title: values.title || '', title: values.title || '',
description: values.description || '', description: values.description || '',
guideImage: guideImage || {}, guideImage: guideImage || {},
guideImageDescription: values.guideImageDescription || '',
fields: normalizedFields, fields: normalizedFields,
screenshots: values.screenshots, screenshots: values.screenshots,
autoPublish: values.autoPublish, autoPublish: values.autoPublish,
@@ -652,7 +666,7 @@ export default function OrderServicePanel() {
/> />
</div> </div>
<div className="order-service-basic-grid"> <div className="order-service-basic-grid">
<div> <div className="order-service-basic-fields">
<Row gutter={[12, 0]}> <Row gutter={[12, 0]}>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item label="配置名称" name="name" rules={[{ required: true }]}> <Form.Item label="配置名称" name="name" rules={[{ required: true }]}>
@@ -699,16 +713,36 @@ export default function OrderServicePanel() {
</Row> </Row>
</div> </div>
<div className="order-service-guide"> <div className="order-service-guide">
<Text strong>用户引导图</Text> <div className="order-service-guide-head">
<Text type="secondary">首版一张,展示在字段上方</Text> <Text strong>用户引导图</Text>
<ImageUpload <Text type="secondary">
scene="order-service" 一张,显示在客户页“买家主页截图”上传区右侧,可点击放大查看
scope="admin" </Text>
maxCount={1} </div>
value={guideFiles} <div className="order-service-guide-upload">
onChange={handleGuideImageChange} <ImageUpload
uploader={uploadGuideImage} scene="order-service"
/> scope="admin"
maxCount={1}
value={guideFiles}
onChange={handleGuideImageChange}
uploader={uploadGuideImage}
/>
</div>
<Form.Item
className="order-service-guide-description"
extra="选填,显示在引导图下方;建议说明截图范围或关键识别信息。"
label="图片说明"
name="guideImageDescription"
>
<Input.TextArea
autoSize={{ minRows: 2, maxRows: 4 }}
disabled={!guideImage}
maxLength={200}
placeholder="例如:请截取买家个人主页,并确认游戏 ID 清晰可见"
showCount
/>
</Form.Item>
</div> </div>
</div> </div>
</section> </section>
@@ -601,6 +601,8 @@ function OrderServiceForm({
label: '截图', label: '截图',
helpText: '', helpText: '',
} }
const guideImageUrl =
item.guideImage && 'url' in item.guideImage ? String(item.guideImage.url || '').trim() : ''
return ( return (
<Card size="small" className="collect-link-order-card"> <Card size="small" className="collect-link-order-card">
@@ -618,14 +620,6 @@ function OrderServiceForm({
{item.title ? <Title level={5}>{item.title}</Title> : null} {item.title ? <Title level={5}>{item.title}</Title> : null}
{item.description ? <Text type="secondary">{item.description}</Text> : null} {item.description ? <Text type="secondary">{item.description}</Text> : null}
{item.guideImage && 'url' in item.guideImage && item.guideImage.url ? (
<img
alt="填写引导图"
src={String(item.guideImage.url)}
style={{ width: '100%', borderRadius: 8 }}
/>
) : null}
{item.fields && item.fields.length > 0 ? ( {item.fields && item.fields.length > 0 ? (
<Form layout="vertical" initialValues={item.values || {}} onFinish={onSubmit}> <Form layout="vertical" initialValues={item.values || {}} onFinish={onSubmit}>
{item.fields.map((field) => ( {item.fields.map((field) => (
@@ -672,24 +666,47 @@ function OrderServiceForm({
} }
extra={rule.helpText} extra={rule.helpText}
> >
<ImageUpload <div
scene="collect-material" className={`collect-link-screenshot-control ${guideImageUrl ? 'has-guide' : ''}`}
scope="collect" >
maxCount={rule.maxCount} <div className="collect-link-screenshot-upload">
showPasteHint <ImageUpload
captureGlobalPaste scene="collect-material"
value={screenshots} scope="collect"
onChange={onScreenshotsChange} maxCount={rule.maxCount}
uploader={async (file, scene) => { showPasteHint
const response = await uploadOrderServiceFile( captureGlobalPaste
file, value={screenshots}
scene, onChange={onScreenshotsChange}
code, uploader={async (file, scene) => {
item.workOrderId, const response = await uploadOrderServiceFile(
) file,
return response.data.file scene,
}} code,
/> item.workOrderId,
)
return response.data.file
}}
/>
</div>
{guideImageUrl ? (
<aside className="collect-link-guide-example">
<Image
alt="买家主页截图填写示例"
height={88}
preview={{ mask: '查看大图' }}
src={guideImageUrl}
width={128}
/>
<div className="collect-link-guide-copy">
<Text strong>截图填写参考</Text>
<Text type="secondary">
{item.guideImageDescription?.trim() || '点击图片可查看大图'}
</Text>
</div>
</aside>
) : null}
</div>
</Form.Item> </Form.Item>
) : null} ) : null}
@@ -1143,6 +1143,7 @@ export type AdminOrderServiceProfile = {
title: string title: string
description: string description: string
guideImage: UploadedFile | Record<string, unknown> guideImage: UploadedFile | Record<string, unknown>
guideImageDescription: string
fields: OrderServiceField[] fields: OrderServiceField[]
screenshots: { screenshots: {
enabled: boolean enabled: boolean
@@ -1192,6 +1193,7 @@ export function saveAdminOrderServiceProfile(payload: {
title?: string title?: string
description?: string description?: string
guideImage?: UploadedFile | Record<string, unknown> guideImage?: UploadedFile | Record<string, unknown>
guideImageDescription?: string
fields: OrderServiceField[] fields: OrderServiceField[]
screenshots: { screenshots: {
enabled: boolean enabled: boolean
+1
View File
@@ -613,6 +613,7 @@ export type OrderServiceItem = {
screenshots?: UploadedFile[] screenshots?: UploadedFile[]
screenshotsRule?: OrderServiceScreenshots screenshotsRule?: OrderServiceScreenshots
guideImage?: UploadedFile | Record<string, unknown> guideImage?: UploadedFile | Record<string, unknown>
guideImageDescription?: string
acceptanceImageUrls?: string[] acceptanceImageUrls?: string[]
acceptanceVideo?: { acceptanceVideo?: {
url: string url: string
+42 -27
View File
@@ -1858,26 +1858,59 @@
.order-service-basic-grid { .order-service-basic-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 230px; gap: 14px;
gap: 18px;
} }
.order-service-basic-grid .ant-form-item { .order-service-basic-grid .ant-form-item {
margin-bottom: 12px; margin-bottom: 12px;
} }
.order-service-basic-fields {
min-width: 0;
}
.order-service-guide { .order-service-guide {
display: grid; display: grid;
grid-template-columns: minmax(180px, 220px) minmax(260px, 1fr);
grid-template-rows: auto 1fr;
align-items: start;
align-content: start; align-content: start;
gap: 4px; gap: 12px 18px;
padding: 12px; padding: 14px;
background: #f8fafc; background: #f8fafc;
border: 1px dashed #d9e1ec; border: 1px dashed #d9e1ec;
border-radius: 10px; border-radius: 10px;
} }
.order-service-guide .ant-upload-wrapper { .order-service-guide-head {
margin-top: 6px; grid-column: 1 / -1;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 10px;
}
.order-service-guide-head .ant-typography:last-child {
font-size: 12px;
}
.order-service-guide-upload {
min-width: 0;
}
.order-service-guide-upload .ant-upload-list-item-container,
.order-service-guide-upload .ant-upload-select {
width: 180px !important;
height: 140px !important;
}
.order-service-guide-description.ant-form-item {
margin-bottom: 0;
}
.order-service-guide-description .ant-form-item-extra {
margin-top: 2px;
font-size: 11px;
} }
.order-service-status-step-grid { .order-service-status-step-grid {
@@ -2170,10 +2203,6 @@
grid-template-columns: minmax(120px, 1fr) minmax(140px, 1fr) 110px 64px 104px; grid-template-columns: minmax(120px, 1fr) minmax(140px, 1fr) 110px 64px 104px;
} }
.order-service-basic-grid {
grid-template-columns: minmax(0, 1fr) 200px;
}
.order-service-status-step-grid { .order-service-status-step-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -2206,21 +2235,8 @@
padding: 16px 16px 0; padding: 16px 16px 0;
} }
.order-service-basic-grid {
grid-template-columns: 1fr;
}
.order-service-guide { .order-service-guide {
grid-template-columns: minmax(0, 1fr) 160px; grid-template-columns: minmax(160px, 200px) minmax(0, 1fr);
}
.order-service-guide > :not(.ant-upload-wrapper) {
grid-column: 1;
}
.order-service-guide .ant-upload-wrapper {
grid-column: 2;
grid-row: 1 / span 3;
} }
.order-service-section-head { .order-service-section-head {
@@ -2270,12 +2286,11 @@
.order-service-guide { .order-service-guide {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto;
} }
.order-service-guide > :not(.ant-upload-wrapper), .order-service-guide-head {
.order-service-guide .ant-upload-wrapper {
grid-column: 1; grid-column: 1;
grid-row: auto;
} }
.order-service-status-step-grid { .order-service-status-step-grid {
+100
View File
@@ -1060,6 +1060,64 @@
margin-top: 18px; margin-top: 18px;
} }
.collect-link-screenshot-control {
display: grid;
gap: 14px;
align-items: start;
}
.collect-link-screenshot-control.has-guide {
grid-template-columns: 120px minmax(180px, 260px);
gap: 16px;
}
.collect-link-guide-example {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
min-width: 0;
max-width: 100%;
align-content: start;
justify-items: start;
}
.collect-link-guide-example .ant-image {
display: block;
width: 128px !important;
height: 88px !important;
overflow: hidden;
border: 1px solid #dbe3ee;
border-radius: 7px;
cursor: zoom-in;
}
.collect-link-guide-example .ant-image-img {
display: block;
width: 128px !important;
height: 88px !important;
object-fit: cover;
object-position: center;
}
.collect-link-guide-copy {
display: grid;
align-content: center;
gap: 3px;
min-width: 0;
}
.collect-link-guide-copy .ant-typography:first-child {
color: #334155;
font-size: 12px;
}
.collect-link-guide-copy .ant-typography:last-child {
max-width: 220px;
font-size: 11px;
line-height: 1.55;
white-space: pre-wrap;
}
.collect-link-actions { .collect-link-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -1086,6 +1144,21 @@
padding: 18px; padding: 18px;
} }
.collect-link-screenshot-control.has-guide {
grid-template-columns: 112px minmax(160px, 220px);
gap: 12px;
}
.collect-link-guide-example .ant-image {
width: 116px !important;
height: 80px !important;
}
.collect-link-guide-example .ant-image-img {
width: 116px !important;
height: 80px !important;
}
.collect-link-actions { .collect-link-actions {
justify-content: stretch; justify-content: stretch;
} }
@@ -1104,6 +1177,33 @@
padding: 14px; padding: 14px;
} }
.collect-link-screenshot-control.has-guide {
grid-template-columns: 104px minmax(0, 1fr);
gap: 8px;
}
.collect-link-guide-example {
gap: 5px;
}
.collect-link-guide-example .ant-image {
width: 96px !important;
height: 68px !important;
}
.collect-link-guide-example .ant-image-img {
width: 96px !important;
height: 68px !important;
}
.collect-link-guide-copy .ant-typography:first-child {
font-size: 11px;
}
.collect-link-guide-copy .ant-typography:last-child {
font-size: 10px;
}
.collect-link-success { .collect-link-success {
min-height: 320px; min-height: 320px;
padding: 16px 4px 4px; padding: 16px 4px 4px;