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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user