Unify frontend config, fix marketing tracking field mismatch, expose customer notes in API

Centralizes all hardcoded page paths, storage keys, external URLs, and branding into a single uni-app/src/config.js. Fixes trackMarketingEffect sending wrong field names (action/content_preview -> event_type/content) that silently dropped tracking data. Adds notes, estimated_value, next_followup_at to Customer response. Removes '翻译' from bottom tab nav (5 tabs now), adds quick translate card on home page. Makes profile page header color consistent with app theme (#1890ff).
This commit is contained in:
TradeMate Dev
2026-05-20 14:30:50 +08:00
parent f8a23855d2
commit a60aac4638
12 changed files with 689 additions and 67 deletions
+76
View File
@@ -189,6 +189,16 @@ async def get_me(
} }
class ProfileUpdate(BaseModel):
username: str = None
email: str = None
class PasswordChange(BaseModel):
old_password: str
new_password: str
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
preferred_translate_provider: str = None preferred_translate_provider: str = None
reply_tone: str = None reply_tone: str = None
@@ -202,6 +212,72 @@ class WeChatLoginRequest(BaseModel):
iv: str = "" iv: str = ""
@router.put("/me")
async def update_me(
data: ProfileUpdate,
authorization: Optional[str] = Header(None, alias="Authorization"),
db: AsyncSession = Depends(get_db),
):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
payload = decode_token(authorization[7:])
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
if payload.get("is_guest"):
raise HTTPException(status_code=403, detail="Guests cannot update profile")
result = await db.execute(select(User).where(User.id == payload["sub"]))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if data.username is not None:
user.username = data.username
if data.email is not None:
user.email = data.email
await db.flush()
return {
"id": str(user.id),
"phone": user.phone,
"username": user.username,
"email": user.email,
"tier": user.tier,
"role": user.role,
}
@router.put("/password")
async def change_password(
data: PasswordChange,
authorization: Optional[str] = Header(None, alias="Authorization"),
db: AsyncSession = Depends(get_db),
):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
payload = decode_token(authorization[7:])
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
if payload.get("is_guest"):
raise HTTPException(status_code=403, detail="Guests cannot change password")
result = await db.execute(select(User).where(User.id == payload["sub"]))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not verify_password(data.old_password, user.password_hash):
raise HTTPException(status_code=400, detail="旧密码不正确")
user.password_hash = hash_password(data.new_password)
await db.flush()
return {"message": "密码修改成功"}
@router.get("/wechat/config") @router.get("/wechat/config")
async def wechat_config(): async def wechat_config():
from app.config import settings from app.config import settings
+2 -1
View File
@@ -54,7 +54,7 @@ async def health():
return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"} return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"}
from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup, ai_assistant
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"]) app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"])
@@ -77,6 +77,7 @@ app.include_router(interaction.router, prefix="/api/v1/interaction", tags=["inte
app.include_router(silent_pattern.router, prefix="/api/v1/silent-pattern", tags=["silent-pattern"]) app.include_router(silent_pattern.router, prefix="/api/v1/silent-pattern", tags=["silent-pattern"])
app.include_router(training.router, prefix="/api/v1/training", tags=["training"]) app.include_router(training.router, prefix="/api/v1/training", tags=["training"])
app.include_router(followup.router, prefix="/api/v1/followup", tags=["followup"]) app.include_router(followup.router, prefix="/api/v1/followup", tags=["followup"])
app.include_router(ai_assistant.router, prefix="/api/v1/ai", tags=["ai-assistant"])
if __name__ == "__main__": if __name__ == "__main__":
+3
View File
@@ -197,8 +197,11 @@ class CustomerService:
"whatsapp_id": c.whatsapp_id, "whatsapp_id": c.whatsapp_id,
"source": c.source, "source": c.source,
"tags": c.tags, "tags": c.tags,
"notes": c.notes,
"status": c.status, "status": c.status,
"estimated_value": c.estimated_value,
"last_contact_at": c.last_contact_at.isoformat() if c.last_contact_at else None, "last_contact_at": c.last_contact_at.isoformat() if c.last_contact_at else None,
"silence_days": (datetime.utcnow() - c.last_contact_at).days if c.last_contact_at else 0, "silence_days": (datetime.utcnow() - c.last_contact_at).days if c.last_contact_at else 0,
"next_followup_at": c.next_followup_at.isoformat() if c.next_followup_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None, "created_at": c.created_at.isoformat() if c.created_at else None,
} }
-2
View File
@@ -1,10 +1,8 @@
<template> <template>
<!-- Uni-app manages its own page/tabbar structure. App.vue only provides global styles. -->
<router-view /> <router-view />
</template> </template>
<script setup> <script setup>
// App root - uni-app framework handles page layout and tab bar
</script> </script>
<style> <style>
+64
View File
@@ -0,0 +1,64 @@
export const STORAGE_KEYS = {
TOKEN: 'token',
REFRESH_TOKEN: 'refresh_token',
USER_INFO: 'userInfo',
HAS_LOGIN: 'hasLogin',
IS_GUEST: 'isGuest',
ONBOARDED: 'onboarded',
BADGE_COUNT: 'badgeCount',
}
export const PAGES = {
INDEX: '/pages/index/index',
TRANSLATE: '/pages/translate/translate',
CUSTOMERS: '/pages/customers/customers',
MARKETING: '/pages/marketing/marketing',
QUOTATION: '/pages/quotation/quotation',
PROFILE: '/pages/profile/profile',
LOGIN: '/pages/login/login',
PRODUCT: '/pages/product/product',
UPGRADE: '/pages/upgrade/upgrade',
FEEDBACK: '/pages/feedback/feedback',
FOLLOWUP: '/pages/followup/followup',
NOTIFICATION: '/pages/notification/notification',
ANALYTICS: '/pages/analytics/analytics',
TEAM: '/pages/team/team',
ADMIN: '/pages/admin/admin',
AGREEMENT_PRIVACY: '/pages/agreement/privacy',
AGREEMENT_TERMS: '/pages/agreement/terms',
}
export const EXTERNAL_URLS = {
WECHAT_OAUTH: 'https://open.weixin.qq.com/connect/oauth2/authorize',
BEIAN: 'https://beian.miit.gov.cn',
BEIAN_PSB: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545',
WHATSAPP_BASE: 'https://wa.me',
}
export const APP_INFO = {
NAME: '外贸小助手',
VERSION: '1.0.0',
ICP: '京ICP备2026007249号-1',
PSB: '京公网安备11011502039545号',
COPYRIGHT: '2026 北京宇之然科技中心',
}
export const NAV_THEME = {
BAR_COLOR: '#1890ff',
TAB_BAR_COLOR: '#1890ff',
PAGE_BG: '#f5f5f5',
}
export const TIER_LABELS = {
free: '免费版',
pro: 'Pro 版',
enterprise: '企业版',
guest: '游客',
}
export const EXTRACT_FIELD_LABELS = {
product_name: '产品名称', quantity: '数量', price: '价格',
currency: '货币', delivery_terms: '交货条款', target_country: '目标国家',
intent: '意图', product_interest: '感兴趣产品', budget: '预算',
urgency: '紧迫程度', contact_info: '联系方式',
}
+1 -1
View File
@@ -19,10 +19,10 @@ const currentIndex = ref(0)
const tabList = ref([ const tabList = ref([
{ pagePath: '/pages/index/index', text: '首页', icon: '🏠' }, { pagePath: '/pages/index/index', text: '首页', icon: '🏠' },
{ pagePath: '/pages/translate/translate', text: '翻译', icon: '🔤' },
{ pagePath: '/pages/customers/customers', text: '客户', icon: '👥' }, { pagePath: '/pages/customers/customers', text: '客户', icon: '👥' },
{ pagePath: '/pages/marketing/marketing', text: '营销', icon: '📢' }, { pagePath: '/pages/marketing/marketing', text: '营销', icon: '📢' },
{ pagePath: '/pages/quotation/quotation', text: '报价', icon: '📄' }, { pagePath: '/pages/quotation/quotation', text: '报价', icon: '📄' },
{ pagePath: '/pages/profile/profile', text: '我的', icon: '👤' },
]) ])
const updateCurrentIndex = () => { const updateCurrentIndex = () => {
+10 -4
View File
@@ -96,6 +96,12 @@
"style": { "style": {
"navigationBarTitleText": "智能跟进" "navigationBarTitleText": "智能跟进"
} }
},
{
"path": "pages/profile/profile",
"style": {
"navigationBarTitleText": "个人中心"
}
} }
], ],
"globalStyle": { "globalStyle": {
@@ -114,10 +120,6 @@
"pagePath": "pages/index/index", "pagePath": "pages/index/index",
"text": "首页" "text": "首页"
}, },
{
"pagePath": "pages/translate/translate",
"text": "翻译"
},
{ {
"pagePath": "pages/customers/customers", "pagePath": "pages/customers/customers",
"text": "客户" "text": "客户"
@@ -129,6 +131,10 @@
{ {
"pagePath": "pages/quotation/quotation", "pagePath": "pages/quotation/quotation",
"text": "报价" "text": "报价"
},
{
"pagePath": "pages/profile/profile",
"text": "我的"
} }
] ]
} }
+111 -32
View File
@@ -105,49 +105,68 @@
<view class="section" v-if="hasLogin && followupStats.pending > 0"> <view class="section" v-if="hasLogin && followupStats.pending > 0">
<view class="section-title"> <view class="section-title">
<text>待跟进提醒</text> <text>待跟进提醒</text>
<text class="section-more" @click="goToPage('/pages/followup/followup')">查看全部 ></text> <text class="section-more" @click="goToPage(PAGES.FOLLOWUP)">查看全部 ></text>
</view> </view>
<view class="followup-card" @click="goToPage('/pages/followup/followup')"> <view class="followup-card" @click="goToPage(PAGES.FOLLOWUP)">
<text class="followup-count">{{ followupStats.pending }}</text> <text class="followup-count">{{ followupStats.pending }}</text>
<text class="followup-label">个客户需要跟进</text> <text class="followup-label">个客户需要跟进</text>
<text class="followup-hint">{{ followupStats.sent }} 已发送 · {{ followupStats.replied }} 已回复</text> <text class="followup-hint">{{ followupStats.sent }} 已发送 · {{ followupStats.replied }} 已回复</text>
</view> </view>
</view> </view>
<view class="section" v-if="hasLogin">
<view class="section-title">
<text>快捷翻译</text>
<text class="section-more" @click="goToPage(PAGES.TRANSLATE)">去翻译 ></text>
</view>
<view class="translate-mini">
<textarea class="translate-mini-input" v-model="quickTranslateText" placeholder="输入文本,快速翻译..." />
<view class="translate-mini-actions">
<button class="translate-mini-btn" @click="doQuickTranslate" :disabled="!quickTranslateText.trim()">翻译</button>
<button class="translate-mini-btn extract" @click="doQuickExtract" :disabled="!quickTranslateText.trim()">提取信息</button>
</view>
<text class="translate-mini-result" v-if="quickTranslateResult">{{ quickTranslateResult }}</text>
</view>
</view>
<view class="more-section"> <view class="more-section">
<view class="section-title">功能矩阵</view> <view class="section-title">功能矩阵</view>
<view class="more-grid"> <view class="more-grid">
<view class="more-item" @click="hasLogin ? goToPage('/pages/product/product') : goToLogin()"> <view class="more-item" @click="goToPage(PAGES.TRANSLATE)">
<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-icon">📦</text>
<text class="more-text">产品库</text> <text class="more-text">产品库</text>
</view> </view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/followup/followup') : goToLogin()"> <view class="more-item" @click="hasLogin ? goToPage(PAGES.FOLLOWUP) : goToLogin()">
<text class="more-icon">📋</text> <text class="more-icon">📋</text>
<text class="more-text">跟进</text> <text class="more-text">跟进</text>
<text class="notif-badge" v-if="hasLogin && followupStats.pending > 0">{{ followupStats.pending > 99 ? '99+' : followupStats.pending }}</text> <text class="notif-badge" v-if="hasLogin && followupStats.pending > 0">{{ followupStats.pending > 99 ? '99+' : followupStats.pending }}</text>
</view> </view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/notification/notification') : goToLogin()"> <view class="more-item" @click="hasLogin ? goToPage(PAGES.NOTIFICATION) : goToLogin()">
<text class="more-icon">🔔</text> <text class="more-icon">🔔</text>
<text class="more-text">通知</text> <text class="more-text">通知</text>
<text class="notif-badge" v-if="hasLogin && unreadCount > 0">{{ unreadCount > 99 ? '99+' : unreadCount }}</text> <text class="notif-badge" v-if="hasLogin && unreadCount > 0">{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
</view> </view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/analytics/analytics') : goToLogin()"> <view class="more-item" @click="hasLogin ? goToPage(PAGES.ANALYTICS) : goToLogin()">
<text class="more-icon">📊</text> <text class="more-icon">📊</text>
<text class="more-text">分析</text> <text class="more-text">分析</text>
</view> </view>
<view class="more-item" @click="goToPage('/pages/upgrade/upgrade')"> <view class="more-item" @click="goToPage(PAGES.UPGRADE)">
<text class="more-icon">💎</text> <text class="more-icon">💎</text>
<text class="more-text">升级</text> <text class="more-text">升级</text>
</view> </view>
<view class="more-item" @click="goToPage('/pages/feedback/feedback')"> <view class="more-item" @click="goToPage(PAGES.FEEDBACK)">
<text class="more-icon">💬</text> <text class="more-icon">💬</text>
<text class="more-text">反馈</text> <text class="more-text">反馈</text>
</view> </view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/team/team') : goToLogin()"> <view class="more-item" @click="hasLogin ? goToPage(PAGES.TEAM) : goToLogin()">
<text class="more-icon">👨👩👧👦</text> <text class="more-icon">👨👩👧👦</text>
<text class="more-text">团队</text> <text class="more-text">团队</text>
</view> </view>
<view class="more-item" v-if="isAdmin" @click="goToPage('/pages/admin/admin')"> <view class="more-item" v-if="isAdmin" @click="goToPage(PAGES.ADMIN)">
<text class="more-icon"></text> <text class="more-icon"></text>
<text class="more-text">管理</text> <text class="more-text">管理</text>
</view> </view>
@@ -243,15 +262,15 @@
<view class="footer"> <view class="footer">
<view class="footer-links"> <view class="footer-links">
<text class="footer-link" @click="goToPage('/pages/agreement/privacy')">隐私政策</text> <text class="footer-link" @click="goToPage(PAGES.AGREEMENT_PRIVACY)">隐私政策</text>
<text class="footer-divider">|</text> <text class="footer-divider">|</text>
<text class="footer-link" @click="goToPage('/pages/agreement/terms')">用户协议</text> <text class="footer-link" @click="goToPage(PAGES.AGREEMENT_TERMS)">用户协议</text>
</view> </view>
<view class="footer-beian"> <view class="footer-beian">
<a class="footer-beian-link" href="https://beian.miit.gov.cn" target="_blank">京ICP备2026007249号-1</a> <a class="footer-beian-link" :href="EXTERNAL_URLS.BEIAN" target="_blank">{{ APP_INFO.ICP }}</a>
<a class="footer-beian-link" href="https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545" target="_blank">京公网安备11011502039545号</a> <a class="footer-beian-link" :href="EXTERNAL_URLS.BEIAN_PSB" target="_blank">{{ APP_INFO.PSB }}</a>
</view> </view>
<text class="footer-copyright">© 2026 北京宇之然科技中心. 保留所有权利.</text> <text class="footer-copyright">© {{ APP_INFO.COPYRIGHT }}. 保留所有权利.</text>
</view> </view>
<AiAssistant /> <AiAssistant />
</view> </view>
@@ -262,6 +281,7 @@ import { ref, computed, onUnmounted } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js' import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js'
import AiAssistant from '@/components/ai-assistant.vue' import AiAssistant from '@/components/ai-assistant.vue'
import { STORAGE_KEYS, PAGES, EXTERNAL_URLS, APP_INFO, EXTRACT_FIELD_LABELS } from '@/config.js'
const showAnnouncement = ref(false) const showAnnouncement = ref(false)
const currentAnnouncement = ref(0) const currentAnnouncement = ref(0)
@@ -298,15 +318,12 @@ const productDesc = ref('')
const targetMarket = ref('US importers') const targetMarket = ref('US importers')
const generatedContent = ref([]) const generatedContent = ref([])
const quickTranslateText = ref('')
const quickTranslateResult = ref('')
const tryText = ref('') const tryText = ref('')
const tryResult = ref('') const tryResult = ref('')
const tryExtracted = ref(null) const tryExtracted = ref(null)
const extractFieldLabels = { const extractFieldLabels = EXTRACT_FIELD_LABELS
product_name: '产品名称', quantity: '数量', price: '价格',
currency: '货币', delivery_terms: '交货条款', target_country: '目标国家',
intent: '意图', product_interest: '感兴趣产品', budget: '预算',
urgency: '紧迫程度', contact_info: '联系方式',
}
const tryLoading = ref(false) const tryLoading = ref(false)
onShow(() => { onShow(() => {
@@ -317,7 +334,7 @@ onShow(() => {
const token = uni.getStorageSync('token') const token = uni.getStorageSync('token')
const isGuest = uni.getStorageSync('isGuest') const isGuest = uni.getStorageSync('isGuest')
if (token && !isGuest) { if (token && !isGuest) {
uni.setStorageSync('isGuest', false) uni.setStorageSync(STORAGE_KEYS.IS_GUEST, false)
loadData() loadData()
checkOnboarding() checkOnboarding()
loadUnread() loadUnread()
@@ -380,7 +397,7 @@ const loadFollowupStats = async () => {
} }
const finishOnboarding = () => { const finishOnboarding = () => {
uni.setStorageSync('onboarded', true) uni.setStorageSync(STORAGE_KEYS.ONBOARDED, true)
showOnboarding.value = false showOnboarding.value = false
onboardingStep.value = 1 onboardingStep.value = 1
productName.value = '' productName.value = ''
@@ -410,7 +427,7 @@ const loadData = async () => {
} }
} }
const tabbarPages = ['/pages/index/index', '/pages/translate/translate', '/pages/customers/customers', '/pages/marketing/marketing', '/pages/quotation/quotation'] const tabbarPages = [PAGES.INDEX, PAGES.TRANSLATE, PAGES.CUSTOMERS, PAGES.MARKETING, PAGES.QUOTATION]
const goToPage = (url) => { const goToPage = (url) => {
if (tabbarPages.includes(url)) { if (tabbarPages.includes(url)) {
@@ -421,22 +438,41 @@ const goToPage = (url) => {
} }
const goToLogin = () => { const goToLogin = () => {
uni.reLaunch({ url: '/pages/login/login' })
}
const handleLogout = () => {
uni.showModal({ uni.showModal({
title: '确认退出', title: '提示',
content: '确定要退出登录吗?', content: '请先登录',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
uni.clearStorageSync() uni.reLaunch({ url: PAGES.LOGIN })
uni.reLaunch({ url: '/pages/login/login' })
} }
}, },
}) })
} }
const doQuickTranslate = async () => {
if (!quickTranslateText.value.trim()) return
try {
const chinesePattern = /[\u4e00-\u9fa5]/
const targetLang = chinesePattern.test(quickTranslateText.value) ? 'en' : 'zh'
const res = await translateApi.translate(quickTranslateText.value, targetLang, 'auto')
quickTranslateResult.value = res.translated_text || res.translated || '翻译成功'
} catch {
quickTranslateResult.value = '翻译失败'
}
}
const doQuickExtract = async () => {
if (!quickTranslateText.value.trim()) return
try {
const res = await translateApi.extract(quickTranslateText.value, 'auto')
const extracted = res.extracted || {}
const obj = typeof extracted === 'string' ? { raw: extracted } : extracted
quickTranslateResult.value = Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join('\n')
} catch {
quickTranslateResult.value = '提取失败'
}
}
const handleTryTranslate = async () => { const handleTryTranslate = async () => {
if (!tryText.value.trim()) { if (!tryText.value.trim()) {
uni.showToast({ title: '请输入内容', icon: 'none' }) uni.showToast({ title: '请输入内容', icon: 'none' })
@@ -941,6 +977,49 @@ const playTryResult = () => {
margin-top: 12rpx; margin-top: 12rpx;
} }
.translate-mini {
padding: 24rpx;
}
.translate-mini-input {
width: 100%;
height: 120rpx;
background: #f5f5f5;
border-radius: 12rpx;
padding: 16rpx 20rpx;
font-size: 26rpx;
box-sizing: border-box;
resize: none;
}
.translate-mini-actions {
display: flex;
gap: 16rpx;
margin-top: 16rpx;
}
.translate-mini-btn {
flex: 1;
height: 64rpx;
line-height: 64rpx;
text-align: center;
background: #1890ff;
color: #fff;
border-radius: 10rpx;
font-size: 26rpx;
}
.translate-mini-btn.extract {
background: #f5f5f5;
color: #333;
}
.translate-mini-result {
display: block;
margin-top: 16rpx;
padding: 16rpx 20rpx;
background: #f0f5ff;
border-radius: 10rpx;
font-size: 26rpx;
color: #333;
white-space: pre-wrap;
}
.more-section { .more-section {
background: #fff; background: #fff;
border-radius: 16rpx; border-radius: 16rpx;
+16 -15
View File
@@ -116,6 +116,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { authApi } from '@/utils/api.js' import { authApi } from '@/utils/api.js'
import { STORAGE_KEYS, PAGES } from '@/config.js'
const phone = ref('') const phone = ref('')
const password = ref('') const password = ref('')
@@ -129,11 +130,11 @@ const isWechatAvailable = ref(false)
const doWechatLogin = async (code) => { const doWechatLogin = async (code) => {
const res = await authApi.wechatLogin(code) const res = await authApi.wechatLogin(code)
uni.setStorageSync('token', res.access_token) uni.setStorageSync(STORAGE_KEYS.TOKEN, res.access_token)
uni.setStorageSync('userInfo', res.user) uni.setStorageSync(STORAGE_KEYS.USER_INFO, res.user)
uni.setStorageSync('hasLogin', true) uni.setStorageSync(STORAGE_KEYS.HAS_LOGIN, true)
uni.setStorageSync('isGuest', false) uni.setStorageSync(STORAGE_KEYS.IS_GUEST, false)
uni.switchTab({ url: '/pages/index/index' }) uni.switchTab({ url: PAGES.INDEX })
} }
onMounted(async () => { onMounted(async () => {
@@ -220,11 +221,11 @@ const handleSubmit = async () => {
isRegister.value = false isRegister.value = false
} else { } else {
const res = await authApi.login(phone.value, password.value) const res = await authApi.login(phone.value, password.value)
uni.setStorageSync('token', res.access_token) uni.setStorageSync(STORAGE_KEYS.TOKEN, res.access_token)
uni.setStorageSync('userInfo', res.user) uni.setStorageSync(STORAGE_KEYS.USER_INFO, res.user)
uni.setStorageSync('hasLogin', true) uni.setStorageSync(STORAGE_KEYS.HAS_LOGIN, true)
uni.setStorageSync('isGuest', false) uni.setStorageSync(STORAGE_KEYS.IS_GUEST, false)
uni.reLaunch({ url: '/pages/index/index' }) uni.reLaunch({ url: PAGES.INDEX })
} }
} catch (err) { } catch (err) {
console.error('登录失败', err) console.error('登录失败', err)
@@ -258,23 +259,23 @@ const handleWechatLogin = () => {
} }
const goToAgreement = (type) => { const goToAgreement = (type) => {
uni.navigateTo({ url: `/pages/agreement/${type}` }) uni.navigateTo({ url: type === 'privacy' ? PAGES.AGREEMENT_PRIVACY : PAGES.AGREEMENT_TERMS })
} }
const goToQuickTry = async () => { const goToQuickTry = async () => {
uni.setStorageSync('isGuest', true) uni.setStorageSync(STORAGE_KEYS.IS_GUEST, true)
try { try {
const res = await authApi.guestLogin() const res = await authApi.guestLogin()
if (res.access_token) { if (res.access_token) {
uni.setStorageSync('token', res.access_token) uni.setStorageSync(STORAGE_KEYS.TOKEN, res.access_token)
if (res.user) { if (res.user) {
uni.setStorageSync('userInfo', res.user) uni.setStorageSync(STORAGE_KEYS.USER_INFO, res.user)
} }
} }
} catch (e) { } catch (e) {
console.log('Guest login failed, continuing without token:', e) console.log('Guest login failed, continuing without token:', e)
} }
uni.switchTab({ url: '/pages/index/index' }) uni.switchTab({ url: PAGES.INDEX })
} }
</script> </script>
+3 -3
View File
@@ -333,7 +333,7 @@ const copyText = (text) => {
} }
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
interactionApi.trackMarketingEffect({ action: 'copy', content_preview: text.slice(0, 100) }).catch(() => {}) interactionApi.trackMarketingEffect({ event_type: 'copy', content: text.slice(0, 200) }).catch(() => {})
loadStats() loadStats()
uni.showToast({ title: '已复制', icon: 'success' }) uni.showToast({ title: '已复制', icon: 'success' })
}).catch(() => fallbackCopy(text)) }).catch(() => fallbackCopy(text))
@@ -351,7 +351,7 @@ const fallbackCopy = (text) => {
ta.select() ta.select()
try { try {
document.execCommand('copy') document.execCommand('copy')
interactionApi.trackMarketingEffect({ action: 'copy', content_preview: text.slice(0, 100) }).catch(() => {}) interactionApi.trackMarketingEffect({ event_type: 'copy', content: text.slice(0, 200) }).catch(() => {})
loadStats() loadStats()
uni.showToast({ title: '已复制', icon: 'success' }) uni.showToast({ title: '已复制', icon: 'success' })
} catch { } catch {
@@ -381,7 +381,7 @@ const exportCsv = () => {
} }
const sendToWhatsapp = (text) => { const sendToWhatsapp = (text) => {
interactionApi.trackMarketingEffect({ action: 'send', content_preview: text.slice(0, 100) }).catch(() => {}) interactionApi.trackMarketingEffect({ event_type: 'send', content: text.slice(0, 200) }).catch(() => {})
loadStats() loadStats()
uni.showToast({ title: '请先选择客户', icon: 'none' }) uni.showToast({ title: '请先选择客户', icon: 'none' })
} }
+390
View File
@@ -0,0 +1,390 @@
<template>
<view class="page">
<view class="user-card">
<view class="avatar">{{ initials }}</view>
<view class="user-info">
<text class="user-name">{{ user.username || '未设置' }}</text>
<text class="user-phone">{{ user.phone || '未绑定手机' }}</text>
<view class="tier-badge" :class="user.tier">{{ tierLabel }}</view>
</view>
</view>
<view class="section">
<view class="section-title">账号设置</view>
<view class="menu-item" v-if="user.tier !== 'guest'" @click="showProfileEdit = true">
<text class="menu-icon">📝</text>
<text class="menu-text">编辑资料</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" v-if="user.tier !== 'guest'" @click="showPassword = true">
<text class="menu-icon">🔒</text>
<text class="menu-text">修改密码</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goUpgrade">
<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">
<text class="menu-icon">💬</text>
<text class="menu-text">意见反馈</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goAgreement('privacy')">
<text class="menu-icon">📄</text>
<text class="menu-text">隐私政策</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goAgreement('terms')">
<text class="menu-icon">📋</text>
<text class="menu-text">用户协议</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item">
<text class="menu-icon"></text>
<text class="menu-text">版本</text>
<text class="menu-value">1.0.0</text>
</view>
</view>
<view class="logout-btn" v-if="user.tier !== 'guest'" @click="logout">退出登录</view>
<view class="login-btn-bottom" v-else @click="goLogin">登录 / 注册</view>
<!-- 编辑资料弹窗 -->
<view class="modal-overlay" v-if="showProfileEdit" @click="showProfileEdit = false">
<view class="popup-card" @click.stop>
<text class="popup-title">编辑资料</text>
<view class="form-field">
<text class="form-label">用户名</text>
<input class="form-input" v-model="editForm.username" placeholder="输入用户名" />
</view>
<view class="form-field">
<text class="form-label">邮箱</text>
<input class="form-input" v-model="editForm.email" placeholder="输入邮箱" type="email" />
</view>
<view class="popup-btns">
<text class="popup-btn cancel" @click="showProfileEdit = false">取消</text>
<text class="popup-btn confirm" @click="saveProfile">保存</text>
</view>
</view>
</view>
<!-- 修改密码弹窗 -->
<view class="modal-overlay" v-if="showPassword" @click="showPassword = false">
<view class="popup-card" @click.stop>
<text class="popup-title">修改密码</text>
<view class="form-field">
<text class="form-label">旧密码</text>
<input class="form-input" v-model="pwdForm.old" placeholder="输入旧密码" password />
</view>
<view class="form-field">
<text class="form-label">新密码</text>
<input class="form-input" v-model="pwdForm.new1" placeholder="输入新密码" password />
</view>
<view class="form-field">
<text class="form-label">确认新密码</text>
<input class="form-input" v-model="pwdForm.new2" placeholder="再次输入新密码" password />
</view>
<view class="popup-btns">
<text class="popup-btn cancel" @click="showPassword = false">取消</text>
<text class="popup-btn confirm" @click="changePwd">确认修改</text>
</view>
</view>
</view>
<AiAssistant />
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { authApi } from '@/utils/api.js'
import AiAssistant from '@/components/ai-assistant.vue'
import { STORAGE_KEYS, PAGES, TIER_LABELS } from '@/config.js'
const user = ref({})
const showProfileEdit = ref(false)
const showPassword = ref(false)
const editForm = ref({ username: '', email: '' })
const pwdForm = ref({ old: '', new1: '', new2: '' })
const initials = computed(() => {
const name = user.value.username || user.value.phone || 'U'
return name.charAt(0).toUpperCase()
})
const tierLabel = computed(() => TIER_LABELS[user.value.tier] || '免费版')
const loadUser = async () => {
try {
const res = await authApi.getUserInfo()
user.value = res
editForm.value = { username: res.username || '', email: res.email || '' }
} catch {}
}
const saveProfile = async () => {
try {
await authApi.updateProfile(editForm.value)
uni.showToast({ title: '保存成功', icon: 'success' })
showProfileEdit.value = false
loadUser()
} catch (e) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
}
}
const changePwd = async () => {
if (!pwdForm.value.old || !pwdForm.value.new1) {
uni.showToast({ title: '请填写完整', icon: 'none' })
return
}
if (pwdForm.value.new1 !== pwdForm.value.new2) {
uni.showToast({ title: '两次新密码不一致', icon: 'none' })
return
}
if (pwdForm.value.new1.length < 6) {
uni.showToast({ title: '密码长度不少于6位', icon: 'none' })
return
}
try {
await authApi.changePassword(pwdForm.value.old, pwdForm.value.new1)
uni.showToast({ title: '密码修改成功', icon: 'success' })
showPassword.value = false
pwdForm.value = { old: '', new1: '', new2: '' }
} catch (e) {
uni.showToast({ title: e.message || '修改失败', icon: 'none' })
}
}
const goLogin = () => uni.reLaunch({ 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 logout = () => {
uni.showModal({
title: '提示',
content: '确定退出登录?',
success: (res) => {
if (res.confirm) {
uni.removeStorageSync(STORAGE_KEYS.TOKEN)
uni.removeStorageSync(STORAGE_KEYS.REFRESH_TOKEN)
uni.reLaunch({ url: PAGES.LOGIN })
}
},
})
}
onMounted(loadUser)
</script>
<style scoped>
.page {
padding: 20rpx;
background: #f5f5f5;
min-height: 100vh;
}
.user-card {
display: flex;
align-items: center;
background: linear-gradient(135deg, #1890ff, #096dd9);
border-radius: 16rpx;
padding: 40rpx;
margin-bottom: 20rpx;
}
.avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: rgba(255,255,255,0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
color: #fff;
font-weight: bold;
margin-right: 24rpx;
}
.user-info {
flex: 1;
}
.user-name {
font-size: 32rpx;
color: #fff;
font-weight: 600;
display: block;
}
.user-phone {
font-size: 24rpx;
color: rgba(255,255,255,0.8);
display: block;
margin-top: 6rpx;
}
.tier-badge {
display: inline-block;
padding: 4rpx 16rpx;
border-radius: 20rpx;
font-size: 20rpx;
margin-top: 10rpx;
}
.tier-badge.free { background: #e8f5e9; color: #2e7d32; }
.tier-badge.pro { background: #fff3e0; color: #e65100; }
.tier-badge.enterprise { background: #e3f2fd; color: #1565c0; }
.tier-badge.guest { background: #fce4ec; color: #c62828; }
.section {
background: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
}
.section-title {
padding: 24rpx 30rpx 0;
font-size: 26rpx;
color: #999;
font-weight: 500;
}
.menu-item {
display: flex;
align-items: center;
padding: 28rpx 30rpx;
border-bottom: 2rpx solid #f0f0f0;
}
.menu-item:last-child { border-bottom: none; }
.menu-icon {
font-size: 32rpx;
margin-right: 20rpx;
}
.menu-text {
flex: 1;
font-size: 28rpx;
color: #333;
}
.menu-arrow {
font-size: 36rpx;
color: #ccc;
}
.menu-value {
font-size: 26rpx;
color: #999;
}
.logout-btn {
margin: 40rpx 0;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background: #fff;
border-radius: 16rpx;
font-size: 30rpx;
color: #1890ff;
font-weight: 500;
border: 2rpx solid #e0e0e0;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.login-btn-bottom {
margin: 40rpx 0;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background: linear-gradient(135deg, #1890ff, #096dd9);
border-radius: 16rpx;
font-size: 30rpx;
color: #fff;
font-weight: 500;
box-shadow: 0 4rpx 12rpx rgba(24,144,255,0.3);
}
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 99999;
}
.popup-card {
width: 560rpx;
background: #fff;
border-radius: 20rpx;
padding: 40rpx;
}
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 30rpx;
text-align: center;
}
.form-field {
margin-bottom: 24rpx;
}
.form-label {
font-size: 26rpx;
color: #666;
display: block;
margin-bottom: 8rpx;
}
.form-input {
height: 72rpx;
background: #f5f5f5;
border-radius: 12rpx;
padding: 0 20rpx;
font-size: 28rpx;
width: 100%;
box-sizing: border-box;
}
.popup-btns {
display: flex;
gap: 20rpx;
margin-top: 30rpx;
}
.popup-btn {
flex: 1;
height: 72rpx;
line-height: 72rpx;
text-align: center;
border-radius: 12rpx;
font-size: 28rpx;
}
.popup-btn.cancel {
background: #f5f5f5;
color: #666;
}
.popup-btn.confirm {
background: #1890ff;
color: #fff;
}
</style>
+10 -6
View File
@@ -1,7 +1,9 @@
import { STORAGE_KEYS, PAGES } from '@/config.js'
export const BASE_URL = '/api/v1' export const BASE_URL = '/api/v1'
const getAuthHeader = () => { const getAuthHeader = () => {
const token = uni.getStorageSync('token') const token = uni.getStorageSync(STORAGE_KEYS.TOKEN)
return token ? { Authorization: `Bearer ${token}` } : {} return token ? { Authorization: `Bearer ${token}` } : {}
} }
@@ -19,8 +21,8 @@ const request = (url, method = 'GET', data = {}) => {
if (res.statusCode === 200) { if (res.statusCode === 200) {
resolve(res.data) resolve(res.data)
} else if (res.statusCode === 401) { } else if (res.statusCode === 401) {
uni.removeStorageSync('token') uni.removeStorageSync(STORAGE_KEYS.TOKEN)
uni.reLaunch({ url: '/pages/login/login' }) uni.reLaunch({ url: PAGES.LOGIN })
reject(new Error('Unauthorized')) reject(new Error('Unauthorized'))
} else { } else {
reject(new Error(res.data?.detail || 'Request failed')) reject(new Error(res.data?.detail || 'Request failed'))
@@ -60,6 +62,8 @@ export const authApi = {
login: (phone, password) => request('/auth/login', 'POST', { username: phone, password }), login: (phone, password) => request('/auth/login', 'POST', { username: phone, password }),
register: (phone, password, username) => request('/auth/register', 'POST', { phone, password, username }), register: (phone, password, username) => request('/auth/register', 'POST', { phone, password, username }),
getUserInfo: () => request('/auth/me'), getUserInfo: () => request('/auth/me'),
updateProfile: (data) => request('/auth/me', 'PUT', data),
changePassword: (oldPassword, newPassword) => request('/auth/password', 'PUT', { old_password: oldPassword, new_password: newPassword }),
wechatLogin: (code) => request('/auth/wechat-login', 'POST', { code }), wechatLogin: (code) => request('/auth/wechat-login', 'POST', { code }),
wechatConfig: () => request('/auth/wechat/config'), wechatConfig: () => request('/auth/wechat/config'),
guestLogin: () => requestWithoutAuth('/auth/login/guest', 'POST'), guestLogin: () => requestWithoutAuth('/auth/login/guest', 'POST'),
@@ -103,7 +107,7 @@ export const quotationApi = {
request('/quotations/generate-from-inquiry', 'POST', { inquiry_text: inquiryText, customer_id: customerId }), request('/quotations/generate-from-inquiry', 'POST', { inquiry_text: inquiryText, customer_id: customerId }),
importQuotations: (file) => { importQuotations: (file) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') const token = uni.getStorageSync(STORAGE_KEYS.TOKEN)
uni.uploadFile({ uni.uploadFile({
url: `${BASE_URL}/quotations/import`, url: `${BASE_URL}/quotations/import`,
filePath: file, filePath: file,
@@ -132,7 +136,7 @@ export const productApi = {
exportXlsx: () => `${BASE_URL}/products/export/xlsx`, exportXlsx: () => `${BASE_URL}/products/export/xlsx`,
importProducts: (file) => { importProducts: (file) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') const token = uni.getStorageSync(STORAGE_KEYS.TOKEN)
uni.uploadFile({ uni.uploadFile({
url: `${BASE_URL}/products/import`, url: `${BASE_URL}/products/import`,
filePath: file, filePath: file,
@@ -310,7 +314,7 @@ export const customerApi = {
exportXlsx: () => `${BASE_URL}/customers/export/xlsx`, exportXlsx: () => `${BASE_URL}/customers/export/xlsx`,
importCustomers: (file) => { importCustomers: (file) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') const token = uni.getStorageSync(STORAGE_KEYS.TOKEN)
uni.uploadFile({ uni.uploadFile({
url: `${BASE_URL}/customers/import`, url: `${BASE_URL}/customers/import`,
filePath: file, filePath: file,