Add admin-frontend and user-frontend standalone projects, certification/invoice/discovery features, fix auth header and theme consistency
This commit is contained in:
@@ -1,13 +1,45 @@
|
||||
<template>
|
||||
<view class="admin-container">
|
||||
<scroll-view class="tabs" scroll-x>
|
||||
<view class="tab" :class="{ active: tab === 'overview' }" @click="tab = 'overview'">概览</view>
|
||||
<view class="tab" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户</view>
|
||||
<view class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">统计</view>
|
||||
<view class="tab" :class="{ active: tab === 'logs' }" @click="tab = 'logs'">日志</view>
|
||||
<view class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">配置</view>
|
||||
<view class="tab" :class="{ active: tab === 'quota' }" @click="tab = 'quota'">翻译配额</view>
|
||||
</scroll-view>
|
||||
<view class="sidebar">
|
||||
<view class="sidebar-header">
|
||||
<text class="sidebar-title">管理后台</text>
|
||||
</view>
|
||||
<view class="tab" :class="{ active: tab === 'overview' }" @click="tab = 'overview'">
|
||||
<text class="tab-icon">📊</text><text>概览</text>
|
||||
</view>
|
||||
<view class="tab" :class="{ active: tab === 'users' }" @click="tab = 'users'">
|
||||
<text class="tab-icon">👥</text><text>用户</text>
|
||||
</view>
|
||||
<view class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">
|
||||
<text class="tab-icon">📈</text><text>统计</text>
|
||||
</view>
|
||||
<view class="tab" :class="{ active: tab === 'logs' }" @click="tab = 'logs'">
|
||||
<text class="tab-icon">📋</text><text>日志</text>
|
||||
</view>
|
||||
<view class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">
|
||||
<text class="tab-icon">⚙️</text><text>配置</text>
|
||||
</view>
|
||||
<view class="tab" :class="{ active: tab === 'quota' }" @click="tab = 'quota'">
|
||||
<text class="tab-icon">📄</text><text>翻译配额</text>
|
||||
</view>
|
||||
<view class="tab" :class="{ active: tab === 'cert' }" @click="tab = 'cert'; loadCerts()">
|
||||
<text class="tab-icon">🪪</text><text>认证审核</text>
|
||||
</view>
|
||||
<view class="tab" :class="{ active: tab === 'invoice' }" @click="tab = 'invoice'; loadAdminInvoices()">
|
||||
<text class="tab-icon">🧾</text><text>发票管理</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="main-content">
|
||||
<scroll-view class="tabs mobile-only" scroll-x>
|
||||
<view class="tab" :class="{ active: tab === 'overview' }" @click="tab = 'overview'">概览</view>
|
||||
<view class="tab" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户</view>
|
||||
<view class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">统计</view>
|
||||
<view class="tab" :class="{ active: tab === 'logs' }" @click="tab = 'logs'">日志</view>
|
||||
<view class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">配置</view>
|
||||
<view class="tab" :class="{ active: tab === 'quota' }" @click="tab = 'quota'">翻译配额</view>
|
||||
<view class="tab" :class="{ active: tab === 'cert' }" @click="tab = 'cert'; loadCerts()">认证审核</view>
|
||||
<view class="tab" :class="{ active: tab === 'invoice' }" @click="tab = 'invoice'; loadAdminInvoices()">发票管理</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 概览 -->
|
||||
<view v-if="tab === 'overview'">
|
||||
@@ -266,6 +298,92 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 认证审核 -->
|
||||
<view v-if="tab === 'cert'">
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">实名认证审核</text>
|
||||
</view>
|
||||
<view class="filter-bar">
|
||||
<text class="filter-btn" :class="{ active: certFilter === '' }" @click="certFilter = ''; loadCerts()">全部</text>
|
||||
<text class="filter-btn" :class="{ active: certFilter === 'pending' }" @click="certFilter = 'pending'; loadCerts()">待审核</text>
|
||||
<text class="filter-btn" :class="{ active: certFilter === 'approved' }" @click="certFilter = 'approved'; loadCerts()">已通过</text>
|
||||
<text class="filter-btn" :class="{ active: certFilter === 'rejected' }" @click="certFilter = 'rejected'; loadCerts()">已驳回</text>
|
||||
</view>
|
||||
<view class="cert-list" v-if="certs.length">
|
||||
<view class="cert-card" v-for="c in certs" :key="c.id">
|
||||
<view class="cert-header">
|
||||
<text class="cert-type">{{ c.cert_type === 'individual' ? '个人认证' : '企业认证' }}</text>
|
||||
<text class="cert-status" :class="c.status">{{ { pending: '待审核', approved: '已通过', rejected: '已驳回' }[c.status] }}</text>
|
||||
</view>
|
||||
<view class="cert-body">
|
||||
<text class="cert-field" v-if="c.personal_name">姓名:{{ c.personal_name }}</text>
|
||||
<text class="cert-field" v-if="c.company_name">企业:{{ c.company_name }}</text>
|
||||
<text class="cert-field" v-if="c.tax_id">税号:{{ c.tax_id }}</text>
|
||||
<text class="cert-field">用户ID:{{ c.user_id?.substring(0, 8) }}...</text>
|
||||
<text class="cert-date">{{ c.created_at?.substring(0, 10) }}</text>
|
||||
</view>
|
||||
<view class="cert-actions" v-if="c.status === 'pending'">
|
||||
<text class="action-btn approve" @click="reviewCert(c.id, 'approve')">通过</text>
|
||||
<text class="action-btn reject" @click="showRejectCert = c.id">驳回</text>
|
||||
</view>
|
||||
<text v-if="c.reject_reason" class="cert-reason">驳回原因:{{ c.reject_reason }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="empty-text">暂无认证申请</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发票管理 -->
|
||||
<view v-if="tab === 'invoice'">
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">开票管理</text>
|
||||
</view>
|
||||
<view class="filter-bar">
|
||||
<text class="filter-btn" :class="{ active: invFilter === '' }" @click="invFilter = ''; loadAdminInvoices()">全部</text>
|
||||
<text class="filter-btn" :class="{ active: invFilter === 'pending' }" @click="invFilter = 'pending'; loadAdminInvoices()">待开票</text>
|
||||
<text class="filter-btn" :class="{ active: invFilter === 'issued' }" @click="invFilter = 'issued'; loadAdminInvoices()">已开票</text>
|
||||
<text class="filter-btn" :class="{ active: invFilter === 'rejected' }" @click="invFilter = 'rejected'; loadAdminInvoices()">已驳回</text>
|
||||
</view>
|
||||
<view class="inv-list" v-if="adminInvoices.length">
|
||||
<view class="inv-card" v-for="inv in adminInvoices" :key="inv.id">
|
||||
<view class="inv-header">
|
||||
<text class="inv-title">{{ inv.title }}</text>
|
||||
<text class="inv-amount">¥{{ inv.amount }}</text>
|
||||
</view>
|
||||
<view class="inv-meta">
|
||||
<text class="inv-type">{{ inv.invoice_type === 'individual' ? '个人' : '企业' }}发票</text>
|
||||
<text class="inv-status" :class="inv.status">{{ { pending: '待开票', issued: '已开票', rejected: '已驳回' }[inv.status] }}</text>
|
||||
</view>
|
||||
<view class="inv-actions" v-if="inv.status === 'pending'">
|
||||
<text class="action-btn approve" @click="processInv(inv.id, 'issue')">确认已开</text>
|
||||
<text class="action-btn reject" @click="showRejectInv = inv.id">驳回</text>
|
||||
</view>
|
||||
<text v-if="inv.reject_reason" class="inv-reason">驳回原因:{{ inv.reject_reason }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else class="empty-text">暂无开票申请</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 驳回弹窗 -->
|
||||
<view class="modal-mask" v-if="showRejectCert || showRejectInv" @click="showRejectCert = null; showRejectInv = null">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">驳回原因</text>
|
||||
<text class="modal-close" @click="showRejectCert = null; showRejectInv = null">✕</text>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<input class="reject-input" v-model="rejectReason" placeholder="输入驳回原因" />
|
||||
<view class="modal-actions">
|
||||
<text class="action-btn cancel" @click="showRejectCert = null; showRejectInv = null">取消</text>
|
||||
<text class="action-btn reject" @click="confirmReject">确认驳回</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户详情弹窗 -->
|
||||
<view class="modal-mask" v-if="userDetail" @click="userDetail = null">
|
||||
<view class="modal-content" @click.stop>
|
||||
@@ -294,6 +412,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<AiAssistant />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -572,19 +691,110 @@ const resetQuota = async (version) => {
|
||||
}
|
||||
}
|
||||
|
||||
const certs = ref([])
|
||||
const certFilter = ref('pending')
|
||||
const showRejectCert = ref(null)
|
||||
const showRejectInv = ref(null)
|
||||
const rejectReason = ref('')
|
||||
const adminInvoices = ref([])
|
||||
const invFilter = ref('pending')
|
||||
|
||||
const loadCerts = async () => {
|
||||
try {
|
||||
const res = await adminApi.listCertifications(1, 50, certFilter.value || undefined)
|
||||
certs.value = res.items || []
|
||||
} catch (e) { certs.value = [] }
|
||||
}
|
||||
|
||||
const reviewCert = async (id, action) => {
|
||||
try {
|
||||
await adminApi.reviewCertification(id, action)
|
||||
uni.showToast({ title: action === 'approve' ? '已通过' : '已驳回', icon: 'success' })
|
||||
loadCerts()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const confirmReject = async () => {
|
||||
const id = showRejectCert.value || showRejectInv.value
|
||||
const type = showRejectCert.value ? 'cert' : 'inv'
|
||||
if (!rejectReason.value) { uni.showToast({ title: '请输入驳回原因', icon: 'none' }); return }
|
||||
showRejectCert.value = null
|
||||
showRejectInv.value = null
|
||||
try {
|
||||
if (type === 'cert') {
|
||||
await adminApi.reviewCertification(id, 'reject', rejectReason.value)
|
||||
} else {
|
||||
await adminApi.processInvoice(id, 'reject', rejectReason.value)
|
||||
}
|
||||
uni.showToast({ title: '已驳回', icon: 'success' })
|
||||
rejectReason.value = ''
|
||||
loadCerts()
|
||||
loadAdminInvoices()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const loadAdminInvoices = async () => {
|
||||
try {
|
||||
const res = await adminApi.listInvoices(1, 50, invFilter.value || undefined)
|
||||
adminInvoices.value = res.items || []
|
||||
} catch (e) { adminInvoices.value = [] }
|
||||
}
|
||||
|
||||
const processInv = async (id, action) => {
|
||||
try {
|
||||
await adminApi.processInvoice(id, action)
|
||||
uni.showToast({ title: action === 'issue' ? '已开票' : '已驳回', icon: 'success' })
|
||||
loadAdminInvoices()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
watch(tab, (val) => {
|
||||
if (val === 'stats') loadUsageStats()
|
||||
else if (val === 'logs') { logPage.value = 1; loadLogs() }
|
||||
else if (val === 'config') loadConfig()
|
||||
else if (val === 'quota') loadQuotas()
|
||||
else if (val === 'cert') loadCerts()
|
||||
else if (val === 'invoice') loadAdminInvoices()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.admin-container { min-height: 100vh; background: #f5f5f5; padding: 20rpx; }
|
||||
.tabs { width: 100%; background: #fff; border-radius: 16rpx; margin-bottom: 30rpx; white-space: nowrap; }
|
||||
.admin-container { min-height: 100vh; background: #f5f5f5; display: flex; }
|
||||
|
||||
.tabs { width: 100%; background: #fff; border-radius: 16rpx; margin-bottom: 30rpx; white-space: nowrap; overflow-x: auto; }
|
||||
.tab { display: inline-block; text-align: center; padding: 20rpx 28rpx; font-size: 26rpx; color: #666; font-weight: 500; }
|
||||
.tab.active { color: #667eea; border-bottom: 4rpx solid #667eea; }
|
||||
|
||||
.pc-only { display: none; }
|
||||
.mobile-only { display: block; }
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.admin-container { padding: 0; }
|
||||
.pc-only { display: block; }
|
||||
.mobile-only { display: none; }
|
||||
.sidebar { display: flex; flex-direction: column; width: 220px; min-height: 100vh; background: #001529; position: fixed; top: 0; left: 0; z-index: 9999; }
|
||||
.sidebar-header { display: flex; align-items: center; padding: 16px 20px; background: #002140; border-bottom: 1px solid #003a5c; }
|
||||
.sidebar-title { color: #fff; font-size: 16px; font-weight: 600; }
|
||||
.sidebar .tab { display: flex; align-items: center; gap: 10px; padding: 14px 24px; font-size: 14px; color: rgba(255,255,255,0.65); cursor: pointer; transition: all 0.2s; }
|
||||
.sidebar .tab:hover { color: #fff; background: #002140; }
|
||||
.sidebar .tab.active { color: #fff; background: #1890ff; }
|
||||
.tab-icon { font-size: 16px; }
|
||||
.main-content { margin-left: 220px; flex: 1; padding: 24px; min-height: 100vh; width: calc(100% - 220px); }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.admin-container { padding: 20rpx; flex-direction: column; }
|
||||
.sidebar { display: none; }
|
||||
.main-content { flex: 1; width: 100%; }
|
||||
.tabs { width: 100%; background: #fff; border-radius: 16rpx; margin-bottom: 30rpx; white-space: nowrap; overflow-x: auto; }
|
||||
}
|
||||
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20rpx; margin-bottom: 30rpx; }
|
||||
.stat-card { background: #fff; border-radius: 16rpx; padding: 30rpx; text-align: center; }
|
||||
.stat-value { font-size: 48rpx; font-weight: bold; color: #667eea; display: block; }
|
||||
@@ -679,4 +889,34 @@ watch(tab, (val) => {
|
||||
.quota-input { width: 140rpx; height: 56rpx; background: #fff; border: 1rpx solid #e0e0e0; border-radius: 8rpx; padding: 0 12rpx; font-size: 24rpx; text-align: center; }
|
||||
.quota-reset-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #fff7e6; color: #fa8c16; border-radius: 6rpx; }
|
||||
.quota-save-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #52c41a; color: #fff; border-radius: 6rpx; }
|
||||
.filter-bar { display: flex; gap: 12rpx; margin-bottom: 16rpx; flex-wrap: wrap; }
|
||||
.filter-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #f5f5f5; color: #666; border-radius: 6rpx; }
|
||||
.filter-btn.active { background: #667eea; color: #fff; }
|
||||
.cert-list, .inv-list { display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.cert-card, .inv-card { padding: 16rpx; background: #f9f9f9; border-radius: 10rpx; }
|
||||
.cert-header, .inv-header { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
|
||||
.cert-type { font-size: 24rpx; color: #333; font-weight: 500; }
|
||||
.cert-status { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 4rpx; }
|
||||
.cert-status.pending { background: #fff7e6; color: #d46b08; }
|
||||
.cert-status.approved { background: #f6ffed; color: #389e0d; }
|
||||
.cert-status.rejected { background: #fff2f0; color: #cf1322; }
|
||||
.cert-body { }
|
||||
.cert-field { font-size: 22rpx; color: #666; display: block; margin: 4rpx 0; }
|
||||
.cert-date { font-size: 20rpx; color: #999; display: block; margin-top: 4rpx; }
|
||||
.cert-actions { display: flex; gap: 12rpx; margin-top: 8rpx; }
|
||||
.action-btn.approve { background: #f6ffed; color: #52c41a; }
|
||||
.action-btn.reject { background: #fff2f0; color: #f5222d; }
|
||||
.action-btn.cancel { background: #f5f5f5; color: #666; }
|
||||
.cert-reason, .inv-reason { font-size: 20rpx; color: #cf1322; display: block; margin-top: 4rpx; }
|
||||
.inv-title { font-size: 24rpx; color: #333; font-weight: 500; }
|
||||
.inv-amount { font-size: 24rpx; color: #f5222d; font-weight: 600; }
|
||||
.inv-meta { display: flex; justify-content: space-between; margin: 6rpx 0; }
|
||||
.inv-type { font-size: 20rpx; color: #666; }
|
||||
.inv-status { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 4rpx; }
|
||||
.inv-status.pending { background: #fff7e6; color: #d46b08; }
|
||||
.inv-status.issued { background: #f6ffed; color: #389e0d; }
|
||||
.inv-status.rejected { background: #fff2f0; color: #cf1322; }
|
||||
.inv-actions { display: flex; gap: 12rpx; margin-top: 8rpx; }
|
||||
.reject-input { width: 100%; height: 80rpx; border: 1rpx solid #d9d9d9; border-radius: 8rpx; padding: 0 16rpx; font-size: 26rpx; box-sizing: border-box; }
|
||||
.modal-actions { display: flex; gap: 16rpx; margin-top: 20rpx; justify-content: flex-end; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header-card">
|
||||
<text class="title">实名认证</text>
|
||||
<text class="subtitle">认证后可申请发票</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading.status" class="loading-wrap">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="cert" class="section">
|
||||
<view class="status-bar" :class="cert.status">
|
||||
<text class="status-icon">{{ statusIcon }}</text>
|
||||
<text class="status-text">{{ statusText }}</text>
|
||||
<text v-if="cert.reject_reason" class="reject-reason">原因:{{ cert.reject_reason }}</text>
|
||||
</view>
|
||||
<view class="info-card">
|
||||
<view class="info-row">
|
||||
<text class="info-label">认证类型</text>
|
||||
<text class="info-value">{{ cert.cert_type === 'individual' ? '个人认证' : '企业认证' }}</text>
|
||||
</view>
|
||||
<view class="info-row" v-if="cert.personal_name">
|
||||
<text class="info-label">姓名</text>
|
||||
<text class="info-value">{{ cert.personal_name }}</text>
|
||||
</view>
|
||||
<view class="info-row" v-if="cert.company_name">
|
||||
<text class="info-label">企业名称</text>
|
||||
<text class="info-value">{{ cert.company_name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="cert.status === 'rejected'" class="action-bar">
|
||||
<button class="primary-btn" @click="resetForm">重新提交</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="section">
|
||||
<view class="input-group">
|
||||
<text class="input-label">认证类型</text>
|
||||
<view class="type-selector">
|
||||
<text class="type-option" :class="{ active: form.cert_type === 'individual' }" @click="form.cert_type = 'individual'">个人认证</text>
|
||||
<text class="type-option" :class="{ active: form.cert_type === 'enterprise' }" @click="form.cert_type = 'enterprise'">企业认证</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-group">
|
||||
<text class="input-label">姓名</text>
|
||||
<input class="input-field" v-model="form.personal_name" placeholder="输入真实姓名" />
|
||||
</view>
|
||||
<view class="input-group">
|
||||
<text class="input-label">身份证号</text>
|
||||
<input class="input-field" v-model="form.personal_id" placeholder="输入身份证号码" />
|
||||
</view>
|
||||
|
||||
<view v-if="form.cert_type === 'enterprise'" class="enterprise-fields">
|
||||
<view class="input-group">
|
||||
<text class="input-label">企业名称</text>
|
||||
<input class="input-field" v-model="form.company_name" placeholder="输入营业执照上的企业名称" />
|
||||
</view>
|
||||
<view class="input-group">
|
||||
<text class="input-label">统一社会信用代码</text>
|
||||
<input class="input-field" v-model="form.tax_id" placeholder="输入税号" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="notice">
|
||||
<text class="notice-text">提交后预计 1-3 个工作日审核完成</text>
|
||||
</view>
|
||||
|
||||
<button class="primary-btn" :disabled="loading.submit" @click="doSubmit">
|
||||
<text v-if="loading.submit">提交中...</text>
|
||||
<text v-else>提交认证</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { certificationApi } from '@/utils/api.js'
|
||||
|
||||
const loading = reactive({ status: false, submit: false })
|
||||
const cert = ref(null)
|
||||
const form = reactive({
|
||||
cert_type: 'individual',
|
||||
personal_name: '',
|
||||
personal_id: '',
|
||||
company_name: '',
|
||||
tax_id: '',
|
||||
})
|
||||
|
||||
const statusIcon = computed(() => ({ pending: '⏳', approved: '✅', rejected: '❌' })[cert.value?.status] || '')
|
||||
const statusText = computed(() => ({ pending: '审核中', approved: '已认证', rejected: '认证未通过' })[cert.value?.status] || '')
|
||||
|
||||
async function loadStatus() {
|
||||
loading.status = true
|
||||
try {
|
||||
const res = await certificationApi.status()
|
||||
if (res.success && res.data) {
|
||||
cert.value = res.data
|
||||
form.personal_name = res.data.personal_name || ''
|
||||
form.company_name = res.data.company_name || ''
|
||||
}
|
||||
} catch (e) {
|
||||
// not certified yet
|
||||
}
|
||||
loading.status = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
cert.value = null
|
||||
form.cert_type = 'individual'
|
||||
form.personal_name = ''
|
||||
form.personal_id = ''
|
||||
form.company_name = ''
|
||||
form.tax_id = ''
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
if (!form.personal_name) { uni.showToast({ title: '请填写姓名', icon: 'none' }); return }
|
||||
if (!form.personal_id) { uni.showToast({ title: '请填写身份证号', icon: 'none' }); return }
|
||||
if (form.cert_type === 'enterprise' && !form.company_name) {
|
||||
uni.showToast({ title: '请填写企业名称', icon: 'none' }); return
|
||||
}
|
||||
loading.submit = true
|
||||
try {
|
||||
const res = await certificationApi.submit(form)
|
||||
if (res.success) {
|
||||
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||
loadStatus()
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
|
||||
}
|
||||
loading.submit = false
|
||||
}
|
||||
|
||||
onMounted(loadStatus)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
page { background: #f5f5f5; }
|
||||
.page { padding: 16px; }
|
||||
.header-card { background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||||
.header-card .title { font-size: 20px; font-weight: 600; color: #fff; display: block; }
|
||||
.header-card .subtitle { font-size: 13px; color: rgba(255,255,255,.8); display: block; margin-top: 4px; }
|
||||
.section { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
|
||||
.loading-wrap { text-align: center; padding: 40px; }
|
||||
.loading-text { color: #999; }
|
||||
.status-bar { text-align: center; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
|
||||
.status-bar.pending { background: #fff7e6; }
|
||||
.status-bar.approved { background: #f6ffed; }
|
||||
.status-bar.rejected { background: #fff2f0; }
|
||||
.status-icon { font-size: 32px; display: block; }
|
||||
.status-text { font-size: 16px; font-weight: 500; display: block; margin-top: 4px; }
|
||||
.reject-reason { font-size: 13px; color: #ff4d4f; display: block; margin-top: 8px; }
|
||||
.info-card { }
|
||||
.info-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.info-label { color: #666; font-size: 14px; }
|
||||
.info-value { color: #333; font-size: 14px; font-weight: 500; }
|
||||
.action-bar { margin-top: 16px; }
|
||||
.input-group { margin-bottom: 16px; }
|
||||
.input-label { font-size: 14px; color: #333; display: block; margin-bottom: 6px; font-weight: 500; }
|
||||
.input-field { width: 100%; height: 44px; border: 1px solid #d9d9d9; border-radius: 8px; padding: 0 12px; font-size: 14px; box-sizing: border-box; }
|
||||
.type-selector { display: flex; gap: 12px; }
|
||||
.type-option { flex: 1; text-align: center; padding: 10px; border: 2px solid #d9d9d9; border-radius: 8px; font-size: 14px; }
|
||||
.type-option.active { border-color: #667eea; color: #667eea; font-weight: 500; }
|
||||
.notice { background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px; padding: 10px; margin-bottom: 16px; }
|
||||
.notice-text { font-size: 13px; color: #ad8b00; }
|
||||
.primary-btn { width: 100%; height: 44px; background: #667eea; color: #fff; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; border: none; }
|
||||
.primary-btn:disabled { opacity: .6; }
|
||||
</style>
|
||||
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<view class="discovery-container">
|
||||
<view class="header-card">
|
||||
<text class="title">挖掘新客</text>
|
||||
<text class="subtitle">AI 帮你找到潜在客户并生成开发信</text>
|
||||
</view>
|
||||
|
||||
<view class="tab-bar">
|
||||
<view class="tab-item" :class="{ active: activeTab === 'search' }" @click="activeTab = 'search'">
|
||||
<text class="tab-text">客户挖掘</text>
|
||||
</view>
|
||||
<view class="tab-item" :class="{ active: activeTab === 'outreach' }" @click="activeTab = 'outreach'">
|
||||
<text class="tab-text">开发信生成</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activeTab === 'search'" class="section">
|
||||
<view class="input-group">
|
||||
<text class="input-label">你的产品/服务</text>
|
||||
<textarea class="input-area" v-model="searchForm.product" placeholder="例如:太阳能电池板 200W 单晶硅" />
|
||||
</view>
|
||||
<view class="input-group">
|
||||
<text class="input-label">目标市场</text>
|
||||
<input class="input-field" v-model="searchForm.market" placeholder="例如:美国、欧洲、东南亚" />
|
||||
</view>
|
||||
<button class="primary-btn" :disabled="loading.search" @click="doSearch">
|
||||
<text v-if="loading.search">搜索中...</text>
|
||||
<text v-else>开始挖掘</text>
|
||||
</button>
|
||||
|
||||
<view v-if="searchResult" class="result-area">
|
||||
|
||||
<view v-if="searchResult.companies && searchResult.companies.length > 0">
|
||||
<view class="result-card" v-for="(company, i) in searchResult.companies" :key="'c' + i">
|
||||
<text class="company-title">{{ company.title }}</text>
|
||||
<text class="company-url" @click="openUrl(company.url)">{{ company.url }}</text>
|
||||
<text class="company-snippet" v-if="company.snippet">{{ company.snippet }}</text>
|
||||
<view class="company-actions" v-if="analysisLoading !== i">
|
||||
<text class="action-btn-sm" @click="analyzeCompany(company, i)">🔍 分析</text>
|
||||
<text class="action-btn-sm add" @click="showAddCustomer(company)">➕ 加入客户</text>
|
||||
</view>
|
||||
<view class="company-actions" v-else>
|
||||
<text class="loading-text">分析中...</text>
|
||||
</view>
|
||||
|
||||
<view v-if="company.analysis" class="analysis-result">
|
||||
<view class="score-bar">
|
||||
<text class="score-label">匹配度</text>
|
||||
<view class="score-track">
|
||||
<view class="score-fill" :style="{ width: (company.analysis.match_score || 0) + '%' }" />
|
||||
</view>
|
||||
<text class="score-val">{{ company.analysis.match_score || '?' }}/100</text>
|
||||
</view>
|
||||
<text class="analysis-text" v-if="company.analysis.match_reason">{{ company.analysis.match_reason }}</text>
|
||||
<view class="contact-row" v-if="company.analysis.contact_info">
|
||||
<text class="contact-item" v-for="(emails, key) in company.analysis.contact_info" :key="key">
|
||||
{{ key }}: {{ Array.isArray(emails) ? emails.join(', ') : emails }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="searchResult.buyer_personas">
|
||||
<view class="strategy-card">
|
||||
<text class="section-label">由于搜索服务未配置,以下是 AI 推荐的发现策略:</text>
|
||||
</view>
|
||||
<view class="result-card" v-for="(persona, i) in searchResult.buyer_personas" :key="'p' + i">
|
||||
<text class="result-title">{{ persona.type }}</text>
|
||||
<text class="result-desc">{{ persona.description }}</text>
|
||||
<view class="tag-row">
|
||||
<text class="tag" v-for="(ch, ci) in persona.channels" :key="'c' + ci">{{ ch }}</text>
|
||||
</view>
|
||||
<view class="query-box" v-if="persona.search_queries">
|
||||
<text class="query-label">搜索关键词:</text>
|
||||
<text class="query-text" v-for="(q, qi) in persona.search_queries" :key="'q' + qi">{{ q }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="strategy-card" v-if="searchResult.strategy">
|
||||
<text class="strategy-title">策略建议</text>
|
||||
<text class="strategy-text">{{ searchResult.strategy }}</text>
|
||||
</view>
|
||||
|
||||
<view class="tips-card" v-if="searchResult.tips">
|
||||
<text class="tips-title">💡 实用建议</text>
|
||||
<text class="tip-item" v-for="(tip, ti) in searchResult.tips" :key="'t' + ti">{{ ti + 1 }}. {{ tip }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="activeTab === 'outreach'" class="section">
|
||||
<view class="input-group">
|
||||
<text class="input-label">目标公司名称</text>
|
||||
<input class="input-field" v-model="outreachForm.company" placeholder="例如:ABC Trading Co." />
|
||||
</view>
|
||||
<view class="input-group">
|
||||
<text class="input-label">公司简介(可选)</text>
|
||||
<textarea class="input-area" v-model="outreachForm.companyDesc" placeholder="对方的业务范围、主营产品等" />
|
||||
</view>
|
||||
<view class="input-group">
|
||||
<text class="input-label">你的产品名称</text>
|
||||
<input class="input-field" v-model="outreachForm.product" placeholder="例如:太阳能电池板" />
|
||||
</view>
|
||||
<view class="input-group">
|
||||
<text class="input-label">产品优势(可选)</text>
|
||||
<textarea class="input-area" v-model="outreachForm.productAdv" placeholder="价格优势、品质认证、交期快等" />
|
||||
</view>
|
||||
<button class="primary-btn" :disabled="loading.outreach" @click="doOutreach">
|
||||
<text v-if="loading.outreach">生成中...</text>
|
||||
<text v-else>生成开发信</text>
|
||||
</button>
|
||||
|
||||
<view v-if="outreachResult" class="result-area">
|
||||
<view class="outreach-card" v-if="outreachResult.email_body">
|
||||
<text class="outreach-label">📧 邮件正文</text>
|
||||
<text class="outreach-content" style="white-space: pre-line">{{ outreachResult.email_body }}</text>
|
||||
<button class="copy-btn" @click="copyText(outreachResult.email_body)">复制</button>
|
||||
</view>
|
||||
<view class="outreach-card" v-if="outreachResult.linkedin_message">
|
||||
<text class="outreach-label">💼 LinkedIn 私信</text>
|
||||
<text class="outreach-content">{{ outreachResult.linkedin_message }}</text>
|
||||
<button class="copy-btn" @click="copyText(outreachResult.linkedin_message)">复制</button>
|
||||
</view>
|
||||
<view class="outreach-card" v-if="outreachResult.whatsapp_message">
|
||||
<text class="outreach-label">📱 WhatsApp 消息</text>
|
||||
<text class="outreach-content">{{ outreachResult.whatsapp_message }}</text>
|
||||
<button class="copy-btn" @click="copyText(outreachResult.whatsapp_message)">复制</button>
|
||||
</view>
|
||||
<view class="tips-card" v-if="outreachResult.tips">
|
||||
<text class="tips-title">💡 发送建议</text>
|
||||
<text class="tip-item" v-for="(tip, ti) in outreachResult.tips" :key="'ot' + ti">{{ ti + 1 }}. {{ tip }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const activeTab = ref('search')
|
||||
|
||||
const searchForm = reactive({
|
||||
product: '',
|
||||
market: 'US',
|
||||
})
|
||||
|
||||
const outreachForm = reactive({
|
||||
company: '',
|
||||
companyDesc: '',
|
||||
product: '',
|
||||
productAdv: '',
|
||||
})
|
||||
|
||||
const loading = reactive({ search: false, outreach: false })
|
||||
const analysisLoading = ref(-1)
|
||||
const searchResult = ref(null)
|
||||
const outreachResult = ref(null)
|
||||
|
||||
const apiBase = '/api/v1'
|
||||
|
||||
async function doSearch() {
|
||||
if (!searchForm.product.trim()) {
|
||||
uni.showToast({ title: '请填写产品描述', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loading.search = true
|
||||
searchResult.value = null
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: `${apiBase}/discovery/search`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
product_description: searchForm.product,
|
||||
target_market: searchForm.market,
|
||||
},
|
||||
})
|
||||
const body = res.data
|
||||
if (body.success && body.data) {
|
||||
searchResult.value = body.data
|
||||
} else {
|
||||
uni.showToast({ title: '搜索失败,请重试', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
} finally {
|
||||
loading.search = false
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeCompany(company, index) {
|
||||
analysisLoading.value = index
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: `${apiBase}/discovery/analyze`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
company_url: company.url,
|
||||
product_description: searchForm.product,
|
||||
},
|
||||
})
|
||||
const body = res.data
|
||||
if (body.success && body.data) {
|
||||
company.analysis = body.data
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '分析失败', icon: 'none' })
|
||||
} finally {
|
||||
analysisLoading.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
function showAddCustomer(company) {
|
||||
uni.showModal({
|
||||
title: '加入客户列表',
|
||||
content: `将「${company.title}」添加到客户管理?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const token = uni.getStorageSync('token')
|
||||
await uni.request({
|
||||
url: `${apiBase}/customers`,
|
||||
method: 'POST',
|
||||
header: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
name: company.title,
|
||||
company: company.title,
|
||||
website: company.url,
|
||||
notes: company.snippet || '',
|
||||
status: 'potential',
|
||||
},
|
||||
})
|
||||
uni.showToast({ title: '已加入客户列表', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '添加失败,请手动添加', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openUrl(url) {
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
success: () => uni.showToast({ title: '网址已复制', icon: 'success' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function doOutreach() {
|
||||
if (!outreachForm.company.trim()) {
|
||||
uni.showToast({ title: '请填写目标公司名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!outreachForm.product.trim()) {
|
||||
uni.showToast({ title: '请填写你的产品名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loading.outreach = true
|
||||
outreachResult.value = null
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: `${apiBase}/discovery/outreach`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
company: {
|
||||
name: outreachForm.company,
|
||||
description: outreachForm.companyDesc,
|
||||
},
|
||||
product: {
|
||||
name: outreachForm.product,
|
||||
advantages: outreachForm.productAdv,
|
||||
},
|
||||
},
|
||||
})
|
||||
const body = res.data
|
||||
if (body.success && body.data) {
|
||||
outreachResult.value = body.data
|
||||
} else {
|
||||
uni.showToast({ title: '生成失败,请重试', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
} finally {
|
||||
loading.outreach = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'success' }),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.discovery-container { padding: 30rpx; }
|
||||
.header-card { margin-bottom: 30rpx; }
|
||||
.header-card .title { font-size: 36rpx; font-weight: 700; color: #333; }
|
||||
.header-card .subtitle { font-size: 24rpx; color: #999; margin-top: 8rpx; display: block; }
|
||||
|
||||
.tab-bar { display: flex; background: #f5f5f5; border-radius: 12rpx; padding: 4rpx; margin-bottom: 30rpx; }
|
||||
.tab-item { flex: 1; text-align: center; padding: 16rpx 0; border-radius: 10rpx; }
|
||||
.tab-item.active { background: #fff; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); }
|
||||
.tab-text { font-size: 26rpx; color: #666; }
|
||||
.tab-item.active .tab-text { color: #1890ff; font-weight: 600; }
|
||||
|
||||
.section { margin-bottom: 30rpx; }
|
||||
.input-group { margin-bottom: 24rpx; }
|
||||
.input-label { font-size: 26rpx; color: #333; font-weight: 500; margin-bottom: 10rpx; display: block; }
|
||||
.input-area { width: 100%; min-height: 120rpx; background: #fff; border: 2rpx solid #e8e8e8; border-radius: 12rpx; padding: 20rpx; font-size: 26rpx; color: #333; box-sizing: border-box; }
|
||||
.input-field { width: 100%; height: 72rpx; background: #fff; border: 2rpx solid #e8e8e8; border-radius: 12rpx; padding: 0 20rpx; font-size: 26rpx; color: #333; box-sizing: border-box; }
|
||||
.primary-btn { width: 100%; height: 80rpx; background: #1890ff; color: #fff; font-size: 28rpx; font-weight: 600; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; border: none; margin-top: 10rpx; }
|
||||
.primary-btn[disabled] { opacity: 0.6; }
|
||||
.result-area { margin-top: 30rpx; }
|
||||
|
||||
.company-title { font-size: 28rpx; font-weight: 600; color: #333; display: block; margin-bottom: 6rpx; }
|
||||
.company-url { font-size: 22rpx; color: #1890ff; display: block; margin-bottom: 8rpx; }
|
||||
.company-snippet { font-size: 24rpx; color: #666; line-height: 1.5; display: block; margin-bottom: 14rpx; }
|
||||
.company-actions { display: flex; gap: 14rpx; margin-bottom: 12rpx; }
|
||||
.action-btn-sm { font-size: 24rpx; color: #1890ff; background: #eaf4ff; padding: 8rpx 20rpx; border-radius: 8rpx; }
|
||||
.action-btn-sm.add { color: #52c41a; background: #f0fff0; }
|
||||
.loading-text { font-size: 24rpx; color: #999; }
|
||||
|
||||
.analysis-result { background: #f9f9f9; border-radius: 10rpx; padding: 18rpx; margin-top: 8rpx; }
|
||||
.score-bar { display: flex; align-items: center; gap: 10rpx; margin-bottom: 10rpx; }
|
||||
.score-label { font-size: 22rpx; color: #666; flex-shrink: 0; }
|
||||
.score-track { flex: 1; height: 12rpx; background: #eee; border-radius: 6rpx; overflow: hidden; }
|
||||
.score-fill { height: 100%; background: #1890ff; border-radius: 6rpx; transition: width 0.3s; }
|
||||
.score-val { font-size: 22rpx; color: #1890ff; font-weight: 600; flex-shrink: 0; }
|
||||
.analysis-text { font-size: 22rpx; color: #666; display: block; margin-bottom: 8rpx; }
|
||||
.contact-row { display: flex; flex-wrap: wrap; gap: 6rpx; }
|
||||
.contact-item { font-size: 20rpx; color: #999; background: #fff; padding: 4rpx 10rpx; border-radius: 4rpx; }
|
||||
|
||||
.section-label { font-size: 24rpx; color: #fa8c16; background: #fff7e6; padding: 12rpx 18rpx; border-radius: 8rpx; display: block; margin-bottom: 16rpx; }
|
||||
|
||||
.result-card { background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
|
||||
.result-title { font-size: 28rpx; font-weight: 600; color: #1890ff; margin-bottom: 8rpx; display: block; }
|
||||
.result-desc { font-size: 24rpx; color: #666; line-height: 1.6; display: block; margin-bottom: 14rpx; }
|
||||
.tag-row { display: flex; flex-wrap: wrap; gap: 10rpx; margin-bottom: 12rpx; }
|
||||
.tag { font-size: 22rpx; color: #1890ff; background: #eaf4ff; padding: 6rpx 16rpx; border-radius: 6rpx; }
|
||||
.query-box { background: #f9f9f9; border-radius: 8rpx; padding: 14rpx; }
|
||||
.query-label { font-size: 22rpx; color: #999; display: block; margin-bottom: 6rpx; }
|
||||
.query-text { font-size: 22rpx; color: #333; background: #fff; padding: 4rpx 12rpx; border-radius: 4rpx; margin: 4rpx 6rpx 4rpx 0; display: inline-block; border: 1rpx solid #e8e8e8; }
|
||||
|
||||
.strategy-card, .tips-card, .outreach-card { background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
|
||||
.strategy-title, .tips-title { font-size: 26rpx; font-weight: 600; color: #333; display: block; margin-bottom: 12rpx; }
|
||||
.strategy-text { font-size: 24rpx; color: #666; line-height: 1.6; display: block; }
|
||||
.tip-item { font-size: 24rpx; color: #666; line-height: 1.8; display: block; }
|
||||
|
||||
.outreach-label { font-size: 26rpx; font-weight: 600; color: #333; display: block; margin-bottom: 12rpx; }
|
||||
.outreach-content { font-size: 24rpx; color: #444; line-height: 1.7; display: block; margin-bottom: 14rpx; }
|
||||
.copy-btn { font-size: 22rpx; color: #1890ff; background: #eaf4ff; border: none; border-radius: 6rpx; padding: 8rpx 24rpx; }
|
||||
</style>
|
||||
@@ -136,6 +136,10 @@
|
||||
<text class="more-icon">🔤</text>
|
||||
<text class="more-text">翻译</text>
|
||||
</view>
|
||||
<view class="more-item" @click="goToPage(PAGES.DISCOVERY)">
|
||||
<text class="more-icon">🔍</text>
|
||||
<text class="more-text">挖掘新客</text>
|
||||
</view>
|
||||
<view class="more-item" @click="hasLogin ? goToPage(PAGES.PRODUCT) : goToLogin()">
|
||||
<text class="more-icon">📦</text>
|
||||
<text class="more-text">产品库</text>
|
||||
@@ -166,10 +170,6 @@
|
||||
<text class="more-icon">👨👩👧👦</text>
|
||||
<text class="more-text">团队</text>
|
||||
</view>
|
||||
<view class="more-item" v-if="isAdmin" @click="goToPage(PAGES.ADMIN)">
|
||||
<text class="more-icon">⚙️</text>
|
||||
<text class="more-text">管理</text>
|
||||
</view>
|
||||
<view class="more-item" @click="showWechatModal = true">
|
||||
<text class="more-icon">💁</text>
|
||||
<text class="more-text">联系客服</text>
|
||||
@@ -277,7 +277,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js'
|
||||
import AiAssistant from '@/components/ai-assistant.vue'
|
||||
@@ -292,14 +292,12 @@ const announcements = [
|
||||
]
|
||||
let announcementTimer = null
|
||||
|
||||
const hasLogin = computed(() => {
|
||||
const token = uni.getStorageSync('token')
|
||||
const isGuest = uni.getStorageSync('isGuest')
|
||||
return !!token && !isGuest
|
||||
})
|
||||
const isAdmin = computed(() => {
|
||||
return hasLogin.value && userInfo.value?.role === 'admin'
|
||||
})
|
||||
const hasLogin = ref(false)
|
||||
function checkLogin() {
|
||||
const token = uni.getStorageSync(STORAGE_KEYS.TOKEN)
|
||||
const isGuest = uni.getStorageSync(STORAGE_KEYS.IS_GUEST)
|
||||
hasLogin.value = !!token && !isGuest
|
||||
}
|
||||
const userInfo = ref(null)
|
||||
const stats = ref({
|
||||
customers: 0,
|
||||
@@ -331,9 +329,8 @@ onShow(() => {
|
||||
currentAnnouncement.value = (currentAnnouncement.value + 1) % announcements.length
|
||||
}, 4000)
|
||||
|
||||
const token = uni.getStorageSync('token')
|
||||
const isGuest = uni.getStorageSync('isGuest')
|
||||
if (token && !isGuest) {
|
||||
checkLogin()
|
||||
if (hasLogin.value) {
|
||||
uni.setStorageSync(STORAGE_KEYS.IS_GUEST, false)
|
||||
loadData()
|
||||
checkOnboarding()
|
||||
@@ -449,6 +446,24 @@ const goToLogin = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定退出当前账号?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.removeStorageSync(STORAGE_KEYS.TOKEN)
|
||||
uni.removeStorageSync(STORAGE_KEYS.REFRESH_TOKEN)
|
||||
uni.removeStorageSync(STORAGE_KEYS.USER_INFO)
|
||||
uni.removeStorageSync(STORAGE_KEYS.HAS_LOGIN)
|
||||
uni.removeStorageSync(STORAGE_KEYS.IS_GUEST)
|
||||
hasLogin.value = false
|
||||
uni.switchTab({ url: PAGES.INDEX })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const doQuickTranslate = async () => {
|
||||
if (!quickTranslateText.value.trim()) return
|
||||
try {
|
||||
@@ -1237,4 +1252,11 @@ const playTryResult = () => {
|
||||
color: #bbb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* On desktop, hide the feature matrix section (items are in the sidebar) */
|
||||
@media (min-width: 1024px) {
|
||||
.more-section {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header-card">
|
||||
<text class="title">发票管理</text>
|
||||
<text class="subtitle">申请开票与开票记录</text>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="tab-bar">
|
||||
<text class="tab-item" :class="{ active: tab === 'apply' }" @click="tab = 'apply'">申请开票</text>
|
||||
<text class="tab-item" :class="{ active: tab === 'history' }" @click="tab = 'history'; loadInvoices()">开票记录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="tab === 'apply'" class="section">
|
||||
<view class="input-group">
|
||||
<text class="input-label">发票类型</text>
|
||||
<view class="type-selector">
|
||||
<text class="type-option" :class="{ active: form.invoice_type === 'individual' }" @click="form.invoice_type = 'individual'">个人发票</text>
|
||||
<text class="type-option" :class="{ active: form.invoice_type === 'enterprise' }" @click="form.invoice_type = 'enterprise'">企业发票</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-group">
|
||||
<text class="input-label">发票抬头</text>
|
||||
<input class="input-field" v-model="form.title" :placeholder="form.invoice_type === 'individual' ? '输入您的姓名' : '输入企业名称'" />
|
||||
</view>
|
||||
|
||||
<view class="input-group" v-if="form.invoice_type === 'enterprise'">
|
||||
<text class="input-label">税号</text>
|
||||
<input class="input-field" v-model="form.tax_id" placeholder="输入统一社会信用代码" />
|
||||
</view>
|
||||
|
||||
<view class="input-group">
|
||||
<text class="input-label">开票金额(元)</text>
|
||||
<input class="input-field" v-model="form.amount" type="number" placeholder="输入开票金额" />
|
||||
</view>
|
||||
|
||||
<view class="notice">
|
||||
<text class="notice-text">个人发票需先完成个人实名认证,企业发票需先完成企业认证</text>
|
||||
</view>
|
||||
|
||||
<button class="primary-btn" :disabled="loading.apply" @click="doApply">
|
||||
<text v-if="loading.apply">提交中...</text>
|
||||
<text v-else>提交开票申请</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-else class="section">
|
||||
<view v-if="loading.history" class="loading-wrap">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="invoices.length === 0" class="empty-wrap">
|
||||
<text class="empty-text">暂无开票记录</text>
|
||||
</view>
|
||||
<view v-else class="invoice-list">
|
||||
<view class="invoice-item" v-for="inv in invoices" :key="inv.id">
|
||||
<view class="inv-header">
|
||||
<text class="inv-type">{{ inv.invoice_type === 'individual' ? '个人' : '企业' }}发票</text>
|
||||
<text class="inv-status" :class="inv.status">{{ statusLabel(inv.status) }}</text>
|
||||
</view>
|
||||
<view class="inv-body">
|
||||
<text class="inv-title">{{ inv.title }}</text>
|
||||
<text class="inv-amount">¥{{ inv.amount }}</text>
|
||||
</view>
|
||||
<text v-if="inv.reject_reason" class="inv-reject">驳回原因:{{ inv.reject_reason }}</text>
|
||||
<text class="inv-date">{{ inv.created_at ? inv.created_at.substring(0, 10) : '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { invoiceApi } from '@/utils/api.js'
|
||||
|
||||
const tab = ref('apply')
|
||||
const loading = reactive({ apply: false, history: false })
|
||||
const invoices = ref([])
|
||||
const form = reactive({
|
||||
invoice_type: 'individual',
|
||||
title: '',
|
||||
tax_id: '',
|
||||
amount: '',
|
||||
})
|
||||
|
||||
function statusLabel(s) {
|
||||
return { pending: '待开票', issued: '已开票', rejected: '已驳回' }[s] || s
|
||||
}
|
||||
|
||||
async function doApply() {
|
||||
if (!form.title) { uni.showToast({ title: '请填写发票抬头', icon: 'none' }); return }
|
||||
if (!form.amount || parseFloat(form.amount) <= 0) { uni.showToast({ title: '请填写有效金额', icon: 'none' }); return }
|
||||
if (form.invoice_type === 'enterprise' && !form.tax_id) { uni.showToast({ title: '请填写税号', icon: 'none' }); return }
|
||||
loading.apply = true
|
||||
try {
|
||||
const res = await invoiceApi.apply({
|
||||
invoice_type: form.invoice_type,
|
||||
title: form.title,
|
||||
tax_id: form.tax_id || undefined,
|
||||
amount: parseFloat(form.amount),
|
||||
})
|
||||
if (res.success) {
|
||||
uni.showToast({ title: '申请成功', icon: 'success' })
|
||||
form.title = ''; form.tax_id = ''; form.amount = ''
|
||||
tab.value = 'history'
|
||||
loadInvoices()
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
|
||||
}
|
||||
loading.apply = false
|
||||
}
|
||||
|
||||
async function loadInvoices() {
|
||||
loading.history = true
|
||||
try {
|
||||
const res = await invoiceApi.list()
|
||||
if (res.success) invoices.value = res.data || []
|
||||
} catch (e) { invoices.value = [] }
|
||||
loading.history = false
|
||||
}
|
||||
|
||||
onMounted(() => {})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
page { background: #f5f5f5; }
|
||||
.page { padding: 16px; }
|
||||
.header-card { background: linear-gradient(135deg, #52c41a, #1890ff); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||||
.header-card .title { font-size: 20px; font-weight: 600; color: #fff; display: block; }
|
||||
.header-card .subtitle { font-size: 13px; color: rgba(255,255,255,.8); display: block; margin-top: 4px; }
|
||||
.section { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
|
||||
.tab-bar { display: flex; gap: 0; }
|
||||
.tab-item { flex: 1; text-align: center; padding: 10px; font-size: 15px; color: #666; border-bottom: 2px solid transparent; }
|
||||
.tab-item.active { color: #1890ff; border-bottom-color: #1890ff; font-weight: 500; }
|
||||
.input-group { margin-bottom: 16px; }
|
||||
.input-label { font-size: 14px; color: #333; display: block; margin-bottom: 6px; font-weight: 500; }
|
||||
.input-field { width: 100%; height: 44px; border: 1px solid #d9d9d9; border-radius: 8px; padding: 0 12px; font-size: 14px; box-sizing: border-box; }
|
||||
.type-selector { display: flex; gap: 12px; }
|
||||
.type-option { flex: 1; text-align: center; padding: 10px; border: 2px solid #d9d9d9; border-radius: 8px; font-size: 14px; }
|
||||
.type-option.active { border-color: #1890ff; color: #1890ff; font-weight: 500; }
|
||||
.notice { background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px; padding: 10px; margin-bottom: 16px; }
|
||||
.notice-text { font-size: 13px; color: #ad8b00; }
|
||||
.primary-btn { width: 100%; height: 44px; background: #1890ff; color: #fff; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; border: none; }
|
||||
.primary-btn:disabled { opacity: .6; }
|
||||
.loading-wrap { text-align: center; padding: 40px; }
|
||||
.loading-text { color: #999; }
|
||||
.empty-wrap { text-align: center; padding: 40px; }
|
||||
.empty-text { color: #999; }
|
||||
.invoice-list { }
|
||||
.invoice-item { padding: 12px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.inv-header { display: flex; justify-content: space-between; margin-bottom: 6px; }
|
||||
.inv-type { font-size: 13px; color: #666; }
|
||||
.inv-status { font-size: 12px; padding: 2px 8px; border-radius: 4px; }
|
||||
.inv-status.pending { background: #fff7e6; color: #d46b08; }
|
||||
.inv-status.issued { background: #f6ffed; color: #389e0d; }
|
||||
.inv-status.rejected { background: #fff2f0; color: #cf1322; }
|
||||
.inv-body { display: flex; justify-content: space-between; }
|
||||
.inv-title { font-size: 15px; color: #333; font-weight: 500; flex: 1; }
|
||||
.inv-amount { font-size: 16px; color: #f5222d; font-weight: 600; }
|
||||
.inv-reject { font-size: 12px; color: #cf1322; display: block; margin-top: 4px; }
|
||||
.inv-date { font-size: 12px; color: #999; display: block; margin-top: 4px; }
|
||||
</style>
|
||||
@@ -28,6 +28,20 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section" v-if="user.tier !== 'guest'">
|
||||
<view class="section-title">认证与发票</view>
|
||||
<view class="menu-item" @click="goCertification">
|
||||
<text class="menu-icon">🪪</text>
|
||||
<text class="menu-text">实名认证</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goInvoice">
|
||||
<text class="menu-icon">🧾</text>
|
||||
<text class="menu-text">发票管理</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-title">其他</view>
|
||||
<view class="menu-item" @click="goFeedback">
|
||||
@@ -174,6 +188,8 @@ const goLogin = () => uni.navigateTo({ url: PAGES.LOGIN })
|
||||
const goUpgrade = () => uni.navigateTo({ url: PAGES.UPGRADE })
|
||||
const goFeedback = () => uni.navigateTo({ url: PAGES.FEEDBACK })
|
||||
const goAgreement = (type) => uni.navigateTo({ url: `/pages/agreement/${type}` })
|
||||
const goCertification = () => uni.navigateTo({ url: PAGES.CERTIFICATION })
|
||||
const goInvoice = () => uni.navigateTo({ url: PAGES.INVOICE })
|
||||
|
||||
const logout = () => {
|
||||
uni.showModal({
|
||||
|
||||
Reference in New Issue
Block a user