feat: P3阶段完成 - 全部模块迁移完成 🎉

## P3.1: 争议仲裁模块(disputes)✅
- API: disputes.ts
- 模块导出

## P3.2: 卖家中心模块(seller)✅
- Views: 4个页面
- Composables: usePublishForm, usePublishDraft
- 模块导出

## P3.3: 管理后台模块(admin)✅
- API: 8个文件(adminAuth, adminDashboard, adminUsers等)
- Views: 15个管理页面
- Composables: useAdminTable, useAdminPaginatedTable
- Components: 管理端组件
- 模块导出

---

## 🎉 Features 架构迁移全部完成!

### 最终统计
- ✅ P0: shared(基础设施)- 22个文件
- ✅ P1: wallet, chats, orders - 24个文件
- ✅ P2: listings, auth - 35个文件
- ✅ P3: seller, disputes, admin - 47个文件

**总计:** 9个模块,128个文件完成迁移

### 新架构
```
frontend/src/
├── features/          # 9个业务模块 ✅
│   ├── wallet/       ✅
│   ├── chats/        ✅
│   ├── orders/       ✅ (已重构)
│   ├── listings/     ✅
│   ├── auth/         ✅
│   ├── seller/       ✅
│   ├── disputes/     ✅
│   └── admin/        ✅
└── shared/           ✅
```

