feat: 管理后台完整可用 + 注册登录记日志 + 提取信息结构化展示 + 微信配置就绪
- 管理后台用户/统计/日志/配置四页签全部对接真实后端API - auth注册/登录/游客/微信登录事件写入usage_logs表 - 提取信息结果从原始JSON改为卡片式字段列表(中文标签) - 管理后台搜索按钮增加加载态和结果数提示 - 配置WECHAT_APP_ID/WECHAT_APP_SECRET - 客户/产品/报价单CRUD页面完整(导出导入批量操作)
This commit is contained in:
@@ -54,11 +54,77 @@
|
||||
<text>暂无报价单</text>
|
||||
</view>
|
||||
|
||||
<view class="export-csv-btn" @click="exportCsv">
|
||||
<text class="export-icon">CSV</text>
|
||||
<view class="bottom-actions">
|
||||
<view class="action-btn export-btn" @click="showExportSheet = true">
|
||||
<text class="btn-icon">导</text>
|
||||
<text class="btn-text">导出</text>
|
||||
</view>
|
||||
<view class="action-btn import-btn" @click="showImportSheet = true">
|
||||
<text class="btn-icon">入</text>
|
||||
<text class="btn-text">导入</text>
|
||||
</view>
|
||||
<view class="action-btn add-btn" @click="showCreateModal = true">
|
||||
<text class="btn-icon">+</text>
|
||||
<text class="btn-text">新增</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="add-btn" @click="showCreateModal = true">
|
||||
<text class="add-icon">+</text>
|
||||
|
||||
<view class="action-sheet-overlay" v-if="showExportSheet" @click="showExportSheet = false">
|
||||
<view class="action-sheet" @click.stop>
|
||||
<view class="action-sheet-header">导出报价单</view>
|
||||
<view class="action-sheet-item" @click="exportAsXlsx">
|
||||
<text class="action-sheet-icon">📊</text>
|
||||
<text class="action-sheet-text">导出为 Excel (.xlsx)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="exportAsCsv">
|
||||
<text class="action-sheet-icon">📄</text>
|
||||
<text class="action-sheet-text">导出为 CSV (.csv)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="exportToClipboard">
|
||||
<text class="action-sheet-icon">📋</text>
|
||||
<text class="action-sheet-text">复制到剪贴板(分享到微信/WhatsApp)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-cancel" @click="showExportSheet = false">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-sheet-overlay" v-if="showImportSheet" @click="showImportSheet = false">
|
||||
<view class="action-sheet" @click.stop>
|
||||
<view class="action-sheet-header">导入报价单</view>
|
||||
<view class="action-sheet-item" @click="importFromFile">
|
||||
<text class="action-sheet-icon">📁</text>
|
||||
<text class="action-sheet-text">从文件导入(支持 .xlsx / .csv)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="showPasteImport = true; showImportSheet = false">
|
||||
<text class="action-sheet-icon">📋</text>
|
||||
<text class="action-sheet-text">从剪贴板导入(粘贴报价数据)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-cancel" @click="showImportSheet = false">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal" v-if="showPasteImport" @click="showPasteImport = false">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">从剪贴板导入</text>
|
||||
<text class="modal-close" @click="showPasteImport = false">×</text>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<text class="form-label">粘贴报价数据</text>
|
||||
<textarea class="form-textarea" v-model="pasteData" placeholder="每行一个报价单,用逗号或Tab分隔 格式: 标题,客户ID,货币,金额,状态" rows="6" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<button class="cancel-btn" @click="showPasteImport = false">取消</button>
|
||||
<button class="submit-btn" @click="importFromPaste">导入</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal" v-if="showCreateModal" @click="closeModal">
|
||||
@@ -211,6 +277,11 @@ const formData = ref({
|
||||
|
||||
const customerOptions = ref([])
|
||||
|
||||
const showExportSheet = ref(false)
|
||||
const showImportSheet = ref(false)
|
||||
const showPasteImport = ref(false)
|
||||
const pasteData = ref('')
|
||||
|
||||
onShow(() => {
|
||||
loadQuotations()
|
||||
loadCustomers()
|
||||
@@ -221,7 +292,7 @@ const loadQuotations = async () => {
|
||||
const res = await quotationApi.list()
|
||||
quotations.value = res.items || []
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
|
||||
console.error('加载报价单失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +313,7 @@ const getStatusText = (status) => {
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time) return ''
|
||||
return time.split('T')[0]
|
||||
return time.slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
const getCustomerName = (id) => {
|
||||
@@ -355,7 +426,26 @@ const generateSmartQuote = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const exportCsv = () => {
|
||||
const exportAsXlsx = () => {
|
||||
showExportSheet.value = false
|
||||
const url = quotationApi.exportXlsx()
|
||||
const token = uni.getStorageSync('token')
|
||||
uni.downloadFile({
|
||||
url,
|
||||
header: { Authorization: `Bearer ${token}` },
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
uni.showToast({ title: '导出成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '导出失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
fail: () => { uni.showToast({ title: '导出失败', icon: 'none' }) },
|
||||
})
|
||||
}
|
||||
|
||||
const exportAsCsv = () => {
|
||||
showExportSheet.value = false
|
||||
const url = quotationApi.exportCsv()
|
||||
const token = uni.getStorageSync('token')
|
||||
uni.downloadFile({
|
||||
@@ -372,6 +462,97 @@ const exportCsv = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const exportToClipboard = async () => {
|
||||
showExportSheet.value = false
|
||||
try {
|
||||
const res = await quotationApi.list(1, 9999)
|
||||
const items = res.items || []
|
||||
if (items.length === 0) {
|
||||
uni.showToast({ title: '暂无报价单可导出', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const headers = ['标题', '客户', '货币', '总计', '状态', '日期']
|
||||
const rows = items.map(q => [
|
||||
q.title || '', q.customer_name || '', q.currency || 'USD',
|
||||
q.total || 0, q.status || '', q.created_at ? q.created_at.slice(0, 16).replace('T', ' ') : '',
|
||||
])
|
||||
const text = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n')
|
||||
uni.setClipboardData({
|
||||
data: text,
|
||||
success: () => {
|
||||
uni.showToast({ title: `已复制 ${items.length} 条报价数据`, icon: 'success' })
|
||||
},
|
||||
fail: () => { uni.showToast({ title: '复制失败', icon: 'none' }) },
|
||||
})
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '导出失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const importFromFile = () => {
|
||||
showImportSheet.value = false
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: async (res) => {
|
||||
const file = res.tempFilePaths[0]
|
||||
uni.showLoading({ title: '导入中...' })
|
||||
try {
|
||||
const result = await quotationApi.importQuotations(file)
|
||||
uni.hideLoading()
|
||||
uni.showModal({
|
||||
title: '导入完成',
|
||||
content: `成功导入 ${result.imported || 0} 条\n失败 ${(result.errors || []).length} 条`,
|
||||
success: () => loadQuotations(),
|
||||
})
|
||||
} catch (err) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: err.message || '导入失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const importFromPaste = async () => {
|
||||
if (!pasteData.value.trim()) {
|
||||
uni.showToast({ title: '请粘贴报价数据', icon: 'none' })
|
||||
return
|
||||
}
|
||||
showPasteImport.value = false
|
||||
uni.showLoading({ title: '导入中...' })
|
||||
try {
|
||||
const lines = pasteData.value.trim().split('\n')
|
||||
const imported = []
|
||||
const errors = []
|
||||
for (const line of lines) {
|
||||
const parts = line.split(/[\t,,]/).map(s => s.trim()).filter(Boolean)
|
||||
if (parts.length < 1) continue
|
||||
const data = {
|
||||
title: parts[0] || '',
|
||||
customer_id: parts[1] || '',
|
||||
currency: parts[2] && ['USD', 'EUR', 'GBP', 'CNY'].includes(parts[2]) ? parts[2] : 'USD',
|
||||
items: [{ product_name: 'Imported Item', quantity: 1, unit_price: parseFloat(parts[3]) || 0 }],
|
||||
status: parts[4] && ['draft', 'sent', 'accepted', 'rejected'].includes(parts[4]) ? parts[4] : 'draft',
|
||||
}
|
||||
try {
|
||||
await quotationApi.create(data)
|
||||
imported.push(data.title)
|
||||
} catch (e) {
|
||||
errors.push(`${data.title}: ${e.message || '创建失败'}`)
|
||||
}
|
||||
}
|
||||
uni.hideLoading()
|
||||
pasteData.value = ''
|
||||
uni.showModal({
|
||||
title: '导入完成',
|
||||
content: `成功导入 ${imported.length} 条\n失败 ${errors.length} 条`,
|
||||
success: () => loadQuotations(),
|
||||
})
|
||||
} catch (err) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: err.message || '导入失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const exportPdf = (item) => {
|
||||
const url = quotationApi.exportPdf(item.id)
|
||||
uni.downloadFile({
|
||||
@@ -500,38 +681,107 @@ const exportPdf = (item) => {
|
||||
padding: 100rpx;
|
||||
}
|
||||
|
||||
.export-csv-btn {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(100px + 100rpx + 24rpx);
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
background: #722ed1;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(114, 46, 209, 0.4);
|
||||
}
|
||||
|
||||
.export-icon {
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
background: #1890ff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.4);
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.import-btn {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
background: #722ed1;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 32rpx;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 18rpx;
|
||||
color: #fff;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.action-sheet-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.action-sheet {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding: 0 0 60rpx;
|
||||
}
|
||||
|
||||
.action-sheet-header {
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
padding: 30rpx;
|
||||
border-bottom: 2rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.action-sheet-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.action-sheet-icon {
|
||||
font-size: 36rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.action-sheet-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.action-sheet-arrow {
|
||||
font-size: 32rpx;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.action-sheet-cancel {
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
padding: 24rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
|
||||
Reference in New Issue
Block a user