From 1fb0cc427359279417ba64da4fc51dbb4ceed75d Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Tue, 13 Oct 2026 09:19:34 +0800 Subject: [PATCH] feat(withdraw): build dedicated withdrawal page with optimized layout and multichannel support --- pwuniapp/common/icons.js | 30 + pwuniapp/pages.json | 10 + pwuniapp/pages/expert/detail.vue | 272 ++++- pwuniapp/pages/expert/workspace.vue | 7 + pwuniapp/pages/order/list.vue | 93 ++ pwuniapp/pages/user/index.vue | 17 +- pwuniapp/pages/user/withdraw.vue | 1373 +++++++++++++++++++++++++ server/tests/client-features.spec.ts | 127 +++ server/tests/content-security.spec.ts | 49 +- 9 files changed, 1962 insertions(+), 16 deletions(-) create mode 100644 pwuniapp/pages/user/withdraw.vue create mode 100644 server/tests/client-features.spec.ts diff --git a/pwuniapp/common/icons.js b/pwuniapp/common/icons.js index b3daedd..58d082e 100644 --- a/pwuniapp/common/icons.js +++ b/pwuniapp/common/icons.js @@ -72,6 +72,36 @@ export const ICONS = { `), + chevronLeft: svgToUri(` + + + + `), + + alipay: svgToUri(` + + + + + + + + `), + + bankCard: svgToUri(` + + + + + `), + + checkCircle: svgToUri(` + + + + + `), + starFilled: svgToUri(` diff --git a/pwuniapp/pages.json b/pwuniapp/pages.json index 3b77df7..94b0721 100644 --- a/pwuniapp/pages.json +++ b/pwuniapp/pages.json @@ -112,6 +112,16 @@ "backgroundColor": "#0A0F1D" } }, + { + "path": "pages/user/withdraw", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "收益提现", + "navigationBarBackgroundColor": "#0A0F1D", + "navigationBarTextStyle": "white", + "backgroundColor": "#0A0F1D" + } + }, { "path": "pages/activity/create", "style": { diff --git a/pwuniapp/pages/expert/detail.vue b/pwuniapp/pages/expert/detail.vue index 4ab0da5..aecdd2d 100644 --- a/pwuniapp/pages/expert/detail.vue +++ b/pwuniapp/pages/expert/detail.vue @@ -168,12 +168,70 @@ 私聊 + + + 举报 + 当前选定: ¥{{ currentSelectedService ? currentSelectedService.price : expert.price }} + + + + + + + 举报违规行为 + + 举报对象:{{ expert.nickname }} + + + + + + + + + + 选择违规类型 + + + {{ item }} + + + + + + + 详细违规线索说明 + + {{ reportForm.description.length }}/200 + + + + + + + + + @@ -181,7 +239,7 @@ import { ref, computed } from 'vue'; import { onLoad } from '@dcloudio/uni-app'; import SkeletonScreen from '@/components/SkeletonScreen.vue'; -import { getCompanionDetail, getExpertReviews, replyOrderReview } from '@/common/api.js'; +import { getCompanionDetail, getExpertReviews, replyOrderReview, submitReport as submitReportApi } from '@/common/api.js'; import { ICONS } from '@/common/icons.js'; const isLoading = ref(true); @@ -325,6 +383,57 @@ const goToOrder = () => { url: `/pages/order/create?expertId=${expert.value.id}&serviceId=${srv.id}` }); }; + +// 举报违规行为状态与提交 +const reportModalVisible = ref(false); +const submittingReport = ref(false); +const presetReportReasons = [ + '涉黄低俗言论', + '私下诱导转账/欺诈', + '态度恶劣/人身攻击', + '开挂/作弊/战绩造假', + '线下违规/安全隐患', + '其他违规行为' +]; +const reportForm = ref({ + reason: '涉黄低俗言论', + description: '' +}); + +const openReportModal = () => { + reportForm.value = { + reason: '涉黄低俗言论', + description: '' + }; + reportModalVisible.value = true; +}; + +const closeReportModal = () => { + reportModalVisible.value = false; +}; + +const submitReportTicket = async () => { + submittingReport.value = true; + uni.showLoading({ title: '正在提交举报...' }); + try { + await submitReportApi({ + reporterId: 1001, + targetType: 'EXPERT', + targetId: String(expert.value.id || '101'), + targetName: expert.value.nickname || '同城陪伴师', + reason: reportForm.value.reason, + description: reportForm.value.description.trim() + }); + uni.hideLoading(); + closeReportModal(); + uni.showToast({ title: '举报已受理,平台将在24小时内核实处置', icon: 'success' }); + } catch (err) { + uni.hideLoading(); + uni.showToast({ title: err.message || '举报提交失败', icon: 'none' }); + } finally { + submittingReport.value = false; + } +}; diff --git a/pwuniapp/pages/expert/workspace.vue b/pwuniapp/pages/expert/workspace.vue index bb57c2e..5d5a1df 100644 --- a/pwuniapp/pages/expert/workspace.vue +++ b/pwuniapp/pages/expert/workspace.vue @@ -156,6 +156,7 @@ 陪玩服务管理 ({{ expertServices.length }}) + @@ -505,6 +506,12 @@ const toggleServiceStatus = async (item) => { const showWithdrawRecordsModal = ref(false); const withdrawRecords = ref([]); +const goToWithdrawPage = () => { + uni.navigateTo({ + url: '/pages/user/withdraw' + }); +}; + const openWithdrawRecords = async () => { try { const list = await getUserWithdrawals(1001); diff --git a/pwuniapp/pages/order/list.vue b/pwuniapp/pages/order/list.vue index bfbcf61..0ba020e 100644 --- a/pwuniapp/pages/order/list.vue +++ b/pwuniapp/pages/order/list.vue @@ -1038,10 +1038,19 @@ const submitReview = async () => { .order-actions { display: flex; justify-content: flex-end; + align-items: center; gap: 16rpx; padding-top: 16rpx; border-top: 1px solid rgba(255, 255, 255, 0.05); + .btn-report-link { + font-size: 22rpx; + color: #64748B; + text-decoration: underline; + margin-right: auto; + padding: 8rpx 0; + } + .action-btn { height: 60rpx; line-height: 58rpx; @@ -1395,6 +1404,72 @@ const submitReview = async () => { } } + .evidence-section { + margin-bottom: 24rpx; + + .evidence-grid { + display: flex; + flex-wrap: wrap; + gap: 16rpx; + margin-top: 12rpx; + + .evidence-item { + position: relative; + width: 130rpx; + height: 130rpx; + border-radius: 16rpx; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + + .evidence-img { + width: 100%; + height: 100%; + } + + .evidence-delete { + position: absolute; + top: 6rpx; + right: 6rpx; + width: 36rpx; + height: 36rpx; + background: rgba(0, 0, 0, 0.65); + border-radius: 18rpx; + display: flex; + align-items: center; + justify-content: center; + + .close-icon { + width: 20rpx; + height: 20rpx; + } + } + } + + .evidence-upload-btn { + width: 130rpx; + height: 130rpx; + border-radius: 16rpx; + border: 1px dashed rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.03); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8rpx; + + .upload-icon-svg { + width: 36rpx; + height: 36rpx; + } + + .upload-txt { + font-size: 20rpx; + color: #94A3B8; + } + } + } + } + .modal-actions { display: flex; gap: 20rpx; @@ -1416,6 +1491,11 @@ const submitReview = async () => { background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%); color: #FFFFFF; box-shadow: 0 4rpx 16rpx rgba(99, 102, 241, 0.4); + + &.danger { + background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%); + box-shadow: 0 4rpx 16rpx rgba(239, 68, 68, 0.4); + } } } } @@ -1482,6 +1562,19 @@ const submitReview = async () => { } } + .evidence-section { + .evidence-upload-btn { + background: #F5F2EC; + border-color: #E7E5E4; + .upload-txt { + color: #78716C; + } + } + .evidence-item { + border-color: #E7E5E4; + } + } + .modal-actions { .modal-btn { &.cancel-btn { diff --git a/pwuniapp/pages/user/index.vue b/pwuniapp/pages/user/index.vue index fbb9272..8a36f7f 100644 --- a/pwuniapp/pages/user/index.vue +++ b/pwuniapp/pages/user/index.vue @@ -936,20 +936,9 @@ const minWithdrawAmount = computed(() => { const openWithdrawModal = () => { if (!ensureLoggedIn()) return; - const comm = Number(user.value.commission || 0); - if (comm <= 0) { - uni.showToast({ title: '暂无可提现佣金', icon: 'none' }); - return; - } - withdrawForm.value = { - channel: 'WECHAT', - amount: String(comm), - alipayAccount: '', - bankCard: '', - bankName: '', - realName: user.value.nickname || '' - }; - showWithdrawModal.value = true; + uni.navigateTo({ + url: '/pages/user/withdraw' + }); }; const openWithdrawRecordsModal = async () => { diff --git a/pwuniapp/pages/user/withdraw.vue b/pwuniapp/pages/user/withdraw.vue new file mode 100644 index 0000000..716cedd --- /dev/null +++ b/pwuniapp/pages/user/withdraw.vue @@ -0,0 +1,1373 @@ + + + + + diff --git a/server/tests/client-features.spec.ts b/server/tests/client-features.spec.ts new file mode 100644 index 0000000..0a8cd15 --- /dev/null +++ b/server/tests/client-features.spec.ts @@ -0,0 +1,127 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ContentSecurityService } from '../src/core/security/content-security.service'; + +describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const uniPagesDir = path.join(rootDir, 'pwuniapp', 'pages'); + const serverSrcDir = path.join(rootDir, 'server', 'src'); + const contentSecurityService = new ContentSecurityService(); + + describe('1. 举报违规行为通道闭环验证', () => { + it('后端 repository 必须实现 createReport 与 getReports 并支持存证入库', () => { + const repoPath = path.join(serverSrcDir, 'db', 'repository.ts'); + const content = fs.readFileSync(repoPath, 'utf-8'); + + expect(content).toContain('createReport'); + expect(content).toContain('getReports'); + expect(content).toContain('reports'); + expect(content).toContain('reporter_id'); + expect(content).toContain('target_type'); + }); + + it('后端 index.ts 必须暴露 POST /api/reports 路由并集成文本安全检测', () => { + const indexPath = path.join(serverSrcDir, 'index.ts'); + const content = fs.readFileSync(indexPath, 'utf-8'); + + expect(content).toContain("app.post('/api/reports'"); + expect(content).toContain('contentSecurityService.checkText'); + expect(content).toContain('repo.createReport'); + }); + + it('违规线索说明命中涉黄/欺诈敏感词时,内容安全风控应拦截', () => { + const checkResult = contentSecurityService.checkText('加微信私下转账免手续费直接打款'); + expect(checkResult.pass).toBe(false); + expect(checkResult.code).toBe(87014); + expect(checkResult.suggest).toBe('block'); + }); + }); + + describe('2. 订单待支付倒计时与退款仲裁工单闭环验证', () => { + it('15分钟待支付倒计时计算逻辑应准确', () => { + const createdAt = new Date().toISOString(); + const createdTime = new Date(createdAt).getTime(); + const expireTime = createdTime + 15 * 60 * 1000; + const now = createdTime + 5 * 60 * 1000; // 过去了5分钟 + const remainingSeconds = Math.max(0, Math.floor((expireTime - now) / 1000)); + + expect(remainingSeconds).toBe(10 * 60); // 剩余 600 秒 + const minutes = Math.floor(remainingSeconds / 60); + const seconds = remainingSeconds % 60; + expect(`${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`).toBe('10:00'); + }); + + it('后端 repository 必须实现 applyOrderRefund 并原子写入 arbitrations 仲裁表', () => { + const repoPath = path.join(serverSrcDir, 'db', 'repository.ts'); + const content = fs.readFileSync(repoPath, 'utf-8'); + + expect(content).toContain('applyOrderRefund'); + expect(content).toContain('争议仲裁中'); + expect(content).toContain('arbitrations'); + }); + + it('后端 index.ts POST /api/orders/:id/refund 必须接收理由与说明并执行风控拦截', () => { + const indexPath = path.join(serverSrcDir, 'index.ts'); + const content = fs.readFileSync(indexPath, 'utf-8'); + + expect(content).toContain("app.post('/api/orders/:id/refund'"); + expect(content).toContain('reason'); + expect(content).toContain('contentSecurityService.checkText'); + expect(content).toContain('repo.applyOrderRefund'); + }); + }); + + describe('3. 个人资料性别与签名更新闭环验证', () => { + it('后端 repository 必须在 updateUserProfile 中支持 gender 与 signature 字段处理', () => { + const repoPath = path.join(serverSrcDir, 'db', 'repository.ts'); + const content = fs.readFileSync(repoPath, 'utf-8'); + + expect(content).toContain('gender'); + expect(content).toContain('signature'); + expect(content).toContain('slice(0, 64)'); + }); + + it('后端 index.ts POST /api/users/:id/profile 必须支持性别白名单与个性签名风控', () => { + const indexPath = path.join(serverSrcDir, 'index.ts'); + const content = fs.readFileSync(indexPath, 'utf-8'); + + expect(content).toContain("app.post('/api/users/:id/profile'"); + expect(content).toContain('signature'); + expect(content).toContain('contentSecurityService.checkText'); + }); + }); + + describe('4. 前端核心页面规范与结构完整性校验', () => { + it('个人中心页面 (user/index.vue) 必须包含个性签名展示与性别修改入口', () => { + const userVuePath = path.join(uniPagesDir, 'user', 'index.vue'); + const content = fs.readFileSync(userVuePath, 'utf-8'); + + expect(content).toContain('user.signature'); + expect(content).toContain('editForm.gender'); + expect(content).toContain('editForm.signature'); + expect(content).toContain('个性签名'); + }); + + it('订单中心页面 (order/list.vue) 必须包含倒计时徽章、退款仲裁工单与举报入口', () => { + const orderVuePath = path.join(uniPagesDir, 'order', 'list.vue'); + const content = fs.readFileSync(orderVuePath, 'utf-8'); + + expect(content).toContain('countdown-badge'); + expect(content).toContain('getOrderCountdown'); + expect(content).toContain('openRefundModal'); + expect(content).toContain('openReportModal'); + expect(content).toContain('refundForm'); + expect(content).toContain('reportForm'); + }); + + it('陪玩详情页面 (expert/detail.vue) 必须包含违规举报操作入口与弹窗', () => { + const detailVuePath = path.join(uniPagesDir, 'expert', 'detail.vue'); + const content = fs.readFileSync(detailVuePath, 'utf-8'); + + expect(content).toContain('openReportModal'); + expect(content).toContain('submitReportTicket'); + expect(content).toContain('presetReportReasons'); + expect(content).toContain('review-modal-mask'); + }); + }); +}); diff --git a/server/tests/content-security.spec.ts b/server/tests/content-security.spec.ts index 8319e0b..bd9fe3d 100644 --- a/server/tests/content-security.spec.ts +++ b/server/tests/content-security.spec.ts @@ -130,7 +130,7 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安 const raw = fs.readFileSync(pagesJsonPath, 'utf-8'); const config = JSON.parse(raw); expect(Array.isArray(config.pages)).toBe(true); - expect(config.pages.length).toBe(16); + expect(config.pages.length).toBe(17); for (const pageItem of config.pages) { const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`); @@ -674,6 +674,53 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安 }); }); }); + + describe('15. 独立专属收益提现页面验证 (pages/user/withdraw.vue)', () => { + it('pages.json 必须成功注册 pages/user/withdraw 且物理文件必须存在', () => { + const wdPath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'withdraw.vue'); + expect(fs.existsSync(wdPath)).toBe(true); + const pagesJsonPath = path.join(rootDir, 'pwuniapp', 'pages.json'); + const pagesJson = fs.readFileSync(pagesJsonPath, 'utf-8'); + expect(pagesJson).toContain('pages/user/withdraw'); + expect(pagesJson).toContain('收益提现'); + }); + + it('独立提现页面必须支持微信零钱、支付宝、银行卡三渠道与金额输入校验', () => { + const wdPath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'withdraw.vue'); + const content = fs.readFileSync(wdPath, 'utf-8'); + expect(content).toContain('currentChannel'); + expect(content).toContain('WECHAT'); + expect(content).toContain('ALIPAY'); + expect(content).toContain('BANK'); + expect(content).toContain('withdrawAmount'); + expect(content).toContain('handleFillAllCommission'); + expect(content).toContain('withdrawCommission'); + expect(content).toContain('showRecordsDrawer'); + }); + + it('个人中心与工作台必须实现对专属提现页面的原生导航跳转', () => { + const userPath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'index.vue'); + const userContent = fs.readFileSync(userPath, 'utf-8'); + expect(userContent).toContain('/pages/user/withdraw'); + + const wsPath = path.join(rootDir, 'pwuniapp', 'pages', 'expert', 'workspace.vue'); + const wsContent = fs.readFileSync(wsPath, 'utf-8'); + expect(wsContent).toContain('/pages/user/withdraw'); + }); + + it('提现页面代码严禁包含任何系统 Emoji 与 showModal 4 字门禁违规', () => { + const wdPath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'withdraw.vue'); + const content = fs.readFileSync(wdPath, 'utf-8'); + const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u; + expect(emojiRegex.test(content)).toBe(false); + + const modalMatches = content.matchAll(/(?:confirmText|cancelText):\s*['"]([^'"]+)['"]/g); + for (const match of modalMatches) { + const btnText = match[1]; + expect(btnText.length).toBeLessThanOrEqual(4); + } + }); + }); });