下一步:清理旧文件、更新路由配置

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml2213
2026-06-04 09:05:34 +08:00
co-authored by Claude Opus 4.7
parent 3534cffce1
commit c9397635e2
47 changed files with 9406 additions and 0 deletions
@@ -0,0 +1,9 @@
<template>
<section class="page">
<div class="page-header">
<p class="eyebrow">Earnings</p>
<h1>收益流水</h1>
<p>查看订单金额、平台抽成、结算和冻结金额。</p>
</div>
</section>
</template>
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { fetchOrders, type Order } from '@/api/orders'
import { useSessionStore } from '@/stores/session'
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const session = useSessionStore()
const loading = ref(false)
const orders = ref<Order[]>([])
const status = ref('')
const sellerOrders = computed(() => orders.value.filter((order) => order.owner_id === session.userId))
const todoOrders = computed(() =>
sellerOrders.value.filter((order) =>
[
'pending_handoff',
'renting',
'overdue',
'pending_checkout_confirm',
'pending_checkout_accept',
'checkout_disputing',
'disputing',
'abnormal',
].includes(order.status),
),
)
const displayOrders = computed(() => {
const source = todoOrders.value
if (!status.value) return source
return source.filter((order) => order.status === status.value)
})
const pendingHandoffCount = computed(
() => sellerOrders.value.filter((order) => order.status === 'pending_handoff' && order.handoff_status === 'pending_owner').length,
)
const pendingCheckoutCount = computed(
() => sellerOrders.value.filter((order) => order.status === 'pending_checkout_confirm').length,
)
const abnormalCount = computed(
() => sellerOrders.value.filter((order) => ['overdue', 'checkout_disputing', 'disputing', 'abnormal'].includes(order.status)).length,
)
onMounted(loadOrders)
async function loadOrders() {
loading.value = true
try {
orders.value = await fetchOrders()
} finally {
loading.value = false
}
}
function actionText(order: Order) {
if (order.status === 'pending_handoff' && order.handoff_status === 'pending_owner') return '提交交接'
if (order.status === 'pending_checkout_confirm') return '处理结账'
if (order.status === 'pending_checkout_accept') return '等待租客'
if (['overdue', 'checkout_disputing', 'disputing', 'abnormal'].includes(order.status)) return '查看处理'
return '详情'
}
function money(value: unknown) {
return Math.round(Number(value || 0))
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Handoffs</p>
<h1>交接管理</h1>
<p>处理待交接、租赁中、待结账确认和异常订单。</p>
</div>
<div class="toolbar-actions">
<el-select v-model="status" clearable placeholder="待办状态" style="width: 190px">
<el-option label="待交接" value="pending_handoff" />
<el-option label="使用中" value="renting" />
<el-option label="逾期中" value="overdue" />
<el-option label="待确认结账" value="pending_checkout_confirm" />
<el-option label="待租客确认修正" value="pending_checkout_accept" />
<el-option label="结账争议中" value="checkout_disputing" />
<el-option label="申诉中" value="disputing" />
<el-option label="异常" value="abnormal" />
</el-select>
<el-button @click="loadOrders">刷新</el-button>
</div>
</div>
<div class="metric-grid">
<div class="metric-card">
<span>待交接</span>
<strong>{{ pendingHandoffCount }} 单</strong>
</div>
<div class="metric-card">
<span>待结账</span>
<strong>{{ pendingCheckoutCount }} 单</strong>
</div>
<div class="metric-card">
<span>异常/争议</span>
<strong>{{ abnormalCount }} 单</strong>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="displayOrders">
<el-table-column prop="order_no" label="订单号" min-width="220" />
<el-table-column prop="title" label="账号" min-width="180" />
<el-table-column label="金额" width="120">
<template #default="{ row }">¥{{ money(row.display_amount) }}</template>
</el-table-column>
<el-table-column label="订单状态" width="150">
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column label="交接状态" width="180">
<template #default="{ row }">{{ handoffStatusLabel(row.handoff_status) }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<RouterLink :to="`/orders/${row.id}`">
<el-button size="small" type="primary">{{ actionText(row) }}</el-button>
</RouterLink>
</template>
</el-table-column>
</el-table>
</section>
</template>
@@ -0,0 +1,490 @@
<script setup lang="ts">
import { Delete, DocumentChecked, Picture, RefreshRight, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { usePublishForm } from '@/composables/usePublishForm'
import OptionChips from './components/OptionChips.vue'
import PublishSection from './components/PublishSection.vue'
const {
commonOnlineTimes,
dailyLossOptions,
formatNumber,
loading,
uploading,
fileInput,
activeUploadKey,
form,
quantityValues,
quantityModes,
screenshotFiles,
selectedSkins,
uploadedScreenshotCount,
requiredScreenshotCount,
serverOptions,
faceOptions,
rankOptions,
insuranceOptions,
levelOptions,
loginMethodOptions,
regionOptions,
banRecordOptions,
skinGroups,
quantityItems,
screenshotSlots,
priceConfig,
fireLevelPlaceholder,
coinMAmount,
dailyLossMAmount,
calculatedDefaultSaleRatio,
maxAcceleratedSaleRatio,
calculatedCoinBasePrice,
calculatedConsumablePrice,
calculatedSellerPrice,
calculatedRatioText,
calculatedDefaultSaleRatioText,
saleRatioRangeText,
acceleratedSaleRatioPlaceholder,
recommendedDepositAmount,
depositBreakdownItems,
publishTitle,
hasAcceleratedSaleRatioInput,
isQuantityItemDisabled,
handleSaveDraft,
handleResetDraft,
toggleSkin,
toggleRegion,
triggerUpload,
handleScreenshotUpload,
removeScreenshot,
getScreenshotPreviewURL,
handleFireLevelInput,
handleAcceleratedSaleRatioInput,
useRecommendedDeposit,
setQuantityMode,
clampAcceleratedSaleRatioInput,
useReferenceSaleRatio,
useMaxAcceleratedSaleRatio,
handleSubmit,
isScreenshotRequired,
} = usePublishForm({
draftKey: 'hfb.pc.publish.draft',
submitSuccessPath: '/seller/listings',
persistBeforeUnload: true,
async confirmReset() {
await ElMessageBox.confirm('将清空当前填写内容和本地草稿。', '重置发布内容', {
confirmButtonText: '重置',
cancelButtonText: '取消',
type: 'warning',
})
},
notifySuccess: (message) => ElMessage.success(message),
notifyWarning: (message) => ElMessage.warning(message),
notifyError: (message) => ElMessage.error(message),
})
function toggleSkinOption(value: string | number) {
toggleSkin(String(value))
}
function selectDailyLoss(value: string | number) {
form.daily_loss_m = Number(value)
}
</script>
<template>
<main class="publish-page">
<div class="publish-layout">
<section class="form-column">
<PublishSection title="基础资料" :description="publishTitle">
<div class="field-row panel-field-row">
<label>区服<span>*</span></label>
<OptionChips :options="serverOptions" :model-value="form.server_region" @select="form.server_region = String($event)" />
</div>
<div v-if="faceOptions.length" class="field-row panel-field-row">
<label>是否本人人脸</label>
<OptionChips :options="faceOptions" :model-value="form.face_owner" @select="form.face_owner = String($event)" />
</div>
<div class="compact-grid panel-input-grid">
<label class="input-block">
<span>哈夫币/M<b>*</b></span>
<el-input-number v-model="form.haf_coin_amount" :min="0" :controls="false" placeholder="100M 写 100" />
</label>
<label class="input-block">
<span>绝密KD</span>
<el-input-number v-model="form.secret_kd" :min="0" :controls="false" :step="0.1" placeholder="填写绝密KD" />
</label>
<label class="input-block">
<span>烽火等级<b>*</b></span>
<el-input-number
:model-value="form.fire_level"
:min="0"
:controls="false"
:placeholder="fireLevelPlaceholder"
@update:model-value="handleFireLevelInput"
/>
</label>
</div>
<div class="field-row panel-field-row">
<label>段位<span>*</span></label>
<OptionChips :options="rankOptions" :model-value="form.rank_level" @select="form.rank_level = String($event)" />
</div>
<div class="field-row panel-field-row">
<label>赛季保险<span>*</span></label>
<OptionChips
:options="insuranceOptions"
:model-value="form.season_insurance"
@select="form.season_insurance = String($event)"
/>
</div>
<div class="level-panel">
<div class="level-row">
<strong><span>*</span>体力</strong>
<div class="level-options">
<button
v-for="opt in levelOptions"
:key="`stamina-${opt}`"
type="button"
class="level-btn"
:class="{ active: form.stamina_level === opt }"
@click="form.stamina_level = opt"
>
{{ opt }}
</button>
</div>
</div>
<div class="level-row">
<strong><span>*</span>负重</strong>
<div class="level-options">
<button
v-for="opt in levelOptions"
:key="`load-${opt}`"
type="button"
class="level-btn"
:class="{ active: form.load_level === opt }"
@click="form.load_level = opt"
>
{{ opt }}
</button>
</div>
</div>
</div>
</PublishSection>
<PublishSection v-if="quantityItems.length" title="额外消耗品" description="收费项会计入发布价格">
<div class="quantity-table">
<div class="quantity-header">
<span>物资</span>
<span>单价</span>
<span>数量<b>*</b></span>
<span>计费</span>
<span>状态</span>
</div>
<div
v-for="item in quantityItems"
:key="item.key"
class="quantity-item"
:class="{ disabled: isQuantityItemDisabled(item) }"
>
<div class="quantity-meta">
<strong :title="item.placeholder || item.label"><span>*</span>{{ item.label }}</strong>
<small v-if="item.placeholder">{{ item.placeholder }}</small>
</div>
<span class="quantity-price">{{ item.price }}</span>
<el-input-number
v-model="quantityValues[item.key]"
:min="0"
:controls="false"
:disabled="isQuantityItemDisabled(item)"
/>
<div class="mode-toggle">
<button
type="button"
:class="{ active: quantityModes[item.key] === '赠送' }"
:disabled="isQuantityItemDisabled(item)"
@click="setQuantityMode(item, '赠送')"
>
赠送
</button>
<button
type="button"
:class="{ active: (quantityModes[item.key] || '收费') === '收费' }"
:disabled="isQuantityItemDisabled(item)"
@click="setQuantityMode(item, '收费')"
>
收费
</button>
</div>
<span class="quantity-status">
{{ isQuantityItemDisabled(item) ? '3*3 已包含' : '可填写' }}
</span>
</div>
</div>
</PublishSection>
<PublishSection title="皮肤与交接">
<div v-if="skinGroups.length" class="skin-groups">
<div
v-for="group in skinGroups"
:key="group.key"
class="skin-group"
>
<div class="skin-title">
<strong>{{ group.title }}</strong>
</div>
<OptionChips :options="group.options" :active-values="selectedSkins" @select="toggleSkinOption" />
</div>
</div>
<div class="field-row login-method-row">
<label>上号方式</label>
<OptionChips
:options="loginMethodOptions"
:model-value="form.login_method"
@select="form.login_method = String($event)"
/>
<p class="field-hint">优先推荐账密登录,出售速度通常更快;请结合账号安全情况自行选择。</p>
</div>
<div class="time-preset-panel">
<div class="time-preset-row">
<strong>在线开始</strong>
<div class="time-preset-content">
<el-time-picker
v-model="form.online_start"
value-format="HH:mm"
format="HH:mm"
placeholder="开始时间"
class="time-input"
/>
<OptionChips
:options="commonOnlineTimes"
:model-value="form.online_start"
key-prefix="start-"
@select="form.online_start = String($event)"
/>
</div>
</div>
<div class="time-preset-row">
<strong>在线结束</strong>
<div class="time-preset-content">
<el-time-picker
v-model="form.online_end"
value-format="HH:mm"
format="HH:mm"
placeholder="结束时间"
class="time-input"
/>
<OptionChips
:options="commonOnlineTimes"
:model-value="form.online_end"
key-prefix="end-"
@select="form.online_end = String($event)"
/>
</div>
</div>
</div>
<p class="field-hint">请填写能稳定联系上您的时间,便于扫码、冻结人脸和订单交接。</p>
<div v-if="banRecordOptions.length" class="field-row">
<label>封禁记录<span>*</span></label>
<OptionChips :options="banRecordOptions" :model-value="form.ban_record" @select="form.ban_record = String($event)" />
</div>
<div v-if="regionOptions.length" class="region-panel login-region-panel">
<div class="region-title">
<strong>常用登录地区</strong>
<span>已选 {{ form.common_regions.length }}</span>
</div>
<div class="region-grid">
<button
v-for="region in regionOptions"
:key="region"
type="button"
class="region-btn"
:class="{ active: form.common_regions.includes(region) }"
@click="toggleRegion(region)"
>
{{ region }}
</button>
</div>
</div>
</PublishSection>
<PublishSection title="截图材料" :description="`${uploadedScreenshotCount}/${screenshotSlots.length} 已上传`">
<div class="upload-grid">
<div v-for="slot in screenshotSlots" :key="slot.key" class="upload-item">
<div class="upload-copy">
<strong>{{ slot.label }}<span v-if="isScreenshotRequired(slot)">*</span></strong>
<small>{{ slot.hint }}</small>
</div>
<div v-if="screenshotFiles[slot.key]" class="upload-preview">
<img :src="getScreenshotPreviewURL(slot.key)" :alt="slot.label" />
<button type="button" @click="removeScreenshot(slot.key)">
<el-icon><Delete /></el-icon>
</button>
</div>
<button v-else type="button" class="upload-add" :disabled="uploading" @click="triggerUpload(slot.key)">
<el-icon><Picture /></el-icon>
<span>{{ uploading && activeUploadKey === slot.key ? '上传中' : '上传' }}</span>
</button>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden-file"
@change="handleScreenshotUpload"
/>
</PublishSection>
<PublishSection title="押金与价格" description="系统按资料自动计算售价">
<div class="compact-grid two">
<label class="input-block">
<span>押金<b>*</b></span>
<div class="deposit-control">
<el-input-number
v-model="form.deposit_amount"
:min="0"
:controls="false"
:placeholder="priceConfig.deposit_placeholder"
/>
<button class="recommend-button" type="button" @click="useRecommendedDeposit">
使用推荐 ¥{{ recommendedDepositAmount }}
</button>
</div>
<div class="deposit-breakdown">
<span v-for="item in depositBreakdownItems" :key="`${item.label}-${item.count}`">
{{ item.label }}<template v-if="item.count > 1"> x{{ item.count }}</template> ¥{{ item.amount }}
</span>
</div>
</label>
<label class="input-block">
<span>每日损耗<b>*</b></span>
<OptionChips
:options="dailyLossOptions"
:model-value="dailyLossMAmount"
compact
suffix="M/天"
@select="selectDailyLoss"
/>
</label>
</div>
<div class="ratio-panel">
<div class="ratio-reference">
<span>参考比例</span>
<strong>{{ calculatedDefaultSaleRatioText }}</strong>
</div>
<p>{{ saleRatioRangeText }},比例越高出租速度越快。</p>
<div class="ratio-mode-grid">
<button
type="button"
class="ratio-mode-btn"
:class="{ active: !hasAcceleratedSaleRatioInput() }"
:disabled="calculatedDefaultSaleRatio <= 0"
@click="useReferenceSaleRatio"
>
<strong>参考比例</strong>
<span>{{ calculatedDefaultSaleRatio > 0 ? `1元=${formatNumber(calculatedDefaultSaleRatio)}万` : '自动计算' }}</span>
</button>
<button
type="button"
class="ratio-mode-btn"
:class="{ active: form.accelerated_sale_ratio === maxAcceleratedSaleRatio && maxAcceleratedSaleRatio > 0 }"
:disabled="maxAcceleratedSaleRatio <= 0"
@click="useMaxAcceleratedSaleRatio"
>
<strong>最高比例 (加速)</strong>
<span>{{ maxAcceleratedSaleRatio > 0 ? `最高 1元=${formatNumber(maxAcceleratedSaleRatio)}万` : '自动计算' }}</span>
</button>
<div
class="ratio-custom-card"
:class="{ active: hasAcceleratedSaleRatioInput() && form.accelerated_sale_ratio !== maxAcceleratedSaleRatio }"
>
<strong>自定比例</strong>
<el-input-number
:model-value="form.accelerated_sale_ratio"
:min="0"
:controls="false"
:placeholder="acceleratedSaleRatioPlaceholder"
@update:model-value="handleAcceleratedSaleRatioInput"
@blur="clampAcceleratedSaleRatioInput"
/>
</div>
</div>
</div>
<div class="price-grid">
<div class="price-cell">
<span>卖家比例</span>
<strong>{{ calculatedRatioText }}</strong>
</div>
<div class="price-cell">
<span>纯币基础价</span>
<strong>{{ calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : '--' }}</strong>
</div>
<div class="price-cell">
<span>额外消耗品</span>
<strong>¥{{ calculatedConsumablePrice }}</strong>
</div>
<div class="price-cell accent">
<span>发布价格</span>
<strong>{{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }}</strong>
</div>
</div>
<label class="input-block remark">
<span>备注</span>
<el-input
v-model="form.remark"
type="textarea"
:rows="4"
placeholder="如有不可使用的物资,请在此备注,并在买家下单后主动提醒。"
/>
</label>
</PublishSection>
</section>
<aside class="summary-column">
<div class="summary-panel">
<div class="summary-main">
<span>卖家发布价格</span>
<strong>{{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }}</strong>
</div>
<div class="summary-list">
<div>
<span>哈夫币</span>
<strong>{{ coinMAmount || '--' }}M</strong>
</div>
<div>
<span>押金</span>
<strong>{{ form.deposit_amount === '' ? '--' : `¥${form.deposit_amount}` }}</strong>
</div>
<div>
<span>推荐押金</span>
<strong>¥{{ recommendedDepositAmount }}</strong>
</div>
<div>
<span>截图材料</span>
<strong>{{ uploadedScreenshotCount }}/{{ requiredScreenshotCount }}</strong>
</div>
</div>
<div class="summary-actions">
<el-button :icon="UploadFilled" class="btn-publish" type="primary" :loading="loading" @click="handleSubmit">立即发布</el-button>
<el-button :icon="DocumentChecked" :disabled="loading" @click="handleSaveDraft">保存草稿</el-button>
<el-button :icon="RefreshRight" :disabled="loading" @click="handleResetDraft">重置草稿</el-button>
</div>
</div>
</aside>
</div>
</main>
</template>
<style scoped src="./SellerListingCreateView.css"></style>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { fetchSellerListings, offlineListing, submitListingReview, type Listing } from '@/api/listings'
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
import { getListingSellerPrice } from '@/utils/listingDisplay'
const loading = ref(false)
const listings = ref<Listing[]>([])
onMounted(loadListings)
async function loadListings() {
loading.value = true
try {
listings.value = await fetchSellerListings()
} finally {
loading.value = false
}
}
async function submitReview(id: number) {
const listing = await submitListingReview(id)
ElMessage.success(
listing.status === 'published' && listing.review_status === 'approved'
? '已上架'
: '已提交审核,等待后台处理',
)
await loadListings()
}
async function offline(id: number) {
await offlineListing(id)
ElMessage.success('已下架')
await loadListings()
}
function listingPrice(row: Listing) {
return `¥${Math.round(getListingSellerPrice(row))}`
}
</script>
<template>
<section class="page">
<div class="page-header page-header-row">
<div>
<p class="eyebrow">Seller</p>
<h1>我的发布</h1>
<p>管理账号发布、审核状态、上架和下架。</p>
</div>
<RouterLink to="/seller/listings/create">
<el-button type="primary">发布账号</el-button>
</RouterLink>
</div>
<el-table v-loading="loading" class="table-panel" :data="listings">
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="server_region" label="区服" width="120" />
<el-table-column prop="haf_coin_amount" label="哈夫币" width="120" />
<el-table-column label="价格" width="100">
<template #default="{ row }">{{ listingPrice(row) }}</template>
</el-table-column>
<el-table-column prop="deposit_amount" label="押金" width="100" />
<el-table-column label="状态" width="110">
<template #default="{ row }">{{ listingStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column label="审核" width="110">
<template #default="{ row }">{{ listingReviewStatusLabel(row.review_status) }}</template>
</el-table-column>
<el-table-column prop="review_reason" label="审核原因" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="190">
<template #default="{ row }">
<el-button
size="small"
:disabled="row.review_status === 'pending' || row.status === 'rented' || row.status === 'published'"
@click="submitReview(row.id)"
>
提审
</el-button>
<el-button size="small" type="danger" :disabled="row.status === 'rented'" @click="offline(row.id)">下架</el-button>
</template>
</el-table-column>
</el-table>
</section>
</template>