额外消耗品
@@ -332,6 +344,38 @@ function updateNumberField(field: 'coin_consumed_m' | 'otherAmountYuan', event:
background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%);
}
+.checkout-remark-field {
+ display: grid;
+ gap: 6px;
+}
+
+.checkout-remark-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.checkout-remark-head strong {
+ color: #1f2937;
+ font-size: 14px;
+}
+
+.remark-tag {
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: #f1f5f9;
+ color: #64748b;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.remark-hint {
+ margin: 0;
+ color: #8a94a6;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
.checkout-resource-panel {
display: grid;
gap: 10px;
diff --git a/frontend/src/features/orders/composables/useOrderActions.ts b/frontend/src/features/orders/composables/useOrderActions.ts
index 0a6f44a..d40a929 100644
--- a/frontend/src/features/orders/composables/useOrderActions.ts
+++ b/frontend/src/features/orders/composables/useOrderActions.ts
@@ -382,6 +382,7 @@ export function useOrderActions(options: UseOrderActionsOptions) {
title: '确认提交结账',
message:
'结账数据会影响租金、押金赔付和双方结算金额。请确认哈夫币消耗、额外消耗品、押金赔付和证据链接已认真核对,提交后将发送给号主确认。' +
+ '\n\n「结账备注」仅作留言沟通,不参与结算金额计算。' +
depositWarning,
})
if (!ok) return
diff --git a/frontend/src/features/orders/composables/useOrderSnapshot.spec.ts b/frontend/src/features/orders/composables/useOrderSnapshot.spec.ts
index 5865a7a..1b4a83d 100644
--- a/frontend/src/features/orders/composables/useOrderSnapshot.spec.ts
+++ b/frontend/src/features/orders/composables/useOrderSnapshot.spec.ts
@@ -1,7 +1,16 @@
import { describe, expect, it } from 'vitest'
import type { Order } from '../api/orders'
-import { orderListRentalTerms, orderRentalTerms } from './useOrderSnapshot'
+import {
+ buildCheckoutContentWithSummary,
+ CHECKOUT_SYSTEM_SUMMARY_MARKER,
+ orderListRentalTerms,
+ orderRentalTerms,
+ parseCheckoutSystemSummary,
+ readCheckoutSystemSummary,
+ splitCheckoutContent,
+ type CheckoutResource,
+} from './useOrderSnapshot'
describe('orderRentalTerms', () => {
it('estimates rental days from the account snapshot', () => {
@@ -30,3 +39,227 @@ describe('orderListRentalTerms', () => {
})
})
})
+
+const ammoResource: CheckoutResource = {
+ key: 'ammo6',
+ label: '6级弹',
+ price: '1元/3',
+ mode: '收费',
+ quantity: 10,
+ unitPrice: 0.33,
+}
+
+const awmResource: CheckoutResource = {
+ key: 'awm',
+ label: 'AWM子弹',
+ price: '0.6元/1',
+ mode: '收费',
+ quantity: 10,
+ unitPrice: 0.6,
+}
+
+const giftResource: CheckoutResource = {
+ key: 'gift',
+ label: '8号弹',
+ price: '1元/1',
+ mode: '赠送',
+ quantity: 5,
+ unitPrice: 1,
+}
+
+describe('buildCheckoutContentWithSummary', () => {
+ it('分隔手填备注与系统结算信息,消耗品逐行输出并给出合计', () => {
+ const content = buildCheckoutContentWithSummary({
+ content: '16个6甲2个6头59发子弹你看对',
+ coinConsumedM: 150,
+ depositDeductAmountYuan: 1.8,
+ checkoutResources: [ammoResource, awmResource, giftResource],
+ resourceUsage: { ammo6: 3, awm: 2, gift: 1 },
+ remainingHafCoinM: 0,
+ })
+
+ expect(content).toBe(
+ [
+ '16个6甲2个6头59发子弹你看对',
+ CHECKOUT_SYSTEM_SUMMARY_MARKER,
+ '额外消耗品:6级弹 3/10,金额¥1.0',
+ '额外消耗品:AWM子弹 2/10,金额¥1.2',
+ '额外消耗品:8号弹 1/5,赠送不扣款',
+ '额外消耗品合计:¥2.2',
+ '哈夫币实际消耗:150M,预计剩余0M',
+ '押金赔付扣除:¥1.80(将直接从预收押金中扣除)',
+ ].join('\n')
+ )
+ })
+
+ it('无结算信息时只保留备注,不写分隔标记', () => {
+ const content = buildCheckoutContentWithSummary({
+ content: ' 归还账号 ',
+ coinConsumedM: 0,
+ checkoutResources: [],
+ resourceUsage: {},
+ remainingHafCoinM: 0,
+ })
+
+ expect(content).toBe('归还账号')
+ expect(content).not.toContain(CHECKOUT_SYSTEM_SUMMARY_MARKER)
+ })
+
+ it('备注与结算信息都为空时给出兜底文案', () => {
+ expect(
+ buildCheckoutContentWithSummary({
+ content: '',
+ coinConsumedM: 0,
+ checkoutResources: [],
+ resourceUsage: {},
+ remainingHafCoinM: 0,
+ })
+ ).toBe('租客发起结账。')
+ })
+
+ it('未填备注时仅输出系统结算信息', () => {
+ const content = buildCheckoutContentWithSummary({
+ content: '',
+ coinConsumedM: 20,
+ checkoutResources: [],
+ resourceUsage: {},
+ remainingHafCoinM: 5,
+ })
+
+ expect(splitCheckoutContent(content)).toEqual({
+ remark: '',
+ systemLines: ['哈夫币实际消耗:20M,预计剩余5M'],
+ systemText: '哈夫币实际消耗:20M,预计剩余5M',
+ })
+ })
+})
+
+describe('splitCheckoutContent', () => {
+ it('按分隔标记拆分备注与系统结算信息', () => {
+ const parts = splitCheckoutContent(
+ [
+ '第一行备注',
+ '第二行备注',
+ CHECKOUT_SYSTEM_SUMMARY_MARKER,
+ '哈夫币实际消耗:150M,预计剩余0M',
+ ].join('\n')
+ )
+
+ expect(parts.remark).toBe('第一行备注\n第二行备注')
+ expect(parts.systemLines).toEqual(['哈夫币实际消耗:150M,预计剩余0M'])
+ expect(parts.systemText).toBe('哈夫币实际消耗:150M,预计剩余0M')
+ })
+
+ it('兼容无标记的历史数据,按前缀识别系统行', () => {
+ const parts = splitCheckoutContent(
+ [
+ '16个6甲2个6头59发子弹你看对',
+ '额外消耗品:6级弹 3/10,金额¥0.3',
+ '哈夫币实际消耗:150M,预计剩余0M',
+ ].join('\n')
+ )
+
+ expect(parts.remark).toBe('16个6甲2个6头59发子弹你看对')
+ expect(parts.systemLines).toEqual([
+ '额外消耗品:6级弹 3/10,金额¥0.3',
+ '哈夫币实际消耗:150M,预计剩余0M',
+ ])
+ })
+
+ it('普通交接说明整体视为备注', () => {
+ expect(splitCheckoutContent('账号已交接,密码见群聊')).toEqual({
+ remark: '账号已交接,密码见群聊',
+ systemLines: [],
+ systemText: '',
+ })
+ })
+
+ it('空内容返回空结构', () => {
+ expect(splitCheckoutContent('')).toEqual({ remark: '', systemLines: [], systemText: '' })
+ expect(splitCheckoutContent(null)).toEqual({ remark: '', systemLines: [], systemText: '' })
+ })
+})
+
+describe('parseCheckoutSystemSummary', () => {
+ it('把逐行系统结算信息解析成明细行', () => {
+ const summary = parseCheckoutSystemSummary([
+ '额外消耗品:6级弹 3/10,金额¥1.0',
+ '额外消耗品:AWM子弹 2/10,金额¥1.2',
+ '额外消耗品:8号弹 1/5,赠送不扣款',
+ '额外消耗品合计:¥2.2',
+ '哈夫币实际消耗:150M,预计剩余0M',
+ '押金赔付扣除:¥1.80(将直接从预收押金中扣除)',
+ ])
+
+ expect(summary.items).toEqual([
+ { name: '6级弹', count: '3/10', amount: '¥1.0', free: false },
+ { name: 'AWM子弹', count: '2/10', amount: '¥1.2', free: false },
+ { name: '8号弹', count: '1/5', amount: '', free: true },
+ ])
+ expect(summary.totalText).toBe('¥2.2')
+ expect(summary.coinValue).toBe('150M')
+ expect(summary.coinNote).toBe('预计剩余0M')
+ expect(summary.depositValue).toBe('¥1.80')
+ expect(summary.depositNote).toBe('将直接从预收押金中扣除')
+ expect(summary.empty).toBe(false)
+ })
+
+ it('兼容旧数据:同一行的多件消耗品自动拆行并补出合计', () => {
+ const summary = parseCheckoutSystemSummary([
+ '额外消耗品:6级弹 3/10,金额¥0.3;AWM子弹 2/10,金额¥1.2;巴雷特子弹 1/10,金额¥0.3',
+ '哈夫币实际消耗:150M,预计剩余0M',
+ ])
+
+ expect(summary.items.map(item => item.name)).toEqual(['6级弹', 'AWM子弹', '巴雷特子弹'])
+ expect(summary.items.map(item => item.amount)).toEqual(['¥0.3', '¥1.2', '¥0.3'])
+ expect(summary.totalText).toBe('¥1.8')
+ })
+
+ it('保留打超说明与未识别的其它系统行', () => {
+ const summary = parseCheckoutSystemSummary([
+ '哈夫币实际消耗:200M,打超50M(超额按比例从押金结算)',
+ '其它系统说明',
+ ])
+
+ expect(summary.coinValue).toBe('200M')
+ expect(summary.coinNote).toBe('打超50M(超额按比例从押金结算)')
+ expect(summary.extraLines).toEqual(['其它系统说明'])
+ })
+
+ it('空输入返回空结构', () => {
+ expect(parseCheckoutSystemSummary(null).empty).toBe(true)
+ expect(parseCheckoutSystemSummary([]).empty).toBe(true)
+ })
+
+ it('生成的结账内容可直接读回明细', () => {
+ const content = buildCheckoutContentWithSummary({
+ content: '归还留言',
+ coinConsumedM: 150,
+ checkoutResources: [ammoResource, awmResource],
+ resourceUsage: { ammo6: 3, awm: 2 },
+ remainingHafCoinM: 0,
+ })
+
+ const summary = readCheckoutSystemSummary(content)
+ expect(summary.items.map(item => item.name)).toEqual(['6级弹', 'AWM子弹'])
+ expect(summary.totalText).toBe('¥2.2')
+ expect(summary.coinValue).toBe('150M')
+ })
+})
+
+describe('buildCheckoutContentWithSummary 消耗品明细', () => {
+ it('全部为赠送品时不输出合计行', () => {
+ const content = buildCheckoutContentWithSummary({
+ content: '仅赠送品',
+ coinConsumedM: 0,
+ checkoutResources: [giftResource],
+ resourceUsage: { gift: 2 },
+ remainingHafCoinM: 0,
+ })
+
+ expect(content).toBe(
+ ['仅赠送品', CHECKOUT_SYSTEM_SUMMARY_MARKER, '额外消耗品:8号弹 2/5,赠送不扣款'].join('\n')
+ )
+ expect(readCheckoutSystemSummary(content).totalText).toBe('')
+ })
+})
diff --git a/frontend/src/features/orders/composables/useOrderSnapshot.ts b/frontend/src/features/orders/composables/useOrderSnapshot.ts
index 4e19681..61d39f3 100644
--- a/frontend/src/features/orders/composables/useOrderSnapshot.ts
+++ b/frontend/src/features/orders/composables/useOrderSnapshot.ts
@@ -26,6 +26,192 @@ export interface CheckoutResource {
unitPrice: number
}
+/**
+ * 结账内容分隔标记:写在「租客手填备注」与「系统结算信息」之间。
+ * 提交时写入,展示端据此拆分;历史数据没有标记,按行前缀兜底识别。
+ */
+export const CHECKOUT_SYSTEM_SUMMARY_MARKER = '【系统结算信息·参与结算】'
+
+/** 系统结算信息的行前缀(单件消耗品、合计、哈夫币、押金)。 */
+const CHECKOUT_CONSUMABLE_PREFIX = '额外消耗品:'
+const CHECKOUT_CONSUMABLE_TOTAL_PREFIX = '额外消耗品合计:'
+const CHECKOUT_COIN_PREFIX = '哈夫币实际消耗:'
+const CHECKOUT_DEPOSIT_PREFIX = '押金赔付扣除:'
+
+/** 用于识别未带分隔标记的历史数据。 */
+const CHECKOUT_SYSTEM_LINE_PREFIXES = [
+ CHECKOUT_CONSUMABLE_PREFIX,
+ CHECKOUT_CONSUMABLE_TOTAL_PREFIX,
+ CHECKOUT_COIN_PREFIX,
+ CHECKOUT_DEPOSIT_PREFIX,
+]
+
+export interface CheckoutContentParts {
+ /** 租客手填备注:仅作双方沟通留言,不参与结算计算。 */
+ remark: string
+ /** 系统按实际消耗生成的结算信息行。 */
+ systemLines: string[]
+ /** 系统结算信息整体文本,无则为空串。 */
+ systemText: string
+}
+
+/**
+ * 拆分结账内容为「手填备注」与「系统结算信息」。
+ * 备注不参与租金、押金赔付和最终结算金额计算,仅用于沟通留痕。
+ */
+export function splitCheckoutContent(content: string | null | undefined): CheckoutContentParts {
+ const lines = linesToList(String(content || ''))
+ const markerIndex = lines.indexOf(CHECKOUT_SYSTEM_SUMMARY_MARKER)
+ if (markerIndex >= 0) {
+ const systemLines = lines.slice(markerIndex + 1)
+ return {
+ remark: lines.slice(0, markerIndex).join('\n'),
+ systemLines,
+ systemText: systemLines.join('\n'),
+ }
+ }
+ // 兼容旧数据:系统行按固定前缀识别,其余行视为手填备注
+ const remarkLines: string[] = []
+ const systemLines: string[] = []
+ for (const line of lines) {
+ const isSystemLine = CHECKOUT_SYSTEM_LINE_PREFIXES.some(prefix => line.startsWith(prefix))
+ if (isSystemLine) systemLines.push(line)
+ else remarkLines.push(line)
+ }
+ return { remark: remarkLines.join('\n'), systemLines, systemText: systemLines.join('\n') }
+}
+
+/** 单件额外消耗品明细行。 */
+export interface CheckoutSystemItem {
+ /** 消耗品名称,如 6级弹。 */
+ name: string
+ /** 用量与库存上限,如 3/10;无法解析时为空串。 */
+ count: string
+ /** 金额文本,如 ¥0.3;赠送品为空串。 */
+ amount: string
+ /** 是否赠送(不扣款)。 */
+ free: boolean
+}
+
+/** 系统结算信息结构化结果,用于逐行明细展示。 */
+export interface CheckoutSystemSummary {
+ /** 额外消耗品明细 */
+ items: CheckoutSystemItem[]
+ /** 额外消耗品合计文本,如 ¥1.8 */
+ totalText: string
+ /** 哈夫币实际消耗,如 150M */
+ coinValue: string
+ /** 哈夫币剩余 / 打超说明 */
+ coinNote: string
+ /** 押金赔付扣除金额,如 ¥1.80 */
+ depositValue: string
+ /** 押金扣除说明 */
+ depositNote: string
+ /** 未识别出的其它系统行,原样展示 */
+ extraLines: string[]
+ /** 是否没有任何可结构化的系统信息 */
+ empty: boolean
+}
+
+/** 解析单件消耗品文本:`6级弹 3/10,金额¥0.3` / `8号弹 2/5,赠送不扣款`。 */
+function parseCheckoutConsumableSegment(segment: string): CheckoutSystemItem | null {
+ const text = String(segment || '').trim()
+ if (!text) return null
+ const matched = text.match(/^(.*?)\s*([\d.]+\s*\/\s*[\d.]+)\s*(.*)$/)
+ const count = (matched?.[2] || '').replace(/\s+/g, '')
+ if (!count) return { name: text, count: '', amount: '', free: false }
+ const tail = matched?.[3] || ''
+ const amountMatch = tail.match(/金额¥([\d.]+)/)
+ const amountText = amountMatch?.[1] || ''
+ return {
+ name: (matched?.[1] || text).trim(),
+ count,
+ amount: amountText ? `¥${amountText}` : '',
+ free: !amountText && /赠送/.test(tail),
+ }
+}
+
+/**
+ * 把系统结算信息行解析成可逐行渲染的明细。
+ * 兼容历史数据:多件消耗品挤在同一行(用「;」分隔)时自动拆行,
+ * 且没有合计行时按各件金额累加补出合计。
+ */
+export function parseCheckoutSystemSummary(lines: unknown): CheckoutSystemSummary {
+ const items: CheckoutSystemItem[] = []
+ const extraLines: string[] = []
+ let totalText = ''
+ let coinValue = ''
+ let coinNote = ''
+ let depositValue = ''
+ let depositNote = ''
+ let chargedTotal = 0
+ let chargedAmountKnown = true
+
+ for (const rawLine of Array.isArray(lines) ? lines : []) {
+ const line = String(rawLine || '').trim()
+ if (!line) continue
+ if (line.startsWith(CHECKOUT_CONSUMABLE_TOTAL_PREFIX)) {
+ const value = line.slice(CHECKOUT_CONSUMABLE_TOTAL_PREFIX.length).replace(/^¥/, '').trim()
+ totalText = `¥${value}`
+ continue
+ }
+ if (line.startsWith(CHECKOUT_CONSUMABLE_PREFIX)) {
+ for (const segment of line.slice(CHECKOUT_CONSUMABLE_PREFIX.length).split(';')) {
+ const item = parseCheckoutConsumableSegment(segment)
+ if (!item) continue
+ items.push(item)
+ if (item.free) continue
+ const amount = Number(item.amount.replace(/^¥/, ''))
+ if (item.amount && Number.isFinite(amount)) chargedTotal = roundMoney(chargedTotal + amount)
+ else chargedAmountKnown = false
+ }
+ continue
+ }
+ if (line.startsWith(CHECKOUT_COIN_PREFIX)) {
+ const [value = '', ...rest] = line.slice(CHECKOUT_COIN_PREFIX.length).trim().split(',')
+ coinValue = value.trim()
+ coinNote = rest.join(',').trim()
+ continue
+ }
+ if (line.startsWith(CHECKOUT_DEPOSIT_PREFIX)) {
+ const body = line.slice(CHECKOUT_DEPOSIT_PREFIX.length).trim()
+ const matched = body.match(/^(.*?)[((](.*?)[))]?$/)
+ if (matched) {
+ depositValue = (matched[1] || '').trim()
+ depositNote = (matched[2] || '').trim()
+ } else {
+ depositValue = body
+ }
+ continue
+ }
+ extraLines.push(line)
+ }
+
+ const hasChargedItem = items.some(item => !item.free)
+ if (!totalText && hasChargedItem && chargedAmountKnown) {
+ totalText = `¥${money(chargedTotal)}`
+ }
+
+ return {
+ items,
+ totalText,
+ coinValue,
+ coinNote,
+ depositValue,
+ depositNote,
+ extraLines,
+ empty:
+ items.length === 0 && !totalText && !coinValue && !depositValue && extraLines.length === 0,
+ }
+}
+
+/** 直接从结账内容读取系统结算信息明细。 */
+export function readCheckoutSystemSummary(
+ content: string | null | undefined
+): CheckoutSystemSummary {
+ return parseCheckoutSystemSummary(splitCheckoutContent(content).systemLines)
+}
+
export function useOrderCheckoutSnapshot(options: {
order: Ref
checkoutForm: Ref
@@ -151,43 +337,54 @@ export function buildCheckoutContentWithSummary(options: {
resourceUsage: Record
remainingHafCoinM: number
}) {
- const lines = [options.content.trim()].filter(Boolean)
+ // 手填备注与系统结算信息分开生成:备注仅为留言,结算信息由平台按实际消耗自动计算
+ const remark = options.content.trim()
+ const systemLines: string[] = []
const usedResources = options.checkoutResources.filter(
item => readResourceUsageAmount(options.resourceUsage, item.key) > 0
)
- if (usedResources.length) {
- lines.push(
- `额外消耗品:${usedResources
- .map(item => {
- const used = readResourceUsageAmount(options.resourceUsage, item.key)
- const amount = roundMoney(used * item.unitPrice)
- return `${item.label} ${used}/${item.quantity}${
- isChargedResource(item) ? `,金额¥${money(amount)}` : ',赠送不扣款'
- }`
- })
- .join(';')}`
+ // 每件消耗品单独一行,便于展示端渲染成明细行并补出合计
+ let consumableTotalYuan = 0
+ let hasChargedResource = false
+ for (const item of usedResources) {
+ const used = readResourceUsageAmount(options.resourceUsage, item.key)
+ const charged = isChargedResource(item)
+ const amount = roundMoney(used * item.unitPrice)
+ if (charged) {
+ hasChargedResource = true
+ consumableTotalYuan = roundMoney(consumableTotalYuan + amount)
+ }
+ systemLines.push(
+ `${CHECKOUT_CONSUMABLE_PREFIX}${item.label} ${used}/${item.quantity}${
+ charged ? `,金额¥${money(amount)}` : ',赠送不扣款'
+ }`
)
}
+ if (hasChargedResource) {
+ systemLines.push(`${CHECKOUT_CONSUMABLE_TOTAL_PREFIX}¥${money(consumableTotalYuan)}`)
+ }
if (Number(options.coinConsumedM || 0) > 0) {
const remain = Number(options.remainingHafCoinM || 0)
if (remain >= 0) {
- lines.push(
- `哈夫币实际消耗:${quantity(options.coinConsumedM)}M,预计剩余${quantity(remain)}M`
+ systemLines.push(
+ `${CHECKOUT_COIN_PREFIX}${quantity(options.coinConsumedM)}M,预计剩余${quantity(remain)}M`
)
} else {
- lines.push(
- `哈夫币实际消耗:${quantity(options.coinConsumedM)}M,打超${quantity(-remain)}M(超额按比例从押金结算)`
+ systemLines.push(
+ `${CHECKOUT_COIN_PREFIX}${quantity(options.coinConsumedM)}M,打超${quantity(-remain)}M(超额按比例从押金结算)`
)
}
}
const depositDeductAmountYuan = Number(options.depositDeductAmountYuan || 0)
if (depositDeductAmountYuan > 0) {
- lines.push(`押金赔付扣除:¥${depositDeductAmountYuan.toFixed(2)}(将直接从预收押金中扣除)`)
+ systemLines.push(
+ `${CHECKOUT_DEPOSIT_PREFIX}¥${depositDeductAmountYuan.toFixed(2)}(将直接从预收押金中扣除)`
+ )
}
- if (lines.length === 0) {
- lines.push('租客发起结账。')
+ if (systemLines.length === 0) {
+ return remark || '租客发起结账。'
}
- return lines.join('\n')
+ return [remark, CHECKOUT_SYSTEM_SUMMARY_MARKER, ...systemLines].filter(Boolean).join('\n')
}
export function hydrateCounterFormFromCheckout(checkout: Checkout): CounterFormSnapshot {
diff --git a/frontend/src/features/orders/index.ts b/frontend/src/features/orders/index.ts
index 225c944..4d3596f 100644
--- a/frontend/src/features/orders/index.ts
+++ b/frontend/src/features/orders/index.ts
@@ -1,6 +1,7 @@
// Orders 模块统一导出
export * from './api/orders'
export * from './composables/useOrderSnapshot'
+export { default as CheckoutSystemDetail } from './components/CheckoutSystemDetail.vue'
export { default as OrderAccountSnapshot } from './components/OrderAccountSnapshot.vue'
export { default as OrderCheckoutSummary } from './components/OrderCheckoutSummary.vue'
export { default as OrderHandoffTimeline } from './components/OrderHandoffTimeline.vue'