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
@@ -32,6 +32,11 @@ type OrderAgreementsDTO struct {
RenterAgreement AgreementContentDTO `json:"renter_agreement"`
}
type PostRentalNoticeDTO struct {
Title string `json:"title"`
Content string `json:"content"`
}
type AgreementContentDTO struct {
Title string `json:"title"`
Content string `json:"content"`
@@ -54,6 +54,15 @@ func (h *Handler) OrderAgreements(c *gin.Context) {
response.OK(c, agreements)
}
func (h *Handler) PostRentalNotice(c *gin.Context) {
notice, err := h.service.PostRentalNotice()
if err != nil {
writeConfigError(c, err)
return
}
response.OK(c, notice)
}
func (h *Handler) HomeAnnouncements(c *gin.Context) {
announcements, err := h.service.HomeAnnouncements()
if err != nil {
@@ -0,0 +1,52 @@
package systemconfig
import "encoding/json"
const postRentalNoticeConfigKey = "profile.post_rental_notice"
func defaultPostRentalNoticeConfigValue() string {
raw, err := json.Marshal(DefaultPostRentalNotice())
if err != nil {
return "{}"
}
return string(raw)
}
func DefaultPostRentalNotice() PostRentalNoticeDTO {
return PostRentalNoticeDTO{
Title: "租后须知(重要!)",
Content: `一、上号前须知
1. 租期说明
租期内无法退租(除非发现不可抗力),如遇游戏的风控策略调整导致账号异常,如账号主动提交过人脸识别认证(按交易协议比例赔偿)。
2. 账号信息不齐
租客流需在首次上号后10分钟完成验号,超时则单次以租期已开始计算,此后中断与异常问题由租客自行承担。
仅以在首次上号10分钟之前完成查验;
未上号马号主与关联等,如果出现异常抢拍后经过时,除押金全额返还后,可能会影响结账比例规则判定。
二、账号使用规范
3. 切勿使用规制
账号内绑"绑币"外,其它物品谨慎消耗,送意开货时用品、子弹包、资源包、全能包、合金包。
活动赠送或抽奖券及二次充值。
若账号因租客违规操作、使用外挂、恶意消耗、转移资产或违反游戏规则造成损失,可能从押金中扣除或进入争议处理,根本扣除额按按违规严重程度而定。
4. 禁止转租
不可转租、转借或用于任何非订单目的。
号主无法输入人脸验证;按交接规则卫按级号号不得修改账号绑定信息或任何影响号主取回账号的内容。
三、封禁处理规则
5. 账号封禁
账号封禁判责规则按"按照订单记录、截图证据、聊天记录和双方说明"进行判断,若租客操作符合规则但仍封禁,须按封禁情况审核后按实际损失或押金比例约40%补偿号主。
不可转按换现金或按时归还后不予退租,按照平台规则处理退款和赔付。`,
}
}
@@ -32,6 +32,7 @@ var defaultConfigs = []defaultConfig{
{Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"},
{Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"},
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
{Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"},
{Key: "chat.default_support_admin_id", Value: "1", Description: "订单群聊回退客服 ID(仅当无可用客服时使用)"},
{Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"},
{Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"},
@@ -54,6 +55,7 @@ var adminVisibleConfigKeys = []string{
"order.agreements",
"order.pending_payment_timeout_minutes",
"order.return_overdue_grace_minutes",
"profile.post_rental_notice",
}
func NewRepository(db *gorm.DB) *Repository {
@@ -59,6 +59,17 @@ func (s *Service) OrderAgreements() (*OrderAgreementsDTO, error) {
return &agreements, nil
}
func (s *Service) PostRentalNotice() (*PostRentalNoticeDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
notice, err := s.postRentalNotice()
if err != nil {
return nil, err
}
return &notice, nil
}
func (s *Service) publishOptions() (PublishOptionsDTO, error) {
value, err := s.repo.FindValue(publishOptionsConfigKey)
if err != nil {
@@ -98,6 +109,19 @@ func (s *Service) orderAgreements() (OrderAgreementsDTO, error) {
return agreements, nil
}
func (s *Service) postRentalNotice() (PostRentalNoticeDTO, error) {
value, err := s.repo.FindValue(postRentalNoticeConfigKey)
if err != nil {
return PostRentalNoticeDTO{}, err
}
notice := DefaultPostRentalNotice()
if err := json.Unmarshal([]byte(value), &notice); err != nil {
notice = DefaultPostRentalNotice()
}
normalizePostRentalNotice(&notice)
return notice, nil
}
func (s *Service) HomeAnnouncements() (*HomeAnnouncementsDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
@@ -223,6 +247,18 @@ func normalizeOrderAgreements(agreements *OrderAgreementsDTO) {
}
}
func normalizePostRentalNotice(notice *PostRentalNoticeDTO) {
defaults := DefaultPostRentalNotice()
notice.Title = strings.TrimSpace(notice.Title)
notice.Content = strings.TrimSpace(notice.Content)
if notice.Title == "" {
notice.Title = defaults.Title
}
if notice.Content == "" {
notice.Content = defaults.Content
}
}
func normalizePublishOptions(options *PublishOptionsDTO) {
defaults := DefaultPublishOptions()
if len(options.ServerOptions) == 0 {
+1
View File
@@ -196,6 +196,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
api.GET("/listing-publish-options", systemConfigHandler.PublishOptions)
api.GET("/listing-sale-price-config", systemConfigHandler.SalePriceConfig)
api.GET("/order-agreements", systemConfigHandler.OrderAgreements)
api.GET("/post-rental-notice", systemConfigHandler.PostRentalNotice)
api.GET("/home-announcements", systemConfigHandler.HomeAnnouncements)
api.GET("/mobile-home-config", systemConfigHandler.HomeConfig)
api.GET("/public/files/object", fileHandler.PublicObject)
@@ -1,200 +0,0 @@
-- ============================================
-- 数据库索引性能测试脚本
-- 用途: 对比索引创建前后的查询性能
-- 使用方法: mysql -u root -p hfb_sys < test_index_performance.sql
-- ============================================
SET NAMES utf8mb4;
-- 关闭查询缓存,确保测试准确性
SET SESSION query_cache_type = OFF;
-- 开启性能分析
SET profiling = 1;
-- ============================================
-- 测试 1: 订单状态+创建时间查询
-- ============================================
-- 清空缓冲区
RESET QUERY CACHE;
-- 查看执行计划
EXPLAIN SELECT * FROM rental_orders
WHERE status = 'active'
ORDER BY created_at DESC
LIMIT 20\G
-- 执行查询并记录时间
SELECT '测试 1: 订单状态+创建时间查询' AS test_name;
SELECT * FROM rental_orders
WHERE status = 'active'
ORDER BY created_at DESC
LIMIT 20;
-- ============================================
-- 测试 2: 钱包流水按用户+业务类型查询
-- ============================================
RESET QUERY CACHE;
EXPLAIN SELECT * FROM wallet_ledger
WHERE user_id = 1 AND biz_type = 'order_payment'
ORDER BY created_at DESC
LIMIT 20\G
SELECT '测试 2: 钱包流水按用户+业务类型查询' AS test_name;
SELECT * FROM wallet_ledger
WHERE user_id = 1 AND biz_type = 'order_payment'
ORDER BY created_at DESC
LIMIT 20;
-- ============================================
-- 测试 3: 订单结算状态查询
-- ============================================
RESET QUERY CACHE;
EXPLAIN SELECT * FROM rental_orders
WHERE settlement_status = 'unsettled' AND owner_id = 1
ORDER BY created_at
LIMIT 20\G
SELECT '测试 3: 订单结算状态查询' AS test_name;
SELECT * FROM rental_orders
WHERE settlement_status = 'unsettled' AND owner_id = 1
ORDER BY created_at
LIMIT 20;
-- ============================================
-- 测试 4: 商品发布时间查询
-- ============================================
RESET QUERY CACHE;
EXPLAIN SELECT * FROM rental_listings
WHERE status = 'active' AND review_status = 'approved'
ORDER BY published_at DESC
LIMIT 20\G
SELECT '测试 4: 商品发布时间查询' AS test_name;
SELECT * FROM rental_listings
WHERE status = 'active' AND review_status = 'approved'
ORDER BY published_at DESC
LIMIT 20;
-- ============================================
-- 测试 5: 用户实名认证状态查询
-- ============================================
RESET QUERY CACHE;
EXPLAIN SELECT * FROM users
WHERE realname_status = 'verified' AND status = 'active'
LIMIT 100\G
SELECT '测试 5: 用户实名认证状态查询' AS test_name;
SELECT COUNT(*) FROM users
WHERE realname_status = 'verified' AND status = 'active';
-- ============================================
-- 显示性能分析结果
-- ============================================
-- 显示所有查询的性能概览
SELECT '性能测试结果' AS title;
SHOW PROFILES;
-- ============================================
-- 索引使用情况检查
-- ============================================
SELECT '索引使用情况检查' AS title;
-- 检查新增索引是否存在
SELECT
TABLE_NAME,
INDEX_NAME,
GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND INDEX_NAME IN (
'idx_rental_orders_status_created_at',
'idx_wallet_ledger_user_biz_created',
'idx_rental_orders_settlement',
'idx_rental_listings_published',
'idx_users_realname_status'
)
GROUP BY TABLE_NAME, INDEX_NAME
ORDER BY TABLE_NAME, INDEX_NAME;
-- ============================================
-- 慢查询统计
-- ============================================
SELECT '慢查询统计' AS title;
SHOW GLOBAL STATUS LIKE 'Slow_queries';
-- ============================================
-- 索引基数检查(检查索引选择性)
-- ============================================
SELECT '索引基数检查' AS title;
-- rental_orders 表索引基数
SELECT
INDEX_NAME,
SEQ_IN_INDEX,
COLUMN_NAME,
CARDINALITY,
SUB_PART,
NULLABLE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'rental_orders'
AND INDEX_NAME LIKE 'idx_%'
ORDER BY INDEX_NAME, SEQ_IN_INDEX;
-- wallet_ledger 表索引基数
SELECT
INDEX_NAME,
SEQ_IN_INDEX,
COLUMN_NAME,
CARDINALITY,
SUB_PART,
NULLABLE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'wallet_ledger'
AND INDEX_NAME LIKE 'idx_%'
ORDER BY INDEX_NAME, SEQ_IN_INDEX;
-- ============================================
-- 表统计信息
-- ============================================
SELECT '表统计信息' AS title;
SELECT
TABLE_NAME,
TABLE_ROWS,
AVG_ROW_LENGTH,
DATA_LENGTH,
INDEX_LENGTH,
ROUND(DATA_LENGTH / 1024 / 1024, 2) AS data_mb,
ROUND(INDEX_LENGTH / 1024 / 1024, 2) AS index_mb,
ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS total_mb
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('rental_orders', 'wallet_ledger', 'rental_listings', 'users')
ORDER BY TABLE_NAME;
-- ============================================
-- 测试完成
-- ============================================
SELECT '索引性能测试完成!' AS message;
SELECT '请查看 SHOW PROFILES 的结果,对比优化前后的查询时间' AS tips;
-- 关闭性能分析
SET profiling = 0;
@@ -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
}
+9
View File
@@ -11,6 +11,7 @@ import {
EditPen,
Finished,
House,
InfoFilled,
Search,
Service,
Shop,
@@ -203,6 +204,14 @@ async function handleSupportClick() {
{{ realnameBadge.text }}
</span>
</RouterLink>
<RouterLink
class="dropdown-item"
to="/post-rental-notice"
@click="showUserDropdown = false"
>
<el-icon><InfoFilled /></el-icon>
<span>租后须知</span>
</RouterLink>
<div class="dropdown-divider"></div>
<section class="dropdown-service-group">
+6
View File
@@ -48,6 +48,12 @@ export const accountRoutes: RouteRecordRaw[] = [
component: () => import('@/features/auth/views/RealnameView.vue'),
meta: { requiresAuth: true },
},
{
path: '/post-rental-notice',
name: 'post-rental-notice',
component: () => import('@/features/auth/views/PostRentalNoticeView.vue'),
meta: { requiresAuth: true },
},
{
path: '/messages',
name: 'messages',