Add admin-frontend and user-frontend standalone projects, certification/invoice/discovery features, fix auth header and theme consistency

This commit is contained in:
TradeMate Dev
2026-05-22 18:35:30 +08:00
parent 18c6cf5406
commit 52dba37f22
79 changed files with 10333 additions and 248 deletions
+5 -199
View File
@@ -1,207 +1,13 @@
<template>
<div class="app-wrapper">
<div class="app-nav" id="appNav">
<div class="app-nav-brand">TradeMate</div>
<div
v-for="(item, index) in navList"
:key="index"
class="app-nav-item"
:class="{ active: currentIndex === index }"
@click="switchTab(index)"
>
<span class="app-nav-icon">{{ item.icon }}</span>
<span class="app-nav-text">{{ item.text }}</span>
</div>
</div>
<router-view />
</div>
<router-view />
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const currentIndex = ref(0)
const navList = [
{ pagePath: '/pages/index/index', text: '首页', icon: '🏠' },
{ pagePath: '/pages/customers/customers', text: '客户', icon: '👥' },
{ pagePath: '/pages/marketing/marketing', text: '营销', icon: '📢' },
{ pagePath: '/pages/quotation/quotation', text: '报价', icon: '📄' },
{ pagePath: '/pages/profile/profile', text: '我的', icon: '👤' },
]
const updateCurrentIndex = () => {
const hash = window.location.hash || ''
for (let i = 0; i < navList.length; i++) {
if (hash.includes(navList[i].pagePath)) {
currentIndex.value = i
return
}
}
currentIndex.value = 0
}
const switchTab = (index) => {
if (currentIndex.value === index) return
uni.switchTab({ url: navList[index].pagePath })
}
onMounted(() => {
updateCurrentIndex()
window.addEventListener('hashchange', updateCurrentIndex)
})
onUnmounted(() => {
window.removeEventListener('hashchange', updateCurrentIndex)
})
</script>
<style>
/* Global reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
width: 100%;
}
/* Let uni-app pages scroll */
uni-page {
overflow-y: auto !important;
}
uni-page-body {
overflow-y: auto !important;
min-height: 100% !important;
}
/* ===== Nav: hidden on mobile (uni-app default tab bar is used) ===== */
.app-nav {
display: none;
}
/* ===== Desktop responsive (≥1024px) ===== */
@media (min-width: 1024px) {
/* Sidebar nav */
.app-nav {
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
width: 220px;
height: 100vh;
background: #fff;
box-shadow: 4px 0 16px rgba(0,0,0,0.08);
z-index: 999999;
border-right: 1px solid #e0e0e0;
padding-top: 24px;
}
.app-nav-brand {
font-size: 20px;
font-weight: 700;
color: #1890ff;
padding: 20px 24px 32px;
text-align: center;
}
.app-nav-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 14px 24px;
margin: 2px 12px;
border-radius: 12px;
gap: 14px;
cursor: pointer;
transition: background 0.15s;
}
.app-nav-item:hover {
background: #f0f7ff;
}
.app-nav-item.active {
background: #e6f0ff;
}
.app-nav-icon {
font-size: 22px;
line-height: 1;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
}
.app-nav-text {
font-size: 15px;
color: #555;
}
.app-nav-item.active .app-nav-text {
color: #1890ff;
font-weight: 600;
}
/* Hide the built-in uni-app tab bar on desktop */
uni-tabbar {
display: none !important;
}
/* Sidebar — shift page content right */
uni-page-body {
margin-left: 220px !important;
font-size: 16px !important;
}
/* Remove the tab-bar-height bottom padding that uni-app adds for tab pages */
.uni-app--showtabbar uni-page-wrapper:after {
display: none !important;
}
/* Constrain + center page content */
uni-page-body > view {
max-width: 1200px !important;
margin-left: auto !important;
margin-right: auto !important;
padding: 40px 48px !important;
}
/* Cards more breathing room */
uni-page-body .card {
padding: 32px !important;
border-radius: 20px !important;
}
/* Buttons: reasonable desktop sizing */
uni-page-body button,
uni-page-body .btn-primary,
uni-page-body .btn-secondary,
uni-page-body .uni-btn,
uni-page-body [class*="btn-"] {
min-width: 120px;
padding: 12px 32px !important;
font-size: 15px !important;
}
/* Inputs */
uni-page-body input,
uni-page-body textarea,
uni-page-body .uni-input-input {
font-size: 15px !important;
padding: 10px 16px !important;
}
/* Fix floating AI assistant (clear sidebar) */
.ai-float-btn {
right: 40px !important;
bottom: 40px !important;
}
.ai-dialog {
right: 40px !important;
bottom: 100px !important;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app { height: 100%; width: 100%; }
uni-page { overflow-y: auto !important; }
uni-page-body { overflow-y: auto !important; min-height: 100% !important; }
</style>
+3
View File
@@ -22,8 +22,11 @@ export const PAGES = {
FOLLOWUP: '/pages/followup/followup',
NOTIFICATION: '/pages/notification/notification',
ANALYTICS: '/pages/analytics/analytics',
DISCOVERY: '/pages/discovery/discovery',
TEAM: '/pages/team/team',
ADMIN: '/pages/admin/admin',
CERTIFICATION: '/pages/certification/certification',
INVOICE: '/pages/invoice/invoice',
AGREEMENT_PRIVACY: '/pages/agreement/privacy',
AGREEMENT_TERMS: '/pages/agreement/terms',
}
+19 -1
View File
@@ -46,7 +46,7 @@
{
"path": "pages/admin/admin",
"style": {
"navigationBarTitleText": "管理后台"
"navigationStyle": "custom"
}
},
{
@@ -102,6 +102,24 @@
"style": {
"navigationBarTitleText": "个人中心"
}
},
{
"path": "pages/discovery/discovery",
"style": {
"navigationBarTitleText": "挖掘新客"
}
},
{
"path": "pages/certification/certification",
"style": {
"navigationBarTitleText": "实名认证"
}
},
{
"path": "pages/invoice/invoice",
"style": {
"navigationBarTitleText": "发票管理"
}
}
],
"globalStyle": {
+250 -10
View File
@@ -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>
+357
View File
@@ -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>
+38 -16
View File
@@ -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>
+165
View File
@@ -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>
+16
View File
@@ -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({
+24
View File
@@ -183,6 +183,20 @@ export const adminApi = {
request(`/admin/translation-quotas/${encodeURIComponent(version)}`, 'PUT', data),
resetTranslationQuota: (version) =>
request(`/admin/translation-quotas/${encodeURIComponent(version)}/reset`, 'POST'),
listCertifications: (page = 1, size = 20, status) => {
let url = `/admin/certifications?page=${page}&size=${size}`
if (status) url += `&status=${status}`
return request(url)
},
reviewCertification: (id, action, reason) =>
request(`/admin/certifications/${id}/review`, 'POST', { action, reason }),
listInvoices: (page = 1, size = 20, status) => {
let url = `/admin/invoices?page=${page}&size=${size}`
if (status) url += `&status=${status}`
return request(url)
},
processInvoice: (id, action, reason) =>
request(`/admin/invoices/${id}/process`, 'POST', { action, reason }),
}
export const aiChatApi = {
@@ -303,6 +317,16 @@ export const whatsappApi = {
request('/whatsapp/send', 'POST', { to, text, template_name: templateName, template_params: templateParams, media_url: mediaUrl, media_type: mediaType }),
}
export const certificationApi = {
submit: (data) => request('/certification/submit', 'POST', data),
status: () => request('/certification/status'),
}
export const invoiceApi = {
apply: (data) => request('/invoices/apply', 'POST', data),
list: () => request('/invoices/list'),
}
export const customerApi = {
list: (page = 1, size = 20, status) => {
let params = `page=${page}&size=${size}`