feat: 添加租后须知功能并优化发布默认设置

新增功能:
- 添加租后须知查看功能(PC端+移动端)
- 管理后台支持配置租后须知内容
- 默认提供完整的租后须知模板

功能实现:
- 后端:新增 PostRentalNotice API 接口和配置管理
- PC端:在用户下拉菜单添加租后须知入口,独立页面展示
- 移动端:在个人中心实名认证下方添加入口,弹窗展示
- 管理端:新增租后须知配置弹窗,支持编辑和恢复默认

优化改进:
- 修复发布页面默认选中"参考比例"而非"自定比例"
- 移除错误的迁移测试脚本(与 MySQL 8.0 不兼容)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml
2026-06-05 09:55:11 +08:00
co-authored by Claude Opus 4.7
parent 0b9b0b5ea7
commit 5d5bd5af77
15 changed files with 563 additions and 201 deletions
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ref, watch } from 'vue'
import { updateSystemConfig, type SystemConfig } from '@/features/admin/api/systemConfigs'
import { safeParseJSON } from '@/utils/json'
interface PostRentalNotice {
title: string
content: string
}
const defaultPostRentalNotice: PostRentalNotice = {
title: '租后须知(重要!)',
content: `一、上号前须知
1. 租期说明
租期内无法退租(除非发现不可抗力),如遇游戏的风控策略调整导致账号异常,如账号主动提交过人脸识别认证(按交易协议比例赔偿)。
2. 账号信息不齐
租客流需在首次上号后10分钟完成验号,超时则单次以租期已开始计算,此后中断与异常问题由租客自行承担。
仅以在首次上号10分钟之前完成查验;
未上号马号主与关联等,如果出现异常抢拍后经过时,除押金全额返还后,可能会影响结账比例规则判定。
二、账号使用规范
3. 切勿使用规制
账号内绑"绑币"外,其它物品谨慎消耗,送意开货时用品、子弹包、资源包、全能包、合金包。
活动赠送或抽奖券及二次充值。
若账号因租客违规操作、使用外挂、恶意消耗、转移资产或违反游戏规则造成损失,可能从押金中扣除或进入争议处理,根本扣除额按按违规严重程度而定。
4. 禁止转租
不可转租、转借或用于任何非订单目的。
号主无法输入人脸验证;按交接规则卫按级号号不得修改账号绑定信息或任何影响号主取回账号的内容。
三、封禁处理规则
5. 账号封禁
账号封禁判责规则按"按照订单记录、截图证据、聊天记录和双方说明"进行判断,若租客操作符合规则但仍封禁,须按封禁情况审核后按实际损失或押金比例约40%补偿号主。
不可转按换现金或按时归还后不予退租,按照平台规则处理退款和赔付。`,
}
const props = defineProps<{
modelValue: boolean
config: SystemConfig
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'saved'): void
}>()
const submitting = ref(false)
const description = ref('')
const draft = ref<PostRentalNotice>(cloneNotice(defaultPostRentalNotice))
watch(
() => props.modelValue,
(val) => {
if (val) {
draft.value = parsePostRentalNotice(props.config.value)
description.value = props.config.description || ''
}
},
{ immediate: true },
)
function parsePostRentalNotice(raw: string) {
const parsed = safeParseJSON(raw, defaultPostRentalNotice)
return cloneNotice({
title: readText(parsed?.title, defaultPostRentalNotice.title),
content: readText(parsed?.content, defaultPostRentalNotice.content),
})
}
function readText(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value : fallback
}
function cloneNotice(value: PostRentalNotice) {
return JSON.parse(JSON.stringify(value)) as PostRentalNotice
}
function resetDefaults() {
draft.value = cloneNotice(defaultPostRentalNotice)
}
async function handleSave() {
submitting.value = true
try {
const value = JSON.stringify(draft.value, null, 2)
await updateSystemConfig(props.config.key, {
value,
description: description.value,
})
ElMessage.success('租后须知配置已更新')
emit('saved')
emit('update:modelValue', false)
} catch (error) {
ElMessage.error(readError(error, '保存失败'))
} finally {
submitting.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="编辑租后须知"
width="720px"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="dialog-body">
<div class="dialog-header">
<p class="config-key-label"><strong>{{ config.key }}</strong></p>
<el-button size="small" @click="resetDefaults">恢复默认文本</el-button>
</div>
<section class="notice-editor-card">
<el-form-item label="标题" class="full-control">
<el-input v-model="draft.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="内容正文" class="full-control">
<el-input
v-model="draft.content"
type="textarea"
:rows="18"
resize="vertical"
placeholder="请输入内容正文"
/>
</el-form-item>
</section>
<el-form-item label="配置说明" class="desc-item">
<el-input v-model="description" type="textarea" :rows="3" placeholder="配置说明" />
</el-form-item>
</div>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-body {
display: grid;
gap: 16px;
max-height: 68vh;
overflow-y: auto;
padding-right: 8px;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.config-key-label {
margin: 0;
color: #374151;
}
.notice-editor-card {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
}
.full-control,
.desc-item {
margin-bottom: 0;
}
</style>
@@ -27,6 +27,7 @@ import SalePriceDialog from '../components/SalePriceDialog.vue'
import HomeAnnouncementsDialog from '../components/HomeAnnouncementsDialog.vue'
import HomeBannersDialog from '../components/HomeBannersDialog.vue'
import OrderAgreementsDialog from '../components/OrderAgreementsDialog.vue'
import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue'
import GeneralConfigDialog from '../components/GeneralConfigDialog.vue'
import AutoWelcomeConfig from '../components/AutoWelcomeConfig.vue'
@@ -51,6 +52,7 @@ const salePriceVisible = ref(false)
const announcementsVisible = ref(false)
const bannersVisible = ref(false)
const agreementsVisible = ref(false)
const postRentalNoticeVisible = ref(false)
const generalVisible = ref(false)
const publishConfig = computed(() => configs.value.find((item) => item.key === 'listing.publish_options') || null)
@@ -58,6 +60,7 @@ const salePriceConfig = computed(() => configs.value.find((item) => item.key ===
const homeAnnouncementsConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_announcements') || null)
const homeBannersConfig = computed(() => configs.value.find((item) => item.key === 'mobile.home_banners') || null)
const orderAgreementsConfig = computed(() => configs.value.find((item) => item.key === 'order.agreements') || null)
const postRentalNoticeConfig = computed(() => configs.value.find((item) => item.key === 'profile.post_rental_notice') || null)
const regularConfigs = computed(() =>
configs.value.filter(
@@ -66,7 +69,8 @@ const regularConfigs = computed(() =>
item.key !== 'listing.sale_price_config' &&
item.key !== 'mobile.home_announcements' &&
item.key !== 'mobile.home_banners' &&
item.key !== 'order.agreements',
item.key !== 'order.agreements' &&
item.key !== 'profile.post_rental_notice',
),
)
@@ -136,6 +140,8 @@ function openEdit(row: SystemConfig) {
bannersVisible.value = true
} else if (row.key === 'order.agreements') {
agreementsVisible.value = true
} else if (row.key === 'profile.post_rental_notice') {
postRentalNoticeVisible.value = true
} else {
generalVisible.value = true
}
@@ -356,6 +362,39 @@ function formatConfigValue(row: SystemConfig) {
</div>
</section>
<section v-if="postRentalNoticeConfig" class="publish-config-panel">
<div class="publish-config-main">
<div>
<p class="eyebrow">Post Rental Notice</p>
<h2>租后须知配置</h2>
<span>管理移动端个人中心展示的租后须知内容用户在实名认证下方可查看</span>
</div>
<el-button type="primary" @click="openEdit(postRentalNoticeConfig)">编辑租后须知</el-button>
</div>
<div class="publish-stat-grid home-stat-grid">
<div class="publish-stat">
<strong>1</strong>
<span>须知文档</span>
</div>
<div class="publish-stat">
<strong>移动端</strong>
<span>个人中心展示</span>
</div>
<div class="publish-stat">
<strong>实名认证</strong>
<span>下方位置</span>
</div>
<div class="publish-stat">
<strong>接口</strong>
<span>/api/post-rental-notice</span>
</div>
<div class="publish-stat">
<strong>更新</strong>
<span>{{ formatHomeConfigStatus(postRentalNoticeConfig, '未初始化') }}</span>
</div>
</div>
</section>
<AutoWelcomeConfig />
<el-table v-loading="loading" class="table-panel" :data="regularConfigs">
@@ -414,6 +453,13 @@ function formatConfigValue(row: SystemConfig) {
@saved="loadConfigs"
/>
<PostRentalNoticeDialog
v-if="postRentalNoticeConfig"
v-model="postRentalNoticeVisible"
:config="postRentalNoticeConfig"
@saved="loadConfigs"
/>
<GeneralConfigDialog
v-if="currentEditingConfig"
v-model="generalVisible"
@@ -6,6 +6,7 @@ import { showDialog, showToast } from "vant";
import MobileBottomNav from "@/components/MobileBottomNav.vue";
import { fetchWalletBalance, fetchWalletLedger, type WalletLedger } from "@/features/wallet/api/wallet";
import { fetchPostRentalNotice, type PostRentalNotice } from "@/features/orders/api/orders";
import { formatDateMinute } from "@/utils/time";
import { uploadFile } from "@/shared/api/files";
@@ -60,6 +61,11 @@ const showLedgers = ref(false);
const loadingLedgers = ref(false);
const ledgers = ref<WalletLedger[]>([]);
// 租后须知
const showPostRentalNotice = ref(false);
const loadingPostRentalNotice = ref(false);
const postRentalNotice = ref<PostRentalNotice | null>(null);
const settingsGroups = [
{
title: "账号与安全",
@@ -108,6 +114,20 @@ async function openLedgers() {
}
}
async function openPostRentalNotice() {
showPostRentalNotice.value = true;
if (!postRentalNotice.value) {
loadingPostRentalNotice.value = true;
try {
postRentalNotice.value = await fetchPostRentalNotice();
} catch {
showToast({ message: "无法获取租后须知", icon: "cross" });
} finally {
loadingPostRentalNotice.value = false;
}
}
}
function handleWithdraw() {
showDialog({
title: "提现提示",
@@ -348,6 +368,7 @@ function resolveAvatarURL(url: string | undefined | null) {
</span>
</template>
</van-cell>
<van-cell title="租后须知" icon="info-o" is-link @click="openPostRentalNotice" />
<van-cell title="系统设置" icon="setting-o" is-link @click="showSettings = true" />
</van-cell-group>
</div>
@@ -382,6 +403,27 @@ function resolveAvatarURL(url: string | undefined | null) {
</div>
</van-popup>
<!-- 租后须知 Popup -->
<van-popup
v-model:show="showPostRentalNotice"
position="bottom"
round
class="notice-popup"
:style="{ height: '75%' }"
>
<header class="popup-header">
<h3>{{ postRentalNotice?.title || '租后须知' }}</h3>
<button class="popup-close" @click="showPostRentalNotice = false"></button>
</header>
<div class="popup-body">
<van-loading v-if="loadingPostRentalNotice" class="center-loading" vertical>加载中...</van-loading>
<div v-else-if="postRentalNotice" class="notice-content">
{{ postRentalNotice.content }}
</div>
<van-empty v-else description="暂无租后须知" />
</div>
</van-popup>
<!-- 设置面板 - Popup -->
<van-popup
v-model:show="showSettings"
@@ -858,6 +900,26 @@ function resolveAvatarURL(url: string | undefined | null) {
color: #ef4444;
}
/* 租后须知 Popup */
.notice-popup {
display: flex;
flex-direction: column;
}
.notice-content {
padding: 16px 0;
color: #374151;
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
}
.center-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
}
/* ========== 设置面板 ========== */
.settings-panel {
display: flex;
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { InfoFilled } from '@element-plus/icons-vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { fetchPostRentalNotice, type PostRentalNotice } from '@/features/orders/api/orders'
import { useSessionStore } from '@/stores/session'
const session = useSessionStore()
const router = useRouter()
const loading = ref(false)
const notice = ref<PostRentalNotice | null>(null)
onMounted(async () => {
if (!session.isLoggedIn) {
router.replace({ path: '/login', query: { redirect: '/post-rental-notice' } })
return
}
await loadNotice()
})
async function loadNotice() {
loading.value = true
try {
notice.value = await fetchPostRentalNotice()
} catch (error) {
console.error('Failed to load post rental notice:', error)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="page-container">
<div class="page-card">
<header class="page-header">
<div class="page-header-content">
<el-icon class="page-icon" :size="32"><InfoFilled /></el-icon>
<div>
<p class="page-eyebrow">Post Rental Notice</p>
<h1 class="page-title">{{ notice?.title || '租后须知' }}</h1>
<p class="page-subtitle">租赁账号后的重要须知事项请仔细阅读</p>
</div>
</div>
</header>
<div v-loading="loading" class="notice-body">
<div v-if="notice" class="notice-content">
{{ notice.content }}
</div>
<el-empty v-else-if="!loading" description="暂无租后须知内容" />
</div>
</div>
</div>
</template>
<style scoped>
.page-container {
max-width: 900px;
margin: 0 auto;
}
.page-card {
background: #fff;
border-radius: 16px;
border: 1px solid #eef1f5;
box-shadow: 0 4px 16px rgba(23, 35, 61, 0.04);
overflow: hidden;
}
.page-header {
padding: 32px 32px 24px;
background: linear-gradient(135deg, #fff9f5 0%, #fff 100%);
border-bottom: 1px solid #f5f6f8;
}
.page-header-content {
display: flex;
align-items: flex-start;
gap: 16px;
}
.page-icon {
color: #ff6a00;
flex-shrink: 0;
}
.page-eyebrow {
margin: 0 0 6px;
color: #ff6a00;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.page-title {
margin: 0 0 8px;
color: #17233d;
font-size: 28px;
font-weight: 900;
line-height: 1.2;
}
.page-subtitle {
margin: 0;
color: #64748b;
font-size: 14px;
line-height: 1.5;
}
.notice-body {
padding: 32px;
min-height: 400px;
}
.notice-content {
color: #334155;
font-size: 15px;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
@@ -108,6 +108,11 @@ export interface OrderAgreements {
renter_agreement: OrderAgreementContent
}
export interface PostRentalNotice {
title: string
content: string
}
export interface SubmitCheckoutPayload {
content: string
consumable_amount: number
@@ -137,6 +142,11 @@ export async function fetchOrderAgreements() {
return data.data
}
export async function fetchPostRentalNotice() {
const { data } = await apiClient.get<ApiResponse<PostRentalNotice>>('/post-rental-notice')
return data.data
}
export async function payOrder(id: number) {
const { data } = await apiClient.post<ApiResponse<PayOrderResult>>(`/orders/${id}/pay`)
return data.data
@@ -88,6 +88,10 @@ function normalizeDraftForm(value: unknown): PublishForm {
next.common_regions = Array.isArray(value.common_regions)
? value.common_regions.filter((region): region is string => typeof region === 'string')
: []
// 默认使用参考比例:如果草稿中的 accelerated_sale_ratio 是 0 或未设置,重置为空字符串
if (!next.accelerated_sale_ratio || next.accelerated_sale_ratio === 0) {
next.accelerated_sale_ratio = ''
}
return next
}