diff --git a/backend/internal/modules/systemconfig/publish_options.go b/backend/internal/modules/systemconfig/publish_options.go index 87a4924..7997c3b 100644 --- a/backend/internal/modules/systemconfig/publish_options.go +++ b/backend/internal/modules/systemconfig/publish_options.go @@ -58,12 +58,15 @@ func DefaultPublishOptions() PublishOptionsDTO { }, }, 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: "level6Ammo", Label: "6级子弹", Price: "0.26元/发"}, - {Key: "gridCard9", Label: "9格体验卡", Price: "5元/张", Placeholder: "如果是赛季9格,此处填0即可"}, + {Key: "level6Ammo", Label: "6级弹", Price: "0.1元/发", Placeholder: "按单发数量填写,60发=6元"}, + {Key: "awmAmmo", Label: "AWM子弹", Price: "0.6元/发"}, + {Key: "barrettAmmo", Label: "巴雷特子弹", Price: "0.3元/发"}, + {Key: "armor6", Label: "6甲", Price: "2.5元/个"}, + {Key: "helmet6", Label: "6头", Price: "1.5元/个"}, + {Key: "gridCard9", Label: "9格体验卡", Price: "3元/张", Placeholder: "如果是赛季9格,此处填0即可"}, + {Key: "redPacket45", Label: "45格大红包", Price: "1.5元/个"}, + {Key: "coffeeBean", Label: "高级咖啡豆", Price: "2.5元/个"}, + {Key: "bulletPart", Label: "高级子弹零件", Price: "3元/个"}, }, ScreenshotSlots: []PublishScreenshotSlot{ {Key: "coin", Label: "纯币截图", Required: true, Hint: "请上传纯币截图(必传)"}, diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index faf1451..081b6da 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -3,6 +3,7 @@ package systemconfig import ( "encoding/json" "errors" + "strings" "hfb_sys/backend/internal/model" @@ -132,11 +133,54 @@ func (r *Repository) ensureDefaults() error { if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil { return err } + if item.Key == publishOptionsConfigKey { + if err := updateLegacyPublishOptions(tx, item); err != nil { + return err + } + } } return nil }) } +func updateLegacyPublishOptions(tx *gorm.DB, item defaultConfig) error { + var row model.SystemConfig + if err := tx.Where("`key` = ?", item.Key).First(&row).Error; err != nil { + return err + } + if !isLegacyPublishOptionsValue(row.Value) { + return nil + } + row.Value = item.Value + row.Description = item.Description + return tx.Save(&row).Error +} + +func isLegacyPublishOptionsValue(value string) bool { + legacyMarkers := []string{ + `"kit5"`, + "5级全装套", + "6级子弹", + "6头数量", + "6甲数量", + "0.26元/发", + "0.8元/发", + "çº", + "å¼", + "å…", + "å¤", + "ä½", + "é«", + "ï¼", + } + for _, marker := range legacyMarkers { + if strings.Contains(value, marker) { + return true + } + } + return false +} + func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error { raw, err := json.Marshal(detail) if err != nil { diff --git a/backend/seeds/dev_orders.sql b/backend/seeds/dev_orders.sql index c98ed76..65689a3 100644 --- a/backend/seeds/dev_orders.sql +++ b/backend/seeds/dev_orders.sql @@ -78,9 +78,9 @@ VALUES 'ban_record', '无封禁记录', 'resources', JSON_ARRAY( JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 80, 'mode', '收费'), - JSON_OBJECT('key', 'helmet6', 'label', '6头数量', 'quantity', 6, 'mode', '收费'), - JSON_OBJECT('key', 'armor6', 'label', '6甲数量', 'quantity', 4, 'mode', '收费'), - JSON_OBJECT('key', 'level6Ammo', 'label', '6级子弹', 'quantity', 360, 'mode', '收费') + JSON_OBJECT('key', 'helmet6', 'label', '6头', 'quantity', 6, 'mode', '收费'), + JSON_OBJECT('key', 'armor6', 'label', '6甲', 'quantity', 4, 'mode', '收费'), + JSON_OBJECT('key', 'level6Ammo', 'label', '6级弹', 'quantity', 360, 'mode', '收费') ), 'skin_groups', JSON_OBJECT( 'melee', JSON_ARRAY('龙牙', '信条'), @@ -116,7 +116,7 @@ VALUES 'ban_record', '无封禁记录', 'resources', JSON_ARRAY( JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 30, 'mode', '收费'), - JSON_OBJECT('key', 'helmet6', 'label', '6头数量', 'quantity', 2, 'mode', '收费') + JSON_OBJECT('key', 'helmet6', 'label', '6头', 'quantity', 2, 'mode', '收费') ), 'skin_groups', JSON_OBJECT( 'melee', JSON_ARRAY('怜悯'), @@ -187,9 +187,9 @@ VALUES 'ban_record', '无封禁记录', 'resources', JSON_ARRAY( JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 120, 'mode', '收费'), - JSON_OBJECT('key', 'helmet6', 'label', '6头数量', 'quantity', 10, 'mode', '收费'), - JSON_OBJECT('key', 'armor6', 'label', '6甲数量', 'quantity', 8, 'mode', '收费'), - JSON_OBJECT('key', 'level6Ammo', 'label', '6级子弹', 'quantity', 800, 'mode', '收费') + JSON_OBJECT('key', 'helmet6', 'label', '6头', 'quantity', 10, 'mode', '收费'), + JSON_OBJECT('key', 'armor6', 'label', '6甲', 'quantity', 8, 'mode', '收费'), + JSON_OBJECT('key', 'level6Ammo', 'label', '6级弹', 'quantity', 800, 'mode', '收费') ), 'skin_groups', JSON_OBJECT( 'melee', JSON_ARRAY('坠星者', '处刑者'), @@ -225,7 +225,7 @@ VALUES 'ban_record', '无封禁记录', 'resources', JSON_ARRAY( JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 80, 'mode', '赠送'), - JSON_OBJECT('key', 'kit5', 'label', '5级全装套数量', 'quantity', 6, 'mode', '收费') + JSON_OBJECT('key', 'redPacket45', 'label', '45格大红包', 'quantity', 6, 'mode', '收费') ), 'skin_groups', JSON_OBJECT( 'melee', JSON_ARRAY('影锋'), @@ -259,8 +259,8 @@ VALUES 'online_time', JSON_OBJECT('start', '14:00', 'end', '01:00'), 'ban_record', '无封禁记录', 'resources', JSON_ARRAY( - JSON_OBJECT('key', 'kit5', 'label', '5级全装套数量', 'quantity', 3, 'mode', '收费'), - JSON_OBJECT('key', 'helmet6', 'label', '6头数量', 'quantity', 1, 'mode', '收费') + JSON_OBJECT('key', 'coffeeBean', 'label', '高级咖啡豆', 'quantity', 3, 'mode', '收费'), + JSON_OBJECT('key', 'helmet6', 'label', '6头', 'quantity', 1, 'mode', '收费') ), 'skin_groups', JSON_OBJECT( 'melee', JSON_ARRAY('北极星'), @@ -295,8 +295,8 @@ VALUES 'ban_record', '无封禁记录', 'resources', JSON_ARRAY( JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 48, 'mode', '收费'), - JSON_OBJECT('key', 'armor6', 'label', '6甲数量', 'quantity', 3, 'mode', '收费'), - JSON_OBJECT('key', 'helmet6', 'label', '6头数量', 'quantity', 4, 'mode', '收费') + JSON_OBJECT('key', 'armor6', 'label', '6甲', 'quantity', 3, 'mode', '收费'), + JSON_OBJECT('key', 'helmet6', 'label', '6头', 'quantity', 4, 'mode', '收费') ), 'skin_groups', JSON_OBJECT( 'melee', JSON_ARRAY('赤枭', '黑海'), @@ -331,7 +331,7 @@ VALUES 'online_time', JSON_OBJECT('start', '10:00', 'end', '24:00'), 'ban_record', '无封禁记录', 'resources', JSON_ARRAY( - JSON_OBJECT('key', 'level6Ammo', 'label', '6级子弹', 'quantity', 520, 'mode', '赠送'), + JSON_OBJECT('key', 'level6Ammo', 'label', '6级弹', 'quantity', 520, 'mode', '赠送'), JSON_OBJECT('key', 'awmAmmo', 'label', 'AWM子弹', 'quantity', 100, 'mode', '收费'), JSON_OBJECT('key', 'gridCard9', 'label', '9格体验卡', 'quantity', 2, 'mode', '收费') ), diff --git a/docs/发布比例计算.md b/docs/发布比例计算.md new file mode 100644 index 0000000..95db984 --- /dev/null +++ b/docs/发布比例计算.md @@ -0,0 +1,61 @@ +好的,咱们把所有的账目和加减逻辑全部锁死,整理出一套最严密、绝对不会看错的最终版逻辑总览。 + +整体的核心逻辑是:**账号底子越差(缺配置)或包里哈夫币越多,比例数字就越大($1:\text{数字}$,数字越大代表给客户的哈夫币越多,号越便宜、越容易租)。** + +以下是最终的完整逻辑链条: + +--- + +### 1. 核心大框架:基础比例(对标保险箱) + +这是账号在配置全满(完美状态)下的初始比例: + +* **9 格保险箱**:基础比例为 **$1:40$** +* **6 格保险箱**:基础比例为 **$1:42$** +* **4 格保险箱**:基础比例为 **$1:47$** +* **2 格保险箱**:基础比例为 **$1:48$** + +--- + +### 2. 账号配置浮动逻辑(5项标准) + +完美的“理想账号”有 5 个指标:**红皮、刀皮、满体力、满负重、砖皮**。 +规则是:**每缺少其中任意 1 项,比例数字直接 $+1$**。 + +以 **9 格保险箱** 为例,随着配置缺失,比例梯队如下: + +* **5项完美全满**:**$1:40$** +* **缺 1 项**(剩4项):**$1:41$** +* **缺 2 项**(剩3项):**$1:42$** +* **缺 3 项**(比如**只满足了满体力+满负重**):**$1:43$** +* **缺 4 项**(剩1项):**$1:44$** +* **5项全无**(白号):**$1:45$** + +*(注:如果是 6格/4格/2格 保箱,则在此基础上各自的初始值往上加项即可。)* + +--- + +### 3. 大额哈夫币“再叠加”逻辑(高币号修正) + +当号里的哈夫币过多时(由于需求少、难成交),在上面算出的“配置比例”基础上,还要**额外再加上大额币的修正值**: + +* **哈夫币 $> 130\text{M}$**:最终比例再 **$+1$** +* **哈夫币 $> 230\text{M}$**:最终比例再 **$+2.5$** +* **哈夫币 $> 380\text{M}$**:最终比例再 **$+4$** +* **哈夫币 $> 530\text{M}$**:最终比例再 **$+5$** + +--- + +### 🛠️ 终极公式计算演示 + +> **最终出租比例 = 该保箱基础比例 + (5 - 实际拥有项数) + 大额币修正值** + +**实战看盘举例:** +一个客户来放号,是个 **9格保箱** 账号(基础 40),包里有 **$250\text{M}$ 哈夫币**,账号里**只有满体和满负重**。 + +1. **查保箱基础**:9格 = $40$ +2. **算缺失项**:5项里只有2项,缺了3项 = $+3$ (此时比例来到 $43$) +3. **叠大额币**:$250\text{M}$ 触发了 $> 230\text{M}$ 的档位 = $+2.5$ +4. **总计**:$40 + 3 + 2.5 = 45.5$ + +**最终该号的出租比例就是:$1:45.5$** \ No newline at end of file diff --git a/docs/额外消耗品_5.12.jpg b/docs/额外消耗品_5.12.jpg new file mode 100644 index 0000000..e7ca467 Binary files /dev/null and b/docs/额外消耗品_5.12.jpg differ diff --git a/frontend/src/api/homeConfig.ts b/frontend/src/api/homeConfig.ts index b71c198..9470c1e 100644 --- a/frontend/src/api/homeConfig.ts +++ b/frontend/src/api/homeConfig.ts @@ -1,6 +1,5 @@ import { apiClient } from './client' import { - defaultListingPublishOptions, mergeListingPublishOptions, type ListingPublishOptions, } from './listingOptions' @@ -87,7 +86,7 @@ export function mergeHomeConfig(config?: Partial): MobileHomeC return { announcements: announcements.length ? announcements : defaultHomeAnnouncements, banners: banners.length ? banners : defaultHomeBanners, - publish_options: mergeListingPublishOptions(config?.publish_options || defaultListingPublishOptions), + publish_options: mergeListingPublishOptions(config?.publish_options), } } diff --git a/frontend/src/api/listingOptions.ts b/frontend/src/api/listingOptions.ts index f41020e..ff467c6 100644 --- a/frontend/src/api/listingOptions.ts +++ b/frontend/src/api/listingOptions.ts @@ -2,17 +2,11 @@ import { apiClient } from './client' export type ChargeMode = '赠送' | '收费' -export type QuantityKey = - | 'awmAmmo' - | 'helmet6' - | 'armor6' - | 'kit5' - | 'level6Ammo' - | 'gridCard9' +export type QuantityKey = string -export type ScreenshotKey = 'coin' | 'gameId' | 'totalAsset' | 'tencentSecurity' | 'skin' +export type ScreenshotKey = string -export type SkinCategoryKey = 'melee' | 'operator' | 'weapon' +export type SkinCategoryKey = string export interface PublishOptionGroup { key: SkinCategoryKey | string @@ -53,63 +47,17 @@ interface ApiResponse { 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: 'level6Ammo', 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 const emptyListingPublishOptions: ListingPublishOptions = { + server_options: [], + face_options: [], + rank_options: [], + insurance_options: [], + level_options: [], + login_method_options: [], + region_options: [], + skin_groups: [], + quantity_items: [], + screenshot_slots: [], } export async function fetchListingPublishOptions() { @@ -119,15 +67,69 @@ export async function fetchListingPublishOptions() { export function mergeListingPublishOptions(options?: Partial): 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, + server_options: normalizeStringList(options?.server_options), + face_options: normalizeStringList(options?.face_options), + rank_options: normalizeStringList(options?.rank_options), + insurance_options: normalizeStringList(options?.insurance_options), + level_options: normalizeStringList(options?.level_options), + login_method_options: normalizeStringList(options?.login_method_options), + region_options: normalizeStringList(options?.region_options), + skin_groups: normalizeOptionGroups(options?.skin_groups), + quantity_items: normalizeQuantityItems(options?.quantity_items), + screenshot_slots: normalizeScreenshotSlots(options?.screenshot_slots), } } + +function normalizeStringList(values?: unknown[]) { + return Array.isArray(values) + ? values.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean) + : [] +} + +function normalizeOptionGroups(values?: unknown[]): PublishOptionGroup[] { + if (!Array.isArray(values)) return [] + return values + .map((item) => { + const group = isRecord(item) ? item : {} + return { + key: typeof group.key === 'string' ? group.key.trim() : '', + title: typeof group.title === 'string' ? group.title.trim() : '', + options: normalizeStringList(Array.isArray(group.options) ? group.options : []), + } + }) + .filter((item) => item.key && item.title) +} + +function normalizeQuantityItems(values?: unknown[]): PublishQuantityItem[] { + if (!Array.isArray(values)) return [] + return values + .map((item) => { + const row = isRecord(item) ? item : {} + return { + key: typeof row.key === 'string' ? row.key.trim() : '', + label: typeof row.label === 'string' ? row.label.trim() : '', + price: typeof row.price === 'string' ? row.price.trim() : '', + placeholder: typeof row.placeholder === 'string' ? row.placeholder.trim() : undefined, + } + }) + .filter((item) => item.key && item.label) +} + +function normalizeScreenshotSlots(values?: unknown[]): PublishScreenshotSlot[] { + if (!Array.isArray(values)) return [] + return values + .map((item) => { + const row = isRecord(item) ? item : {} + return { + key: typeof row.key === 'string' ? row.key.trim() : '', + label: typeof row.label === 'string' ? row.label.trim() : '', + required: row.required === true, + hint: typeof row.hint === 'string' ? row.hint.trim() : '', + } + }) + .filter((item) => item.key && item.label) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/frontend/src/views/admin/AdminSystemConfigsView.vue b/frontend/src/views/admin/AdminSystemConfigsView.vue index 512a418..b54dc7a 100644 --- a/frontend/src/views/admin/AdminSystemConfigsView.vue +++ b/frontend/src/views/admin/AdminSystemConfigsView.vue @@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus' import { computed, onMounted, ref } from 'vue' import { - defaultListingPublishOptions, + emptyListingPublishOptions, mergeListingPublishOptions, type ListingPublishOptions, } from '@/api/listingOptions' @@ -23,7 +23,7 @@ const configs = ref([]) const activeConfig = ref(null) const value = ref('') const description = ref('') -const publishOptionsDraft = ref(cloneOptions(defaultListingPublishOptions)) +const publishOptionsDraft = ref(cloneOptions(emptyListingPublishOptions)) const homeAnnouncementLines = ref(itemsToLines(defaultHomeAnnouncements)) const homeBannersDraft = ref(cloneHomeBanners(defaultHomeBanners)) const uploadingBannerIndex = ref(null) @@ -141,20 +141,20 @@ async function handleSave() { function parsePublishOptions(raw: string) { try { - const parsed = raw.trim() ? JSON.parse(raw) : defaultListingPublishOptions + const parsed = raw.trim() ? JSON.parse(raw) : emptyListingPublishOptions return cloneOptions(mergeListingPublishOptions(parsed)) } catch { - ElMessage.warning('发布选项 JSON 解析失败,已使用默认配置') - return cloneOptions(defaultListingPublishOptions) + ElMessage.warning('发布选项 JSON 解析失败,已清空草稿') + return cloneOptions(emptyListingPublishOptions) } } function safeParsePublishOptions(raw: string) { try { - const parsed = raw.trim() ? JSON.parse(raw) : defaultListingPublishOptions + const parsed = raw.trim() ? JSON.parse(raw) : emptyListingPublishOptions return cloneOptions(mergeListingPublishOptions(parsed)) } catch { - return cloneOptions(defaultListingPublishOptions) + return cloneOptions(emptyListingPublishOptions) } } @@ -222,7 +222,7 @@ function removeSkinGroup(index: number) { function addQuantityItem() { publishOptionsDraft.value.quantity_items.push({ - key: `item_${Date.now()}` as never, + key: `item_${Date.now()}`, label: '', price: '', }) @@ -234,7 +234,7 @@ function removeQuantityItem(index: number) { function addScreenshotSlot() { publishOptionsDraft.value.screenshot_slots.push({ - key: `screenshot_${Date.now()}` as never, + key: `screenshot_${Date.now()}`, label: '', required: false, hint: '', @@ -245,10 +245,6 @@ function removeScreenshotSlot(index: number) { publishOptionsDraft.value.screenshot_slots.splice(index, 1) } -function resetPublishOptions() { - publishOptionsDraft.value = cloneOptions(defaultListingPublishOptions) -} - function addHomeBanner() { homeBannersDraft.value.push({ eyebrow: '首页推荐', @@ -330,7 +326,7 @@ function readError(error: unknown, fallback: string) {

Publish Options

发布表单选项

- 管理移动端发布页的区服、段位、皮肤、资源道具、截图材料和地区选项。 + 管理移动端发布页的区服、段位、皮肤、额外消耗品、截图材料和地区选项。
编辑发布选项 @@ -345,7 +341,7 @@ function readError(error: unknown, fallback: string) {
{{ publishStats.resourceCount }} - 资源道具 + 额外消耗品
{{ publishStats.screenshotCount }} @@ -421,7 +417,6 @@ function readError(error: unknown, fallback: string) {
发布页选项配置 - 恢复默认选项
@@ -512,8 +507,8 @@ function readError(error: unknown, fallback: string) {
- 资源道具 - 添加道具 + 额外消耗品 + 添加消耗品
diff --git a/frontend/src/views/mobile/MobileHomeView.vue b/frontend/src/views/mobile/MobileHomeView.vue index 6779877..964da3c 100644 --- a/frontend/src/views/mobile/MobileHomeView.vue +++ b/frontend/src/views/mobile/MobileHomeView.vue @@ -4,7 +4,7 @@ import { RouterLink, useRoute } from "vue-router"; import { showToast } from "vant"; import { - defaultListingPublishOptions, + emptyListingPublishOptions, type ListingPublishOptions, } from "@/api/listingOptions"; import { fetchListings, type Listing } from "@/api/listings"; @@ -39,7 +39,7 @@ const route = useRoute(); const loading = ref(false); const loadFailed = ref(false); const listings = ref([]); -const publishOptions = ref(defaultListingPublishOptions); +const publishOptions = ref(emptyListingPublishOptions); const sortOpen = ref(false); const activeSort = ref("comprehensive"); const filterOpen = ref(false); @@ -176,7 +176,7 @@ async function loadHomeConfig() { } catch { announcements.value = defaultHomeAnnouncements; bannerSlides.value = defaultHomeBanners; - publishOptions.value = defaultListingPublishOptions; + publishOptions.value = emptyListingPublishOptions; } } diff --git a/frontend/src/views/mobile/MobileListingDetailView.vue b/frontend/src/views/mobile/MobileListingDetailView.vue index 267656f..2c0b73f 100644 --- a/frontend/src/views/mobile/MobileListingDetailView.vue +++ b/frontend/src/views/mobile/MobileListingDetailView.vue @@ -268,7 +268,7 @@ function isNavActive(path: string) {
-

资源道具

+

额外消耗品

(defaultListingPublishOptions); +const publishOptions = ref(emptyListingPublishOptions); function isNavActive(path: string) { if (path === "/m") return route.path === "/m"; @@ -74,43 +71,20 @@ function defaultForm(): PublishForm { ban_record: "", common_regions: [], deposit_amount: "", - ratio: "", - final_price: 0, - rent_days: 0, remark: "", }; } function defaultQuantityValues(): Record { - return { - awmAmmo: 0, - helmet6: 0, - armor6: 0, - kit5: 0, - level6Ammo: 0, - gridCard9: 0, - }; + return {}; } function defaultQuantityModes(): Record { - return { - awmAmmo: "收费", - helmet6: "收费", - armor6: "收费", - kit5: "收费", - level6Ammo: "收费", - gridCard9: "收费", - }; + return {}; } function defaultScreenshotFiles(): Record { - return { - coin: "", - gameId: "", - totalAsset: "", - tencentSecurity: "", - skin: "", - }; + return {}; } const form = reactive(defaultForm()); @@ -138,10 +112,17 @@ 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) + screenshotSlots.value + .map((item) => screenshotFiles[item.key]) + .filter((url): url is string => Boolean(url)) ); -const dailyLossText = computed(() => - form.rent_days ? calculateDailyLoss(Number(form.haf_coin_amount || 0)) : "" +const coinWanAmount = computed(() => Number(form.haf_coin_amount || 0)); +const calculatedRatio = computed(() => calculatePublishRatio()); +const calculatedFinalPrice = computed(() => + calculatedRatio.value > 0 ? roundMoney(coinWanAmount.value / calculatedRatio.value) : 0 +); +const calculatedRentDays = computed(() => + coinWanAmount.value > 0 ? calculateRentDays(coinWanAmount.value) : 0 ); onMounted(() => { @@ -161,7 +142,7 @@ async function loadPublishOptions() { try { publishOptions.value = await fetchListingPublishOptions(); } catch { - publishOptions.value = defaultListingPublishOptions; + publishOptions.value = emptyListingPublishOptions; } } @@ -188,10 +169,7 @@ function restoreDraft() { if (!raw) return; try { const draft = JSON.parse(raw) as Partial; - Object.assign(form, defaultForm(), draft.form || {}); - form.common_regions = Array.isArray(draft.form?.common_regions) - ? [...draft.form.common_regions] - : []; + Object.assign(form, normalizeDraftForm(draft.form)); Object.assign(quantityValues, defaultQuantityValues(), draft.quantityValues || {}); Object.assign(quantityModes, defaultQuantityModes(), draft.quantityModes || {}); Object.assign(screenshotFiles, defaultScreenshotFiles(), draft.screenshotFiles || {}); @@ -203,6 +181,27 @@ function restoreDraft() { } } +function normalizeDraftForm(value: unknown): PublishForm { + const next = defaultForm(); + if (!isRecord(value)) return next; + + for (const key of Object.keys(next) as Array) { + if (key === "common_regions") continue; + const draftValue = value[key]; + if (draftValue !== undefined) { + next[key] = draftValue as never; + } + } + next.common_regions = Array.isArray(value.common_regions) + ? value.common_regions.filter((region): region is string => typeof region === "string") + : []; + return next; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + function resetDraftState() { Object.assign(form, defaultForm()); Object.assign(quantityValues, defaultQuantityValues()); @@ -288,23 +287,6 @@ function handleFireLevelInput(value: string | number) { form.fire_level = Math.trunc(level); } -function calculatePrice() { - const coinWan = Number(form.haf_coin_amount || 0); - const ratio = Number(form.ratio || 0); - if (coinWan <= 0) { - showToast({ message: "请先填写哈夫币/万", icon: "warning-o" }); - return; - } - if (ratio <= 0) { - showToast({ message: "比例需大于0", icon: "warning-o" }); - return; - } - const finalPrice = roundMoney(coinWan / ratio); - const rentDays = coinWan >= 30000 ? 7 : coinWan >= 10000 ? 3 : 1; - form.final_price = finalPrice; - form.rent_days = rentDays; -} - async function handleSubmit() { const error = validateForm(); if (error) { @@ -314,8 +296,8 @@ async function handleSubmit() { loading.value = true; try { const title = `${form.server_region} ${form.rank_level} ${form.haf_coin_amount}万哈夫币`; - const rentDays = Math.max(Number(form.rent_days || 1), 1); - const dailyPrice = roundMoney(Number(form.final_price) / rentDays); + const rentDays = Math.max(calculatedRentDays.value, 1); + const dailyPrice = roundMoney(calculatedFinalPrice.value / rentDays); const hourlyPrice = Math.max(roundMoney(dailyPrice / 24), 0.01); await createListing({ @@ -359,8 +341,8 @@ function validateForm() { if (form.deposit_amount === "" || Number(form.deposit_amount) < 0) { return "请填写押金"; } - if (!form.final_price || !form.rent_days) { - return "请先点击计算价格"; + if (!calculatedFinalPrice.value || !calculatedRentDays.value) { + return "请完善币数、保险、体力和负重后再发布"; } for (const item of screenshotSlots.value) { if (item.required && !screenshotFiles[item.key]) { @@ -382,7 +364,7 @@ function buildAssetSummary() { key: item.key, label: item.label, quantity: Number(quantityValues[item.key] || 0), - mode: quantityModes[item.key], + mode: quantityModes[item.key] || "收费", })), skin_groups: skinGroups.value.reduce>((groups, group) => { groups[group.key] = group.options.filter((skin) => @@ -404,10 +386,79 @@ function roundMoney(value: number) { return Math.round(value * 100) / 100; } -function calculateDailyLoss(coinWan: number) { - if (coinWan >= 30000) return "30M"; - if (coinWan >= 10000) return "20M"; - return "10M"; +function calculatePublishRatio() { + if ( + coinWanAmount.value <= 0 || + !form.season_insurance || + !form.stamina_level || + !form.load_level + ) { + return 0; + } + const baseRatio = getInsuranceBaseRatio(form.season_insurance); + if (baseRatio <= 0) return 0; + + return baseRatio + (5 - getConfigHitCount()) + getCoinCorrection(coinWanAmount.value); +} + +function getInsuranceBaseRatio(insurance: string) { + const slots = getInsuranceSlots(insurance); + const ratioBySlots: Record = { + 9: 40, + 6: 42, + 4: 47, + 2: 48, + }; + return ratioBySlots[slots] || 0; +} + +function getInsuranceSlots(insurance: string) { + const match = insurance.match(/^(\d+)\*(\d+)$/); + if (!match) return 0; + return Number(match[1]) * Number(match[2]); +} + +function getConfigHitCount() { + const checks = [ + hasSelectedSkinGroup("operator"), + hasSelectedSkinGroup("melee"), + isMaxLevel(form.stamina_level), + isMaxLevel(form.load_level), + hasSelectedSkinGroup("weapon"), + ]; + return checks.filter(Boolean).length; +} + +function hasSelectedSkinGroup(groupKey: string) { + const group = skinGroups.value.find((item) => item.key === groupKey); + if (!group) return false; + return group.options.some((skin) => selectedSkins.value.includes(skin)); +} + +function isMaxLevel(value: string) { + const currentLevel = readLevelNumber(value); + const maxLevel = Math.max(...levelOptions.value.map(readLevelNumber).filter(Boolean)); + if (currentLevel > 0 && maxLevel > 0) return currentLevel >= maxLevel; + return value === levelOptions.value[levelOptions.value.length - 1]; +} + +function readLevelNumber(value: string) { + const match = value.match(/\d+/); + return match ? Number(match[0]) : 0; +} + +function getCoinCorrection(coinWan: number) { + if (coinWan > 53000) return 5; + if (coinWan > 38000) return 4; + if (coinWan > 23000) return 2.5; + if (coinWan > 13000) return 1; + return 0; +} + +function calculateRentDays(coinWan: number) { + if (coinWan >= 30000) return 7; + if (coinWan >= 10000) return 3; + return 1; } function readError(error: unknown, fallback: string) { @@ -562,7 +613,7 @@ function readError(error: unknown, fallback: string) {
-

资源道具

+

额外消耗品

@@ -588,7 +639,7 @@ function readError(error: unknown, fallback: string) {
-

价格结算

+

押金与价格

- - - - - - 计算价格 - -
- - -
diff --git a/frontend/src/views/public/HomeView.vue b/frontend/src/views/public/HomeView.vue index 2445e8c..df09f1b 100644 --- a/frontend/src/views/public/HomeView.vue +++ b/frontend/src/views/public/HomeView.vue @@ -16,7 +16,7 @@ import { type HomeBannerSlide, } from "@/api/homeConfig"; import { - defaultListingPublishOptions, + emptyListingPublishOptions, type ListingPublishOptions, } from "@/api/listingOptions"; import { fetchListings, type Listing } from "@/api/listings"; @@ -37,7 +37,7 @@ const loading = ref(false); const listings = ref([]); const announcements = ref(defaultHomeAnnouncements); const banners = ref(defaultHomeBanners); -const publishOptions = ref(defaultListingPublishOptions); +const publishOptions = ref(emptyListingPublishOptions); const filters = reactive({ keyword: "", server: "", @@ -131,7 +131,7 @@ async function loadHome() { } catch { announcements.value = defaultHomeAnnouncements; banners.value = defaultHomeBanners; - publishOptions.value = defaultListingPublishOptions; + publishOptions.value = emptyListingPublishOptions; } finally { loading.value = false; }