优化后端可以编辑皮肤等等

This commit is contained in:
yml
2026-05-22 23:34:18 +08:00
parent 52ac6b7827
commit b68411166d
11 changed files with 1286 additions and 694 deletions
@@ -16,3 +16,36 @@ type UpdateRequest struct {
Value string `json:"value" binding:"required"`
Description string `json:"description"`
}
type PublishOptionsDTO struct {
ServerOptions []string `json:"server_options"`
FaceOptions []string `json:"face_options"`
RankOptions []string `json:"rank_options"`
InsuranceOptions []string `json:"insurance_options"`
LevelOptions []string `json:"level_options"`
LoginMethodOptions []string `json:"login_method_options"`
RegionOptions []string `json:"region_options"`
SkinGroups []PublishOptionGroup `json:"skin_groups"`
QuantityItems []PublishQuantityItem `json:"quantity_items"`
ScreenshotSlots []PublishScreenshotSlot `json:"screenshot_slots"`
}
type PublishOptionGroup struct {
Key string `json:"key"`
Title string `json:"title"`
Options []string `json:"options"`
}
type PublishQuantityItem struct {
Key string `json:"key"`
Label string `json:"label"`
Price string `json:"price"`
Placeholder string `json:"placeholder,omitempty"`
}
type PublishScreenshotSlot struct {
Key string `json:"key"`
Label string `json:"label"`
Required bool `json:"required"`
Hint string `json:"hint"`
}
@@ -27,6 +27,15 @@ func (h *Handler) List(c *gin.Context) {
response.OK(c, gin.H{"items": items})
}
func (h *Handler) PublishOptions(c *gin.Context) {
options, err := h.service.PublishOptions()
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, options)
}
func (h *Handler) Update(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
@@ -0,0 +1,76 @@
package systemconfig
import "encoding/json"
const publishOptionsConfigKey = "listing.publish_options"
func defaultPublishOptionsConfigValue() string {
raw, err := json.Marshal(DefaultPublishOptions())
if err != nil {
return "{}"
}
return string(raw)
}
func DefaultPublishOptions() PublishOptionsDTO {
return PublishOptionsDTO{
ServerOptions: []string{"QQ", "微信", "Steam"},
FaceOptions: []string{"是", "否"},
RankOptions: []string{"青铜", "白银", "黄金", "铂金", "钻石", "黑鹰", "三角洲巅峰"},
InsuranceOptions: []string{"2*1", "2*2", "2*3", "3*3"},
LevelOptions: []string{"1级", "2级", "3级", "4级", "5级", "6级", "7级"},
LoginMethodOptions: []string{"扫码", "账号密码"},
RegionOptions: []string{
"北京", "天津", "河北", "山西", "内蒙古", "辽宁", "吉林", "黑龙江",
"上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "河南",
"湖北", "湖南", "广东", "广西", "海南", "重庆", "四川", "贵州",
"云南", "陕西", "甘肃", "青海", "宁夏", "新疆", "西藏", "香港",
"澳门", "台湾",
},
SkinGroups: []PublishOptionGroup{
{
Key: "melee",
Title: "近战皮肤",
Options: []string{
"坠星者", "暗星", "龙牙", "信条", "怜悯", "赤枭", "影锋", "黑海", "北极星", "电锯惊魂", "处刑者",
},
},
{
Key: "operator",
Title: "干员皮肤",
Options: []string{
"威龙-凌霄戍卫", "红狼-蚀金玫瑰", "露娜-黑天际线", "蛊-能天使午夜邮差",
"骇爪-维什戴尔", "骇爪-水墨云图", "威龙-吴彦祖", "威龙-铁面判官",
"威龙-壮志凌云", "威龙-蛟龙特战队", "蜂医-送葬人", "蜂医-危险物质",
"蜂医-黑鹰坠落", "牧羊人-街头之星", "牧羊人-黑鹰坠落", "乌鲁鲁-黑鹰坠落",
"红狼-黑鹰坠落", "无名-夜鹰", "深蓝-不破誓约", "红狼-电锯惊魂",
"露娜-古墓丽影劳拉", "赛伊德",
},
},
{
Key: "weapon",
Title: "武器皮肤",
Options: []string{
"M7棱镜攻势S2", "腾龙气象感应", "MP7电玩高手S2", "M250电玩高手S2",
"AS Val悬赏令", "K416命运", "M4A1棱镜攻势", "SCAR-H电玩高手",
"QBZ95王牌之剑", "AUG气象感应", "Vector美杜莎", "KC17-造物纪元",
},
},
},
QuantityItems: []PublishQuantityItem{
{Key: "awmAmmo", Label: "AWM子弹", Price: "0.8元/发"},
{Key: "helmet6", Label: "6头数量", Price: "2元/个"},
{Key: "armor6", Label: "6甲数量", Price: "3元/个"},
{Key: "kit5", Label: "5级全装套数量", Price: "5元/个"},
{Key: "armor6Ammo", Label: "6甲子弹", Price: "0.26元/发"},
{Key: "gridCard9", Label: "9格体验卡", Price: "5元/张", Placeholder: "如果是赛季9格,此处填0即可"},
},
ScreenshotSlots: []PublishScreenshotSlot{
{Key: "coin", Label: "纯币截图", Required: true, Hint: "请上传纯币截图(必传)"},
{Key: "gameId", Label: "游戏ID截图", Required: true, Hint: "请上传游戏ID截图(必传)"},
{Key: "totalAsset", Label: "总资产截图", Required: true, Hint: "请上传总资产截图(必传)"},
{Key: "tencentSecurity", Label: "腾讯安全中心截图", Required: true, Hint: "请上传腾讯安全中心截图(必传)"},
{Key: "skin", Label: "皮肤截图", Required: false, Hint: "请上传皮肤截图(选传)"},
},
}
}
@@ -38,6 +38,7 @@ var defaultConfigs = []defaultConfig{
{Key: "settlement.platform_fee_rate", Value: "0.00", Description: "平台抽成比例"},
{Key: "settlement.owner_cycle_days", Value: "0", Description: "号主结算周期天数"},
{Key: "withdraw.min_amount", Value: "100", Description: "提现最低金额预留"},
{Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"},
}
func NewRepository(db *gorm.DB) *Repository {
@@ -59,6 +60,17 @@ func (r *Repository) List() ([]ConfigDTO, error) {
return items, nil
}
func (r *Repository) FindValue(key string) (string, error) {
if err := r.ensureDefaults(); err != nil {
return "", err
}
var row model.SystemConfig
if err := r.db.Where("`key` = ?", key).First(&row).Error; err != nil {
return "", err
}
return row.Value, nil
}
func (r *Repository) Update(actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) {
var row model.SystemConfig
err := r.db.Transaction(func(tx *gorm.DB) error {
@@ -1,6 +1,9 @@
package systemconfig
import "errors"
import (
"encoding/json"
"errors"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
@@ -22,6 +25,22 @@ func (s *Service) List() ([]ConfigDTO, error) {
return s.repo.List()
}
func (s *Service) PublishOptions() (*PublishOptionsDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
value, err := s.repo.FindValue(publishOptionsConfigKey)
if err != nil {
return nil, err
}
options := DefaultPublishOptions()
if err := json.Unmarshal([]byte(value), &options); err != nil {
options = DefaultPublishOptions()
}
normalizePublishOptions(&options)
return &options, nil
}
func (s *Service) Update(actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
@@ -31,3 +50,37 @@ func (s *Service) Update(actorID uint64, key string, req UpdateRequest, meta Aud
}
return s.repo.Update(actorID, key, req, meta)
}
func normalizePublishOptions(options *PublishOptionsDTO) {
defaults := DefaultPublishOptions()
if len(options.ServerOptions) == 0 {
options.ServerOptions = defaults.ServerOptions
}
if len(options.FaceOptions) == 0 {
options.FaceOptions = defaults.FaceOptions
}
if len(options.RankOptions) == 0 {
options.RankOptions = defaults.RankOptions
}
if len(options.InsuranceOptions) == 0 {
options.InsuranceOptions = defaults.InsuranceOptions
}
if len(options.LevelOptions) == 0 {
options.LevelOptions = defaults.LevelOptions
}
if len(options.LoginMethodOptions) == 0 {
options.LoginMethodOptions = defaults.LoginMethodOptions
}
if len(options.RegionOptions) == 0 {
options.RegionOptions = defaults.RegionOptions
}
if len(options.SkinGroups) == 0 {
options.SkinGroups = defaults.SkinGroups
}
if len(options.QuantityItems) == 0 {
options.QuantityItems = defaults.QuantityItems
}
if len(options.ScreenshotSlots) == 0 {
options.ScreenshotSlots = defaults.ScreenshotSlots
}
}
+1
View File
@@ -126,6 +126,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
api := engine.Group("/api")
{
api.GET("/health", health.Check)
api.GET("/listing-publish-options", systemConfigHandler.PublishOptions)
authRoutes := api.Group("/auth")
{
+7
View File
@@ -17,14 +17,21 @@ declare module 'vue' {
VanCell: typeof import('vant/es')['Cell']
VanCellGroup: typeof import('vant/es')['CellGroup']
VanCheckbox: typeof import('vant/es')['Checkbox']
VanDropdownItem: typeof import('vant/es')['DropdownItem']
VanDropdownMenu: typeof import('vant/es')['DropdownMenu']
VanEmpty: typeof import('vant/es')['Empty']
VanField: typeof import('vant/es')['Field']
VanGrid: typeof import('vant/es')['Grid']
VanGridItem: typeof import('vant/es')['GridItem']
VanIcon: typeof import('vant/es')['Icon']
VanImage: typeof import('vant/es')['Image']
VanLoading: typeof import('vant/es')['Loading']
VanNoticeBar: typeof import('vant/es')['NoticeBar']
VanPopup: typeof import('vant/es')['Popup']
VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanSearch: typeof import('vant/es')['Search']
VanTabbar: typeof import('vant/es')['Tabbar']
VanTabbarItem: typeof import('vant/es')['TabbarItem']
VanTag: typeof import('vant/es')['Tag']
}
}
+133
View File
@@ -0,0 +1,133 @@
import { apiClient } from './client'
export type ChargeMode = '赠送' | '收费'
export type QuantityKey =
| 'awmAmmo'
| 'helmet6'
| 'armor6'
| 'kit5'
| 'armor6Ammo'
| 'gridCard9'
export type ScreenshotKey = 'coin' | 'gameId' | 'totalAsset' | 'tencentSecurity' | 'skin'
export type SkinCategoryKey = 'melee' | 'operator' | 'weapon'
export interface PublishOptionGroup {
key: SkinCategoryKey | string
title: string
options: string[]
}
export interface PublishQuantityItem {
key: QuantityKey
label: string
price: string
placeholder?: string
}
export interface PublishScreenshotSlot {
key: ScreenshotKey
label: string
required: boolean
hint: string
}
export interface ListingPublishOptions {
server_options: string[]
face_options: string[]
rank_options: string[]
insurance_options: string[]
level_options: string[]
login_method_options: string[]
region_options: string[]
skin_groups: PublishOptionGroup[]
quantity_items: PublishQuantityItem[]
screenshot_slots: PublishScreenshotSlot[]
}
interface ApiResponse<T> {
code: string
message: string
data: T
}
export const defaultListingPublishOptions: ListingPublishOptions = {
server_options: ['QQ', '微信', 'Steam'],
face_options: ['是', '否'],
rank_options: ['青铜', '白银', '黄金', '铂金', '钻石', '黑鹰', '三角洲巅峰'],
insurance_options: ['2*1', '2*2', '2*3', '3*3'],
level_options: ['1级', '2级', '3级', '4级', '5级', '6级', '7级'],
login_method_options: ['扫码', '账号密码'],
region_options: [
'北京', '天津', '河北', '山西', '内蒙古', '辽宁', '吉林', '黑龙江',
'上海', '江苏', '浙江', '安徽', '福建', '江西', '山东', '河南',
'湖北', '湖南', '广东', '广西', '海南', '重庆', '四川', '贵州',
'云南', '陕西', '甘肃', '青海', '宁夏', '新疆', '西藏', '香港',
'澳门', '台湾',
],
skin_groups: [
{
key: 'melee',
title: '近战皮肤',
options: ['坠星者', '暗星', '龙牙', '信条', '怜悯', '赤枭', '影锋', '黑海', '北极星', '电锯惊魂', '处刑者'],
},
{
key: 'operator',
title: '干员皮肤',
options: [
'威龙-凌霄戍卫', '红狼-蚀金玫瑰', '露娜-黑天际线', '蛊-能天使午夜邮差',
'骇爪-维什戴尔', '骇爪-水墨云图', '威龙-吴彦祖', '威龙-铁面判官',
'威龙-壮志凌云', '威龙-蛟龙特战队', '蜂医-送葬人', '蜂医-危险物质',
'蜂医-黑鹰坠落', '牧羊人-街头之星', '牧羊人-黑鹰坠落', '乌鲁鲁-黑鹰坠落',
'红狼-黑鹰坠落', '无名-夜鹰', '深蓝-不破誓约', '红狼-电锯惊魂',
'露娜-古墓丽影劳拉', '赛伊德',
],
},
{
key: 'weapon',
title: '武器皮肤',
options: [
'M7棱镜攻势S2', '腾龙气象感应', 'MP7电玩高手S2', 'M250电玩高手S2',
'AS Val悬赏令', 'K416命运', 'M4A1棱镜攻势', 'SCAR-H电玩高手',
'QBZ95王牌之剑', 'AUG气象感应', 'Vector美杜莎', 'KC17-造物纪元',
],
},
],
quantity_items: [
{ key: 'awmAmmo', label: 'AWM子弹', price: '0.8元/发' },
{ key: 'helmet6', label: '6头数量', price: '2元/个' },
{ key: 'armor6', label: '6甲数量', price: '3元/个' },
{ key: 'kit5', label: '5级全装套数量', price: '5元/个' },
{ key: 'armor6Ammo', label: '6甲子弹', price: '0.26元/发' },
{ key: 'gridCard9', label: '9格体验卡', price: '5元/张', placeholder: '如果是赛季9格,此处填0即可' },
],
screenshot_slots: [
{ key: 'coin', label: '纯币截图', required: true, hint: '请上传纯币截图(必传)' },
{ key: 'gameId', label: '游戏ID截图', required: true, hint: '请上传游戏ID截图(必传)' },
{ key: 'totalAsset', label: '总资产截图', required: true, hint: '请上传总资产截图(必传)' },
{ key: 'tencentSecurity', label: '腾讯安全中心截图', required: true, hint: '请上传腾讯安全中心截图(必传)' },
{ key: 'skin', label: '皮肤截图', required: false, hint: '请上传皮肤截图(选传)' },
],
}
export async function fetchListingPublishOptions() {
const { data } = await apiClient.get<ApiResponse<ListingPublishOptions>>('/listing-publish-options')
return mergeListingPublishOptions(data.data)
}
export function mergeListingPublishOptions(options?: Partial<ListingPublishOptions>): ListingPublishOptions {
return {
server_options: options?.server_options?.length ? options.server_options : defaultListingPublishOptions.server_options,
face_options: options?.face_options?.length ? options.face_options : defaultListingPublishOptions.face_options,
rank_options: options?.rank_options?.length ? options.rank_options : defaultListingPublishOptions.rank_options,
insurance_options: options?.insurance_options?.length ? options.insurance_options : defaultListingPublishOptions.insurance_options,
level_options: options?.level_options?.length ? options.level_options : defaultListingPublishOptions.level_options,
login_method_options: options?.login_method_options?.length ? options.login_method_options : defaultListingPublishOptions.login_method_options,
region_options: options?.region_options?.length ? options.region_options : defaultListingPublishOptions.region_options,
skin_groups: options?.skin_groups?.length ? options.skin_groups : defaultListingPublishOptions.skin_groups,
quantity_items: options?.quantity_items?.length ? options.quantity_items as PublishQuantityItem[] : defaultListingPublishOptions.quantity_items,
screenshot_slots: options?.screenshot_slots?.length ? options.screenshot_slots as PublishScreenshotSlot[] : defaultListingPublishOptions.screenshot_slots,
}
}
@@ -1,7 +1,12 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import {
defaultListingPublishOptions,
mergeListingPublishOptions,
type ListingPublishOptions,
} from '@/api/listingOptions'
import { fetchSystemConfigs, updateSystemConfig, type SystemConfig } from '@/api/systemConfigs'
const loading = ref(false)
@@ -10,6 +15,32 @@ const configs = ref<SystemConfig[]>([])
const activeConfig = ref<SystemConfig | null>(null)
const value = ref('')
const description = ref('')
const publishOptionsDraft = ref<ListingPublishOptions>(cloneOptions(defaultListingPublishOptions))
const publishConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_options') || null)
const regularConfigs = computed(() => configs.value.filter((item) => item.key !== 'listing.publish_options'))
const isPublishOptionsConfig = computed(() => activeConfig.value?.key === 'listing.publish_options')
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 isStructuredConfig = computed(() => {
const trimmed = value.value.trim()
return isPublishOptionsConfig.value || trimmed.startsWith('{') || trimmed.startsWith('[')
})
const dialogWidth = computed(() => (isPublishOptionsConfig.value ? '920px' : '560px'))
onMounted(loadConfigs)
@@ -26,12 +57,18 @@ 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)
}
}
async function handleSave() {
if (!activeConfig.value) return
submitting.value = true
try {
if (isPublishOptionsConfig.value) {
value.value = JSON.stringify(publishOptionsDraft.value, null, 2)
}
await updateSystemConfig(activeConfig.value.key, {
value: value.value,
description: description.value,
@@ -46,6 +83,100 @@ async function handleSave() {
}
}
function parsePublishOptions(raw: string) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : defaultListingPublishOptions
return cloneOptions(mergeListingPublishOptions(parsed))
} catch {
ElMessage.warning('发布选项 JSON 解析失败,已使用默认配置')
return cloneOptions(defaultListingPublishOptions)
}
}
function safeParsePublishOptions(raw: string) {
try {
const parsed = raw.trim() ? JSON.parse(raw) : defaultListingPublishOptions
return cloneOptions(mergeListingPublishOptions(parsed))
} catch {
return cloneOptions(defaultListingPublishOptions)
}
}
function cloneOptions(options: ListingPublishOptions) {
return JSON.parse(JSON.stringify(options)) as ListingPublishOptions
}
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'>, 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()}` as never,
label: '',
price: '',
})
}
function removeQuantityItem(index: number) {
publishOptionsDraft.value.quantity_items.splice(index, 1)
}
function addScreenshotSlot() {
publishOptionsDraft.value.screenshot_slots.push({
key: `screenshot_${Date.now()}` as never,
label: '',
required: false,
hint: '',
})
}
function removeScreenshotSlot(index: number) {
publishOptionsDraft.value.screenshot_slots.splice(index, 1)
}
function resetPublishOptions() {
publishOptionsDraft.value = cloneOptions(defaultListingPublishOptions)
}
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
@@ -63,9 +194,47 @@ function readError(error: unknown, fallback: string) {
<p>管理交接超时归还超时短信限流最低押金和抽成比例</p>
</div>
<el-table v-loading="loading" class="table-panel" :data="configs">
<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>
<el-table v-loading="loading" class="table-panel" :data="regularConfigs">
<el-table-column prop="key" label="配置项" min-width="260" />
<el-table-column prop="value" label="当前值" width="150" />
<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 prop="updated_at" label="更新时间" min-width="180" />
@@ -76,10 +245,157 @@ function readError(error: unknown, fallback: string) {
</el-table-column>
</el-table>
<el-dialog :model-value="!!activeConfig" title="编辑系统配置" width="560px" @update:model-value="activeConfig = null">
<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>
<el-input v-model="value" placeholder="配置值" />
<div v-if="isPublishOptionsConfig" class="publish-options-editor">
<div class="editor-toolbar">
<span>发布页选项配置</span>
<el-button size="small" @click="resetPublishOptions">恢复默认选项</el-button>
</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-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>
<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>
@@ -89,3 +405,101 @@ function readError(error: unknown, fallback: string) {
</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;
}
.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;
}
.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;
}
.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;
}
.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>
@@ -0,0 +1,504 @@
.mobile-publish {
min-height: 100dvh;
background: #f4f6f8;
padding-bottom: 56px;
}
.page-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
height: 48px;
padding: 0 12px;
background: #fff;
border-bottom: 1px solid #eceff3;
}
.page-header h1 {
flex: 1;
margin: 0;
font-size: 17px;
font-weight: 700;
text-align: center;
}
.back-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: none;
color: #25282d;
cursor: pointer;
}
.header-spacer {
width: 36px;
}
.form-body {
padding: 12px 14px 24px;
}
.form-section {
padding: 14px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #edf0f4;
border-radius: 8px;
}
.section-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
color: #171a1f;
}
.field-hint {
margin: -2px 0 8px;
color: #858c96;
font-size: 11px;
line-height: 1.5;
}
.publish-field {
margin-bottom: 8px;
border-radius: 8px;
background: #f8fafc;
overflow: hidden;
}
.publish-field :deep(.van-field__label) {
width: 88px;
color: #30343a;
font-size: 13px;
font-weight: 600;
}
.publish-field :deep(.van-field__control) {
color: #20242a;
font-size: 14px;
}
.radio-group,
.multi-chip-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.radio-group.small {
gap: 6px;
}
.radio-btn {
min-height: 28px;
padding: 5px 12px;
border: 1px solid #d7dce3;
border-radius: 14px;
background: #fff;
color: #5c6470;
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.radio-btn.active {
border-color: #1477ff;
background: #1477ff;
color: #fff;
}
.time-row,
.result-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.level-panel {
display: grid;
gap: 8px;
}
.level-row {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
gap: 8px;
align-items: center;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.level-label {
color: #30343a;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.level-label span {
margin-right: 2px;
color: #ee0a24;
}
.level-options {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.level-btn {
min-width: 0;
height: 30px;
padding: 0;
border: 1px solid #d7dce3;
border-radius: 15px;
background: #fff;
color: #5c6470;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.level-btn.active {
border-color: #1477ff;
background: #1477ff;
color: #fff;
}
.quantity-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 92px;
gap: 8px;
align-items: stretch;
margin-bottom: 8px;
}
.quantity-card {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px;
gap: 10px;
align-items: center;
min-height: 72px;
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
}
.quantity-meta {
display: grid;
min-width: 0;
gap: 5px;
}
.quantity-meta strong {
overflow: hidden;
color: #30343a;
font-size: 13px;
font-weight: 700;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.quantity-meta span {
margin-right: 2px;
color: #ee0a24;
}
.quantity-meta small {
color: #858c96;
font-size: 11px;
font-weight: 600;
}
.quantity-input {
width: 100%;
min-width: 0;
height: 36px;
padding: 0 8px;
border: 1px solid #e0e5ec;
border-radius: 8px;
background: #fff;
color: #20242a;
font-size: 16px;
font-weight: 700;
text-align: center;
outline: none;
}
.quantity-input:focus {
border-color: #1477ff;
}
.mode-toggle {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
min-height: 72px;
}
.mode-toggle button {
border: 1px solid #d7dce3;
border-radius: 8px;
background: #fff;
color: #606873;
font-size: 12px;
font-weight: 700;
}
.mode-toggle button.active {
border-color: #ff6a00;
background: #fff3ea;
color: #e65f00;
}
.skin-groups {
display: grid;
gap: 10px;
margin-bottom: 8px;
}
.skin-group {
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.skin-group-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
color: #30343a;
font-size: 13px;
font-weight: 700;
}
.skin-group-title small {
color: #858c96;
font-size: 11px;
font-weight: 600;
}
.region-panel {
display: grid;
gap: 8px;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.region-title {
display: flex;
align-items: center;
justify-content: space-between;
color: #30343a;
font-size: 13px;
font-weight: 700;
}
.region-title small {
color: #858c96;
font-size: 11px;
font-weight: 600;
}
.region-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.region-btn {
min-width: 0;
height: 28px;
padding: 0 2px;
border: 1px solid #d7dce3;
border-radius: 14px;
background: #fff;
color: #5c6470;
font-size: 11px;
font-weight: 700;
}
.region-btn.active {
border-color: #1477ff;
background: #1477ff;
color: #fff;
}
.upload-list {
display: grid;
gap: 10px;
}
.upload-line {
display: grid;
grid-template-columns: minmax(0, 1fr) 86px;
gap: 10px;
align-items: center;
min-height: 76px;
padding: 10px;
border: 1px solid #edf0f4;
border-radius: 8px;
background: #f8fafc;
}
.upload-meta {
display: grid;
gap: 4px;
}
.upload-meta strong {
color: #242830;
font-size: 13px;
}
.upload-meta span {
color: #f04438;
}
.upload-meta small {
color: #858c96;
font-size: 11px;
line-height: 1.4;
}
.upload-add,
.upload-preview {
width: 76px;
height: 76px;
border-radius: 8px;
}
.upload-add {
display: grid;
place-items: center;
border: 1px dashed #c7cdd6;
background: #fff;
color: #7b8490;
font-size: 11px;
}
.upload-add:disabled {
opacity: 0.5;
}
.upload-preview {
position: relative;
overflow: hidden;
background: #e8ecf1;
}
.upload-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-preview button {
position: absolute;
right: 0;
bottom: 0;
height: 24px;
padding: 0 8px;
border: none;
border-radius: 8px 0 0;
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-size: 11px;
}
.calc-btn {
height: 40px;
margin-bottom: 8px;
border: none;
border-radius: 8px;
background: #1477ff;
color: #fff;
font-weight: 700;
}
.result-field :deep(.van-field__control) {
color: #ff6a00;
font-weight: 800;
}
.submit-btn {
height: 44px;
margin-top: 8px;
border: none !important;
background: #ff6a00 !important;
font-size: 15px;
font-weight: 700;
}
.bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
height: 50px;
background: #fff;
border-top: 1px solid #eee;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #999;
font-size: 10px;
text-decoration: none;
}
.nav-item.active {
color: #1477ff;
}
.nav-item span {
font-weight: 600;
}
.publish-pill {
display: grid;
width: 36px;
height: 26px;
place-items: center;
border-radius: 13px;
background: #ff6a00;
color: #fff;
font-size: 18px;
font-weight: 900;
}
.nav-publish span,
.nav-publish.active span {
color: #ff6a00;
}
@media (min-width: 430px) {
.time-row,
.result-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 370px) {
.level-options,
.region-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@@ -1,34 +1,24 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { showToast } from "vant";
import { uploadFile } from "@/api/files";
import {
defaultListingPublishOptions,
fetchListingPublishOptions,
type ChargeMode,
type ListingPublishOptions,
type QuantityKey,
type ScreenshotKey,
} from "@/api/listingOptions";
import { createListing } from "@/api/listings";
type ChargeMode = "赠送" | "收费";
type QuantityKey =
| "awmAmmo"
| "helmet6"
| "armor6"
| "kit5"
| "armor6Ammo"
| "gridCard9";
type ScreenshotKey =
| "coin"
| "gameId"
| "totalAsset"
| "tencentSecurity"
| "skin";
type SkinCategoryKey = "melee" | "operator" | "weapon";
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const uploading = ref(false);
const publishOptions = ref<ListingPublishOptions>(defaultListingPublishOptions);
function isNavActive(path: string) {
if (path === "/m") return route.path === "/m";
@@ -89,166 +79,31 @@ const selectedSkins = ref<string[]>([]);
const fileInput = ref<HTMLInputElement | null>(null);
const activeUploadKey = ref<ScreenshotKey>("coin");
const serverOptions = ["QQ", "微信", "Steam"];
const faceOptions = ["是", "否"];
const rankOptions = [
"青铜",
"白银",
"黄金",
"铂金",
"钻石",
"黑鹰",
"三角洲巅峰",
];
const insuranceOptions = ["2*1", "2*2", "2*3", "3*3"];
const levelOptions = ["1级", "2级", "3级", "4级", "5级", "6级", "7级"];
const loginMethodOptions = ["扫码", "账号密码"];
const regionOptions = [
"北京",
"天津",
"河北",
"山西",
"内蒙古",
"辽宁",
"吉林",
"黑龙江",
"上海",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"山东",
"河南",
"湖北",
"湖南",
"广东",
"广西",
"海南",
"重庆",
"四川",
"贵州",
"云南",
"陕西",
"甘肃",
"青海",
"宁夏",
"新疆",
"西藏",
"香港",
"澳门",
"台湾",
];
const skinGroups: Array<{
key: SkinCategoryKey;
title: string;
options: string[];
}> = [
{
key: "melee",
title: "近战皮肤",
options: [
"坠星者",
"暗星",
"龙牙",
"信条",
"怜悯",
"赤枭",
"影锋",
"黑海",
"北极星",
"电锯惊魂",
"处刑者",
],
},
{
key: "operator",
title: "干员皮肤",
options: [
"威龙-凌霄戍卫",
"红狼-蚀金玫瑰",
"露娜-黑天际线",
"蛊-能天使午夜邮差",
"骇爪-维什戴尔",
"骇爪-水墨云图",
"威龙-吴彦祖",
"威龙-铁面判官",
"威龙-壮志凌云",
"威龙-蛟龙特战队",
"蜂医-送葬人",
"蜂医-危险物质",
"蜂医-黑鹰坠落",
"牧羊人-街头之星",
"牧羊人-黑鹰坠落",
"乌鲁鲁-黑鹰坠落",
"红狼-黑鹰坠落",
"无名-夜鹰",
"深蓝-不破誓约",
"红狼-电锯惊魂",
"露娜-古墓丽影劳拉",
"赛伊德",
],
},
{
key: "weapon",
title: "武器皮肤",
options: [
"M7棱镜攻势S2",
"腾龙气象感应",
"MP7电玩高手S2",
"M250电玩高手S2",
"AS Val悬赏令",
"K416命运",
"M4A1棱镜攻势",
"SCAR-H电玩高手",
"QBZ95王牌之剑",
"AUG气象感应",
"Vector美杜莎",
"KC17-造物纪元",
],
},
];
const quantityItems: Array<{
key: QuantityKey;
label: string;
price: string;
placeholder?: string;
}> = [
{ key: "awmAmmo", label: "AWM子弹", price: "0.8元/发" },
{ key: "helmet6", label: "6头数量", price: "2元/个" },
{ key: "armor6", label: "6甲数量", price: "3元/个" },
{ key: "kit5", label: "5级全装套数量", price: "5元/个" },
{ key: "armor6Ammo", label: "6甲子弹", price: "0.26元/发" },
{
key: "gridCard9",
label: "9格体验卡",
price: "5元/张",
placeholder: "如果是赛季9格,此处填0即可",
},
];
const screenshotSlots: Array<{
key: ScreenshotKey;
label: string;
required: boolean;
hint: string;
}> = [
{ key: "coin", label: "纯币截图", required: true, hint: "请上传纯币截图(必传)" },
{ key: "gameId", label: "游戏ID截图", required: true, hint: "请上传游戏ID截图(必传)" },
{ key: "totalAsset", label: "总资产截图", required: true, hint: "请上传总资产截图(必传)" },
{
key: "tencentSecurity",
label: "腾讯安全中心截图",
required: true,
hint: "请上传腾讯安全中心截图(必传)",
},
{ key: "skin", label: "皮肤截图", required: false, hint: "请上传皮肤截图(选传)" },
];
const screenshotUrls = computed(() =>
screenshotSlots.map((item) => screenshotFiles[item.key]).filter(Boolean)
const serverOptions = computed(() => publishOptions.value.server_options);
const faceOptions = computed(() => publishOptions.value.face_options);
const rankOptions = computed(() => publishOptions.value.rank_options);
const insuranceOptions = computed(() => publishOptions.value.insurance_options);
const levelOptions = computed(() => publishOptions.value.level_options);
const loginMethodOptions = computed(
() => publishOptions.value.login_method_options
);
const regionOptions = computed(() => publishOptions.value.region_options);
const skinGroups = computed(() => publishOptions.value.skin_groups);
const quantityItems = computed(() => publishOptions.value.quantity_items);
const screenshotSlots = computed(() => publishOptions.value.screenshot_slots);
const screenshotUrls = computed(() =>
screenshotSlots.value.map((item) => screenshotFiles[item.key]).filter(Boolean)
);
onMounted(loadPublishOptions);
async function loadPublishOptions() {
try {
publishOptions.value = await fetchListingPublishOptions();
} catch {
publishOptions.value = defaultListingPublishOptions;
}
}
function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
setter(value);
@@ -380,7 +235,7 @@ function validateForm() {
if (!form.final_price || !form.settlement_amount || !form.rent_days) {
return "请先点击计算价格";
}
for (const item of screenshotSlots) {
for (const item of screenshotSlots.value) {
if (item.required && !screenshotFiles[item.key]) {
return `请上传${item.label}`;
}
@@ -397,14 +252,14 @@ function buildAssetSummary() {
season_insurance: form.season_insurance,
stamina_level: form.stamina_level,
load_level: form.load_level,
resources: quantityItems.map((item) => ({
resources: quantityItems.value.map((item) => ({
key: item.key,
label: item.label,
quantity: Number(quantityValues[item.key] || 0),
mode: quantityModes[item.key],
})),
skins: selectedSkins.value,
skin_groups: skinGroups.reduce<Record<string, string[]>>((groups, group) => {
skin_groups: skinGroups.value.reduce<Record<string, string[]>>((groups, group) => {
groups[group.key] = group.options.filter((skin) =>
selectedSkins.value.includes(skin)
);
@@ -875,509 +730,4 @@ function readError(error: unknown, fallback: string) {
</main>
</template>
<style scoped>
.mobile-publish {
min-height: 100dvh;
background: #f4f6f8;
padding-bottom: 56px;
}
.page-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
height: 48px;
padding: 0 12px;
background: #fff;
border-bottom: 1px solid #eceff3;
}
.page-header h1 {
flex: 1;
margin: 0;
font-size: 17px;
font-weight: 700;
text-align: center;
}
.back-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: none;
background: none;
color: #25282d;
cursor: pointer;
}
.header-spacer {
width: 36px;
}
.form-body {
padding: 12px 14px 24px;
}
.form-section {
padding: 14px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #edf0f4;
border-radius: 8px;
}
.section-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
color: #171a1f;
}
.field-hint {
margin: -2px 0 8px;
color: #858c96;
font-size: 11px;
line-height: 1.5;
}
.publish-field {
margin-bottom: 8px;
border-radius: 8px;
background: #f8fafc;
overflow: hidden;
}
.publish-field :deep(.van-field__label) {
width: 88px;
color: #30343a;
font-size: 13px;
font-weight: 600;
}
.publish-field :deep(.van-field__control) {
color: #20242a;
font-size: 14px;
}
.radio-group,
.multi-chip-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.radio-group.small {
gap: 6px;
}
.radio-btn {
min-height: 28px;
padding: 5px 12px;
border: 1px solid #d7dce3;
border-radius: 14px;
background: #fff;
color: #5c6470;
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.radio-btn.active {
border-color: #1477ff;
background: #1477ff;
color: #fff;
}
.time-row,
.result-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.level-panel {
display: grid;
gap: 8px;
}
.level-row {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
gap: 8px;
align-items: center;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.level-label {
color: #30343a;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.level-label span {
margin-right: 2px;
color: #ee0a24;
}
.level-options {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.level-btn {
min-width: 0;
height: 30px;
padding: 0;
border: 1px solid #d7dce3;
border-radius: 15px;
background: #fff;
color: #5c6470;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.level-btn.active {
border-color: #1477ff;
background: #1477ff;
color: #fff;
}
.quantity-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 92px;
gap: 8px;
align-items: stretch;
margin-bottom: 8px;
}
.quantity-card {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px;
gap: 10px;
align-items: center;
min-height: 72px;
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
}
.quantity-meta {
display: grid;
min-width: 0;
gap: 5px;
}
.quantity-meta strong {
overflow: hidden;
color: #30343a;
font-size: 13px;
font-weight: 700;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.quantity-meta span {
margin-right: 2px;
color: #ee0a24;
}
.quantity-meta small {
color: #858c96;
font-size: 11px;
font-weight: 600;
}
.quantity-input {
width: 100%;
min-width: 0;
height: 36px;
padding: 0 8px;
border: 1px solid #e0e5ec;
border-radius: 8px;
background: #fff;
color: #20242a;
font-size: 16px;
font-weight: 700;
text-align: center;
outline: none;
}
.quantity-input:focus {
border-color: #1477ff;
}
.mode-toggle {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
min-height: 72px;
}
.mode-toggle button {
border: 1px solid #d7dce3;
border-radius: 8px;
background: #fff;
color: #606873;
font-size: 12px;
font-weight: 700;
}
.mode-toggle button.active {
border-color: #ff6a00;
background: #fff3ea;
color: #e65f00;
}
.skin-groups {
display: grid;
gap: 10px;
margin-bottom: 8px;
}
.skin-group {
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.skin-group-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
color: #30343a;
font-size: 13px;
font-weight: 700;
}
.skin-group-title small {
color: #858c96;
font-size: 11px;
font-weight: 600;
}
.region-panel {
display: grid;
gap: 8px;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.region-title {
display: flex;
align-items: center;
justify-content: space-between;
color: #30343a;
font-size: 13px;
font-weight: 700;
}
.region-title small {
color: #858c96;
font-size: 11px;
font-weight: 600;
}
.region-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.region-btn {
min-width: 0;
height: 28px;
padding: 0 2px;
border: 1px solid #d7dce3;
border-radius: 14px;
background: #fff;
color: #5c6470;
font-size: 11px;
font-weight: 700;
}
.region-btn.active {
border-color: #1477ff;
background: #1477ff;
color: #fff;
}
.upload-list {
display: grid;
gap: 10px;
}
.upload-line {
display: grid;
grid-template-columns: minmax(0, 1fr) 86px;
gap: 10px;
align-items: center;
min-height: 76px;
padding: 10px;
border: 1px solid #edf0f4;
border-radius: 8px;
background: #f8fafc;
}
.upload-meta {
display: grid;
gap: 4px;
}
.upload-meta strong {
color: #242830;
font-size: 13px;
}
.upload-meta span {
color: #f04438;
}
.upload-meta small {
color: #858c96;
font-size: 11px;
line-height: 1.4;
}
.upload-add,
.upload-preview {
width: 76px;
height: 76px;
border-radius: 8px;
}
.upload-add {
display: grid;
place-items: center;
border: 1px dashed #c7cdd6;
background: #fff;
color: #7b8490;
font-size: 11px;
}
.upload-add:disabled {
opacity: 0.5;
}
.upload-preview {
position: relative;
overflow: hidden;
background: #e8ecf1;
}
.upload-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-preview button {
position: absolute;
right: 0;
bottom: 0;
height: 24px;
padding: 0 8px;
border: none;
border-radius: 8px 0 0;
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-size: 11px;
}
.calc-btn {
height: 40px;
margin-bottom: 8px;
border: none;
border-radius: 8px;
background: #1477ff;
color: #fff;
font-weight: 700;
}
.result-field :deep(.van-field__control) {
color: #ff6a00;
font-weight: 800;
}
.submit-btn {
height: 44px;
margin-top: 8px;
border: none !important;
background: #ff6a00 !important;
font-size: 15px;
font-weight: 700;
}
.bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
height: 50px;
background: #fff;
border-top: 1px solid #eee;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #999;
font-size: 10px;
text-decoration: none;
}
.nav-item.active {
color: #1477ff;
}
.nav-item span {
font-weight: 600;
}
.publish-pill {
display: grid;
width: 36px;
height: 26px;
place-items: center;
border-radius: 13px;
background: #ff6a00;
color: #fff;
font-size: 18px;
font-weight: 900;
}
.nav-publish span,
.nav-publish.active span {
color: #ff6a00;
}
@media (min-width: 430px) {
.time-row,
.result-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 370px) {
.level-options,
.region-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
</style>
<style scoped src="./MobileSellerListingCreateView.css"></style>