feat(activity): implement P4 member self-service leave and ticket refund channel

这个提交包含在:
xcbyy
2026-10-10 15:56:55 +08:00
父节点 7ac81035b4
当前提交 3a700c42a1
共有 5 个文件被更改,包括 147 次插入 和 7 次删除

查看文件

@@ -137,11 +137,12 @@
<text class="sec-sub">满员即成局</text>
</view>
<view class="member-list" v-if="activity.participants && activity.participants.length > 0">
<view v-for="(p, index) in activity.participants" :key="p.id" class="member-item">
<view v-for="(p, index) in activity.participants" :key="p.id" class="member-item" :class="{ 'my-member-item': Number(p.userId) === CURRENT_USER_ID }">
<image class="member-avatar" :src="p.avatar" mode="aspectFill" />
<view class="member-info">
<view class="member-top-line">
<text class="member-name">{{ p.nickname }}</text>
<text class="my-badge" v-if="Number(p.userId) === CURRENT_USER_ID">我的席位</text>
<text class="member-price-tag" v-if="p.priceAtJoin">
出价: ¥{{ p.priceAtJoin }}
</text>
@@ -153,8 +154,13 @@
</text>
</view>
</view>
<view class="checkin-badge" :class="{ checked: p.checkedIn }">
{{ p.checkedIn ? '已核销到场' : '待现场核销' }}
<view class="member-status-wrap">
<view class="checkin-badge" :class="{ checked: p.checkedIn }">
{{ p.checkedIn ? '已核销到场' : '待现场核销' }}
</view>
<text class="btn-micro-leave btn-active" v-if="Number(p.userId) === CURRENT_USER_ID && !p.checkedIn" @click.stop="handleLeave">
退席
</text>
</view>
</view>
</view>
@@ -384,7 +390,7 @@ const currentThemeKey = ref(getCurrentThemeKey());
const isHost = computed(() => {
if (!activity.value) return false;
return Number(activity.value.creatorId) === CURRENT_USER_ID || activity.value.creatorNickname === '同城局长';
return Number(activity.value.creatorId) === CURRENT_USER_ID;
});
const myParticipantInfo = computed(() => {
@@ -1166,6 +1172,37 @@ const handleJoin = () => {
}
}
&.my-member-item {
background: rgba(99, 102, 241, 0.08);
border-radius: 12rpx;
padding: 12rpx 14rpx;
}
.my-badge {
font-size: 18rpx;
color: #6366F1;
background: rgba(99, 102, 241, 0.18);
padding: 2rpx 10rpx;
border-radius: 6rpx;
font-weight: 600;
border: 1px solid rgba(99, 102, 241, 0.35);
}
.member-status-wrap {
display: flex;
align-items: center;
gap: 12rpx;
.btn-micro-leave {
font-size: 20rpx;
color: #EF4444;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 4rpx 16rpx;
border-radius: 10rpx;
}
}
.checkin-badge {
font-size: 20rpx;
padding: 4rpx 14rpx;

查看文件

@@ -106,8 +106,11 @@
</view>
</view>
<!-- 演示操作按钮 -->
<!-- 门票操作按钮 -->
<view class="action-wrap">
<button class="btn-cancel-ticket btn-active" v-if="!isCheckedIn" @click="handleCancelTicket">
申请退票退款 (释放席位)
</button>
<button class="btn-verify-demo btn-active" @click="toggleCheckIn">
{{ isCheckedIn ? '重置为待核销状态' : '局长现场核销此门票' }}
</button>
@@ -119,7 +122,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { getActivityDetail, verifyActivityCheckin, getMyActivities } from '@/common/api.js';
import { getActivityDetail, verifyActivityCheckin, getMyActivities, leaveActivity } from '@/common/api.js';
import { encodeCode128B } from '@/common/barcode.js';
import { generateQRCodeMatrix } from '@/common/qrcode.js';
@@ -276,6 +279,33 @@ const toggleCheckIn = async () => {
}
};
const handleCancelTicket = () => {
uni.showModal({
title: '申请退票并退款?',
content: '退票后您的专属入场核销码将立即作废,席位将自动释放给其他搭子,报名分摊款将全额原路退还至您的钱包余额。是否确认退票?',
confirmText: '确认退票',
confirmColor: '#EF4444',
cancelText: '再想想',
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({ title: '正在办理退票...' });
const actId = ticketData.value && ticketData.value.id ? ticketData.value.id : 'act_101';
await leaveActivity(actId, 1001);
uni.hideLoading();
uni.showToast({ title: '退票成功,款项已退回钱包', icon: 'success' });
setTimeout(() => {
uni.navigateBack({ delta: 1 });
}, 1200);
} catch (err) {
uni.hideLoading();
uni.showToast({ title: err.message || '退票失败,请重试', icon: 'none' });
}
}
}
});
};
const goBack = () => {
uni.navigateBack();
};
@@ -577,6 +607,17 @@ const goBack = () => {
flex-direction: column;
gap: 20rpx;
.btn-cancel-ticket {
background: rgba(239, 68, 68, 0.12);
color: #F87171;
font-size: 28rpx;
font-weight: 600;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
border: 1px solid rgba(239, 68, 68, 0.35);
}
.btn-verify-demo {
background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%);
color: #FFFFFF;

查看文件

@@ -152,6 +152,7 @@ export async function initDatabase(): Promise<void> {
CREATE TABLE IF NOT EXISTS finance_records (
id VARCHAR(64) PRIMARY KEY,
record_no VARCHAR(64) UNIQUE NOT NULL,
user_id INT,
type VARCHAR(64) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
direction VARCHAR(8) NOT NULL DEFAULT 'IN',
@@ -161,6 +162,10 @@ export async function initDatabase(): Promise<void> {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
try {
await conn.query('ALTER TABLE finance_records ADD COLUMN user_id INT AFTER record_no');
} catch (_) {}
// 7. 提现申请表
await conn.query(`
CREATE TABLE IF NOT EXISTS withdrawals (

查看文件

@@ -396,7 +396,7 @@ export class MysqlRepository {
await conn.query(`
INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id)
VALUES (?, ?, ?, '成员退席原路退款', ?, 'IN', ?, ?)
`, [finId, recordNo, userId, (refundAmount / 100).toFixed(2), `组局 ${id}`, id]);
`, [finId, recordNo, userId, (refundAmount / 100).toFixed(2), `用户 ${userId} (组局退款)`, id]);
await conn.commit();
return {

查看文件

@@ -318,5 +318,62 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
}
});
});
describe('9. 组局满员前「搭子自主退席与全额退款」与席位释放闭环验证 (P4 体验优化专项目标)', () => {
it('MySQL 数据库必须支持 finance_records 表的 user_id 字段补丁', () => {
const mysqlPath = path.join(rootDir, 'server', 'src', 'db', 'mysql.ts');
const content = fs.readFileSync(mysqlPath, 'utf-8');
expect(content).toContain('ALTER TABLE finance_records ADD COLUMN user_id');
});
it('Repository leaveActivity 必须支持原路退款至用户钱包并记入资金流水', () => {
const repoPath = path.join(rootDir, 'server', 'src', 'db', 'repository.ts');
const content = fs.readFileSync(repoPath, 'utf-8');
expect(content).toContain('async leaveActivity(id: string, userId: number)');
expect(content).toContain('DELETE FROM participants WHERE id = ?');
expect(content).toContain('UPDATE users SET balance = balance + ? WHERE id = ?');
expect(content).toContain('成员退席原路退款');
});
it('服务端路由必须提供 POST /api/activities/:id/leave 接口', () => {
const serverIndexPath = path.join(rootDir, 'server', 'src', 'index.ts');
const content = fs.readFileSync(serverIndexPath, 'utf-8');
expect(content).toContain("app.post('/api/activities/:id/leave'");
expect(content).toContain('leaveActivity');
});
it('小程序活动详情页必须支持普通搭子申请退席全额退款与本人席位标识', () => {
const actDetailPath = path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'detail.vue');
const content = fs.readFileSync(actDetailPath, 'utf-8');
expect(content).toContain('leaveActivity');
expect(content).toContain('handleLeave');
expect(content).toContain('申请退席 (全额退款)');
expect(content).toContain('my-badge');
expect(content).toContain('我的席位');
});
it('小程序门票页必须支持未核销门票自助申请退票退款', () => {
const myTicketPath = path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'my-ticket.vue');
const content = fs.readFileSync(myTicketPath, 'utf-8');
expect(content).toContain('leaveActivity');
expect(content).toContain('handleCancelTicket');
expect(content).toContain('申请退票退款 (释放席位)');
});
it('所有 P4 新增代码严禁包含任何 Emoji 违规字符', () => {
const p4Files = [
path.join(rootDir, 'server', 'src', 'db', 'mysql.ts'),
path.join(rootDir, 'server', 'src', 'db', 'repository.ts'),
path.join(rootDir, 'server', 'src', 'index.ts'),
path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'detail.vue'),
path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'my-ticket.vue')
];
const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u;
for (const f of p4Files) {
const text = fs.readFileSync(f, 'utf-8');
expect(emojiRegex.test(text)).toBe(false);
}
});
});
});