feat: 同步签约宝手机端功能到签约宝页面

- sign页面与签约宝精简版对齐:查看合同入口、AI积分区块(合同审查/我的报告/积分充值)
- 新增pages/ai/{points,review,reports}三个页面(与签约宝精简版一致)
- pages.json注册AI页面路由
This commit is contained in:
2026-10-23 19:57:27 +08:00
parent e134209d16
commit c4ca8a87fa
1301 changed files with 314434 additions and 207 deletions
+369
View File
@@ -0,0 +1,369 @@
<template>
<view class="page-wrap">
<uititle title="AI 合同审查"></uititle>
<!-- 积分提示 -->
<view class="notice-bar">
<text>💎 当前积分:{{ pointsBalance }}|每 100KB 约 200 积分</text>
</view>
<!-- 免责声明 -->
<view class="disclaimer-bar">
<text>⚠️ 请确保合同内容清晰可见,如因不清晰导致分析失败,积分正常消耗。</text>
</view>
<!-- 上传区 -->
<view v-if="!reviewFile" class="upload-area" @click="selectReviewFile">
<view class="upload-icon">📤</view>
<view class="upload-text">上传合同文件</view>
<view class="upload-hint">支持 PDF / Word(.doc .docx),最大 20MB</view>
</view>
<!-- 上传中 -->
<view v-if="reviewUploading" class="loading-box">
<u-loading-icon size="48"></u-loading-icon>
<text class="loading-text">正在上传文件到审查系统...</text>
</view>
<!-- 文件信息 + 操作 -->
<view v-if="reviewFile && !reviewUploading" class="file-section">
<view class="file-card">
<view class="file-name">📄 {{ reviewFile.name }}</view>
<view class="file-size">{{ formatFileSize(reviewFile.size) }}</view>
</view>
<view class="review-mode">
<view class="mode-item selected">
<view class="mode-icon">🔍</view>
<view class="mode-info">
<view class="mode-title">标准审查</view>
<view class="mode-desc">风险条款识别 · 合规性检查</view>
</view>
</view>
</view>
<view class="p-20">
<u-button type="primary" shape="circle" :loading="reviewLoading"
:disabled="pointsBalance < reviewPoints + 200"
@click="startReview">
{{ reviewLoading ? '审查中...' : '开始审查(约 ' + reviewPoints + ' 积分)' }}
</u-button>
<view v-if="pointsBalance < reviewPoints + 200" class="warn-text">
积分不足,当前余额 {{ pointsBalance }},需要 {{ reviewPoints }} 积分且审查后余额需保留至少 200 积分
</view>
</view>
</view>
<!-- 审查中 -->
<view v-if="reviewLoading" class="loading-box">
<u-loading-icon size="48"></u-loading-icon>
<text class="loading-text">🤖 AI 正在分析合同,请稍候...</text>
<text class="loading-hint">预计 3-5 分钟,可前往「我的报告」查看进度</text>
</view>
</view>
</template>
<script>
import uititle from '@/components/common/title'
export default {
components: { uititle },
data() {
return {
pointsBalance: 0,
reviewFile: null,
reviewFileId: null,
reviewRecordId: null,
reviewUploading: false,
reviewLoading: false,
reviewPoints: 200,
reviewProgress: 0
}
},
onShow() {
this.getPointsBalance()
},
methods: {
// 获取当前登录用户uid(vuex优先,storage兜底)
getUid() {
let uid = (typeof vk.getVuex === 'function' && vk.getVuex('$user.userInfo._id')) || ''
if (!uid) {
const cache = vk.getStorageSync('uni_id_user_info') || {}
uid = cache._id || cache.uid || ''
}
return uid
},
async getPointsBalance() {
const res = await vk.callFunction({ url: 'client/points/kh/getBalance' })
if (res.code === 0 && res.data) {
this.pointsBalance = res.data.balance || 0
}
},
selectReviewFile() {
uni.chooseFile({
count: 1,
type: 'file',
extension: ['.pdf', '.doc', '.docx'],
success: (res) => {
const filePath = res.tempFilePaths[0]
const fileName = res.tempFiles[0].name || filePath.split('/').pop()
const fileSize = res.tempFiles[0].size
if (fileSize > 20 * 1024 * 1024) {
uni.showToast({ title: '文件大小超过 20MB', icon: 'none' })
return
}
this.reviewFile = { name: fileName, size: fileSize, path: filePath }
this.reviewFileId = null
this.reviewPoints = Math.max(200, Math.ceil(fileSize / (100 * 1024)) * 200)
this.uploadFile(filePath, fileName, fileSize)
}
})
},
async uploadFile(filePath, fileName, fileSize) {
this.reviewUploading = true
try {
const base64 = await this.fileToBase64(filePath)
const res = await vk.callFunction({
url: 'client/ai/pub/review',
data: {
file_base64: base64,
file_name: fileName,
file_size: fileSize,
uid: this.getUid(),
upload_only: true
}
})
if (res.code === 0 && res.data && res.data.file_id) {
this.reviewFileId = res.data.file_id
this.reviewRecordId = res.data.record_id
uni.showToast({ title: '文件上传成功', icon: 'success' })
} else {
uni.showToast({ title: res.msg || '上传失败', icon: 'none' })
this.reviewFile = null
}
} catch (e) {
uni.showToast({ title: '上传异常', icon: 'none' })
this.reviewFile = null
}
this.reviewUploading = false
},
fileToBase64(filePath) {
return new Promise((resolve, reject) => {
// #ifdef H5
const xhr = new XMLHttpRequest()
xhr.open('GET', filePath, true)
xhr.responseType = 'blob'
xhr.onload = () => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result.split(',')[1])
reader.onerror = reject
reader.readAsDataURL(xhr.response)
}
xhr.onerror = reject
xhr.send()
// #endif
// #ifndef H5
uni.getFileSystemManager().readFile({
filePath,
encoding: 'base64',
success: (res) => resolve(res.data),
fail: reject
})
// #endif
})
},
async startReview() {
if (!this.reviewFileId || !this.reviewRecordId) {
uni.showToast({ title: '请先上传文件', icon: 'none' })
return
}
// 积分余额检查
const balanceRes = await vk.callFunction({ url: 'client/points/kh/getBalance' })
if (balanceRes.code !== 0 || !balanceRes.data || balanceRes.data.balance < 200) {
uni.showToast({ title: '积分不足,请先充值', icon: 'none' })
return
}
this.reviewLoading = true
try {
const res = await vk.callFunction({
url: 'client/ai/pub/review',
data: {
record_id: this.reviewRecordId,
file_id: this.reviewFileId,
file_name: this.reviewFile.name,
file_size: this.reviewFile.size
}
})
if (res.code === 0) {
uni.showModal({
title: '审查已提交',
content: 'AI 正在分析中,预计 3-5 分钟完成。您可以在「我的报告」中查看结果。',
showCancel: false,
confirmText: '知道了',
success: () => {
this.resetReview()
uni.navigateBack()
}
})
} else {
uni.showToast({ title: res.msg || '发起审查失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '请求异常', icon: 'none' })
}
this.reviewLoading = false
},
resetReview() {
this.reviewFile = null
this.reviewFileId = null
this.reviewRecordId = null
this.reviewUploading = false
this.reviewLoading = false
this.reviewProgress = 0
},
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
}
}
</script>
<style lang="scss" scoped>
.page-wrap {
min-height: 100vh;
background: #f5f5f5;
}
.notice-bar {
background: #fff8e1;
padding: 20rpx 30rpx;
font-size: 24rpx;
color: #b8860b;
}
.disclaimer-bar {
background: #fef0f0;
padding: 20rpx 30rpx;
font-size: 22rpx;
color: #f56c6c;
}
.upload-area {
margin: 30rpx;
padding: 80rpx 30rpx;
border: 4rpx dashed #d0d0d0;
border-radius: 16rpx;
text-align: center;
background: #fff;
}
.upload-area:active {
border-color: #a92924;
background: #fdf0f0;
}
.upload-icon {
font-size: 80rpx;
}
.upload-text {
font-size: 32rpx;
font-weight: 600;
margin-top: 16rpx;
}
.upload-hint {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.loading-box {
text-align: center;
padding: 60rpx 30rpx;
}
.loading-text {
display: block;
font-size: 28rpx;
margin-top: 16rpx;
}
.loading-hint {
display: block;
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.file-section {
margin: 30rpx;
}
.file-card {
background: #f9f9f9;
padding: 24rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
gap: 16rpx;
}
.file-name {
flex: 1;
font-size: 28rpx;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 24rpx;
color: #999;
flex-shrink: 0;
}
.review-mode {
margin-top: 24rpx;
}
.mode-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx;
border: 2rpx solid #a92924;
border-radius: 12rpx;
background: #fdf0f0;
}
.mode-icon {
font-size: 40rpx;
}
.mode-title {
font-size: 28rpx;
font-weight: 600;
}
.mode-desc {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
.warn-text {
text-align: center;
font-size: 24rpx;
color: #f56c6c;
margin-top: 16rpx;
}
</style>