Files
hfb_sys/frontend/src/views/admin/AdminSystemConfigsView.vue
T
2026-05-23 22:31:27 +08:00

1115 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import {
emptyListingSalePriceConfig,
emptyListingPublishOptions,
mergeListingSalePriceConfig,
mergeListingPublishOptions,
type ListingPublishOptions,
type PublishSalePriceConfig,
} from '@/api/listingOptions'
import {
defaultHomeAnnouncements,
defaultHomeBanners,
mergeHomeConfig,
type HomeBannerSlide,
} from '@/api/homeConfig'
import { uploadAdminFile } from '@/api/files'
import { fetchSystemConfigs, updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const submitting = ref(false)
const configs = ref<SystemConfig[]>([])
const activeConfig = ref<SystemConfig | null>(null)
const value = ref('')
const description = ref('')
const publishOptionsDraft = ref<ListingPublishOptions>(cloneOptions(emptyListingPublishOptions))
const salePriceConfigDraft = ref<PublishSalePriceConfig>(cloneSalePriceConfig(emptyListingSalePriceConfig))
const homeAnnouncementLines = ref(itemsToLines(defaultHomeAnnouncements))
const homeBannersDraft = ref<HomeBannerSlide[]>(cloneHomeBanners(defaultHomeBanners))
const uploadingBannerIndex = ref<number | null>(null)
const publishConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_options') || null)
const salePriceConfig = computed(() => configs.value.find((item) => item.key === 'listing.sale_price_config') || null)
const homeAnnouncementsConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_announcements') || null)
const homeBannersConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_banners') || null)
const regularConfigs = computed(() =>
configs.value.filter(
(item) =>
item.key !== 'listing.publish_options' &&
item.key !== 'listing.sale_price_config' &&
item.key !== 'mobile.home_announcements' &&
item.key !== 'mobile.home_banners',
),
)
const isPublishOptionsConfig = computed(() => activeConfig.value?.key === 'listing.publish_options')
const isSalePriceConfig = computed(() => activeConfig.value?.key === 'listing.sale_price_config')
const isHomeAnnouncementsConfig = computed(() => activeConfig.value?.key === 'mobile.home_announcements')
const isHomeBannersConfig = computed(() => activeConfig.value?.key === 'mobile.home_banners')
const publishStats = computed(() => {
const options = safeParsePublishOptions(publishConfig.value?.value || '')
return {
baseCount:
options.server_options.length +
options.rank_options.length +
options.insurance_options.length +
options.level_options.length,
skinCount: options.skin_groups.reduce((sum, group) => sum + group.options.length, 0),
resourceCount: options.quantity_items.length,
screenshotCount: options.screenshot_slots.length,
regionCount: options.region_options.length,
}
})
const salePriceStats = computed(() => {
const config = safeParseSalePriceConfig(salePriceConfig.value?.value || '')
return {
fixedCount: config.fixed_markup_rules.length,
ratioCount: config.ratio_adjustment_rules.length,
}
})
const homeStats = computed(() => {
const announcements = parseHomeAnnouncements(homeAnnouncementsConfig.value?.value || '', false)
const banners = parseHomeBanners(homeBannersConfig.value?.value || '', false)
return {
announcementCount: announcements.length,
bannerCount: banners.length,
}
})
const isStructuredConfig = computed(() => {
const trimmed = value.value.trim()
return (
isPublishOptionsConfig.value ||
isSalePriceConfig.value ||
isHomeAnnouncementsConfig.value ||
isHomeBannersConfig.value ||
trimmed.startsWith('{') ||
trimmed.startsWith('[')
)
})
const dialogWidth = computed(() => {
if (isPublishOptionsConfig.value || isSalePriceConfig.value || isHomeBannersConfig.value) return '920px'
if (isHomeAnnouncementsConfig.value) return '680px'
return '560px'
})
onMounted(loadConfigs)
async function loadConfigs() {
loading.value = true
try {
configs.value = await fetchSystemConfigs()
} finally {
loading.value = false
}
}
function openEdit(row: SystemConfig) {
activeConfig.value = row
value.value = row.value
description.value = row.description
if (row.key === 'listing.publish_options') {
publishOptionsDraft.value = parsePublishOptions(row.value)
}
if (row.key === 'listing.sale_price_config') {
salePriceConfigDraft.value = parseSalePriceConfig(row.value)
}
if (row.key === 'mobile.home_announcements') {
homeAnnouncementLines.value = itemsToLines(parseHomeAnnouncements(row.value, true))
}
if (row.key === 'mobile.home_banners') {
homeBannersDraft.value = parseHomeBanners(row.value, true)
}
}
async function handleSave() {
if (!activeConfig.value) return
submitting.value = true
try {
if (isPublishOptionsConfig.value) {
value.value = JSON.stringify(publishOptionsDraft.value, null, 2)
}
if (isSalePriceConfig.value) {
value.value = JSON.stringify(salePriceConfigDraft.value, null, 2)
}
if (isHomeAnnouncementsConfig.value) {
value.value = JSON.stringify(linesToItems(homeAnnouncementLines.value), null, 2)
}
if (isHomeBannersConfig.value) {
value.value = JSON.stringify(
homeBannersDraft.value.filter((item) => item.title.trim() || item.image_url?.trim()),
null,
2,
)
}
await updateSystemConfig(activeConfig.value.key, {
value: value.value,
description: description.value,
})
ElMessage.success('配置已更新')
activeConfig.value = null
await loadConfigs()
} catch (error) {
ElMessage.error(readError(error, '保存失败'))
} finally {
submitting.value = false
}
}
function parsePublishOptions(raw: string) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : emptyListingPublishOptions
return cloneOptions(mergeListingPublishOptions(parsed))
} catch {
ElMessage.warning('发布选项 JSON 解析失败,已清空草稿')
return cloneOptions(emptyListingPublishOptions)
}
}
function safeParsePublishOptions(raw: string) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : emptyListingPublishOptions
return cloneOptions(mergeListingPublishOptions(parsed))
} catch {
return cloneOptions(emptyListingPublishOptions)
}
}
function parseSalePriceConfig(raw: string) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : emptyListingSalePriceConfig
return cloneSalePriceConfig(mergeListingSalePriceConfig(parsed))
} catch {
ElMessage.warning('出售定价规则 JSON 解析失败,已清空草稿')
return cloneSalePriceConfig(emptyListingSalePriceConfig)
}
}
function safeParseSalePriceConfig(raw: string) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : emptyListingSalePriceConfig
return cloneSalePriceConfig(mergeListingSalePriceConfig(parsed))
} catch {
return cloneSalePriceConfig(emptyListingSalePriceConfig)
}
}
function parseHomeAnnouncements(raw: string, showWarning = false) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : defaultHomeAnnouncements
return mergeHomeConfig({ announcements: Array.isArray(parsed) ? parsed : [] }).announcements
} catch {
if (showWarning) ElMessage.warning('首页公告 JSON 解析失败,已使用默认配置')
return [...defaultHomeAnnouncements]
}
}
function parseHomeBanners(raw: string, showWarning = false) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : defaultHomeBanners
return cloneHomeBanners(mergeHomeConfig({ banners: Array.isArray(parsed) ? parsed : [] }).banners)
} catch {
if (showWarning) ElMessage.warning('首页轮播 JSON 解析失败,已使用默认配置')
return cloneHomeBanners(defaultHomeBanners)
}
}
function cloneOptions(options: ListingPublishOptions) {
return JSON.parse(JSON.stringify(options)) as ListingPublishOptions
}
function cloneSalePriceConfig(config: PublishSalePriceConfig) {
return JSON.parse(JSON.stringify(config)) as PublishSalePriceConfig
}
function cloneHomeBanners(banners: HomeBannerSlide[]) {
return JSON.parse(JSON.stringify(banners)) as HomeBannerSlide[]
}
function linesToItems(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
}
function itemsToLines(items: string[]) {
return items.join('\n')
}
function updateOptionLines(key: keyof Pick<ListingPublishOptions, 'server_options' | 'face_options' | 'rank_options' | 'insurance_options' | 'level_options' | 'login_method_options' | 'region_options' | 'ban_record_options' | 'ban_evidence_options'>, nextValue: string) {
publishOptionsDraft.value[key] = linesToItems(nextValue)
}
function updateSkinGroupOptions(index: number, nextValue: string) {
const group = publishOptionsDraft.value.skin_groups[index]
if (group) {
group.options = linesToItems(nextValue)
}
}
function addSkinGroup() {
publishOptionsDraft.value.skin_groups.push({
key: `group_${Date.now()}`,
title: '新皮肤分类',
options: [],
})
}
function removeSkinGroup(index: number) {
publishOptionsDraft.value.skin_groups.splice(index, 1)
}
function addQuantityItem() {
publishOptionsDraft.value.quantity_items.push({
key: `item_${Date.now()}`,
label: '',
price: '',
})
}
function removeQuantityItem(index: number) {
publishOptionsDraft.value.quantity_items.splice(index, 1)
}
function addScreenshotSlot() {
publishOptionsDraft.value.screenshot_slots.push({
key: `screenshot_${Date.now()}`,
label: '',
required: false,
hint: '',
})
}
function removeScreenshotSlot(index: number) {
publishOptionsDraft.value.screenshot_slots.splice(index, 1)
}
function addInsuranceBaseRatio() {
publishOptionsDraft.value.ratio_config.insurance_base_ratios.push({
insurance: '',
ratio: 0,
})
}
function removeInsuranceBaseRatio(index: number) {
publishOptionsDraft.value.ratio_config.insurance_base_ratios.splice(index, 1)
}
function addRatioConfigItem() {
publishOptionsDraft.value.ratio_config.config_items.push({
key: `config_${Date.now()}`,
label: '',
kind: 'skin_group',
group_key: '',
missing_penalty: 1,
})
}
function removeRatioConfigItem(index: number) {
publishOptionsDraft.value.ratio_config.config_items.splice(index, 1)
}
function addCoinCorrection() {
publishOptionsDraft.value.ratio_config.coin_corrections.push({
threshold_m: 0,
correction: 0,
})
}
function removeCoinCorrection(index: number) {
publishOptionsDraft.value.ratio_config.coin_corrections.splice(index, 1)
}
function addSaleFixedMarkupRule() {
salePriceConfigDraft.value.fixed_markup_rules.push({
min_m: 0,
max_m: 0,
markup_amount: 0,
})
}
function removeSaleFixedMarkupRule(index: number) {
salePriceConfigDraft.value.fixed_markup_rules.splice(index, 1)
}
function addSaleRatioAdjustmentRule() {
salePriceConfigDraft.value.ratio_adjustment_rules.push({
min_m: 0,
max_m: 0,
ratio_subtract: 0,
})
}
function removeSaleRatioAdjustmentRule(index: number) {
salePriceConfigDraft.value.ratio_adjustment_rules.splice(index, 1)
}
function addHomeBanner() {
homeBannersDraft.value.push({
eyebrow: '首页推荐',
title: '',
badge: 'NEW',
pill: '',
tone: 'blue',
image_url: '',
})
}
function removeHomeBanner(index: number) {
homeBannersDraft.value.splice(index, 1)
}
function resetHomeAnnouncements() {
homeAnnouncementLines.value = itemsToLines(defaultHomeAnnouncements)
}
function resetHomeBanners() {
homeBannersDraft.value = cloneHomeBanners(defaultHomeBanners)
}
async function handleHomeBannerUpload(event: Event, index: number) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingBannerIndex.value = index
try {
const uploaded = await uploadAdminFile(file, 'home-banner')
const banner = homeBannersDraft.value[index]
if (banner) {
banner.image_url = uploaded.url
if (!banner.title) {
banner.title = banner.eyebrow || '首页轮播图'
}
}
ElMessage.success('图片已上传')
} catch (error) {
ElMessage.error(readError(error, '图片上传失败'))
} finally {
uploadingBannerIndex.value = null
}
}
function formatHomeConfigStatus(row: SystemConfig | null, fallback: string) {
if (!row) return fallback
return formatDateTime(row.updated_at, fallback)
}
function formatConfigValue(row: SystemConfig) {
const trimmed = row.value.trim()
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return 'JSON 配置'
}
return row.value
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<section class="page">
<div class="page-header">
<p class="eyebrow">Configs</p>
<h1>系统配置</h1>
<p>管理交接超时归还超时短信限流最低押金和抽成比例</p>
</div>
<section v-if="publishConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Publish Options</p>
<h2>发布表单选项</h2>
<span>管理移动端发布页的区服段位皮肤额外消耗品截图材料和地区选项</span>
</div>
<el-button type="primary" @click="openEdit(publishConfig)">编辑发布选项</el-button>
</div>
<div class="publish-stat-grid">
<div class="publish-stat">
<strong>{{ publishStats.baseCount }}</strong>
<span>基础选项</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.skinCount }}</strong>
<span>皮肤项</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.resourceCount }}</strong>
<span>额外消耗品</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.screenshotCount }}</strong>
<span>截图材料</span>
</div>
<div class="publish-stat">
<strong>{{ publishStats.regionCount }}</strong>
<span>地区</span>
</div>
</div>
</section>
<section v-if="salePriceConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Sale Pricing</p>
<h2>内部出售定价规则</h2>
<span>管理出售专用比例计算规则仅用于平台内部定价计算不在发布页展示</span>
</div>
<el-button type="primary" @click="openEdit(salePriceConfig)">编辑出售规则</el-button>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
<strong>{{ salePriceStats.fixedCount }}</strong>
<span>固定加价</span>
</div>
<div class="publish-stat">
<strong>{{ salePriceStats.ratioCount }}</strong>
<span>比例修正</span>
</div>
<div class="publish-stat">
<strong>内部</strong>
<span>不展示给发布用户</span>
</div>
<div class="publish-stat">
<strong>配置</strong>
<span>listing.sale_price_config</span>
</div>
<div class="publish-stat">
<strong>更新</strong>
<span>{{ formatHomeConfigStatus(salePriceConfig, '未初始化') }}</span>
</div>
</div>
</section>
<section v-if="homeAnnouncementsConfig || homeBannersConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Home Content</p>
<h2>移动端首页运营配置</h2>
<span>管理首页公告滚动内容和顶部轮播图保存后移动端会从接口读取最新配置</span>
</div>
<div class="panel-actions">
<el-button v-if="homeAnnouncementsConfig" @click="openEdit(homeAnnouncementsConfig)">编辑公告</el-button>
<el-button v-if="homeBannersConfig" type="primary" @click="openEdit(homeBannersConfig)">编辑轮播图</el-button>
</div>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
<strong>{{ homeStats.announcementCount }}</strong>
<span>公告条数</span>
</div>
<div class="publish-stat">
<strong>{{ homeStats.bannerCount }}</strong>
<span>轮播图</span>
</div>
<div class="publish-stat">
<strong>接口</strong>
<span>/api/mobile-home-config</span>
</div>
<div class="publish-stat">
<strong>公告</strong>
<span>{{ formatHomeConfigStatus(homeAnnouncementsConfig, '未初始化') }}</span>
</div>
<div class="publish-stat">
<strong>轮播</strong>
<span>{{ formatHomeConfigStatus(homeBannersConfig, '未初始化') }}</span>
</div>
</div>
</section>
<el-table v-loading="loading" class="table-panel" :data="regularConfigs">
<el-table-column prop="key" label="配置项" min-width="260" />
<el-table-column label="当前值" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="formatConfigValue(row) === 'JSON 配置'" type="info">JSON 配置</el-tag>
<span v-else class="config-value">{{ formatConfigValue(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="260" show-overflow-tooltip />
<el-table-column prop="updated_by" label="更新人" width="100" />
<el-table-column label="更新时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :model-value="!!activeConfig" title="编辑系统配置" :width="dialogWidth" @update:model-value="activeConfig = null">
<div v-if="activeConfig" class="dialog-body">
<p><strong>{{ activeConfig.key }}</strong></p>
<div v-if="isPublishOptionsConfig" class="publish-options-editor">
<div class="editor-toolbar">
<span>发布页选项配置</span>
</div>
<div class="editor-grid">
<el-form-item label="区服">
<el-input
:model-value="itemsToLines(publishOptionsDraft.server_options)"
type="textarea"
:rows="4"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('server_options', $event)"
/>
</el-form-item>
<el-form-item label="段位">
<el-input
:model-value="itemsToLines(publishOptionsDraft.rank_options)"
type="textarea"
:rows="4"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('rank_options', $event)"
/>
</el-form-item>
<el-form-item label="保险">
<el-input
:model-value="itemsToLines(publishOptionsDraft.insurance_options)"
type="textarea"
:rows="4"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('insurance_options', $event)"
/>
</el-form-item>
<el-form-item label="体力/负重">
<el-input
:model-value="itemsToLines(publishOptionsDraft.level_options)"
type="textarea"
:rows="4"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('level_options', $event)"
/>
</el-form-item>
<el-form-item label="人脸选项">
<el-input
:model-value="itemsToLines(publishOptionsDraft.face_options)"
type="textarea"
:rows="3"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('face_options', $event)"
/>
</el-form-item>
<el-form-item label="上号方式">
<el-input
:model-value="itemsToLines(publishOptionsDraft.login_method_options)"
type="textarea"
:rows="3"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('login_method_options', $event)"
/>
</el-form-item>
</div>
<el-form-item label="常用登录地区">
<el-input
:model-value="itemsToLines(publishOptionsDraft.region_options)"
type="textarea"
:rows="5"
placeholder="一行一个省市"
@update:model-value="updateOptionLines('region_options', $event)"
/>
</el-form-item>
<div class="editor-grid">
<el-form-item label="封禁记录">
<el-input
:model-value="itemsToLines(publishOptionsDraft.ban_record_options)"
type="textarea"
:rows="3"
placeholder="一行一个选项"
@update:model-value="updateOptionLines('ban_record_options', $event)"
/>
</el-form-item>
<el-form-item label="需传安全图">
<el-input
:model-value="itemsToLines(publishOptionsDraft.ban_evidence_options)"
type="textarea"
:rows="3"
placeholder="选择这些封禁记录时,腾讯安全中心截图必传"
@update:model-value="updateOptionLines('ban_evidence_options', $event)"
/>
</el-form-item>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>发布规则</strong>
</div>
<el-form-item label="最低烽火等级">
<el-input-number v-model="publishOptionsDraft.fire_level_min" :min="1" :step="1" class="full-control" />
</el-form-item>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>押金与价格提示</strong>
</div>
<el-form-item label="押金提示">
<el-input v-model="publishOptionsDraft.price_config.deposit_placeholder" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="价格提示">
<el-input v-model="publishOptionsDraft.price_config.price_placeholder" />
</el-form-item>
<el-form-item label="比例说明">
<el-input v-model="publishOptionsDraft.price_config.ratio_description" />
</el-form-item>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>比例计算配置</strong>
</div>
<div class="editor-block-title subtle-title">
<span>保险基础比例</span>
<el-button size="small" @click="addInsuranceBaseRatio">添加保险比例</el-button>
</div>
<el-table :data="publishOptionsDraft.ratio_config.insurance_base_ratios" size="small" border>
<el-table-column label="保险" min-width="160">
<template #default="{ row }"><el-input v-model="row.insurance" placeholder="3*3" /></template>
</el-table-column>
<el-table-column label="基础比例" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.ratio" :min="0" :step="0.5" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeInsuranceBaseRatio($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="editor-block-title subtle-title">
<span>配置项缺失加成</span>
<el-button size="small" @click="addRatioConfigItem">添加配置项</el-button>
</div>
<el-table :data="publishOptionsDraft.ratio_config.config_items" size="small" border>
<el-table-column label="Key" min-width="140">
<template #default="{ row }"><el-input v-model="row.key" /></template>
</el-table-column>
<el-table-column label="名称" min-width="140">
<template #default="{ row }"><el-input v-model="row.label" /></template>
</el-table-column>
<el-table-column label="类型" min-width="160">
<template #default="{ row }">
<el-select v-model="row.kind">
<el-option label="皮肤分组" value="skin_group" />
<el-option label="满体力" value="max_stamina" />
<el-option label="满负重" value="max_load" />
</el-select>
</template>
</el-table-column>
<el-table-column label="皮肤分组 Key" min-width="150">
<template #default="{ row }"><el-input v-model="row.group_key" /></template>
</el-table-column>
<el-table-column label="缺失加成" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.missing_penalty" :min="0" :step="0.5" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeRatioConfigItem($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="editor-block-title subtle-title">
<span>大额币修正</span>
<el-button size="small" @click="addCoinCorrection">添加修正</el-button>
</div>
<el-table :data="publishOptionsDraft.ratio_config.coin_corrections" size="small" border>
<el-table-column label="大于 M" min-width="130">
<template #default="{ row }"><el-input-number v-model="row.threshold_m" :min="0" :step="10" /></template>
</el-table-column>
<el-table-column label="比例加成" min-width="130">
<template #default="{ row }"><el-input-number v-model="row.correction" :min="0" :step="0.5" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeCoinCorrection($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>皮肤分类</strong>
<el-button size="small" @click="addSkinGroup">添加分类</el-button>
</div>
<div v-for="(group, index) in publishOptionsDraft.skin_groups" :key="`${group.key}-${index}`" class="skin-config-row">
<el-input v-model="group.key" placeholder="分类 key" />
<el-input v-model="group.title" placeholder="分类名称" />
<el-input
:model-value="itemsToLines(group.options)"
type="textarea"
:rows="4"
placeholder="皮肤名称,一行一个"
@update:model-value="updateSkinGroupOptions(index, $event)"
/>
<el-button type="danger" plain @click="removeSkinGroup(index)">删除</el-button>
</div>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>额外消耗品</strong>
<el-button size="small" @click="addQuantityItem">添加消耗品</el-button>
</div>
<el-table :data="publishOptionsDraft.quantity_items" size="small" border>
<el-table-column label="Key" min-width="150">
<template #default="{ row }"><el-input v-model="row.key" /></template>
</el-table-column>
<el-table-column label="名称" min-width="150">
<template #default="{ row }"><el-input v-model="row.label" /></template>
</el-table-column>
<el-table-column label="价格" min-width="130">
<template #default="{ row }"><el-input v-model="row.price" /></template>
</el-table-column>
<el-table-column label="提示" min-width="220">
<template #default="{ row }"><el-input v-model="row.placeholder" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeQuantityItem($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="editor-block">
<div class="editor-block-title">
<strong>截图材料</strong>
<el-button size="small" @click="addScreenshotSlot">添加截图项</el-button>
</div>
<el-table :data="publishOptionsDraft.screenshot_slots" size="small" border>
<el-table-column label="Key" min-width="150">
<template #default="{ row }"><el-input v-model="row.key" /></template>
</el-table-column>
<el-table-column label="名称" min-width="150">
<template #default="{ row }"><el-input v-model="row.label" /></template>
</el-table-column>
<el-table-column label="必填" width="90">
<template #default="{ row }"><el-switch v-model="row.required" /></template>
</el-table-column>
<el-table-column label="提示" min-width="260">
<template #default="{ row }"><el-input v-model="row.hint" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeScreenshotSlot($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div v-else-if="isSalePriceConfig" class="publish-options-editor">
<div class="editor-toolbar">
<span>内部出售定价规则</span>
</div>
<div class="editor-block">
<div class="editor-block-title subtle-title">
<span>90M 及以下固定加价出售价格 = 回收价格 + 固定加价</span>
<el-button size="small" @click="addSaleFixedMarkupRule">添加固定加价</el-button>
</div>
<el-table :data="salePriceConfigDraft.fixed_markup_rules" size="small" border>
<el-table-column label="最小 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
</el-table-column>
<el-table-column label="最大 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.max_m" :min="0" :step="10" /></template>
</el-table-column>
<el-table-column label="加价金额/元" min-width="140">
<template #default="{ row }"><el-input-number v-model="row.markup_amount" :min="0" :step="1" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeSaleFixedMarkupRule($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="editor-block-title subtle-title">
<span>90M 以上比例修正出售比例 = 回收比例 - 比例修正最大 M 0 表示无上限</span>
<el-button size="small" @click="addSaleRatioAdjustmentRule">添加比例修正</el-button>
</div>
<el-table :data="salePriceConfigDraft.ratio_adjustment_rules" size="small" border>
<el-table-column label="最小 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.min_m" :min="0" :step="10" /></template>
</el-table-column>
<el-table-column label="最大 M" min-width="120">
<template #default="{ row }"><el-input-number v-model="row.max_m" :min="0" :step="10" /></template>
</el-table-column>
<el-table-column label="比例修正" min-width="130">
<template #default="{ row }"><el-input-number v-model="row.ratio_subtract" :min="0" :step="0.5" /></template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeSaleRatioAdjustmentRule($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div v-else-if="isHomeAnnouncementsConfig" class="home-config-editor">
<div class="editor-toolbar">
<span>首页公告</span>
<el-button size="small" @click="resetHomeAnnouncements">恢复默认公告</el-button>
</div>
<el-form-item label="公告内容">
<el-input
v-model="homeAnnouncementLines"
type="textarea"
:rows="8"
placeholder="一行一条公告,移动端会自动轮播展示"
/>
</el-form-item>
</div>
<div v-else-if="isHomeBannersConfig" class="home-config-editor">
<div class="editor-toolbar">
<span>首页轮播图</span>
<div class="panel-actions">
<el-button size="small" @click="resetHomeBanners">恢复默认轮播</el-button>
<el-button size="small" type="primary" @click="addHomeBanner">添加轮播</el-button>
</div>
</div>
<el-table :data="homeBannersDraft" size="small" border>
<el-table-column label="图片" min-width="240">
<template #default="{ row, $index }">
<div class="banner-image-editor">
<el-input v-model="row.image_url" placeholder="图片 URL,留空使用文字卡片" />
<div class="banner-image-tools">
<img v-if="row.image_url" :src="row.image_url" alt="轮播图预览" />
<span v-else>无图</span>
<label class="upload-trigger">
<input type="file" accept="image/jpeg,image/png,image/webp" @change="handleHomeBannerUpload($event, $index)" />
{{ uploadingBannerIndex === $index ? '上传中' : '上传图片' }}
</label>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="眉标" min-width="160">
<template #default="{ row }"><el-input v-model="row.eyebrow" placeholder="如 三角洲行动账号专区" /></template>
</el-table-column>
<el-table-column label="主标题" min-width="220">
<template #default="{ row }"><el-input v-model="row.title" placeholder="轮播主文案" /></template>
</el-table-column>
<el-table-column label="角标" width="110">
<template #default="{ row }"><el-input v-model="row.badge" placeholder="HOT" /></template>
</el-table-column>
<el-table-column label="胶囊文案" min-width="220">
<template #default="{ row }"><el-input v-model="row.pill" placeholder="底部补充文案" /></template>
</el-table-column>
<el-table-column label="色调" width="130">
<template #default="{ row }">
<el-select v-model="row.tone" placeholder="色调">
<el-option label="蓝色" value="blue" />
<el-option label="绿色" value="green" />
<el-option label="橙色" value="orange" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button size="small" type="danger" plain @click="removeHomeBanner($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-input v-else-if="isStructuredConfig" v-model="value" type="textarea" :rows="12" placeholder="配置值 JSON" />
<el-input v-else v-model="value" placeholder="配置值" />
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
</div>
<template #footer>
<el-button @click="activeConfig = null">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.publish-config-panel {
display: grid;
gap: 18px;
margin-bottom: 18px;
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.publish-config-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.publish-config-main h2 {
margin: 4px 0 6px;
color: #111827;
font-size: 20px;
}
.publish-config-main span {
color: #6b7280;
font-size: 13px;
}
.publish-stat-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.home-stat-grid .publish-stat strong {
font-size: 18px;
}
.publish-stat {
display: grid;
gap: 4px;
padding: 12px;
border-radius: 8px;
background: #f8fafc;
}
.publish-stat strong {
color: #1477ff;
font-size: 22px;
line-height: 1;
}
.publish-stat span,
.config-value {
color: #4b5563;
font-size: 13px;
}
.publish-options-editor {
display: grid;
gap: 16px;
}
.home-config-editor {
display: grid;
gap: 14px;
}
.banner-image-editor {
display: grid;
gap: 8px;
}
.banner-image-tools {
display: flex;
align-items: center;
gap: 8px;
}
.banner-image-tools img,
.banner-image-tools span {
width: 68px;
height: 38px;
border-radius: 6px;
background: #f3f4f6;
object-fit: cover;
}
.banner-image-tools span {
display: grid;
place-items: center;
color: #9ca3af;
font-size: 12px;
}
.upload-trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
color: #606266;
cursor: pointer;
font-size: 12px;
}
.upload-trigger input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.editor-toolbar,
.editor-block-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.editor-toolbar span {
color: #30343a;
font-weight: 700;
}
.editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.editor-block {
display: grid;
gap: 10px;
}
.subtle-title {
margin-top: 6px;
color: #4b5563;
font-size: 13px;
}
.skin-config-row {
display: grid;
grid-template-columns: 140px 180px minmax(0, 1fr) 72px;
gap: 8px;
align-items: start;
}
@media (max-width: 1100px) {
.publish-stat-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>