新增合作与反馈入口配置

This commit is contained in:
yml2213
2026-08-15 16:37:30 +08:00
parent 134d6fa05e
commit d9f552577a
14 changed files with 332 additions and 36 deletions
+2 -1
View File
@@ -72,7 +72,8 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
!strings.HasPrefix(key, "announcement/") && !strings.HasPrefix(key, "announcement/") &&
!strings.HasPrefix(key, "mohong/") && !strings.HasPrefix(key, "mohong/") &&
!strings.HasPrefix(key, "crash/") && !strings.HasPrefix(key, "crash/") &&
!strings.HasPrefix(key, "aw-recycle/") { !strings.HasPrefix(key, "aw-recycle/") &&
!strings.HasPrefix(key, "cooperation-feedback/") {
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问") response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
return return
} }
+2 -2
View File
@@ -106,7 +106,7 @@ func normalizeContentType(contentType string, data []byte) string {
func fileURLForScene(scene string, key string) string { func fileURLForScene(scene string, key string) string {
fileURL := "/api/files/object?key=" + url.QueryEscape(key) fileURL := "/api/files/object?key=" + url.QueryEscape(key)
if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" { if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key) fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
} }
return fileURL return fileURL
@@ -115,7 +115,7 @@ func fileURLForScene(scene string, key string) string {
func normalizeScene(scene string) string { func normalizeScene(scene string) string {
scene = strings.TrimSpace(strings.ToLower(scene)) scene = strings.TrimSpace(strings.ToLower(scene))
switch scene { switch scene {
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "manual-disbursement": case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement":
return scene return scene
default: default:
return "misc" return "misc"
@@ -40,3 +40,14 @@ func TestAWRecycleSceneUsesPublicFileURL(t *testing.T) {
t.Fatalf("aw-recycle file url = %q, want public file endpoint", got) t.Fatalf("aw-recycle file url = %q, want public file endpoint", got)
} }
} }
func TestCooperationFeedbackSceneUsesPublicFileURL(t *testing.T) {
if got := normalizeScene("cooperation-feedback"); got != "cooperation-feedback" {
t.Fatalf("normalize cooperation-feedback scene = %q", got)
}
key := "cooperation-feedback/2026/07/19/example.webp"
got := fileURLForScene("cooperation-feedback", key)
if !strings.HasPrefix(got, "/api/public/files/object?") {
t.Fatalf("cooperation-feedback file url = %q, want public file endpoint", got)
}
}
@@ -0,0 +1,43 @@
package systemconfig
import (
"encoding/json"
"strings"
)
const cooperationFeedbackConfigKey = "mobile.cooperation_feedback"
func defaultCooperationFeedback() CooperationFeedbackConfig {
return CooperationFeedbackConfig{
Title: "合作与反馈",
Subtitle: "点击查看联系方式",
ImageURL: "",
Enabled: true,
}
}
func defaultCooperationFeedbackConfigValue() string {
raw, err := json.Marshal(defaultCooperationFeedback())
if err != nil {
return `{"title":"合作与反馈","subtitle":"点击查看联系方式","image_url":"","enabled":true}`
}
return string(raw)
}
func normalizeCooperationFeedback(item CooperationFeedbackConfig) CooperationFeedbackConfig {
def := defaultCooperationFeedback()
title := strings.TrimSpace(item.Title)
if title == "" {
title = def.Title
}
subtitle := strings.TrimSpace(item.Subtitle)
if subtitle == "" {
subtitle = def.Subtitle
}
return CooperationFeedbackConfig{
Title: title,
Subtitle: subtitle,
ImageURL: strings.TrimSpace(item.ImageURL),
Enabled: item.Enabled,
}
}
@@ -26,6 +26,7 @@ type HomeConfigDTO struct {
Banners []HomeBannerItem `json:"banners"` Banners []HomeBannerItem `json:"banners"`
PublishOptions PublishOptionsDTO `json:"publish_options"` PublishOptions PublishOptionsDTO `json:"publish_options"`
AWRecycle AWRecycleConfig `json:"aw_recycle"` AWRecycle AWRecycleConfig `json:"aw_recycle"`
CooperationFeedback CooperationFeedbackConfig `json:"cooperation_feedback"`
} }
// AWRecycleConfig 首页「AW炫彩回收」入口:点击展示可配置图片。 // AWRecycleConfig 首页「AW炫彩回收」入口:点击展示可配置图片。
@@ -36,6 +37,14 @@ type AWRecycleConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
// CooperationFeedbackConfig 首页「合作与反馈」入口:点击展示可配置图片。
type CooperationFeedbackConfig struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ImageURL string `json:"image_url"`
Enabled bool `json:"enabled"`
}
type OrderAgreementsDTO struct { type OrderAgreementsDTO struct {
VirtualAssetPurchase AgreementContentDTO `json:"virtual_asset_purchase"` VirtualAssetPurchase AgreementContentDTO `json:"virtual_asset_purchase"`
RenterAgreement AgreementContentDTO `json:"renter_agreement"` RenterAgreement AgreementContentDTO `json:"renter_agreement"`
@@ -48,6 +48,7 @@ var defaultConfigs = []defaultConfig{
{Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"}, {Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"},
{Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"}, {Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"},
{Key: awRecycleConfigKey, Value: defaultAWRecycleConfigValue(), Description: "首页 AW炫彩回收入口配置 JSON(标题/副标题/展示图片)"}, {Key: awRecycleConfigKey, Value: defaultAWRecycleConfigValue(), Description: "首页 AW炫彩回收入口配置 JSON(标题/副标题/展示图片)"},
{Key: cooperationFeedbackConfigKey, Value: defaultCooperationFeedbackConfigValue(), Description: "首页 合作与反馈入口配置 JSON(标题/副标题/展示图片)"},
{Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"},
{Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"}, {Key: salePriceConfigKey, Value: defaultSalePriceConfigValue(), Description: "内部出售定价规则 JSON"},
} }
@@ -71,6 +72,7 @@ var adminVisibleConfigKeys = []string{
"mobile.home_announcements", "mobile.home_announcements",
"mobile.home_banners", "mobile.home_banners",
"mobile.aw_recycle", "mobile.aw_recycle",
"mobile.cooperation_feedback",
"order.agreements", "order.agreements",
"order.pending_payment_timeout_minutes", "order.pending_payment_timeout_minutes",
"order.return_overdue_grace_minutes", "order.return_overdue_grace_minutes",
@@ -32,6 +32,10 @@ func (s *Service) HomeConfig(ctx context.Context) (*HomeConfigDTO, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
cooperationFeedback, err := s.cooperationFeedback(ctx)
if err != nil {
return nil, err
}
publishOptions, err := s.publishOptions(ctx) publishOptions, err := s.publishOptions(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -41,6 +45,7 @@ func (s *Service) HomeConfig(ctx context.Context) (*HomeConfigDTO, error) {
Banners: banners, Banners: banners,
PublishOptions: publishOptions, PublishOptions: publishOptions,
AWRecycle: awRecycle, AWRecycle: awRecycle,
CooperationFeedback: cooperationFeedback,
}, nil }, nil
} }
@@ -79,3 +84,15 @@ func (s *Service) awRecycle(ctx context.Context) (AWRecycleConfig, error) {
} }
return normalizeAWRecycle(item), nil return normalizeAWRecycle(item), nil
} }
func (s *Service) cooperationFeedback(ctx context.Context) (CooperationFeedbackConfig, error) {
value, err := s.repo.FindValue(ctx, cooperationFeedbackConfigKey)
if err != nil {
return defaultCooperationFeedback(), err
}
item := defaultCooperationFeedback()
if err := json.Unmarshal([]byte(value), &item); err != nil {
item = defaultCooperationFeedback()
}
return normalizeCooperationFeedback(item), nil
}
@@ -4,10 +4,7 @@ import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { uploadAdminFile } from '@/shared/api/files' import { uploadAdminFile } from '@/shared/api/files'
import { import type { AWRecycleConfig } from '@/features/listings/api/homeConfig'
defaultAWRecycleConfig,
type AWRecycleConfig,
} from '@/features/listings/api/homeConfig'
import { updateSystemConfig, type SystemConfig } from '@/features/admin/api/systemConfigs' import { updateSystemConfig, type SystemConfig } from '@/features/admin/api/systemConfigs'
import { safeParseJSON } from '@/shared/utils/json' import { safeParseJSON } from '@/shared/utils/json'
import AuthImage from '@/shared/components/business/AuthImage.vue' import AuthImage from '@/shared/components/business/AuthImage.vue'
@@ -15,6 +12,10 @@ import AuthImage from '@/shared/components/business/AuthImage.vue'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
config: SystemConfig config: SystemConfig
defaults: AWRecycleConfig
dialogTitle: string
uploadScene: string
imageHint: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -25,31 +26,31 @@ const emit = defineEmits<{
const submitting = ref(false) const submitting = ref(false)
const uploading = ref(false) const uploading = ref(false)
const description = ref('') const description = ref('')
const draft = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig }) const draft = ref<AWRecycleConfig>({ ...props.defaults })
watch( watch(
() => props.modelValue, () => props.modelValue,
val => { val => {
if (!val) return if (!val) return
draft.value = parseAWRecycle(props.config.value) draft.value = parseImageEntry(props.config.value)
description.value = props.config.description || '' description.value = props.config.description || ''
}, },
{ immediate: true } { immediate: true }
) )
function parseAWRecycle(raw: string): AWRecycleConfig { function parseImageEntry(raw: string): AWRecycleConfig {
const parsed = safeParseJSON(raw, defaultAWRecycleConfig) const parsed = safeParseJSON(raw, props.defaults)
return { return {
title: title:
typeof parsed.title === 'string' && parsed.title.trim() typeof parsed.title === 'string' && parsed.title.trim()
? parsed.title.trim() ? parsed.title.trim()
: defaultAWRecycleConfig.title, : props.defaults.title,
subtitle: subtitle:
typeof parsed.subtitle === 'string' && parsed.subtitle.trim() typeof parsed.subtitle === 'string' && parsed.subtitle.trim()
? parsed.subtitle.trim() ? parsed.subtitle.trim()
: defaultAWRecycleConfig.subtitle, : props.defaults.subtitle,
image_url: typeof parsed.image_url === 'string' ? parsed.image_url.trim() : '', image_url: typeof parsed.image_url === 'string' ? parsed.image_url.trim() : '',
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : true, enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : props.defaults.enabled,
} }
} }
@@ -60,7 +61,7 @@ async function handleUpload(event: Event) {
if (!file) return if (!file) return
uploading.value = true uploading.value = true
try { try {
const uploaded = await uploadAdminFile(file, 'aw-recycle') const uploaded = await uploadAdminFile(file, props.uploadScene)
draft.value.image_url = uploaded.url draft.value.image_url = uploaded.url
ElMessage.success('图片已上传') ElMessage.success('图片已上传')
} catch (error) { } catch (error) {
@@ -75,8 +76,8 @@ async function handleSave() {
try { try {
const value = JSON.stringify( const value = JSON.stringify(
{ {
title: draft.value.title.trim() || defaultAWRecycleConfig.title, title: draft.value.title.trim() || props.defaults.title,
subtitle: draft.value.subtitle.trim() || defaultAWRecycleConfig.subtitle, subtitle: draft.value.subtitle.trim() || props.defaults.subtitle,
image_url: draft.value.image_url.trim(), image_url: draft.value.image_url.trim(),
enabled: draft.value.enabled, enabled: draft.value.enabled,
}, },
@@ -101,7 +102,7 @@ async function handleSave() {
<template> <template>
<el-dialog <el-dialog
:model-value="modelValue" :model-value="modelValue"
title="编辑 AW炫彩回收" :title="dialogTitle"
width="640px" width="640px"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
@@ -115,10 +116,10 @@ async function handleSave() {
<el-switch v-model="draft.enabled" active-text="显示" inactive-text="隐藏" /> <el-switch v-model="draft.enabled" active-text="显示" inactive-text="隐藏" />
</el-form-item> </el-form-item>
<el-form-item label="标题"> <el-form-item label="标题">
<el-input v-model="draft.title" maxlength="32" placeholder="AW炫彩回收" /> <el-input v-model="draft.title" maxlength="32" :placeholder="defaults.title" />
</el-form-item> </el-form-item>
<el-form-item label="副标题"> <el-form-item label="副标题">
<el-input v-model="draft.subtitle" maxlength="64" placeholder="点击查看回收说明" /> <el-input v-model="draft.subtitle" maxlength="64" :placeholder="defaults.subtitle" />
</el-form-item> </el-form-item>
<el-form-item label="展示图片"> <el-form-item label="展示图片">
<div class="image-editor"> <div class="image-editor">
@@ -143,8 +144,7 @@ async function handleSave() {
</label> </label>
</div> </div>
<p class="hint"> <p class="hint">
走后台通用上传scene=aw-recycle公开可访问首页点击入口后展示此图建议竖图或说明海报 {{ imageHint }}
若旧图路径含 misc/ 且无法预览请重新上传
</p> </p>
</div> </div>
</el-form-item> </el-form-item>
@@ -29,7 +29,12 @@ import SalePriceDialog from '../components/SalePriceDialog.vue'
import HomeAnnouncementsDialog from '../components/HomeAnnouncementsDialog.vue' import HomeAnnouncementsDialog from '../components/HomeAnnouncementsDialog.vue'
import HomeBannersDialog from '../components/HomeBannersDialog.vue' import HomeBannersDialog from '../components/HomeBannersDialog.vue'
import AwRecycleDialog from '../components/AwRecycleDialog.vue' import AwRecycleDialog from '../components/AwRecycleDialog.vue'
import { defaultAWRecycleConfig, type AWRecycleConfig } from '@/features/listings/api/homeConfig' import {
defaultAWRecycleConfig,
defaultCooperationFeedbackConfig,
type AWRecycleConfig,
type CooperationFeedbackConfig,
} from '@/features/listings/api/homeConfig'
import ListingPublishAgreementsDialog from '../components/ListingPublishAgreementsDialog.vue' import ListingPublishAgreementsDialog from '../components/ListingPublishAgreementsDialog.vue'
import OrderAgreementsDialog from '../components/OrderAgreementsDialog.vue' import OrderAgreementsDialog from '../components/OrderAgreementsDialog.vue'
import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue' import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue'
@@ -75,6 +80,7 @@ const salePriceVisible = ref(false)
const announcementsVisible = ref(false) const announcementsVisible = ref(false)
const bannersVisible = ref(false) const bannersVisible = ref(false)
const awRecycleVisible = ref(false) const awRecycleVisible = ref(false)
const cooperationFeedbackVisible = ref(false)
const listingPublishAgreementsVisible = ref(false) const listingPublishAgreementsVisible = ref(false)
const agreementsVisible = ref(false) const agreementsVisible = ref(false)
const postRentalNoticeVisible = ref(false) const postRentalNoticeVisible = ref(false)
@@ -102,6 +108,9 @@ const homeBannersConfig = computed(
const awRecycleConfig = computed( const awRecycleConfig = computed(
() => configs.value.find(item => item.key === 'mobile.aw_recycle') || null () => configs.value.find(item => item.key === 'mobile.aw_recycle') || null
) )
const cooperationFeedbackConfig = computed(
() => configs.value.find(item => item.key === 'mobile.cooperation_feedback') || null
)
const listingPublishAgreementsConfig = computed( const listingPublishAgreementsConfig = computed(
() => configs.value.find(item => item.key === 'listing.publish_agreements') || null () => configs.value.find(item => item.key === 'listing.publish_agreements') || null
) )
@@ -148,6 +157,7 @@ const regularConfigs = computed(() =>
item.key !== 'mobile.home_announcements' && item.key !== 'mobile.home_announcements' &&
item.key !== 'mobile.home_banners' && item.key !== 'mobile.home_banners' &&
item.key !== 'mobile.aw_recycle' && item.key !== 'mobile.aw_recycle' &&
item.key !== 'mobile.cooperation_feedback' &&
item.key !== 'listing.publish_agreements' && item.key !== 'listing.publish_agreements' &&
item.key !== 'order.agreements' && item.key !== 'order.agreements' &&
item.key !== 'profile.post_rental_notice' && item.key !== 'profile.post_rental_notice' &&
@@ -201,6 +211,13 @@ const awRecycleStats = computed(() => {
} }
}) })
const cooperationFeedbackStats = computed(() => {
const item = parseCooperationFeedback(cooperationFeedbackConfig.value?.value || '')
return {
enabledLabel: item.enabled ? (item.image_url ? '已配置图' : '已开启') : '已隐藏',
}
})
const renterGrowthStats = computed(() => { const renterGrowthStats = computed(() => {
const config = parseRenterGrowthConfig(renterGrowthConfig.value?.value || '') const config = parseRenterGrowthConfig(renterGrowthConfig.value?.value || '')
return { return {
@@ -268,6 +285,8 @@ function openEdit(row: SystemConfig) {
bannersVisible.value = true bannersVisible.value = true
} else if (row.key === 'mobile.aw_recycle') { } else if (row.key === 'mobile.aw_recycle') {
awRecycleVisible.value = true awRecycleVisible.value = true
} else if (row.key === 'mobile.cooperation_feedback') {
cooperationFeedbackVisible.value = true
} else if (row.key === 'listing.publish_agreements') { } else if (row.key === 'listing.publish_agreements') {
listingPublishAgreementsVisible.value = true listingPublishAgreementsVisible.value = true
} else if (row.key === 'order.agreements') { } else if (row.key === 'order.agreements') {
@@ -319,6 +338,25 @@ function parseAWRecycle(raw: string): AWRecycleConfig {
} }
} }
function parseCooperationFeedback(raw: string): CooperationFeedbackConfig {
const parsed = safeParseJSON(raw, defaultCooperationFeedbackConfig)
return {
title:
typeof parsed.title === 'string' && parsed.title.trim()
? parsed.title.trim()
: defaultCooperationFeedbackConfig.title,
subtitle:
typeof parsed.subtitle === 'string' && parsed.subtitle.trim()
? parsed.subtitle.trim()
: defaultCooperationFeedbackConfig.subtitle,
image_url: typeof parsed.image_url === 'string' ? parsed.image_url.trim() : '',
enabled:
typeof parsed.enabled === 'boolean'
? parsed.enabled
: defaultCooperationFeedbackConfig.enabled,
}
}
function parseOrderAgreements(raw: string) { function parseOrderAgreements(raw: string) {
const parsed = safeParseJSON(raw, defaultOrderAgreements) const parsed = safeParseJSON(raw, defaultOrderAgreements)
return { return {
@@ -555,14 +593,19 @@ function formatConfigValue(row: SystemConfig) {
</section> </section>
<section <section
v-if="homeAnnouncementsConfig || homeBannersConfig || awRecycleConfig" v-if="
homeAnnouncementsConfig || homeBannersConfig || awRecycleConfig || cooperationFeedbackConfig
"
class="publish-config-panel" class="publish-config-panel"
> >
<div class="publish-config-main"> <div class="publish-config-main">
<div> <div>
<p class="eyebrow">Home Content</p> <p class="eyebrow">Home Content</p>
<h2>首页运营配置</h2> <h2>首页运营配置</h2>
<span>管理首页公告轮播图与 AW炫彩回收入口展示图保存后 PC/移动端从接口读取</span> <span
>管理首页公告、轮播图、AW炫彩回收与合作反馈入口展示图,保存后
PC/移动端从接口读取。</span
>
</div> </div>
<div class="panel-actions"> <div class="panel-actions">
<el-button v-if="homeAnnouncementsConfig" @click="openEdit(homeAnnouncementsConfig)" <el-button v-if="homeAnnouncementsConfig" @click="openEdit(homeAnnouncementsConfig)"
@@ -574,6 +617,12 @@ function formatConfigValue(row: SystemConfig) {
<el-button v-if="awRecycleConfig" type="primary" @click="openEdit(awRecycleConfig)" <el-button v-if="awRecycleConfig" type="primary" @click="openEdit(awRecycleConfig)"
>编辑 AW炫彩回收</el-button >编辑 AW炫彩回收</el-button
> >
<el-button
v-if="cooperationFeedbackConfig"
type="primary"
@click="openEdit(cooperationFeedbackConfig)"
>编辑 合作与反馈</el-button
>
</div> </div>
</div> </div>
<div class="publish-stat-grid home-stat-grid"> <div class="publish-stat-grid home-stat-grid">
@@ -589,6 +638,10 @@ function formatConfigValue(row: SystemConfig) {
<strong>{{ awRecycleStats.enabledLabel }}</strong> <strong>{{ awRecycleStats.enabledLabel }}</strong>
<span>AW炫彩回收</span> <span>AW炫彩回收</span>
</div> </div>
<div class="publish-stat">
<strong>{{ cooperationFeedbackStats.enabledLabel }}</strong>
<span>合作与反馈</span>
</div>
<div class="publish-stat"> <div class="publish-stat">
<strong>接口</strong> <strong>接口</strong>
<span>/api/mobile-home-config</span> <span>/api/mobile-home-config</span>
@@ -596,7 +649,10 @@ function formatConfigValue(row: SystemConfig) {
<div class="publish-stat"> <div class="publish-stat">
<strong>更新</strong> <strong>更新</strong>
<span>{{ <span>{{
formatHomeConfigStatus(awRecycleConfig || homeBannersConfig, '未初始化') formatHomeConfigStatus(
cooperationFeedbackConfig || awRecycleConfig || homeBannersConfig,
'未初始化'
)
}}</span> }}</span>
</div> </div>
</div> </div>
@@ -854,6 +910,21 @@ function formatConfigValue(row: SystemConfig) {
v-if="awRecycleConfig" v-if="awRecycleConfig"
v-model="awRecycleVisible" v-model="awRecycleVisible"
:config="awRecycleConfig" :config="awRecycleConfig"
:defaults="defaultAWRecycleConfig"
dialog-title="编辑 AW炫彩回收"
upload-scene="aw-recycle"
image-hint="走后台通用上传(scene=aw-recycle,公开可访问)。首页点击入口后展示此图,建议竖图或说明海报。"
@saved="loadConfigs"
/>
<AwRecycleDialog
v-if="cooperationFeedbackConfig"
v-model="cooperationFeedbackVisible"
:config="cooperationFeedbackConfig"
:defaults="defaultCooperationFeedbackConfig"
dialog-title="编辑 合作与反馈"
upload-scene="cooperation-feedback"
image-hint="走后台通用上传(scene=cooperation-feedback,公开可访问)。首页点击入口后展示此图,建议上传合作或反馈联系方式海报。"
@saved="loadConfigs" @saved="loadConfigs"
/> />
@@ -18,11 +18,19 @@ export interface AWRecycleConfig {
enabled: boolean enabled: boolean
} }
export interface CooperationFeedbackConfig {
title: string
subtitle: string
image_url: string
enabled: boolean
}
export interface MobileHomeConfig { export interface MobileHomeConfig {
announcements: string[] announcements: string[]
banners: HomeBannerSlide[] banners: HomeBannerSlide[]
publish_options: ListingPublishOptions publish_options: ListingPublishOptions
aw_recycle: AWRecycleConfig aw_recycle: AWRecycleConfig
cooperation_feedback: CooperationFeedbackConfig
} }
export const defaultAWRecycleConfig: AWRecycleConfig = { export const defaultAWRecycleConfig: AWRecycleConfig = {
@@ -32,6 +40,13 @@ export const defaultAWRecycleConfig: AWRecycleConfig = {
enabled: true, enabled: true,
} }
export const defaultCooperationFeedbackConfig: CooperationFeedbackConfig = {
title: '合作与反馈',
subtitle: '点击查看联系方式',
image_url: '',
enabled: true,
}
export const defaultHomeAnnouncements = [ export const defaultHomeAnnouncements = [
'平台担保交易,拒绝私下转账/共享验证码,交接全程留痕。', '平台担保交易,拒绝私下转账/共享验证码,交接全程留痕。',
'优先推荐同地区账号,减少异地登录保护触发。', '优先推荐同地区账号,减少异地登录保护触发。',
@@ -101,15 +116,38 @@ export function mergeHomeConfig(config?: Partial<MobileHomeConfig>): MobileHomeC
? awRaw.subtitle.trim() ? awRaw.subtitle.trim()
: defaultAWRecycleConfig.subtitle, : defaultAWRecycleConfig.subtitle,
image_url: image_url:
typeof awRaw?.image_url === 'string' ? awRaw.image_url.trim() : defaultAWRecycleConfig.image_url, typeof awRaw?.image_url === 'string'
? awRaw.image_url.trim()
: defaultAWRecycleConfig.image_url,
enabled: typeof awRaw?.enabled === 'boolean' ? awRaw.enabled : defaultAWRecycleConfig.enabled, enabled: typeof awRaw?.enabled === 'boolean' ? awRaw.enabled : defaultAWRecycleConfig.enabled,
} }
const cooperationRaw = config?.cooperation_feedback
const cooperationFeedback: CooperationFeedbackConfig = {
title:
typeof cooperationRaw?.title === 'string' && cooperationRaw.title.trim()
? cooperationRaw.title.trim()
: defaultCooperationFeedbackConfig.title,
subtitle:
typeof cooperationRaw?.subtitle === 'string' && cooperationRaw.subtitle.trim()
? cooperationRaw.subtitle.trim()
: defaultCooperationFeedbackConfig.subtitle,
image_url:
typeof cooperationRaw?.image_url === 'string'
? cooperationRaw.image_url.trim()
: defaultCooperationFeedbackConfig.image_url,
enabled:
typeof cooperationRaw?.enabled === 'boolean'
? cooperationRaw.enabled
: defaultCooperationFeedbackConfig.enabled,
}
return { return {
announcements: announcements.length ? announcements : defaultHomeAnnouncements, announcements: announcements.length ? announcements : defaultHomeAnnouncements,
banners: banners.length ? banners : defaultHomeBanners, banners: banners.length ? banners : defaultHomeBanners,
publish_options: mergeListingPublishOptions(config?.publish_options), publish_options: mergeListingPublishOptions(config?.publish_options),
aw_recycle: awRecycle, aw_recycle: awRecycle,
cooperation_feedback: cooperationFeedback,
} }
} }
@@ -3,10 +3,12 @@ import { computed, ref, watch } from 'vue'
import { RouterLink, onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import { RouterLink, onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { import {
defaultAWRecycleConfig, defaultAWRecycleConfig,
defaultCooperationFeedbackConfig,
defaultHomeAnnouncements, defaultHomeAnnouncements,
defaultHomeBanners, defaultHomeBanners,
fetchMobileHomeConfig, fetchMobileHomeConfig,
type AWRecycleConfig, type AWRecycleConfig,
type CooperationFeedbackConfig,
type HomeBannerSlide, type HomeBannerSlide,
} from '@/features/listings/api/homeConfig' } from '@/features/listings/api/homeConfig'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -43,6 +45,8 @@ const announcements = ref<string[]>(defaultHomeAnnouncements)
const banners = ref<HomeBannerSlide[]>(defaultHomeBanners) const banners = ref<HomeBannerSlide[]>(defaultHomeBanners)
const awRecycle = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig }) const awRecycle = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig })
const awRecycleDialogVisible = ref(false) const awRecycleDialogVisible = ref(false)
const cooperationFeedback = ref<CooperationFeedbackConfig>({ ...defaultCooperationFeedbackConfig })
const cooperationFeedbackDialogVisible = ref(false)
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions) const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
const sortBy = ref('recommended') const sortBy = ref('recommended')
const sortOptions = [ const sortOptions = [
@@ -156,11 +160,13 @@ async function loadHome() {
announcements.value = config.announcements announcements.value = config.announcements
banners.value = config.banners banners.value = config.banners
awRecycle.value = config.aw_recycle awRecycle.value = config.aw_recycle
cooperationFeedback.value = config.cooperation_feedback
publishOptions.value = config.publish_options publishOptions.value = config.publish_options
} catch { } catch {
announcements.value = defaultHomeAnnouncements announcements.value = defaultHomeAnnouncements
banners.value = defaultHomeBanners banners.value = defaultHomeBanners
awRecycle.value = { ...defaultAWRecycleConfig } awRecycle.value = { ...defaultAWRecycleConfig }
cooperationFeedback.value = { ...defaultCooperationFeedbackConfig }
publishOptions.value = emptyListingPublishOptions publishOptions.value = emptyListingPublishOptions
} finally { } finally {
loading.value = false loading.value = false
@@ -176,6 +182,14 @@ function openAWRecycle() {
awRecycleDialogVisible.value = true awRecycleDialogVisible.value = true
} }
function openCooperationFeedback() {
if (!cooperationFeedback.value.image_url) {
ElMessage.warning('合作与反馈图片尚未配置,请稍后再试')
return
}
cooperationFeedbackDialogVisible.value = true
}
function updateFilters(partial: Partial<typeof filters>) { function updateFilters(partial: Partial<typeof filters>) {
Object.assign(filters, partial) Object.assign(filters, partial)
} }
@@ -366,6 +380,15 @@ loadHome()
<strong>{{ awRecycle.title }}</strong> <strong>{{ awRecycle.title }}</strong>
<small>{{ awRecycle.subtitle }}</small> <small>{{ awRecycle.subtitle }}</small>
</button> </button>
<button
v-if="cooperationFeedback.enabled"
type="button"
class="biz-entry cooperation-feedback"
@click="openCooperationFeedback"
>
<strong>{{ cooperationFeedback.title }}</strong>
<small>{{ cooperationFeedback.subtitle }}</small>
</button>
</div> </div>
<el-dialog <el-dialog
@@ -387,6 +410,25 @@ loadHome()
<el-empty v-else description="暂无展示图片" /> <el-empty v-else description="暂无展示图片" />
</el-dialog> </el-dialog>
<el-dialog
v-model="cooperationFeedbackDialogVisible"
:title="cooperationFeedback.title"
width="720px"
class="aw-recycle-dialog"
align-center
destroy-on-close
>
<AuthImage
v-if="cooperationFeedback.image_url"
:source="cooperationFeedback.image_url"
:alt="cooperationFeedback.title"
fit="contain"
image-class="aw-recycle-image"
:preview-src-list="[cooperationFeedback.image_url]"
/>
<el-empty v-else description="暂无展示图片" />
</el-dialog>
<HomeFilters <HomeFilters
:filters="filters" :filters="filters"
:total-listings="totalListings" :total-listings="totalListings"
@@ -463,7 +505,7 @@ loadHome()
.biz-entry-grid { .biz-entry-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }
@@ -514,6 +556,11 @@ loadHome()
background: linear-gradient(135deg, #ffffff 0%, #f5f8ff 100%); background: linear-gradient(135deg, #ffffff 0%, #f5f8ff 100%);
} }
.biz-entry.cooperation-feedback {
border-color: #d9f0e5;
background: linear-gradient(135deg, #ffffff 0%, #f1fbf6 100%);
}
:deep(.aw-recycle-image) { :deep(.aw-recycle-image) {
display: block; display: block;
width: 100%; width: 100%;
@@ -376,6 +376,11 @@
background: linear-gradient(135deg, #ffffff 0%, #f5f8ff 100%); background: linear-gradient(135deg, #ffffff 0%, #f5f8ff 100%);
} }
.biz-entry.cooperation-feedback {
border-color: #d9f0e5;
background: linear-gradient(135deg, #ffffff 0%, #f1fbf6 100%);
}
.biz-entry strong { .biz-entry strong {
color: #17233d; color: #17233d;
font-size: 15px; font-size: 15px;
@@ -15,10 +15,12 @@ import {
} from '@/features/listings/api/listings' } from '@/features/listings/api/listings'
import { import {
defaultAWRecycleConfig, defaultAWRecycleConfig,
defaultCooperationFeedbackConfig,
defaultHomeAnnouncements, defaultHomeAnnouncements,
defaultHomeBanners, defaultHomeBanners,
fetchMobileHomeConfig, fetchMobileHomeConfig,
type AWRecycleConfig, type AWRecycleConfig,
type CooperationFeedbackConfig,
type HomeBannerSlide, type HomeBannerSlide,
} from '@/features/listings/api/homeConfig' } from '@/features/listings/api/homeConfig'
import { showImagePreview, showToast } from 'vant' import { showImagePreview, showToast } from 'vant'
@@ -81,6 +83,8 @@ const announcements = ref<string[]>(defaultHomeAnnouncements)
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners) const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners)
const awRecycle = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig }) const awRecycle = ref<AWRecycleConfig>({ ...defaultAWRecycleConfig })
const awRecyclePreviewVisible = ref(false) const awRecyclePreviewVisible = ref(false)
const cooperationFeedback = ref<CooperationFeedbackConfig>({ ...defaultCooperationFeedbackConfig })
const cooperationFeedbackPreviewVisible = ref(false)
const supportLoading = ref(false) const supportLoading = ref(false)
const showBackTop = ref(false) const showBackTop = ref(false)
const mobilePageSize = 10 const mobilePageSize = 10
@@ -463,6 +467,7 @@ async function loadHomeConfig() {
announcements.value = config.announcements announcements.value = config.announcements
bannerSlides.value = config.banners bannerSlides.value = config.banners
awRecycle.value = config.aw_recycle awRecycle.value = config.aw_recycle
cooperationFeedback.value = config.cooperation_feedback
publishOptions.value = config.publish_options publishOptions.value = config.publish_options
homeCache.saveHomeConfig({ homeCache.saveHomeConfig({
announcements: config.announcements, announcements: config.announcements,
@@ -473,6 +478,7 @@ async function loadHomeConfig() {
announcements.value = defaultHomeAnnouncements announcements.value = defaultHomeAnnouncements
bannerSlides.value = defaultHomeBanners bannerSlides.value = defaultHomeBanners
awRecycle.value = { ...defaultAWRecycleConfig } awRecycle.value = { ...defaultAWRecycleConfig }
cooperationFeedback.value = { ...defaultCooperationFeedbackConfig }
publishOptions.value = emptyListingPublishOptions publishOptions.value = emptyListingPublishOptions
} }
} }
@@ -485,6 +491,14 @@ function openAWRecycle() {
awRecyclePreviewVisible.value = true awRecyclePreviewVisible.value = true
} }
function openCooperationFeedback() {
if (!cooperationFeedback.value.image_url) {
showToast({ message: '合作与反馈图片尚未配置', icon: 'warning-o' })
return
}
cooperationFeedbackPreviewVisible.value = true
}
function openBannerPreview(slide: HomeBannerSlide) { function openBannerPreview(slide: HomeBannerSlide) {
const url = slide.image_url?.trim() const url = slide.image_url?.trim()
if (!url) return if (!url) return
@@ -512,6 +526,7 @@ async function onRefresh() {
announcements.value = nextHomeConfig.announcements announcements.value = nextHomeConfig.announcements
bannerSlides.value = nextHomeConfig.banners bannerSlides.value = nextHomeConfig.banners
awRecycle.value = nextHomeConfig.aw_recycle awRecycle.value = nextHomeConfig.aw_recycle
cooperationFeedback.value = nextHomeConfig.cooperation_feedback
publishOptions.value = nextHomeConfig.publish_options publishOptions.value = nextHomeConfig.publish_options
homeCache.saveHomeConfig({ homeCache.saveHomeConfig({
announcements: nextHomeConfig.announcements, announcements: nextHomeConfig.announcements,
@@ -894,6 +909,15 @@ syncMobileHomeQuery()
<strong>{{ awRecycle.title }}</strong> <strong>{{ awRecycle.title }}</strong>
<small>{{ awRecycle.subtitle }}</small> <small>{{ awRecycle.subtitle }}</small>
</button> </button>
<button
v-if="cooperationFeedback.enabled"
type="button"
class="biz-entry cooperation-feedback"
@click="openCooperationFeedback"
>
<strong>{{ cooperationFeedback.title }}</strong>
<small>{{ cooperationFeedback.subtitle }}</small>
</button>
</div> </div>
<div class="zone-strip" aria-label="账号专区"> <div class="zone-strip" aria-label="账号专区">
@@ -1111,6 +1135,32 @@ syncMobileHomeQuery()
</div> </div>
</van-popup> </van-popup>
<van-popup
v-model:show="cooperationFeedbackPreviewVisible"
position="center"
round
closeable
teleport="body"
class="aw-recycle-popup"
:style="{ width: 'min(92vw, 420px)', maxHeight: '86vh' }"
>
<div class="aw-recycle-preview">
<header class="aw-recycle-preview-head">
<h3>{{ cooperationFeedback.title }}</h3>
</header>
<div class="aw-recycle-preview-body">
<AuthImage
v-if="cooperationFeedback.image_url"
:source="cooperationFeedback.image_url"
:alt="cooperationFeedback.title"
fit="contain"
image-class="aw-recycle-preview-img"
/>
<van-empty v-else description="暂无展示图片" />
</div>
</div>
</van-popup>
<MobileBottomNav /> <MobileBottomNav />
</main> </main>
</template> </template>
+2
View File
@@ -4,6 +4,7 @@ const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
announcement: 1600, announcement: 1600,
'home-banner': 1920, 'home-banner': 1920,
'aw-recycle': 1920, 'aw-recycle': 1920,
'cooperation-feedback': 1920,
listing: 1920, listing: 1920,
dispute: 1920, dispute: 1920,
handoff: 1920, handoff: 1920,
@@ -17,6 +18,7 @@ const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
announcement: 0.84, announcement: 0.84,
'home-banner': 0.84, 'home-banner': 0.84,
'aw-recycle': 0.84, 'aw-recycle': 0.84,
'cooperation-feedback': 0.84,
listing: 0.84, listing: 0.84,
dispute: 0.86, dispute: 0.86,
handoff: 0.86, handoff: 0.86,