feat: production branch with deploy config for baota panel

- Add deploy/ directory with production env, supervisor, nginx, migration configs
- Include all latest features: admin management, feedback, footer with ICP/beian
- Database: foreign_trade (PostgreSQL), user: foreign_trade
- Frontend: trade.yuzhiran.com, backend proxy via Nginx
This commit is contained in:
TradeMate Dev
2026-05-14 09:19:30 +08:00
parent 23a31f7c00
commit 5a1af9f82f
15 changed files with 1377 additions and 71 deletions
+443 -52
View File
@@ -2,100 +2,439 @@
<view class="admin-container">
<view class="header-card">
<text class="title">管理后台</text>
<text class="subtitle">系统概览</text>
<text class="subtitle">系统管理与监控</text>
</view>
<view class="stats-grid">
<view class="stat-card">
<text class="stat-value">{{ dashboard.users?.total || 0 }}</text>
<text class="stat-label">用户总数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ dashboard.teams?.total || 0 }}</text>
<text class="stat-label">团队数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ dashboard.customers?.total || 0 }}</text>
<text class="stat-label">客户总数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ dashboard.usage?.today || 0 }}</text>
<text class="stat-label">今日请求</text>
</view>
<view class="tabs">
<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>
<view class="section">
<view class="section-header">
<text class="section-title">最近注册用户</text>
<!-- 概览 -->
<view v-if="tab === 'overview'">
<view class="stats-grid">
<view class="stat-card" @click="tab='users'">
<text class="stat-value">{{ dashboard.users?.total || 0 }}</text>
<text class="stat-label">用户总数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ dashboard.teams?.total || 0 }}</text>
<text class="stat-label">团队数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ dashboard.customers?.total || 0 }}</text>
<text class="stat-label">客户总数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ dashboard.usage?.today || 0 }}</text>
<text class="stat-label">今日请求</text>
</view>
</view>
<view class="user-list" v-if="dashboard.recent_users?.length">
<view class="user-item" v-for="u in dashboard.recent_users" :key="u.id">
<view class="user-info">
<text class="user-name">{{ u.username }}</text>
<text class="user-tier" :class="u.tier">{{ u.tier }}</text>
<view class="section">
<view class="section-header">
<text class="section-title">最近注册用户</text>
</view>
<view class="user-list" v-if="dashboard.recent_users?.length">
<view class="user-item" v-for="u in dashboard.recent_users" :key="u.id" @click="showUserDetail(u.id)">
<view class="user-info">
<text class="user-name">{{ u.username }}</text>
<text class="user-tier" :class="u.tier">{{ u.tier }}</text>
<text class="user-role" :class="u.role">{{ u.role }}</text>
</view>
<text class="user-date">{{ formatTime(u.created_at) }}</text>
</view>
<text class="user-date">{{ formatTime(u.created_at) }}</text>
</view>
<text v-else class="empty-text">暂无数据</text>
</view>
</view>
<!-- 用户管理 -->
<view v-if="tab === 'users'">
<view class="section">
<view class="section-header">
<text class="section-title">搜索用户</text>
</view>
<view class="search-bar">
<input class="search-input" v-model="searchQuery" placeholder="用户名/手机号" @confirm="doSearch" />
<text class="search-btn" @click="doSearch">搜索</text>
</view>
<view class="user-list" v-if="searchResults.length">
<view class="user-item" v-for="u in searchResults" :key="u.id" @click="showUserDetail(u.id)">
<view class="user-info">
<text class="user-name">{{ u.username || u.phone }}</text>
<text class="user-tier" :class="u.tier">{{ u.tier }}</text>
<text class="user-role" :class="u.role">{{ u.role }}</text>
</view>
<view class="user-actions">
<text class="action-btn" @click.stop="changeTier(u, 'free')">免费</text>
<text class="action-btn pro-btn" @click.stop="changeTier(u, 'pro')">Pro</text>
<text class="action-btn enterprise-btn" @click.stop="changeTier(u, 'enterprise')">企业</text>
<text class="action-btn admin-btn" :class="u.role === 'admin' ? 'warn' : ''" @click.stop="toggleRole(u)">{{ u.role === 'admin' ? '撤销管理' : '设为管理' }}</text>
<text class="action-btn toggle-btn" :class="u.is_active ? 'warn' : 'success'" @click.stop="toggleActive(u)">{{ u.is_active ? '禁用' : '启用' }}</text>
</view>
</view>
</view>
<text v-else-if="searchQuery && !searching" class="empty-text">无匹配用户</text>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">所有用户</text>
<text class="section-count"> {{ userTotal }} </text>
</view>
<view class="user-list" v-if="users.length">
<view class="user-item" v-for="u in users" :key="u.id" @click="showUserDetail(u.id)">
<view class="user-info">
<text class="user-name">{{ u.username || u.phone }}</text>
<text class="user-tier" :class="u.tier">{{ u.tier }}</text>
<text class="user-role" :class="u.role">{{ u.role }}</text>
</view>
<view class="user-actions">
<text class="action-btn" @click.stop="changeTier(u, 'free')">免费</text>
<text class="action-btn pro-btn" @click.stop="changeTier(u, 'pro')">Pro</text>
<text class="action-btn enterprise-btn" @click.stop="changeTier(u, 'enterprise')">企业</text>
<text class="action-btn admin-btn" :class="u.role === 'admin' ? 'warn' : ''" @click.stop="toggleRole(u)">{{ u.role === 'admin' ? '撤销管理' : '设为管理' }}</text>
<text class="action-btn toggle-btn" :class="u.is_active ? 'warn' : 'success'" @click.stop="toggleActive(u)">{{ u.is_active ? '禁用' : '启用' }}</text>
</view>
</view>
</view>
<view class="pagination" v-if="userTotal > userPageSize">
<text class="page-btn" :class="{ disabled: userPage <= 1 }" @click="changeUserPage(userPage - 1)">上一页</text>
<text class="page-info">{{ userPage }} / {{ userTotalPages }}</text>
<text class="page-btn" :class="{ disabled: userPage >= userTotalPages }" @click="changeUserPage(userPage + 1)">下一页</text>
</view>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">用户管理</text>
<!-- 使用统计 -->
<view v-if="tab === 'stats'">
<view class="stats-grid">
<view class="stat-card">
<text class="stat-value">{{ usageStats.today_total || 0 }}</text>
<text class="stat-label">今日请求</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ usageStats.dau || 0 }}</text>
<text class="stat-label">日活跃用户</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ usageStats.total_users || 0 }}</text>
<text class="stat-label">总用户</text>
</view>
</view>
<view class="user-list" v-if="users.length">
<view class="user-item" v-for="u in users" :key="u.id">
<view class="user-info">
<text class="user-name">{{ u.username || u.phone }}</text>
<text class="user-tier" :class="u.tier">{{ u.tier }}</text>
<view class="section">
<view class="section-header">
<text class="section-title">今日各功能调用</text>
</view>
<view class="bar-chart" v-if="Object.keys(usageStats.by_action || {}).length">
<view class="bar-row" v-for="(count, action) in usageStats.by_action" :key="action">
<text class="bar-label">{{ action }}</text>
<view class="bar-track">
<view class="bar-fill" :style="{ width: barWidth(count) }"></view>
</view>
<text class="bar-value">{{ count }}</text>
</view>
<view class="user-actions">
<text class="action-btn" @click="changeTier(u, 'free')">免费</text>
<text class="action-btn pro-btn" @click="changeTier(u, 'pro')">Pro</text>
<text class="action-btn enterprise-btn" @click="changeTier(u, 'enterprise')">企业</text>
</view>
<text v-else class="empty-text">暂无数据</text>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">近7日趋势</text>
</view>
<view class="bar-chart" v-if="(usageStats.daily_trend || []).length">
<view class="bar-row" v-for="d in usageStats.daily_trend" :key="d.date">
<text class="bar-label">{{ formatShortDate(d.date) }}</text>
<view class="bar-track">
<view class="bar-fill trend" :style="{ width: trendBarWidth(d.count) }"></view>
</view>
<text class="bar-value">{{ d.count }}</text>
</view>
</view>
<text v-else class="empty-text">暂无数据</text>
</view>
</view>
<!-- 操作日志 -->
<view v-if="tab === 'logs'">
<view class="section">
<view class="section-header">
<text class="section-title">筛选条件</text>
</view>
<view class="filter-row">
<input class="filter-input" v-model="logFilter.action" placeholder="动作类型" />
<input class="filter-input" v-model="logFilter.user_id" placeholder="用户ID" />
</view>
<view class="filter-row">
<input class="filter-input" v-model="logFilter.date_from" placeholder="开始日期 2026-05-01" />
<input class="filter-input" v-model="logFilter.date_to" placeholder="结束日期 2026-05-14" />
<text class="search-btn small" @click="applyLogFilter">筛选</text>
<text class="search-btn small gray" @click="resetLogFilter">重置</text>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">操作日志</text>
<text class="section-count"> {{ logs.total || 0 }} </text>
</view>
<view class="log-list" v-if="logs.items?.length">
<view class="log-item" v-for="log in logs.items" :key="log.id">
<view class="log-header">
<text class="log-action">{{ log.action }}</text>
<text class="log-time">{{ formatTime(log.created_at) }}</text>
</view>
<text class="log-user">用户: {{ log.user_id?.slice(0, 8) }}...</text>
<text class="log-ip" v-if="log.ip_address">IP: {{ log.ip_address }}</text>
<text class="log-detail" v-if="log.detail && Object.keys(log.detail).length">{{ JSON.stringify(log.detail) }}</text>
</view>
</view>
<text v-else class="empty-text">暂无日志</text>
<view class="pagination" v-if="logs.total > logPageSize">
<text class="page-btn" :class="{ disabled: logPage <= 1 }" @click="changeLogPage(logPage - 1)">上一页</text>
<text class="page-info">{{ logPage }} / {{ logTotalPages }}</text>
<text class="page-btn" :class="{ disabled: logPage >= logTotalPages }" @click="changeLogPage(logPage + 1)">下一页</text>
</view>
</view>
</view>
<!-- 系统配置 -->
<view v-if="tab === 'config'">
<view class="section" v-for="cfg in configList" :key="cfg.key">
<view class="section-header">
<text class="section-title">{{ cfg.key }}</text>
<text class="config-desc">{{ cfg.description }}</text>
</view>
<view class="config-editor">
<textarea class="config-textarea" :value="formatConfigValue(cfg.value)" @input="e => onConfigEdit(cfg.key, e)" />
<text class="save-btn" @click="saveConfig(cfg.key)">保存</text>
</view>
</view>
<text v-if="!configList.length" class="empty-text">暂无配置</text>
</view>
<!-- 用户详情弹窗 -->
<view class="modal-mask" v-if="userDetail" @click="userDetail = null">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">用户详情</text>
<text class="modal-close" @click="userDetail = null"></text>
</view>
<view class="modal-body">
<view class="detail-row"><text class="detail-label">用户名</text><text>{{ userDetail.username || '-' }}</text></view>
<view class="detail-row"><text class="detail-label">手机号</text><text>{{ userDetail.phone || '-' }}</text></view>
<view class="detail-row"><text class="detail-label">邮箱</text><text>{{ userDetail.email || '-' }}</text></view>
<view class="detail-row"><text class="detail-label">套餐</text><text class="user-tier" :class="userDetail.tier">{{ userDetail.tier }}</text></view>
<view class="detail-row"><text class="detail-label">角色</text><text class="user-role" :class="userDetail.role">{{ userDetail.role }}</text></view>
<view class="detail-row"><text class="detail-label">状态</text><text :class="userDetail.is_active ? 'text-green' : 'text-red'">{{ userDetail.is_active ? '正常' : '已禁用' }}</text></view>
<view class="detail-row"><text class="detail-label">最后登录</text><text>{{ userDetail.last_login_at ? formatTime(userDetail.last_login_at) : '从未' }}</text></view>
<view class="detail-row"><text class="detail-label">登录次数</text><text>{{ userDetail.login_count }}</text></view>
<view class="detail-row"><text class="detail-label">注册时间</text><text>{{ formatTime(userDetail.created_at) }}</text></view>
<view class="divider"></view>
<text class="detail-section-title">资源统计</text>
<view class="detail-row" v-if="userDetail.stats"><text class="detail-label">产品数</text><text>{{ userDetail.stats.products }}</text></view>
<view class="detail-row" v-if="userDetail.stats"><text class="detail-label">客户数</text><text>{{ userDetail.stats.customers }}</text></view>
<view class="detail-row" v-if="userDetail.stats"><text class="detail-label">报价单</text><text>{{ userDetail.stats.quotations }}</text></view>
<view class="detail-row" v-if="userDetail.stats"><text class="detail-label">今日请求</text><text>{{ userDetail.stats.usage_today }}</text></view>
<view class="detail-row" v-if="userDetail.stats"><text class="detail-label">总请求</text><text>{{ userDetail.stats.usage_total }}</text></view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { ref, watch, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { adminApi } from '@/utils/api.js'
const tab = ref('overview')
const dashboard = ref({})
const users = ref([])
const userPage = ref(1)
const userPageSize = 20
const userTotal = ref(0)
onShow(() => {
loadData()
})
const searchQuery = ref('')
const searchResults = ref([])
const searching = ref(false)
const loadData = async () => {
const usageStats = ref({})
const logs = ref({})
const logPage = ref(1)
const logPageSize = 50
const logFilter = ref({ action: '', user_id: '', date_from: '', date_to: '' })
const configList = ref([])
const configEdits = ref({})
const userDetail = ref(null)
const userTotalPages = computed(() => Math.ceil(userTotal.value / userPageSize) || 1)
const logTotalPages = computed(() => Math.ceil((logs.value.total || 0) / logPageSize) || 1)
onShow(() => { loadOverview() })
const loadOverview = async () => {
try {
const [dash, userList] = await Promise.all([
adminApi.getDashboard(),
adminApi.listUsers(),
adminApi.listUsers(userPage.value, userPageSize),
])
dashboard.value = dash
users.value = userList.items || []
userTotal.value = userList.total || 0
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const changeUserPage = (page) => {
if (page < 1 || page > userTotalPages.value) return
userPage.value = page
loadOverview()
}
const doSearch = async () => {
const q = searchQuery.value.trim()
if (!q) return
searching.value = true
try {
searchResults.value = await adminApi.searchUsers(q)
} catch (err) {
uni.showToast({ title: err.message || '搜索失败', icon: 'none' })
} finally {
searching.value = false
}
}
const changeTier = async (user, tier) => {
try {
await adminApi.updateUserTier(user.id, tier)
uni.showToast({ title: '已更新', icon: 'success' })
loadData()
loadOverview()
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
}
}
const toggleRole = async (user) => {
const newRole = user.role === 'admin' ? 'user' : 'admin'
try {
await adminApi.updateUserRole(user.id, newRole)
uni.showToast({ title: newRole === 'admin' ? '已设为管理员' : '已撤销管理员', icon: 'success' })
loadOverview()
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
}
}
const toggleActive = async (user) => {
try {
await adminApi.toggleUserActive(user.id)
uni.showToast({ title: '已切换', icon: 'success' })
loadOverview()
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
}
}
const showUserDetail = async (userId) => {
try {
const detail = await adminApi.getUserDetail(userId)
userDetail.value = detail
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const formatTime = (t) => t ? t.split('T')[0] : ''
const formatShortDate = (d) => d ? d.slice(5) : ''
const maxActionCount = ref(1)
const maxTrendCount = ref(1)
const barWidth = (count) => Math.max((count / (maxActionCount.value || 1)) * 100, 5) + '%'
const trendBarWidth = (count) => Math.max((count / (maxTrendCount.value || 1)) * 100, 5) + '%'
const loadUsageStats = async () => {
try {
const data = await adminApi.getUsageStats()
usageStats.value = data
const byAction = data.by_action || {}
const counts = Object.values(byAction)
maxActionCount.value = counts.length ? Math.max(...counts) : 1
const trend = data.daily_trend || []
const trendCounts = trend.map(d => d.count)
maxTrendCount.value = trendCounts.length ? Math.max(...trendCounts) : 1
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const loadLogs = async () => {
try {
const filters = {}
if (logFilter.value.action) filters.action = logFilter.value.action
if (logFilter.value.user_id) filters.user_id = logFilter.value.user_id
if (logFilter.value.date_from) filters.date_from = logFilter.value.date_from
if (logFilter.value.date_to) filters.date_to = logFilter.value.date_to
logs.value = await adminApi.getLogs(logPage.value, logPageSize, filters)
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const applyLogFilter = () => { logPage.value = 1; loadLogs() }
const resetLogFilter = () => {
logFilter.value = { action: '', user_id: '', date_from: '', date_to: '' }
logPage.value = 1
loadLogs()
}
const changeLogPage = (page) => {
if (page < 1 || page > logTotalPages.value) return
logPage.value = page
loadLogs()
}
const loadConfig = async () => {
try {
configList.value = await adminApi.getConfig()
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const formatConfigValue = (val) => {
if (typeof val === 'object') return JSON.stringify(val, null, 2)
return String(val)
}
const onConfigEdit = (key, e) => {
configEdits.value[key] = e.detail.value
}
const saveConfig = async (key) => {
const raw = configEdits.value[key]
if (!raw) {
uni.showToast({ title: '无改动', icon: 'none' })
return
}
try {
const value = JSON.parse(raw)
await adminApi.updateConfig(key, value)
delete configEdits.value[key]
uni.showToast({ title: '已保存', icon: 'success' })
loadConfig()
} catch (err) {
uni.showToast({ title: 'JSON 格式错误或保存失败', icon: 'none' })
}
}
watch(tab, (val) => {
if (val === 'stats') loadUsageStats()
else if (val === 'logs') { logPage.value = 1; loadLogs() }
else if (val === 'config') loadConfig()
})
</script>
<style lang="scss" scoped>
@@ -103,24 +442,76 @@ const formatTime = (t) => t ? t.split('T')[0] : ''
.header-card { background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 16rpx; padding: 40rpx; margin-bottom: 30rpx; }
.header-card .title { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
.header-card .subtitle { font-size: 26rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; }
.stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20rpx; margin-bottom: 30rpx; }
.tabs { display: flex; background: #fff; border-radius: 16rpx; overflow: hidden; margin-bottom: 30rpx; }
.tab { flex: 1; text-align: center; padding: 20rpx 0; font-size: 26rpx; color: #666; font-weight: 500; }
.tab.active { color: #667eea; border-bottom: 4rpx solid #667eea; }
.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; }
.stat-label { font-size: 24rpx; color: #999; margin-top: 8rpx; }
.section { background: #fff; border-radius: 16rpx; padding: 30rpx; margin-bottom: 30rpx; }
.section-header { margin-bottom: 20rpx; }
.section-header { margin-bottom: 20rpx; display: flex; justify-content: space-between; align-items: center; }
.section-title { font-size: 30rpx; font-weight: 600; }
.section-count { font-size: 24rpx; color: #999; }
.empty-text { text-align: center; color: #ccc; font-size: 26rpx; padding: 40rpx 0; display: block; }
.user-list { display: flex; flex-direction: column; gap: 16rpx; }
.user-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx; background: #f9f9f9; border-radius: 12rpx; }
.user-info { display: flex; align-items: center; gap: 12rpx; }
.user-name { font-size: 28rpx; }
.user-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx; background: #f9f9f9; border-radius: 12rpx; flex-wrap: wrap; gap: 12rpx; }
.user-info { display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap; }
.user-name { font-size: 28rpx; font-weight: 500; }
.user-tier { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 6rpx; }
.user-tier.free { background: #fff7e6; color: #fa8c16; }
.user-tier.pro { background: #e6f7ff; color: #1890ff; }
.user-tier.enterprise { background: #f6ffed; color: #52c41a; }
.user-role { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 6rpx; background: #f0f0f0; color: #666; }
.user-role.admin { background: #fff1f0; color: #f5222d; }
.user-date { font-size: 22rpx; color: #999; }
.user-actions { display: flex; gap: 8rpx; }
.user-actions { display: flex; gap: 8rpx; flex-wrap: wrap; }
.action-btn { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx; background: #f0f0f0; color: #666; }
.pro-btn { background: #e6f7ff; color: #1890ff; }
.enterprise-btn { background: #f6ffed; color: #52c41a; }
.admin-btn { background: #f0f0f0; color: #666; }
.admin-btn.warn { background: #fff1f0; color: #f5222d; }
.toggle-btn.warn { background: #fff7e6; color: #fa8c16; }
.toggle-btn.success { background: #f6ffed; color: #52c41a; }
.search-bar { display: flex; gap: 16rpx; margin-bottom: 20rpx; }
.search-input { flex: 1; height: 70rpx; background: #f5f5f5; border-radius: 12rpx; padding: 0 20rpx; font-size: 26rpx; }
.search-btn { height: 70rpx; line-height: 70rpx; padding: 0 30rpx; background: #667eea; color: #fff; border-radius: 12rpx; font-size: 26rpx; flex-shrink: 0; }
.search-btn.small { height: 56rpx; line-height: 56rpx; padding: 0 20rpx; font-size: 24rpx; }
.search-btn.gray { background: #999; }
.filter-row { display: flex; gap: 12rpx; margin-bottom: 12rpx; }
.filter-input { flex: 1; height: 56rpx; background: #f5f5f5; border-radius: 8rpx; padding: 0 16rpx; font-size: 24rpx; }
.bar-chart { display: flex; flex-direction: column; gap: 12rpx; }
.bar-row { display: flex; align-items: center; gap: 12rpx; }
.bar-label { width: 120rpx; font-size: 22rpx; color: #666; text-align: right; flex-shrink: 0; }
.bar-track { flex: 1; height: 30rpx; background: #f0f0f0; border-radius: 15rpx; overflow: hidden; }
.bar-fill { height: 100%; background: linear-gradient(90deg, #667eea, #764ba2); border-radius: 15rpx; transition: width 0.3s; }
.bar-fill.trend { background: linear-gradient(90deg, #52c41a, #73d13d); }
.bar-value { width: 80rpx; font-size: 22rpx; color: #333; text-align: right; flex-shrink: 0; }
.log-list { display: flex; flex-direction: column; gap: 12rpx; }
.log-item { padding: 20rpx; background: #f9f9f9; border-radius: 12rpx; }
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
.log-action { font-size: 26rpx; font-weight: 500; color: #333; }
.log-time { font-size: 22rpx; color: #999; }
.log-user, .log-ip { font-size: 22rpx; color: #999; display: block; }
.log-detail { font-size: 22rpx; color: #666; margin-top: 6rpx; display: block; word-break: break-all; }
.config-editor { display: flex; gap: 12rpx; align-items: flex-start; }
.config-textarea { flex: 1; height: 160rpx; background: #f5f5f5; border-radius: 12rpx; padding: 16rpx; font-size: 24rpx; font-family: monospace; }
.config-desc { font-size: 22rpx; color: #999; }
.save-btn { height: 70rpx; line-height: 70rpx; padding: 0 30rpx; background: #52c41a; color: #fff; border-radius: 12rpx; font-size: 26rpx; flex-shrink: 0; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 20rpx; margin-top: 20rpx; padding: 20rpx 0; }
.page-btn { font-size: 26rpx; padding: 8rpx 24rpx; background: #667eea; color: #fff; border-radius: 8rpx; }
.page-btn.disabled { opacity: 0.4; }
.page-info { font-size: 24rpx; color: #666; }
.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 999; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #fff; border-radius: 20rpx; width: 85%; max-height: 80vh; overflow-y: auto; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 30rpx; border-bottom: 1rpx solid #f0f0f0; }
.modal-title { font-size: 32rpx; font-weight: 600; }
.modal-close { font-size: 36rpx; color: #999; padding: 0 10rpx; }
.modal-body { padding: 30rpx; }
.detail-row { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0; font-size: 26rpx; }
.detail-label { color: #999; }
.detail-section-title { font-size: 26rpx; font-weight: 600; color: #333; display: block; margin: 12rpx 0; }
.divider { height: 1rpx; background: #f0f0f0; margin: 16rpx 0; }
.text-green { color: #52c41a; }
.text-red { color: #f5222d; }
</style>
+124
View File
@@ -148,6 +148,10 @@
<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>
</view>
</view>
</view>
@@ -196,6 +200,28 @@
</view>
</view>
<view class="modal-overlay" v-if="showWechatModal" @click="showWechatModal = false">
<view class="contact-modal" @click.stop>
<text class="contact-title">📞 联系我们</text>
<view class="contact-body">
<view class="contact-item">
<text class="contact-label">客服微信</text>
<text class="contact-value selectable" selectable>TradeMate_Support</text>
</view>
<view class="contact-item">
<text class="contact-label">用户交流群</text>
<text class="contact-value">添加客服微信后拉你入群</text>
</view>
<view class="contact-qr-placeholder">
<text class="qr-icon">📷</text>
<text class="qr-hint">客服微信二维码</text>
</view>
<text class="contact-tip">添加好友时备注"外贸小助手"</text>
</view>
<button class="announcement-btn" @click="showWechatModal = false">知道了</button>
</view>
</view>
<view class="modal-overlay" v-if="showAnnouncement" @click="showAnnouncement = false">
<view class="announcement-modal" @click.stop>
<text class="announcement-title">📢 系统公告</text>
@@ -211,6 +237,19 @@
<button class="announcement-btn" @click="showAnnouncement = false; goToLogin()">去登录</button>
</view>
</view>
<view class="footer">
<view class="footer-links">
<text class="footer-link" @click="goToPage('/pages/agreement/privacy')">隐私政策</text>
<text class="footer-divider">|</text>
<text class="footer-link" @click="goToPage('/pages/agreement/terms')">用户协议</text>
</view>
<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="https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545" target="_blank">京公网安备11011502039545号</a>
</view>
<text class="footer-copyright">© 2026 北京宇之然科技中心. 保留所有权利.</text>
</view>
</view>
</template>
@@ -242,6 +281,7 @@ const stats = ref({
const silentCustomers = ref([])
const unreadCount = ref(0)
const followupStats = ref({ pending: 0, sent: 0, replied: 0 })
const showWechatModal = ref(false)
const showOnboarding = ref(false)
const onboardingStep = ref(1)
const productName = ref('')
@@ -913,6 +953,46 @@ const playTryResult = () => {
z-index: 999; padding: 40rpx;
}
.contact-modal {
background: #fff;
border-radius: 20rpx;
width: 85%;
max-width: 560rpx;
max-height: 80vh;
overflow-y: auto;
padding: 40rpx;
}
.contact-title {
font-size: 32rpx;
font-weight: 600;
text-align: center;
display: block;
margin-bottom: 30rpx;
}
.contact-body { margin-bottom: 30rpx; }
.contact-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.contact-label { font-size: 26rpx; color: #666; }
.contact-value { font-size: 28rpx; color: #333; font-weight: 500; }
.contact-qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0;
background: #f9f9f9;
border-radius: 12rpx;
margin: 20rpx 0;
}
.qr-icon { font-size: 64rpx; margin-bottom: 16rpx; }
.qr-hint { font-size: 24rpx; color: #999; }
.qr-path { font-size: 20rpx; color: #ccc; margin-top: 8rpx; }
.contact-tip { font-size: 22rpx; color: #999; text-align: center; display: block; }
.announcement-modal {
background: #fff;
border-radius: 24rpx;
@@ -961,4 +1041,48 @@ const playTryResult = () => {
align-items: center;
justify-content: center;
}
.footer {
margin-top: 40rpx;
padding: 40rpx 20rpx 30rpx;
text-align: center;
border-top: 2rpx solid #e8e8e8;
}
.footer-links {
display: flex;
justify-content: center;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
}
.footer-link {
font-size: 24rpx;
color: #1890ff;
}
.footer-divider {
font-size: 24rpx;
color: #d9d9d9;
}
.footer-beian {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 12rpx;
}
.footer-beian-link {
font-size: 22rpx;
color: #999;
}
.footer-copyright {
font-size: 22rpx;
color: #bbb;
display: block;
}
</style>
+20 -1
View File
@@ -112,8 +112,27 @@ export const productApi = {
export const adminApi = {
getDashboard: () => request('/admin/dashboard'),
listUsers: (page = 1, size = 20) => request(`/admin/users?page=${page}&size=${size}`),
listUsers: (page = 1, size = 20, role) => {
let url = `/admin/users?page=${page}&size=${size}`
if (role) url += `&role=${role}`
return request(url)
},
updateUserTier: (userId, tier) => request(`/admin/users/${userId}/tier`, 'PATCH', { tier }),
updateUserRole: (userId, role) => request(`/admin/users/${userId}/role`, 'PATCH', { role }),
toggleUserActive: (userId) => request(`/admin/users/${userId}/toggle-active`, 'POST'),
getUserDetail: (userId) => request(`/admin/users/${userId}`),
searchUsers: (q) => request(`/admin/users/search?q=${encodeURIComponent(q)}`),
getUsageStats: () => request('/admin/usage-stats'),
getLogs: (page = 1, size = 50, filters = {}) => {
let url = `/admin/logs?page=${page}&size=${size}`
if (filters.action) url += `&action=${encodeURIComponent(filters.action)}`
if (filters.user_id) url += `&user_id=${encodeURIComponent(filters.user_id)}`
if (filters.date_from) url += `&date_from=${filters.date_from}`
if (filters.date_to) url += `&date_to=${filters.date_to}`
return request(url)
},
getConfig: () => request('/admin/config'),
updateConfig: (key, value) => request(`/admin/config/${key}`, 'PUT', { value }),
}
export const analyticsApi = {