feat(admin): complete integration of auth, delivery, and system infrastructure modules

This commit is contained in:
comlibmb
2026-02-18 23:30:39 +08:00
parent 7b27694690
commit 5d00e3d74e
37 changed files with 2830 additions and 1075 deletions

View File

@@ -8,17 +8,17 @@
<view class="config-form">
<view class="form-item">
<text class="label">APPID</text>
<input class="form-input" value="wx277a269f3d736d67" />
<input class="form-input" v-model="formData.appid" placeholder="微信开放平台申请移动应用后给予的APPID" />
<text class="tip">微信开放平台申请移动应用后给予的APPID</text>
</view>
<view class="form-item">
<text class="label">AppSecret</text>
<input class="form-input" value="bd08741a055c2ecac5826ff1c048464b" />
<input class="form-input" v-model="formData.appsecret" placeholder="微信开放平台申请移动应用后给予的AppSecret" />
<text class="tip">微信开放平台申请移动应用后给予的AppSecret</text>
</view>
<view class="form-btns">
<button class="btn primary">提交</button>
<button class="btn primary" @click="handleSubmit">提交</button>
</view>
</view>
</view>
@@ -26,7 +26,40 @@
</template>
<script setup lang="uts">
// APP配置逻辑
import { ref, reactive, onMounted } from 'vue'
import { getSystemConfig, saveSystemConfig } from "@/services/admin/systemConfigService.uts"
const formData = reactive({
appid: '',
appsecret: ''
})
onMounted(() => {
loadConfig()
})
async function loadConfig() {
const res = await getSystemConfig('mobile_app_config')
if (res != null) {
Object.assign(formData, res as any)
}
}
async function handleSubmit() {
uni.showLoading({ title: '正在保存...' })
try {
const ok = await saveSystemConfig('mobile_app_config', formData as UTSJSONObject, '移动应用(APP)配置')
if (ok) {
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '系统错误', icon: 'none' })
} finally {
uni.hideLoading()
}
}
</script>
<style scoped>

View File

@@ -1,4 +1,3 @@
<template>
<view class="admin-page">
<view class="admin-card content-card">
@@ -25,7 +24,7 @@
<view class="form-item row">
<text class="label">联系电话:</text>
<view class="form-content">
<input class="form-input" v-model="config.phone" placeholder="400-8888-794" />
<input class="form-input" v-model="config.phone" placeholder="请输入联系电话" />
<text class="tip">PC底部显示的联系电话</text>
</view>
</view>
@@ -33,7 +32,7 @@
<view class="form-item row">
<text class="label">公司地址:</text>
<view class="form-content">
<input class="form-input" v-model="config.address" placeholder="陕西省西安市..." />
<input class="form-input" v-model="config.address" placeholder="请输入公司地址" />
<text class="tip">PC底部显示的公司地址</text>
</view>
</view>
@@ -96,12 +95,13 @@
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, onMounted, reactive } from 'vue'
import { getSystemConfig, saveSystemConfig } from "@/services/admin/systemConfigService.uts"
const config = ref({
logo: 'https://v5.crmeb.net/uploads/attach/2022/05/20220516/6198f7e6f8a8b.png',
phone: '400-8888-794',
address: '陕西省西安市西咸新区洋东新城能源金融贸易区金湾大厦3层',
const config = reactive({
logo: '',
phone: '',
address: '',
keywords: '',
description: '',
qrCodeType: 'routine',
@@ -109,17 +109,40 @@ const config = ref({
newProductCount: 5
})
onMounted(() => {
loadConfig()
})
async function loadConfig() {
const res = await getSystemConfig('pc_site_config')
if (res != null) {
Object.assign(config, res as any)
}
}
const handleUploadLogo = () => {
uni.chooseImage({
count: 1,
success: (res) => {
config.value.logo = res.tempFilePaths[0]
config.logo = res.tempFilePaths[0]
}
})
}
const handleSubmit = () => {
uni.showToast({ title: '保存成功', icon: 'success' })
const handleSubmit = async () => {
uni.showLoading({ title: '正在保存...' })
try {
const ok = await saveSystemConfig('pc_site_config', config as UTSJSONObject, 'PC站点配置')
if (ok) {
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '系统错误', icon: 'none' })
} finally {
uni.hideLoading()
}
}
</script>
@@ -308,4 +331,3 @@ const handleSubmit = () => {
border: none;
}
</style>

View File

@@ -13,7 +13,7 @@
</view>
<view class="table-body">
<view class="table-row">
<view class="col col-title"><text>ZC2884891</text></view>
<view class="col col-title"><text>{{ info?.auth_id || '检测中...' }}</text></view>
<view class="col col-action">
<text class="action-btn" @click="gotoOfficial">进入官网</text>
</view>
@@ -22,29 +22,6 @@
</view>
</view>
<!-- 自定义版权信息 -->
<view class="admin-card info-section mt-24">
<view class="section-header">
<text class="section-title">自定义版权信息</text>
</view>
<view class="table-container header-table">
<view class="table-header">
<view class="col col-text"><text>文字版权信息</text></view>
<view class="col col-image"><text>底部版权图片</text></view>
<view class="col col-action"><text>操作</text></view>
</view>
<view class="table-body">
<view class="table-row">
<view class="col col-text"><text></text></view>
<view class="col col-image"><text></text></view>
<view class="col col-action">
<text class="action-btn" @click="editCopyright">编辑</text>
</view>
</view>
</view>
</view>
</view>
<!-- 服务器信息 -->
<view class="admin-card info-section mt-24">
<view class="section-header">
@@ -60,53 +37,71 @@
<view class="table-row">
<view class="col col-env"><text>服务器系统</text></view>
<view class="col col-req"><text>类UNIX</text></view>
<view class="col col-status"><text>Linux</text></view>
<view class="col col-status"><text>{{ info?.server_os || 'Loading...' }}</text></view>
</view>
<view class="table-row">
<view class="col col-env"><text>WEB环境</text></view>
<view class="col col-req"><text>Apache/Nginx/IIS</text></view>
<view class="col col-status"><text>nginx/1.24.0</text></view>
</view>
</view>
</view>
</view>
<!-- 系统环境要求 -->
<view class="admin-card info-section mt-24">
<view class="section-header">
<text class="section-title">系统环境要求</text>
</view>
<view class="table-container header-table">
<view class="table-header">
<view class="col col-env"><text>环境</text></view>
<view class="col col-req"><text>要求</text></view>
<view class="col col-status"><text>状态</text></view>
</view>
<view class="table-body">
<view class="table-row">
<view class="col col-env"><text>PHP版本</text></view>
<view class="col col-req"><text>7.1-7.4</text></view>
<view class="col col-status"><text>7.4.33</text></view>
<view class="col col-status"><text>{{ info?.web_server || 'Loading...' }}</text></view>
</view>
<view class="table-row">
<view class="col col-env"><text>MySQL版本</text></view>
<view class="col col-req"><text>5.6-8.0</text></view>
<view class="col col-status"><text>8.0.35</text></view>
<view class="col col-env"><text>数据库引擎</text></view>
<view class="col col-req"><text>PostgreSQL</text></view>
<view class="col col-status"><text>{{ info?.db_engine || 'Loading...' }}</text></view>
</view>
<view class="table-row">
<view class="col col-env"><text>数据库版本</text></view>
<view class="col col-req"><text>15.0+</text></view>
<view class="col col-status"><text>{{ info?.db_version || 'Loading...' }}</text></view>
</view>
<view class="table-row">
<view class="col col-env"><text>运行环境</text></view>
<view class="col col-req"><text>UTS Runtime</text></view>
<view class="col col-status"><text>{{ info?.uts_runtime || 'Loading...' }}</text></view>
</view>
</view>
</view>
</view>
</view>
<view v-if="isLoading" class="loading-overlay">
<text>系统环境加载中...</text>
</view>
</view>
</template>
<script setup lang="uts">
function gotoOfficial() {
uni.showToast({ title: '正在打开官网...', icon: 'none' })
import { ref, onMounted } from 'vue'
import { fetchSystemInfo, SystemInfo } from '@/services/admin/maintainService.uts'
const info = ref<SystemInfo | null>(null)
const isLoading = ref(false)
onMounted(() => {
loadData()
})
async function loadData() {
isLoading.value = true
try {
const res = await fetchSystemInfo()
if (res != null) {
info.value = res
}
} catch (e) {
uni.showToast({ title: '获取系统信息失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
function editCopyright() {
uni.showToast({ title: '编辑版权信息', icon: 'none' })
function gotoOfficial() {
// #ifdef H5
window.open('https://www.crmeb.com', '_blank')
// #endif
// #ifndef H5
uni.showToast({ title: '请在浏览器中访问官网', icon: 'none' })
// #endif
}
</script>
@@ -158,13 +153,10 @@ function editCopyright() {
color: #333;
}
/* 各表格占位列宽 */
.col-title { flex: 1; }
.col-text { flex: 1; }
.col-image { flex: 1; }
.col-env { flex: 1; }
.col-req { flex: 1; }
.col-status { flex: 1; }
.col-status { flex: 1; font-family: monospace; }
.col-action { width: 150px; justify-content: flex-end; }
.action-btn {
@@ -175,4 +167,14 @@ function editCopyright() {
.mt-24 {
margin-top: 24px;
}
.loading-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(255,255,255,0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}
</style>

View File

@@ -5,15 +5,13 @@
<view class="search-wrap">
<view class="search-item">
<text class="label">状态:</text>
<picker mode="selector" :range="statusRange" @change="onStatusChange">
<view class="picker-input">{{ statusText }}</view>
</picker>
<uni-data-select v-model="statusValue" :localdata="statusOptions" style="width: 120px;" @change="handleQuery" />
</view>
<view class="search-item">
<text class="label">搜索:</text>
<input class="input" placeholder="请输入姓名或者账号" v-model="searchKey" />
<input class="input" placeholder="请输入姓名或者账号" v-model="searchKey" @confirm="handleQuery" />
</view>
<button class="btn btn-primary" @click="onSearch">查询</button>
<button class="btn btn-primary" @click="handleQuery">查询</button>
</view>
<view class="action-wrap">
@@ -21,7 +19,7 @@
</view>
<!-- 表格区域 -->
<view class="table-wrap">
<view class="table-wrap border-shadow">
<view class="table-header">
<view class="th" style="flex: 2;">姓名</view>
<view class="th" style="flex: 2;">账号</view>
@@ -32,9 +30,42 @@
<view class="th" style="flex: 2;">操作</view>
</view>
<view class="table-body">
<view class="no-data">
<text class="no-data-text">暂无数据</text>
<view v-if="loading" class="loading-box">
<text>加载中...</text>
</view>
<view v-else-if="adminList.length === 0" class="no-data">
<text class="no-data-text">暂无管理员数据</text>
</view>
<view v-else v-for="item in adminList" :key="item.id" class="tr">
<view class="td" style="flex: 2;"><text class="td-txt">{{ item.real_name || '-' }}</text></view>
<view class="td" style="flex: 2;"><text class="td-txt">{{ item.username }}</text></view>
<view class="td" style="flex: 2;">
<view class="role-tags">
<text v-for="role in (item.roles || [])" :key="role" class="role-tag">{{ role }}</text>
<text v-if="!item.roles || item.roles.length === 0" class="td-txt">-</text>
</view>
</view>
<view class="td" style="flex: 3;"><text class="td-txt-small">{{ formatTime(item.last_login_at) }}</text></view>
<view class="td" style="flex: 3;"><text class="td-txt-small">{{ item.last_login_ip || '-' }}</text></view>
<view class="td" style="flex: 1;">
<switch :checked="item.is_active" color="#1890ff" scale="0.7" disabled />
</view>
<view class="td" style="flex: 2;">
<text class="action-btn" @click="onEdit(item)">编辑</text>
<view class="divider"></view>
<text class="action-btn danger">删除</text>
</view>
</view>
</view>
</view>
<!-- 分页栏 -->
<view class="pagination-footer">
<text class="total-txt">共 {{ total }} 条</text>
<view class="page-btns">
<text :class="['p-btn', page <= 1 ? 'disabled' : '']" @click="prevPage"> < </text>
<text class="p-btn active">{{ page }}</text>
<text :class="['p-btn', adminList.length < pageSize ? 'disabled' : '']" @click="nextPage"> > </text>
</view>
</view>
</view>
@@ -42,136 +73,101 @@
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { fetchAdminPage, type AdminUser } from '@/services/admin/authService.uts'
const adminList = ref<AdminUser[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 15
const searchKey = ref('')
const statusRange = ['所有', '启用', '禁用']
const statusIndex = ref(0)
const statusText = ref('请选择')
const statusValue = ref('all')
function onStatusChange(e: any) {
statusIndex.value = parseInt(e.detail.value.toString())
statusText.value = statusRange[statusIndex.value]
const statusOptions = [
{ value: 'all', text: '所有' },
{ value: '1', text: '启用' },
{ value: '0', text: '禁用' }
]
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const status = statusValue.value === 'all' ? null : parseInt(statusValue.value)
const res = await fetchAdminPage(page.value, pageSize, searchKey.value || null, status)
adminList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function onSearch() {
console.log('Search:', searchKey.value, statusText.value)
function handleQuery() {
page.value = 1
loadData()
}
function onAdd() {
console.log('Add admin')
uni.showToast({ title: '添加管理员功能开发中', icon: 'none' })
}
function onEdit(item : AdminUser) {
console.log('Edit admin:', item.id)
uni.showToast({ title: '编辑功能开发中', icon: 'none' })
}
function prevPage() { if (page.value > 1) { page.value--; loadData(); } }
function nextPage() { if (adminList.value.length >= pageSize) { page.value++; loadData(); } }
function formatTime(iso : string | null) : string {
if (!iso) return '-'
return iso.substring(0, 16).replace('T', ' ')
}
</script>
<style scoped>
.admin-page-container {
padding: 15px;
background-color: #f5f7f9;
min-height: 100vh;
}
<style scoped lang="scss">
.admin-page-container { padding: 24px; background-color: #f5f7f9; min-height: 100vh; }
.page-card { background-color: #fff; border-radius: 4px; padding: 24px; }
.page-card {
background-color: #fff;
border-radius: 4px;
padding: 20px;
}
.search-wrap { display: flex; flex-direction: row; align-items: center; padding-bottom: 24px; border-bottom: 1px solid #f0f0f0; margin-bottom: 24px; }
.search-item { display: flex; flex-direction: row; align-items: center; margin-right: 24px; }
.label { font-size: 14px; color: #606266; margin-right: 8px; }
.input { width: 200px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 12px; font-size: 14px; }
.search-wrap {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 20px;
}
.btn { height: 32px; padding: 0 20px; border-radius: 4px; border: none; cursor: pointer; display: flex; align-items: center; }
.btn-primary { background-color: #1890ff; color: #fff; font-size: 14px; }
.search-item {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 20px;
margin-bottom: 10px;
}
.action-wrap { margin-bottom: 20px; }
.label {
font-size: 14px;
color: #606266;
margin-right: 8px;
}
.table-wrap { border: 1px solid #f0f0f0; border-radius: 4px; }
.table-header { display: flex; flex-direction: row; background-color: #f8f8f9; }
.th { padding: 12px 10px; font-size: 14px; font-weight: bold; color: #515a6e; border-bottom: 1px solid #f0f0f0; text-align: center; }
.tr { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-height: 54px; align-items: center; }
.td { padding: 10px; display: flex; align-items: center; justify-content: center; }
.td-txt { font-size: 13px; color: #606266; }
.td-txt-small { font-size: 12px; color: #999; }
.picker-input {
width: 150px;
height: 32px;
line-height: 32px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
background-color: #fff;
}
.role-tags { display: flex; flex-direction: row; flex-wrap: wrap; gap: 4px; justify-content: center; }
.role-tag { background-color: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; padding: 1px 6px; border-radius: 2px; font-size: 11px; }
.input {
width: 220px;
height: 32px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
}
.action-btn { color: #1890ff; font-size: 13px; cursor: pointer; }
.danger { color: #ff4d4f; }
.divider { width: 1px; height: 12px; background-color: #e8e8e8; margin: 0 8px; }
.btn {
height: 32px;
line-height: 30px;
padding: 0 15px;
font-size: 14px;
border-radius: 4px;
border: none;
margin-bottom: 10px;
}
.no-data { padding: 60px 0; text-align: center; }
.no-data-text { font-size: 14px; color: #c5c8ce; }
.loading-box { padding: 60px 0; text-align: center; color: #1890ff; }
.btn-primary {
background-color: #1890ff;
color: #fff;
}
.action-wrap {
margin-bottom: 20px;
}
.table-wrap {
width: 100%;
border: 1px solid #f0f0f0;
}
.table-header {
display: flex;
flex-direction: row;
background-color: #f8f8f9;
}
.th {
padding: 12px 10px;
font-size: 14px;
font-weight: bold;
color: #515a6e;
border-bottom: 1px solid #f0f0f0;
text-align: center;
}
.table-body {
min-height: 100px;
}
.no-data {
padding: 40px 0;
text-align: center;
}
.no-data-text {
font-size: 14px;
color: #c5c8ce;
}
.pagination-footer { margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px; }
.total-txt { font-size: 13px; color: #999; }
.page-btns { display: flex; flex-direction: row; gap: 8px; }
.p-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; cursor: pointer; font-size: 13px; }
.p-btn.active { background-color: #1890ff; color: #fff; border-color: #1890ff; }
.p-btn.disabled { opacity: 0.5; cursor: not-allowed; }
</style>

View File

@@ -1,188 +1,263 @@
<template>
<view class="admin-page-container">
<view class="page-card">
<!-- 搜索栏 -->
<view class="search-wrap">
<view class="search-item">
<text class="label">按钮名称:</text>
<input class="input" placeholder="请输入按钮名称" v-model="searchKey" />
</view>
<button class="btn btn-primary" @click="onSearch">查询</button>
<!-- 顶部操作栏 -->
<view class="action-wrap">
<button class="btn btn-primary" @click="onAdd(null)">添加顶级权限</button>
<button class="btn btn-ghost ml-10" @click="loadData">刷新</button>
</view>
<!-- 表格区域 -->
<view class="table-wrap">
<!-- 树形表格区域 -->
<view class="table-wrap border-shadow">
<view class="table-header">
<view class="th" style="flex: 4; text-align: left; padding-left: 20px;">按钮名称</view>
<view class="th" style="flex: 3;">类型</view>
<view class="th" style="flex: 2;">排序</view>
<view class="th" style="flex: 2;">是否显示</view>
<view class="th" style="flex: 2;">操作</view>
<view class="th" style="flex: 4; text-align: left; padding-left: 20px;">菜单/按钮名称</view>
<view class="th" style="flex: 2;">编码</view>
<view class="th" style="flex: 2;">类型</view>
<view class="th" style="flex: 1;">排序</view>
<view class="th" style="flex: 1;">显示</view>
<view class="th" style="flex: 3;">操作</view>
</view>
<view class="table-body">
<view v-for="item in permissionList" :key="item.id" class="tr">
<view v-if="loading" class="loading-box">
<text>加载中...</text>
</view>
<view v-else-if="permissionList.length === 0" class="no-data">
<text class="no-data-text">暂无数据</text>
</view>
<!-- 递归渲染或平铺渲染 (这里采用平铺+缩进模拟树形) -->
<view v-else v-for="item in permissionList" :key="item.id" class="tr">
<view class="td" style="flex: 4; text-align: left; padding-left: 20px;">
<text v-if="item.hasChildren" class="expand-icon">▶</text>
<text class="menu-name">{{ item.name }}</text>
</view>
<view class="td" style="flex: 3;">{{ item.type }}</view>
<view class="td" style="flex: 2;">{{ item.sort }}</view>
<view class="td" style="flex: 2;"><text class="td-txt-small">{{ item.code }}</text></view>
<view class="td" style="flex: 2;">
<switch :checked="item.isshow" color="#1890ff" @change="onToggleShow(item)" />
<text :class="['type-tag', item.type === 'menu' ? 'menu' : 'button']">
{{ item.type === 'menu' ? '菜单' : '按钮' }}
</text>
</view>
<view class="td" style="flex: 2;">
<text class="action-btn" @click="onEdit(item)">编辑</text>
<view class="td" style="flex: 1;"><text class="td-txt">{{ item.sort_order }}</text></view>
<view class="td" style="flex: 1;">
<switch :checked="item.is_visible" color="#1890ff" scale="0.6" @change="onToggleVisible(item)" />
</view>
<view class="td" style="flex: 3;">
<view class="op-links">
<text class="action-btn" @click="onAdd(item.id)">新增子项</text>
<text class="op-split">|</text>
<text class="action-btn" @click="onEdit(item)">编辑</text>
<text class="op-split">|</text>
<text class="action-btn danger" @click="onDelete(item)">删除</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 添加/编辑 弹窗 -->
<view v-if="showModal" class="modal-overlay" @click="closeModal">
<view class="modal-card" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ isEdit ? '编辑权限' : '添加权限' }}</text>
<text class="close-btn" @click="closeModal">×</text>
</view>
<view class="modal-body">
<scroll-view scroll-y="true" style="max-height: 500px;">
<view class="form-item">
<text class="f-label">父级ID</text>
<input class="f-input disabled" :value="form.parent_id || '顶级'" disabled />
</view>
<view class="form-item">
<text class="f-label">名称:</text>
<input class="f-input" v-model="form.name" placeholder="请输入菜单或按钮名称" />
</view>
<view class="form-item">
<text class="f-label">编码:</text>
<input class="f-input" v-model="form.code" placeholder="如: user_view" />
</view>
<view class="form-item">
<text class="f-label">类型:</text>
<radio-group class="radio-group" @change="(e : any) => form.type = e.detail.value">
<label class="radio-label"><radio value="menu" :checked="form.type === 'menu'" color="#1890ff" /> 菜单</label>
<label class="radio-label"><radio value="button" :checked="form.type === 'button'" color="#1890ff" /> 按钮</label>
</radio-group>
</view>
<view class="form-item" v-if="form.type === 'menu'">
<text class="f-label">路由路径:</text>
<input class="f-input" v-model="form.path" placeholder="请输入前端路由地址" />
</view>
<view class="form-item">
<text class="f-label">排序:</text>
<input class="f-input" type="number" v-model="form.sort_order" />
</view>
</scroll-view>
</view>
<view class="modal-footer">
<button class="btn" @click="closeModal">取消</button>
<button class="btn btn-primary" @click="handleSave">提交</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { fetchPermissionList, savePermission, deletePermission, type AdminPermission } from '@/services/admin/authService.uts'
const searchKey = ref('')
const permissionList = ref<AdminPermission[]>([])
const loading = ref(false)
const showModal = ref(false)
const isEdit = ref(false)
type PermissionItem = {
id: number
name: string
type: string
sort: number
isshow: boolean
hasChildren: boolean
const form = reactive({
id: '' as string | null,
parent_id: '' as string | null,
name: '',
code: '',
type: 'menu',
path: '',
icon: '',
sort_order: 0,
is_visible: true
})
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const res = await fetchPermissionList()
permissionList.value = res
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const permissionList = reactive<PermissionItem[]>([
{ id: 1, name: '主页', type: '菜单:/admin/index', sort: 127, isshow: true, hasChildren: false },
{ id: 2, name: '用户', type: '菜单:/admin/user', sort: 125, isshow: true, hasChildren: true },
{ id: 3, name: '订单', type: '菜单:/admin/order', sort: 120, isshow: true, hasChildren: true },
{ id: 4, name: '商品', type: '菜单:/admin/product', sort: 115, isshow: true, hasChildren: true },
{ id: 5, name: '营销', type: '菜单:/admin/marketing', sort: 110, isshow: true, hasChildren: true }
])
function onSearch() {
console.log('Search:', searchKey.value)
function onAdd(parentId : string | null) {
isEdit.value = false
form.id = null
form.parent_id = parentId
form.name = ''
form.code = ''
form.type = 'menu'
form.path = ''
form.sort_order = 0
form.is_visible = true
showModal.value = true
}
function onToggleShow(item: PermissionItem) {
item.isshow = !item.isshow
function onEdit(item : AdminPermission) {
isEdit.value = true
form.id = item.id
form.parent_id = item.parent_id
form.name = item.name
form.code = item.code
form.type = item.type
form.path = item.path || ''
form.sort_order = item.sort_order
form.is_visible = item.is_visible
showModal.value = true
}
function onEdit(item: PermissionItem) {
console.log('Edit:', item.name)
async function onToggleVisible(item : AdminPermission) {
const nextVal = !item.is_visible
const ok = await savePermission({ ...item, is_visible: nextVal })
if (ok != null) {
item.is_visible = nextVal
uni.showToast({ title: '显示状态已更新' })
}
}
async function handleSave() {
if (!form.name || !form.code) {
uni.showToast({ title: '请填写必填项', icon: 'none' })
return
}
loading.value = true
try {
const resId = await savePermission(form)
if (resId != null) {
uni.showToast({ title: '保存成功' })
showModal.value = false
loadData()
}
} finally {
loading.value = false
}
}
async function onDelete(item : AdminPermission) {
uni.showModal({
title: '确认删除',
content: `确定要删除权限项 "${item.name}" 吗?此操作不可撤销。`,
success: async (res) => {
if (res.confirm) {
const ok = await deletePermission(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
function closeModal() {
showModal.value = false
}
</script>
<style scoped>
.admin-page-container {
padding: 15px;
background-color: #f5f7f9;
min-height: 100vh;
}
<style scoped lang="scss">
.admin-page-container { padding: 24px; background-color: #f5f7f9; min-height: 100vh; }
.page-card { background-color: #fff; border-radius: 4px; padding: 24px; }
.page-card {
background-color: #fff;
border-radius: 4px;
padding: 20px;
}
.action-wrap { margin-bottom: 24px; display: flex; flex-direction: row; }
.btn { height: 32px; padding: 0 16px; font-size: 14px; border-radius: 4px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.btn-primary { background-color: #1890ff; color: #fff; }
.btn-ghost { background-color: #fff; color: #666; border: 1px solid #dcdfe6; }
.ml-10 { margin-left: 10px; }
.search-wrap {
display: flex;
flex-direction: row;
align-items: center;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 20px;
}
.table-wrap { border: 1px solid #f0f0f0; border-radius: 4px; }
.table-header { display: flex; flex-direction: row; background-color: #f8f8f9; }
.th { padding: 12px 10px; font-size: 14px; font-weight: bold; color: #515a6e; border-bottom: 1px solid #f0f0f0; text-align: center; }
.tr { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-height: 50px; align-items: center; }
.td { padding: 8px 10px; font-size: 13px; color: #606266; text-align: center; display: flex; align-items: center; justify-content: center; }
.search-item {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 20px;
}
.menu-name { font-weight: 500; color: #333; }
.td-txt-small { font-size: 12px; color: #999; }
.label {
font-size: 14px;
color: #606266;
margin-right: 8px;
}
.type-tag { padding: 2px 8px; border-radius: 4px; font-size: 11px; }
.type-tag.menu { background-color: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
.type-tag.button { background-color: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
.input {
width: 200px;
height: 32px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
}
.op-links { display: flex; flex-direction: row; align-items: center; gap: 8px; }
.action-btn { color: #1890ff; font-size: 13px; cursor: pointer; }
.danger { color: #ff4d4f; }
.op-split { color: #eee; }
.btn {
height: 32px;
line-height: 30px;
padding: 0 15px;
font-size: 14px;
border-radius: 4px;
border: none;
}
.loading-box, .no-data { padding: 60px 0; text-align: center; width: 100%; }
.no-data-text { font-size: 14px; color: #ccc; }
.btn-primary {
background-color: #1890ff;
color: #fff;
}
.table-wrap {
width: 100%;
border: 1px solid #f0f0f0;
}
.table-header {
display: flex;
flex-direction: row;
background-color: #f8f8f9;
}
.th {
padding: 12px 10px;
font-size: 14px;
font-weight: bold;
color: #515a6e;
border-bottom: 1px solid #f0f0f0;
text-align: center;
}
.tr {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
}
.td {
padding: 12px 10px;
font-size: 14px;
color: #515a6e;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.expand-icon {
font-size: 12px;
color: #999;
margin-right: 5px;
}
.menu-name {
font-size: 14px;
}
.action-btn {
color: #1890ff;
font-size: 13px;
cursor: pointer;
}
/* Modal */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-card { width: 500px; background-color: #fff; border-radius: 8px; overflow: hidden; }
.modal-header { padding: 16px 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.modal-title { font-size: 16px; font-weight: bold; color: #333; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.modal-body { padding: 24px; }
.form-item { margin-bottom: 20px; display: flex; flex-direction: row; align-items: center; }
.align-start { align-items: flex-start; }
.f-label { width: 90px; font-size: 14px; color: #666; text-align: right; margin-right: 15px; }
.f-input { flex: 1; border: 1px solid #dcdfe6; height: 36px; padding: 0 12px; border-radius: 4px; font-size: 14px; }
.f-input.disabled { background-color: #f5f5f5; color: #999; }
.radio-group { display: flex; flex-direction: row; gap: 20px; }
.radio-label { display: flex; flex-direction: row; align-items: center; font-size: 14px; }
.modal-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; }
</style>

View File

@@ -5,15 +5,13 @@
<view class="search-wrap">
<view class="search-item">
<text class="label">状态:</text>
<picker mode="selector" :range="statusRange" @change="onStatusChange">
<view class="picker-input">{{ statusText }}</view>
</picker>
<uni-data-select v-model="statusValue" :localdata="statusOptions" style="width: 120px;" @change="handleQuery" />
</view>
<view class="search-item">
<text class="label">身份昵称:</text>
<input class="input" placeholder="请输入身份昵称" v-model="searchKey" />
<input class="input" placeholder="请输入身份昵称" v-model="searchKey" @confirm="handleQuery" />
</view>
<button class="btn btn-primary" @click="onSearch">查询</button>
<button class="btn btn-primary" @click="handleQuery">查询</button>
</view>
<view class="action-wrap">
@@ -21,17 +19,70 @@
</view>
<!-- 表格区域 -->
<view class="table-wrap">
<view class="table-wrap border-shadow">
<view class="table-header">
<view class="th" style="flex: 1;">ID</view>
<view class="th" style="flex: 1;">序号</view>
<view class="th" style="flex: 3;">身份昵称</view>
<view class="th" style="flex: 2;">状态</view>
<view class="th" style="flex: 2;">操作</view>
</view>
<view class="table-body">
<view class="no-data">
<view v-if="loading" class="loading-box">
<text>加载中...</text>
</view>
<view v-else-if="roleList.length === 0" class="no-data">
<text class="no-data-text">暂无数据</text>
</view>
<view v-else v-for="(item, index) in roleList" :key="item.id" class="tr">
<view class="td" style="flex: 1;"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td" style="flex: 3;"><text class="td-txt">{{ item.name }} ({{ item.code }})</text></view>
<view class="td" style="flex: 2;">
<switch :checked="item.is_active" color="#1890ff" scale="0.7" @change="onToggleStatus(item)" />
</view>
<view class="td" style="flex: 2;">
<text class="action-btn" @click="onEdit(item)">编辑</text>
<view class="divider"></view>
<text class="action-btn danger" @click="onDelete(item)">删除</text>
</view>
</view>
</view>
</view>
<!-- 分页栏 -->
<view class="pagination-footer">
<text class="total-txt">共 {{ total }} 条</text>
<view class="page-btns">
<text :class="['p-btn', page <= 1 ? 'disabled' : '']" @click="prevPage"> < </text>
<text class="p-btn active">{{ page }}</text>
<text :class="['p-btn', roleList.length < pageSize ? 'disabled' : '']" @click="nextPage"> > </text>
</view>
</view>
</view>
<!-- 添加/编辑 弹窗 -->
<view v-if="showModal" class="modal-overlay" @click="closeModal">
<view class="modal-card" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ isEdit ? '编辑身份' : '添加身份' }}</text>
<text class="close-btn" @click="closeModal">×</text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="f-label">身份名称:</text>
<input class="f-input" v-model="form.name" placeholder="请输入身份名称" />
</view>
<view class="form-item">
<text class="f-label">身份编码:</text>
<input class="f-input" v-model="form.code" placeholder="如: super_admin" :disabled="isEdit" />
</view>
<view class="form-item">
<text class="f-label">描述:</text>
<textarea class="f-textarea" v-model="form.description" placeholder="请输入备注描述" />
</view>
</view>
<view class="modal-footer">
<button class="btn" @click="closeModal">取消</button>
<button class="btn btn-primary" @click="handleSave">保存</button>
</view>
</view>
</view>
@@ -39,136 +90,176 @@
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { fetchRolePage, saveRole, deleteRole, type AdminRole } from '@/services/admin/authService.uts'
const roleList = ref<AdminRole[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 15
const searchKey = ref('')
const statusRange = ['所有', '启用', '禁用']
const statusIndex = ref(0)
const statusText = ref('请选择')
const statusValue = ref('all')
function onStatusChange(e: any) {
statusIndex.value = parseInt(e.detail.value.toString())
statusText.value = statusRange[statusIndex.value]
const statusOptions = [
{ value: 'all', text: '所有' },
{ value: '1', text: '启用' },
{ value: '0', text: '禁用' }
]
// 弹窗表单状态
const showModal = ref(false)
const isEdit = ref(false)
const form = reactive({
id: '' as string | null,
name: '',
code: '',
description: '',
is_active: true
})
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const res = await fetchRolePage(page.value, pageSize, searchKey.value || null)
roleList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function onSearch() {
console.log('Search:', searchKey.value, statusText.value)
function handleQuery() {
page.value = 1
loadData()
}
function onAdd() {
console.log('Add role')
isEdit.value = false
form.id = null
form.name = ''
form.code = ''
form.description = ''
form.is_active = true
showModal.value = true
}
function onEdit(item : AdminRole) {
isEdit.value = true
form.id = item.id
form.name = item.name
form.code = item.code
form.description = item.description || ''
form.is_active = item.is_active
showModal.value = true
}
function closeModal() {
showModal.value = false
}
async function handleSave() {
if (!form.name || !form.code) {
uni.showToast({ title: '请完善必要信息', icon: 'none' })
return
}
loading.value = true
try {
const resId = await saveRole(form)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeModal()
loadData()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} finally {
loading.value = false
}
}
async function onDelete(item : AdminRole) {
uni.showModal({
title: '提示',
content: `确定要删除角色 "${item.name}" 吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteRole(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
async function onToggleStatus(item : AdminRole) {
const nextStatus = !item.is_active
const ok = await saveRole({ ...item, is_active: nextStatus })
if (ok != null) {
item.is_active = nextStatus
uni.showToast({ title: '状态已更新' })
}
}
function prevPage() { if (page.value > 1) { page.value--; loadData(); } }
function nextPage() { if (roleList.value.length >= pageSize) { page.value++; loadData(); } }
</script>
<style scoped>
.admin-page-container {
padding: 15px;
background-color: #f5f7f9;
min-height: 100vh;
}
<style scoped lang="scss">
.admin-page-container { padding: 24px; background-color: #f5f7f9; min-height: 100vh; }
.page-card { background-color: #fff; border-radius: 4px; padding: 24px; }
.page-card {
background-color: #fff;
border-radius: 4px;
padding: 20px;
}
.search-wrap { display: flex; flex-direction: row; align-items: center; padding-bottom: 24px; border-bottom: 1px solid #f0f0f0; margin-bottom: 24px; }
.search-item { display: flex; flex-direction: row; align-items: center; margin-right: 24px; }
.label { font-size: 14px; color: #606266; margin-right: 8px; }
.input { width: 200px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 12px; font-size: 14px; }
.search-wrap {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 20px;
}
.btn { height: 32px; padding: 0 20px; border-radius: 4px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.btn-primary { background-color: #1890ff; color: #fff; font-size: 14px; }
.search-item {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 20px;
margin-bottom: 10px;
}
.action-wrap { margin-bottom: 20px; }
.label {
font-size: 14px;
color: #606266;
margin-right: 8px;
}
.table-wrap { border: 1px solid #f0f0f0; border-radius: 4px; }
.table-header { display: flex; flex-direction: row; background-color: #f8f8f9; }
.th { padding: 12px 10px; font-size: 14px; font-weight: bold; color: #515a6e; border-bottom: 1px solid #f0f0f0; text-align: center; }
.tr { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-height: 54px; align-items: center; }
.td { padding: 10px; display: flex; align-items: center; justify-content: center; }
.td-txt { font-size: 13px; color: #606266; }
.picker-input {
width: 150px;
height: 32px;
line-height: 32px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
background-color: #fff;
}
.action-btn { color: #1890ff; font-size: 13px; cursor: pointer; }
.danger { color: #ff4d4f; }
.divider { width: 1px; height: 12px; background-color: #e8e8e8; margin: 0 8px; }
.input {
width: 180px;
height: 32px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
}
.no-data { padding: 60px 0; text-align: center; }
.no-data-text { font-size: 14px; color: #c5c8ce; }
.loading-box { padding: 60px 0; text-align: center; color: #1890ff; }
.btn {
height: 32px;
line-height: 30px;
padding: 0 15px;
font-size: 14px;
border-radius: 4px;
border: none;
margin-bottom: 10px;
}
.pagination-footer { margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px; }
.total-txt { font-size: 13px; color: #999; }
.page-btns { display: flex; flex-direction: row; gap: 8px; }
.p-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; cursor: pointer; font-size: 13px; }
.p-btn.active { background-color: #1890ff; color: #fff; border-color: #1890ff; }
.p-btn.disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background-color: #1890ff;
color: #fff;
}
.action-wrap {
margin-bottom: 20px;
}
.table-wrap {
width: 100%;
border: 1px solid #f0f0f0;
}
.table-header {
display: flex;
flex-direction: row;
background-color: #f8f8f9;
}
.th {
padding: 12px 10px;
font-size: 14px;
font-weight: bold;
color: #515a6e;
border-bottom: 1px solid #f0f0f0;
text-align: center;
}
.table-body {
min-height: 100px;
}
.no-data {
padding: 40px 0;
text-align: center;
}
.no-data-text {
font-size: 14px;
color: #c5c8ce;
}
/* 弹窗样式 */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-card { width: 500px; background-color: #fff; border-radius: 8px; overflow: hidden; }
.modal-header { padding: 16px 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.modal-title { font-size: 16px; font-weight: bold; color: #333; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.modal-body { padding: 24px; }
.form-item { margin-bottom: 20px; display: flex; flex-direction: row; align-items: flex-start; }
.f-label { width: 90px; font-size: 14px; color: #666; text-align: right; padding-top: 6px; }
.f-input { flex: 1; border: 1px solid #dcdfe6; height: 36px; padding: 0 12px; border-radius: 4px; font-size: 14px; }
.f-textarea { flex: 1; border: 1px solid #dcdfe6; height: 80px; padding: 8px 12px; border-radius: 4px; font-size: 14px; }
.modal-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; }
</style>

View File

@@ -6,157 +6,253 @@
</view>
<!-- 表格区域 -->
<view class="table-wrap">
<view class="table-wrap border-shadow">
<view class="table-header">
<view class="th" style="flex: 1;">ID</view>
<view class="th" style="flex: 1;">序号</view>
<view class="th" style="flex: 1.5;">头像</view>
<view class="th" style="flex: 2;">名称</view>
<view class="th" style="flex: 2.5;">手机号码</view>
<view class="th" style="flex: 1.5;">是否显示</view>
<view class="th" style="flex: 1.5;">状态</view>
<view class="th" style="flex: 3;">添加时间</view>
<view class="th" style="flex: 2;">操作</view>
</view>
<view class="table-body">
<view v-for="item in courierList" :key="item.id" class="tr">
<view class="td" style="flex: 1;">{{ item.id }}</view>
<view v-if="loading" class="loading-box">
<text>加载中...</text>
</view>
<view v-else-if="staffList.length === 0" class="no-data">
<text class="no-data-text">暂无配送员数据</text>
</view>
<view v-else v-for="(item, index) in staffList" :key="item.id" class="tr">
<view class="td" style="flex: 1;"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td" style="flex: 1.5;">
<image class="avatar" :src="item.avatar" mode="aspectFill" />
<image v-if="item.avatar" class="avatar" :src="item.avatar" mode="aspectFill" />
<view v-else class="avatar-placeholder"><text>👤</text></view>
</view>
<view class="td" style="flex: 2;">{{ item.name }}</view>
<view class="td" style="flex: 2.5;">{{ item.phone }}</view>
<view class="td" style="flex: 2;"><text class="td-txt">{{ item.nickname }}</text></view>
<view class="td" style="flex: 2.5;"><text class="td-txt">{{ item.phone }}</text></view>
<view class="td" style="flex: 1.5;">
<switch :checked="item.isshow" color="#1890ff" @change="onToggleShow(item)" />
<switch :checked="item.status === 1" color="#1890ff" scale="0.7" @change="onToggleStatus(item)" />
</view>
<view class="td" style="flex: 3;">{{ item.addTime }}</view>
<view class="td" style="flex: 3;"><text class="td-txt-small">{{ formatTime(item.created_at) }}</text></view>
<view class="td" style="flex: 2;">
<text class="action-btn" @click="onEdit(item)">编辑</text>
<text class="action-btn-del" @click="onDel(item)">删除</text>
<view class="divider"></view>
<text class="action-btn danger" @click="onDelete(item)">删除</text>
</view>
</view>
</view>
</view>
<!-- 分页栏 -->
<view class="pagination-footer">
<text class="total-txt">共 {{ total }} 条</text>
<view class="page-btns">
<text :class="['p-btn', page <= 1 ? 'disabled' : '']" @click="prevPage"> < </text>
<text class="p-btn active">{{ page }}</text>
<text :class="['p-btn', staffList.length < pageSize ? 'disabled' : '']" @click="nextPage"> > </text>
</view>
</view>
</view>
<!-- 添加/编辑 弹窗 -->
<view v-if="showModal" class="modal-overlay" @click="closeModal">
<view class="modal-card" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ isEdit ? '编辑配送员' : '添加配送员' }}</text>
<text class="close-btn" @click="closeModal">×</text>
</view>
<view class="modal-body">
<view class="form-item">
<text class="f-label">名称:</text>
<input class="f-input" v-model="form.nickname" placeholder="请输入名称" />
</view>
<view class="form-item">
<text class="f-label">手机号:</text>
<input class="f-input" v-model="form.phone" type="number" placeholder="请输入手机号" />
</view>
<view class="form-item">
<text class="f-label">头像:</text>
<view class="upload-placeholder" @click="handleUpload">
<image v-if="form.avatar" :src="form.avatar" mode="aspectFill" class="avatar-preview" />
<text v-else>+</text>
</view>
</view>
</view>
<view class="modal-footer">
<button class="btn" @click="closeModal">取消</button>
<button class="btn btn-primary" @click="handleSave">保存</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { fetchDeliveryStaffPage, saveDeliveryStaff, deleteDeliveryStaff, type DeliveryStaff } from '@/services/admin/deliveryService.uts'
type CourierItem = {
id: number
avatar: string
name: string
phone: string
isshow: boolean
addTime: string
const staffList = ref<DeliveryStaff[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 15
// Modal state
const showModal = ref(false)
const isEdit = ref(false)
const form = reactive({
id: '' as string | null,
nickname: '',
phone: '',
avatar: '',
status: 1
})
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const res = await fetchDeliveryStaffPage(page.value, pageSize)
staffList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const courierList = reactive<CourierItem[]>([
{ id: 106, avatar: '/static/logo.png', name: 'cheshi', phone: '18943652356', isshow: true, addTime: '2025-06-29 21:45:19' },
{ id: 105, avatar: '/static/logo.png', name: 'dl', phone: '15648569914', isshow: true, addTime: '2025-06-28 18:40:26' },
{ id: 102, avatar: '/static/logo.png', name: '小牛马', phone: '13548652258', isshow: true, addTime: '2025-06-26 15:14:40' }
])
function onAdd() {
console.log('Add courier')
isEdit.value = false
form.id = null
form.nickname = ''
form.phone = ''
form.avatar = ''
form.status = 1
showModal.value = true
}
function onToggleShow(item: CourierItem) {
item.isshow = !item.isshow
function onEdit(item : DeliveryStaff) {
isEdit.value = true
form.id = item.id
form.nickname = item.nickname
form.phone = item.phone
form.avatar = item.avatar || ''
form.status = item.status
showModal.value = true
}
function onEdit(item: CourierItem) {
console.log('Edit:', item.name)
function closeModal() {
showModal.value = false
}
function onDel(item: CourierItem) {
console.log('Delete:', item.id)
async function handleSave() {
if (!form.nickname || !form.phone) {
uni.showToast({ title: '请填写姓名和手机号', icon: 'none' })
return
}
loading.value = true
try {
const resId = await saveDeliveryStaff(form)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeModal()
loadData()
}
} finally {
loading.value = false
}
}
async function onDelete(item : DeliveryStaff) {
uni.showModal({
title: '提示',
content: `确定要删除配送员 "${item.nickname}" 吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteDeliveryStaff(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
async function onToggleStatus(item : DeliveryStaff) {
const nextStatus = item.status === 1 ? 0 : 1
const ok = await saveDeliveryStaff({ ...item, status: nextStatus })
if (ok != null) {
item.status = nextStatus
uni.showToast({ title: '状态已更新' })
}
}
function prevPage() { if (page.value > 1) { page.value--; loadData(); } }
function nextPage() { if (staffList.value.length >= pageSize) { page.value++; loadData(); } }
function formatTime(iso : string | null) : string {
if (!iso) return '-'
return iso.substring(0, 16).replace('T', ' ')
}
function handleUpload() {
uni.showToast({ title: '上传功能开发中', icon: 'none' })
}
</script>
<style scoped>
.admin-page-container {
padding: 15px;
background-color: #f5f7f9;
min-height: 100vh;
}
<style scoped lang="scss">
.admin-page-container { padding: 24px; background-color: #f5f7f9; min-height: 100vh; }
.page-card { background-color: #fff; border-radius: 4px; padding: 24px; }
.page-card {
background-color: #fff;
border-radius: 4px;
padding: 20px;
}
.action-wrap { margin-bottom: 24px; }
.btn { height: 32px; padding: 0 20px; border-radius: 4px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; }
.btn-primary { background-color: #1890ff; color: #fff; }
.action-wrap {
margin-bottom: 20px;
}
.table-wrap { border: 1px solid #f0f0f0; border-radius: 4px; }
.table-header { display: flex; flex-direction: row; background-color: #f8f8f9; }
.th { padding: 12px 10px; font-size: 14px; font-weight: bold; color: #515a6e; border-bottom: 1px solid #f0f0f0; text-align: center; }
.tr { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-height: 60px; align-items: center; }
.td { padding: 10px; display: flex; align-items: center; justify-content: center; }
.td-txt { font-size: 13px; color: #606266; }
.td-txt-small { font-size: 12px; color: #999; }
.btn {
height: 32px;
line-height: 30px;
padding: 0 15px;
font-size: 14px;
border-radius: 4px;
border: none;
}
.avatar { width: 40px; height: 40px; border-radius: 4px; }
.avatar-placeholder { width: 40px; height: 40px; border-radius: 4px; background-color: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 20px; }
.btn-primary {
background-color: #1890ff;
color: #fff;
}
.action-btn { color: #1890ff; font-size: 13px; cursor: pointer; }
.danger { color: #ff4d4f; }
.divider { width: 1px; height: 12px; background-color: #e8e8e8; margin: 0 8px; }
.table-wrap {
width: 100%;
border: 1px solid #f0f0f0;
}
.no-data, .loading-box { padding: 60px 0; text-align: center; width: 100%; }
.no-data-text { font-size: 14px; color: #ccc; }
.table-header {
display: flex;
flex-direction: row;
background-color: #f8f8f9;
}
.pagination-footer { margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px; }
.total-txt { font-size: 13px; color: #999; }
.page-btns { display: flex; flex-direction: row; gap: 8px; }
.p-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; cursor: pointer; font-size: 13px; }
.p-btn.active { background-color: #1890ff; color: #fff; border-color: #1890ff; }
.p-btn.disabled { opacity: 0.5; cursor: not-allowed; }
.th {
padding: 12px 10px;
font-size: 14px;
font-weight: bold;
color: #515a6e;
border-bottom: 1px solid #f0f0f0;
text-align: center;
}
/* Modal */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-card { width: 450px; background-color: #fff; border-radius: 8px; overflow: hidden; }
.modal-header { padding: 16px 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.modal-title { font-size: 16px; font-weight: bold; color: #333; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.modal-body { padding: 24px; }
.form-item { margin-bottom: 20px; display: flex; flex-direction: row; align-items: center; }
.f-label { width: 80px; font-size: 14px; color: #666; text-align: right; margin-right: 15px; }
.f-input { flex: 1; border: 1px solid #dcdfe6; height: 36px; padding: 0 12px; border-radius: 4px; font-size: 14px; }
.tr {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
}
.upload-placeholder { width: 64px; height: 64px; border: 1px dashed #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 24px; color: #ccc; cursor: pointer; }
.avatar-preview { width: 100%; height: 100%; border-radius: 4px; }
.td {
padding: 12px 10px;
font-size: 14px;
color: #515a6e;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 4px;
}
.action-btn {
color: #1890ff;
font-size: 13px;
margin-right: 10px;
cursor: pointer;
}
.action-btn-del {
color: #ed4014;
font-size: 13px;
cursor: pointer;
}
.modal-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; }
</style>

View File

@@ -5,16 +5,9 @@
<view class="search-wrap">
<view class="search-item">
<text class="label">提货点搜索:</text>
<input class="input" placeholder="请输入提货点名称,电话" v-model="searchKey" />
<input class="input" placeholder="请输入提货点名称,电话" v-model="searchKey" @confirm="handleQuery" />
</view>
<button class="btn btn-primary" @click="onSearch">查询</button>
</view>
<!-- 状态 Tabs -->
<view class="tabs-wrap">
<view class="tab-item active"><text class="tab-text">显示中的提货点(2)</text></view>
<view class="tab-item"><text class="tab-text">隐藏中的提货点(0)</text></view>
<view class="tab-item"><text class="tab-text">回收站中的提货点(9)</text></view>
<button class="btn btn-primary" @click="handleQuery">查询</button>
</view>
<view class="action-wrap">
@@ -22,209 +15,272 @@
</view>
<!-- 表格区域 -->
<view class="table-wrap">
<view class="table-wrap border-shadow">
<view class="table-header">
<view class="th" style="flex: 1;">ID</view>
<view class="th" style="flex: 1;">序号</view>
<view class="th" style="flex: 2;">提货点图片</view>
<view class="th" style="flex: 2;">提货点名称</view>
<view class="th" style="flex: 2;">提货点电话</view>
<view class="th" style="flex: 3;">地址</view>
<view class="th" style="flex: 3;">营业时间</view>
<view class="th" style="flex: 1.5;">是否显示</view>
<view class="th" style="flex: 2;">操作</view>
</view>
<view class="table-body">
<view v-for="item in stationList" :key="item.id" class="tr">
<view class="td" style="flex: 1;">{{ item.id }}</view>
<view v-if="loading" class="loading-box">
<text>加载中...</text>
</view>
<view v-else-if="stationList.length === 0" class="no-data">
<text class="no-data-text">暂无提货点数据</text>
</view>
<view v-else v-for="(item, index) in stationList" :key="item.id" class="tr">
<view class="td" style="flex: 1;"><text class="td-txt">{{ (page - 1) * pageSize + index + 1 }}</text></view>
<view class="td" style="flex: 2;">
<image class="station-img" :src="item.image" mode="aspectFill" />
<image v-if="item.image" class="station-img" :src="item.image" mode="aspectFill" />
<view v-else class="img-placeholder"><text>🖼️</text></view>
</view>
<view class="td" style="flex: 2;">{{ item.name }}</view>
<view class="td" style="flex: 2;">{{ item.phone }}</view>
<view class="td" style="flex: 3;">{{ item.address }}</view>
<view class="td" style="flex: 3;">{{ item.hours }}</view>
<view class="td" style="flex: 2;"><text class="td-txt">{{ item.name }}</text></view>
<view class="td" style="flex: 2;"><text class="td-txt">{{ item.phone }}</text></view>
<view class="td" style="flex: 3;"><text class="td-txt ellipsis-2">{{ item.address }}</text></view>
<view class="td" style="flex: 1.5;">
<switch :checked="item.isshow" color="#1890ff" />
<switch :checked="item.status === 1" color="#1890ff" scale="0.7" @change="onToggleStatus(item)" />
</view>
<view class="td" style="flex: 2;">
<text class="action-btn" @click="onEdit(item)">编辑</text>
<text class="action-btn-del" @click="onDel(item)">删除</text>
<view class="divider"></view>
<text class="action-btn danger" @click="onDelete(item)">删除</text>
</view>
</view>
</view>
</view>
<!-- 分页栏 -->
<view class="pagination-footer">
<text class="total-txt">共 {{ total }} 条</text>
<view class="page-btns">
<text :class="['p-btn', page <= 1 ? 'disabled' : '']" @click="prevPage"> < </text>
<text class="p-btn active">{{ page }}</text>
<text :class="['p-btn', stationList.length < pageSize ? 'disabled' : '']" @click="nextPage"> > </text>
</view>
</view>
</view>
<!-- 添加/编辑 弹窗 -->
<view v-if="showModal" class="modal-overlay" @click="closeModal">
<view class="modal-card" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ isEdit ? '编辑提货点' : '添加提货点' }}</text>
<text class="close-btn" @click="closeModal">×</text>
</view>
<view class="modal-body">
<scroll-view scroll-y="true" style="max-height: 500px;">
<view class="form-item">
<text class="f-label">提货点名称:</text>
<input class="f-input" v-model="form.name" placeholder="请输入名称" />
</view>
<view class="form-item">
<text class="f-label">联系电话:</text>
<input class="f-input" v-model="form.phone" type="number" placeholder="请输入电话" />
</view>
<view class="form-item">
<text class="f-label">详细地址:</text>
<input class="f-input" v-model="form.address" placeholder="请输入详细地址" />
</view>
<view class="form-item">
<text class="f-label">展示图片:</text>
<view class="upload-placeholder" @click="handleUpload">
<image v-if="form.image" :src="form.image" mode="aspectFill" class="img-preview" />
<text v-else>+</text>
</view>
</view>
</scroll-view>
</view>
<view class="modal-footer">
<button class="btn" @click="closeModal">取消</button>
<button class="btn btn-primary" @click="handleSave">保存</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { fetchDeliveryStationPage, saveDeliveryStation, deleteDeliveryStation, type DeliveryStation } from '@/services/admin/deliveryService.uts'
const stationList = ref<DeliveryStation[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = 15
const searchKey = ref('')
type StationItem = {
id: number
image: string
name: string
phone: string
address: string
hours: string
isshow: boolean
// Modal state
const showModal = ref(false)
const isEdit = ref(false)
const form = reactive({
id: '' as string | null,
name: '',
phone: '',
address: '',
image: '',
status: 1,
sort_order: 0
})
onMounted(() => {
loadData()
})
async function loadData() {
loading.value = true
try {
const res = await fetchDeliveryStationPage(page.value, pageSize, searchKey.value || null)
stationList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
const stationList = reactive<StationItem[]>([
{ id: 46, image: '/static/logo.png', name: '提货点222', phone: '13769102384', address: '能看见你的困难', hours: '00:00:00 - 23:00:00', isshow: true },
{ id: 44, image: '/static/logo.png', name: '美东科技', phone: '15912341234', address: '襄阳火车站', hours: '08:00:00 - 22:00:00', isshow: true }
])
function onSearch() {
console.log('Search:', searchKey.value)
function handleQuery() {
page.value = 1
loadData()
}
function onAdd() {
console.log('Add station')
isEdit.value = false
form.id = null
form.name = ''
form.phone = ''
form.address = ''
form.image = ''
form.status = 1
form.sort_order = 0
showModal.value = true
}
function onEdit(item: StationItem) {
console.log('Edit:', item.name)
function onEdit(item : DeliveryStation) {
isEdit.value = true
form.id = item.id
form.name = item.name
form.phone = item.phone
form.address = item.address
form.image = item.image || ''
form.status = item.status
form.sort_order = item.sort_order
showModal.value = true
}
function onDel(item: StationItem) {
console.log('Delete:', item.id)
function closeModal() {
showModal.value = false
}
async function handleSave() {
if (!form.name || !form.phone || !form.address) {
uni.showToast({ title: '请完善必要信息', icon: 'none' })
return
}
loading.value = true
try {
const resId = await saveDeliveryStation(form)
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeModal()
loadData()
}
} finally {
loading.value = false
}
}
async function onDelete(item : DeliveryStation) {
uni.showModal({
title: '提示',
content: `确定要删除提货点 "${item.name}" 吗?`,
success: async (res) => {
if (res.confirm) {
const ok = await deleteDeliveryStation(item.id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadData()
}
}
}
})
}
async function onToggleStatus(item : DeliveryStation) {
const nextStatus = item.status === 1 ? 0 : 1
const ok = await saveDeliveryStation({ ...item, status: nextStatus })
if (ok != null) {
item.status = nextStatus
uni.showToast({ title: '状态已更新' })
}
}
function prevPage() { if (page.value > 1) { page.value--; loadData(); } }
function nextPage() { if (stationList.value.length >= pageSize) { page.value++; loadData(); } }
function handleUpload() {
uni.showToast({ title: '上传功能开发中', icon: 'none' })
}
</script>
<style scoped>
.admin-page-container {
padding: 15px;
background-color: #f5f7f9;
min-height: 100vh;
}
<style scoped lang="scss">
.admin-page-container { padding: 24px; background-color: #f5f7f9; min-height: 100vh; }
.page-card { background-color: #fff; border-radius: 4px; padding: 24px; }
.page-card {
background-color: #fff;
border-radius: 4px;
padding: 20px;
}
.search-wrap { display: flex; flex-direction: row; align-items: center; padding-bottom: 24px; border-bottom: 1px solid #f0f0f0; margin-bottom: 24px; }
.search-item { display: flex; flex-direction: row; align-items: center; margin-right: 24px; }
.label { font-size: 14px; color: #606266; margin-right: 8px; }
.input { width: 250px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 12px; font-size: 14px; }
.search-wrap {
display: flex;
flex-direction: row;
align-items: center;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 20px;
}
.btn { height: 32px; padding: 0 20px; border-radius: 4px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; }
.btn-primary { background-color: #1890ff; color: #fff; }
.label {
font-size: 14px;
color: #606266;
}
.action-wrap { margin-bottom: 20px; }
.input {
width: 250px;
height: 32px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
margin-right: 15px;
}
.table-wrap { border: 1px solid #f0f0f0; border-radius: 4px; }
.table-header { display: flex; flex-direction: row; background-color: #f8f8f9; }
.th { padding: 12px 10px; font-size: 14px; font-weight: bold; color: #515a6e; border-bottom: 1px solid #f0f0f0; text-align: center; }
.tr { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-height: 60px; align-items: center; }
.td { padding: 10px; display: flex; align-items: center; justify-content: center; }
.td-txt { font-size: 13px; color: #606266; text-align: center; }
.ellipsis-2 { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.tabs-wrap {
display: flex;
flex-direction: row;
margin-bottom: 20px;
border-bottom: 1px solid #f0f2f5;
}
.station-img { width: 44px; height: 44px; border-radius: 4px; background-color: #f5f5f5; }
.img-placeholder { width: 44px; height: 44px; border-radius: 4px; background-color: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 20px; }
.tab-item {
padding: 10px 15px;
cursor: pointer;
}
.action-btn { color: #1890ff; font-size: 13px; cursor: pointer; }
.danger { color: #ff4d4f; }
.divider { width: 1px; height: 12px; background-color: #e8e8e8; margin: 0 8px; }
.tab-item.active {
border-bottom: 2px solid #1890ff;
}
.no-data, .loading-box { padding: 60px 0; text-align: center; width: 100%; }
.no-data-text { font-size: 14px; color: #ccc; }
.tab-item.active .tab-text {
color: #1890ff;
}
.pagination-footer { margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px; }
.total-txt { font-size: 13px; color: #999; }
.page-btns { display: flex; flex-direction: row; gap: 8px; }
.p-btn { width: 30px; height: 30px; border: 1px solid #dcdee2; display: flex; align-items: center; justify-content: center; border-radius: 4px; cursor: pointer; font-size: 13px; }
.p-btn.active { background-color: #1890ff; color: #fff; border-color: #1890ff; }
.p-btn.disabled { opacity: 0.5; cursor: not-allowed; }
.tab-text {
font-size: 14px;
color: #515a6e;
}
/* Modal */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-card { width: 500px; background-color: #fff; border-radius: 8px; overflow: hidden; }
.modal-header { padding: 16px 20px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.modal-title { font-size: 16px; font-weight: bold; color: #333; }
.close-btn { font-size: 24px; color: #999; cursor: pointer; }
.modal-body { padding: 24px; }
.form-item { margin-bottom: 20px; display: flex; flex-direction: row; align-items: center; }
.f-label { width: 90px; font-size: 14px; color: #666; text-align: right; margin-right: 15px; }
.f-input { flex: 1; border: 1px solid #dcdfe6; height: 36px; padding: 0 12px; border-radius: 4px; font-size: 14px; }
.action-wrap {
margin-bottom: 20px;
}
.upload-placeholder { width: 64px; height: 64px; border: 1px dashed #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 24px; color: #ccc; cursor: pointer; }
.img-preview { width: 100%; height: 100%; border-radius: 4px; }
.btn {
height: 32px;
line-height: 30px;
padding: 0 15px;
font-size: 14px;
border-radius: 4px;
border: none;
}
.btn-primary {
background-color: #1890ff;
color: #fff;
}
.table-wrap {
width: 100%;
border: 1px solid #f0f0f0;
}
.table-header {
display: flex;
flex-direction: row;
background-color: #f8f8f9;
}
.th {
padding: 12px 10px;
font-size: 14px;
font-weight: bold;
color: #515a6e;
border-bottom: 1px solid #f0f0f0;
text-align: center;
}
.tr {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
}
.td {
padding: 12px 10px;
font-size: 14px;
color: #515a6e;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.station-img {
width: 40px;
height: 40px;
border-radius: 4px;
}
.action-btn {
color: #1890ff;
font-size: 13px;
margin-right: 10px;
}
.action-btn-del {
color: #ed4014;
font-size: 13px;
}
.modal-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; }
</style>

View File

@@ -2,332 +2,234 @@
<view class="admin-page">
<view class="admin-sections">
<view class="admin-card settings-card">
<!-- 顶部导航标签 (1:1 复刻 CRMEB: 横向排列) -->
<!-- 顶部导航标签 -->
<view class="tabs-container">
<scroll-view class="tabs-scroll" scroll-x="true" show-scrollbar="false" :enable-flex="true">
<view class="tabs-bar">
<view
v-for="(tab, index) in tabs"
:key="index"
class="tab-item"
:class="{ active: currentTab === index }"
@click="currentTab = index"
>
<text class="tab-text">{{ tab.name }}</text>
<view class="tab-line" v-if="currentTab === index"></view>
<scroll-view class="tabs-scroll" scroll-x="true" show-scrollbar="false" :enable-flex="true">
<view class="tabs-bar">
<view
v-for="(tab, index) in tabs"
:key="index"
class="tab-item"
:class="{ active: currentTab === index }"
@click="currentTab = index"
>
<text class="tab-text">{{ tab.name }}</text>
<view class="tab-line" v-if="currentTab === index"></view>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 表单区域 -->
<view class="form-container">
<!-- 1. 基础配置 -->
<view v-if="currentTab === 0" class="form-content">
<view class="form-item">
<view class="form-label">站点开启:</view>
<view class="form-right">
<radio-group class="radio-group" @change="formData.site_open = parseInt(($event.detail.value as string))">
<label class="radio-label"><radio value="1" :checked="formData.site_open == 1" color="#1890ff" />开启</label>
<label class="radio-label"><radio value="0" :checked="formData.site_open == 0" color="#1890ff" />关闭</label>
</radio-group>
<view class="form-tip">站点开启/关闭(用于升级等临时关闭),关闭后前端会弹窗显示站点升级中,请稍后访问</view>
</view>
</view>
<view class="form-item">
<view class="form-label">网站名称:</view>
<view class="form-right">
<input class="form-input" v-model="formData.site_name" placeholder="请输入网站名称" />
<view class="form-tip">网站名称很多地方会显示的,建议认真填写</view>
</view>
</view>
<view class="form-item">
<view class="form-label">网站地址:</view>
<view class="form-right">
<input class="form-input" v-model="formData.site_url" placeholder="请输入网站地址" />
<view class="form-tip">安装自动配置,不要轻易修改,更换后会影响网站访问、接口请求、本地文件储存、支付回调、微信授权、支付、小程序图片访问、部分二维码、官方授权等</view>
</view>
</view>
<view class="form-item">
<view class="form-label">消息队列:</view>
<view class="form-right">
<radio-group class="radio-group" @change="formData.msg_queue = parseInt(($event.detail.value as string))">
<label class="radio-label"><radio value="1" :checked="formData.msg_queue == 1" color="#1890ff" />开启</label>
<label class="radio-label"><radio value="0" :checked="formData.msg_queue == 0" color="#1890ff" />关闭</label>
</radio-group>
<view class="form-tip">是否启用消息队列启用后提升程序运行速度启用前必须配置Redis缓存文档地址https://doc.crmeb.com/single/crmeb_v4/7217</view>
</view>
</view>
<view class="form-item">
<view class="form-label">联系电话:</view>
<view class="form-right">
<input class="form-input" v-model="formData.contact_phone" placeholder="请输入联系电话" />
<view class="form-tip">联系电话</view>
</view>
</view>
<view class="form-item">
<view class="form-label">授权密钥:</view>
<view class="form-right">
<input class="form-input" v-model="formData.auth_key" placeholder="请输入授权密钥" />
</view>
</view>
</scroll-view>
</view>
<!-- 2. 分享配置 -->
<view v-else-if="currentTab === 1" class="form-content">
<view class="form-item">
<view class="form-label">分享图片:</view>
<view class="form-right">
<view class="upload-placeholder" @click="handleUpload('share_img')">上传图片</view>
<view class="form-tip">分享图片比例5:4建议小于50KB</view>
</view>
<!-- 表单区域 -->
<view class="form-container">
<view v-if="isLoading" class="loading-state">
<text>加载配置中...</text>
</view>
<view class="form-item">
<view class="form-label">分享标题:</view>
<view class="form-right">
<input class="form-input" v-model="formData.share_title" />
<view v-else class="form-scroll-wrap">
<!-- 1. 基础配置 -->
<view v-if="currentTab === 0" class="form-content">
<view class="form-item">
<view class="form-label">站点开启:</view>
<view class="form-right">
<radio-group class="radio-group" @change="formData.site_open = parseInt(($event.detail.value as string))">
<label class="radio-label"><radio value="1" :checked="formData.site_open == 1" color="#1890ff" />开启</label>
<label class="radio-label"><radio value="0" :checked="formData.site_open == 0" color="#1890ff" />关闭</label>
</radio-group>
<view class="form-tip">站点开启/关闭(用于升级等临时关闭)</view>
</view>
</view>
<view class="form-item">
<view class="form-label">网站名称:</view>
<view class="form-right">
<input class="form-input" v-model="formData.site_name" placeholder="请输入网站名称" />
</view>
</view>
<view class="form-item">
<view class="form-label">网站地址:</view>
<view class="form-right">
<input class="form-input" v-model="formData.site_url" placeholder="请输入网站地址" />
</view>
</view>
<view class="form-item">
<view class="form-label">联系电话:</view>
<view class="form-right">
<input class="form-input" v-model="formData.contact_phone" placeholder="请输入联系电话" />
</view>
</view>
</view>
</view>
<view class="form-item">
<view class="form-label">分享简介:</view>
<view class="form-right">
<textarea class="form-textarea" v-model="formData.share_desc" />
</view>
</view>
</view>
<!-- 3. LOGO配置 -->
<view v-else-if="currentTab === 2" class="form-content">
<view class="form-item">
<view class="form-label">后台登录LOGO:</view>
<view class="form-right">
<view class="upload-placeholder" @click="handleUpload('login_logo')">上传截图</view>
<view class="form-tip">建议尺寸270*75</view>
<!-- 2. 分享配置 -->
<view v-else-if="currentTab === 1" class="form-content">
<view class="form-item">
<view class="form-label">分享标题:</view>
<view class="form-right">
<input class="form-input" v-model="formData.share_title" />
</view>
</view>
<view class="form-item">
<view class="form-label">分享简介:</view>
<view class="form-right">
<textarea class="form-textarea" v-model="formData.share_desc" />
</view>
</view>
</view>
</view>
<view class="form-item">
<view class="form-label">后台小LOGO:</view>
<view class="form-right">
<view class="upload-placeholder" @click="handleUpload('small_logo')">上传图片</view>
<view class="form-tip">建议尺寸180*180</view>
</view>
</view>
<view class="form-item">
<view class="form-label">后台大LOGO:</view>
<view class="form-right">
<view class="upload-placeholder" @click="handleUpload('big_logo')">上传图片</view>
<view class="form-tip">建议尺寸170*50</view>
</view>
</view>
</view>
<!-- 4. 自定义JS -->
<view v-else-if="currentTab === 3" class="form-content">
<view class="form-item">
<view class="form-label">移动端JS:</view>
<view class="form-right">
<textarea class="form-textarea code-bg" v-model="formData.mobile_js" />
<!-- 3. LOGO配置 -->
<view v-else-if="currentTab === 2" class="form-content">
<view class="form-item">
<view class="form-label">后台登录LOGO:</view>
<view class="form-right">
<view class="upload-placeholder" @click="handleUpload('login_logo')">
<image v-if="formData.login_logo" :src="formData.login_logo" mode="aspectFit" class="logo-preview" />
<text v-else>上传图片</text>
</view>
</view>
</view>
</view>
</view>
<view class="form-item">
<view class="form-label">管理端JS:</view>
<view class="form-right">
<textarea class="form-textarea code-bg" v-model="formData.admin_js" />
</view>
</view>
<view class="form-item">
<view class="form-label">PC端JS:</view>
<view class="form-right">
<textarea class="form-textarea code-bg" v-model="formData.pc_js" />
</view>
</view>
</view>
<!-- 5. 地图配置 -->
<view v-else-if="currentTab === 4" class="form-content">
<view class="form-item">
<view class="form-label">腾讯地图KEY:</view>
<view class="form-right">
<input class="form-input" v-model="formData.tencent_map_key" />
<view class="form-tip">申请地址https://lbs.qq.com</view>
<!-- 9. WAF配置 -->
<view v-else-if="currentTab === 8" class="form-content">
<view class="form-item">
<view class="form-label">WAF类型:</view>
<view class="form-right">
<radio-group class="radio-group" @change="formData.waf_type = parseInt(($event.detail.value as string))">
<label class="radio-label"><radio value="0" :checked="formData.waf_type == 0" color="#1890ff" />关闭</label>
<label class="radio-label"><radio value="1" :checked="formData.waf_type == 1" color="#1890ff" />拦截</label>
<label class="radio-label"><radio value="2" :checked="formData.waf_type == 2" color="#1890ff" />过滤</label>
</radio-group>
</view>
</view>
<view class="form-item">
<view class="form-label">WAF规则:</view>
<view class="form-right">
<textarea class="form-textarea code-bg" v-model="formData.waf_config" />
</view>
</view>
</view>
</view>
</view>
<!-- 6. 备案配置 -->
<view v-else-if="currentTab === 5" class="form-content">
<view class="form-item">
<view class="form-label">备案号:</view>
<view class="form-right">
<input class="form-input" v-model="formData.filing_no" />
<!-- 提交按钮 -->
<view class="submit-section">
<view class="form-label"></view>
<view class="form-right">
<button class="btn-submit" :disabled="isSaving" @click="handleSubmit">
<text class="btn-txt">{{ isSaving ? '保存中...' : '提交' }}</text>
</button>
</view>
</view>
</view>
<view class="form-item">
<view class="form-label">ICP链接:</view>
<view class="form-right">
<input class="form-input" v-model="formData.icp_link" />
</view>
</view>
</view>
<!-- 7. 模块配置 -->
<view v-else-if="currentTab === 6" class="form-content">
<view class="form-item">
<view class="form-label">功能开启:</view>
<view class="form-right">
<checkbox-group class="checkbox-group" @change="formData.module_config = ($event.detail.value as string[])">
<label class="checkbox-label"><checkbox value="秒杀" :checked="formData.module_config.includes('秒杀')" color="#1890ff" />秒杀</label>
<label class="checkbox-label"><checkbox value="砍价" :checked="formData.module_config.includes('砍价')" color="#1890ff" />砍价</label>
<label class="checkbox-label"><checkbox value="拼团" :checked="formData.module_config.includes('拼团')" color="#1890ff" />拼团</label>
</checkbox-group>
</view>
</view>
</view>
<!-- 8. 远程登录 -->
<view v-else-if="currentTab === 7" class="form-content">
<view class="form-item">
<view class="form-label">远程登录地址:</view>
<view class="form-right">
<input class="form-input" v-model="formData.remote_login_url" />
</view>
</view>
</view>
<!-- 9. WAF配置 -->
<view v-else-if="currentTab === 8" class="form-content">
<view class="form-item">
<view class="form-label">WAF类型:</view>
<view class="form-right">
<radio-group class="radio-group" @change="formData.waf_type = parseInt(($event.detail.value as string))">
<label class="radio-label"><radio value="0" :checked="formData.waf_type == 0" color="#1890ff" />关闭</label>
<label class="radio-label"><radio value="1" :checked="formData.waf_type == 1" color="#1890ff" />拦截</label>
<label class="radio-label"><radio value="2" :checked="formData.waf_type == 2" color="#1890ff" />过滤</label>
</radio-group>
<view class="form-tip">WAF类型关闭所有参数都能正常请求拦截匹配到WAF配置的参数阻断接口请求过滤匹配到WAF配置的参数过滤参数正常请求接口</view>
</view>
</view>
<view class="form-item">
<view class="form-label">WAF配置:</view>
<view class="form-right">
<textarea class="form-textarea code-bg waf-textarea" v-model="formData.waf_config" />
<view class="form-tip">WAF配置验证参数过滤掉不需要的参数或拦截请求多个参数用回车换行分隔</view>
</view>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-section">
<view class="form-label"></view> <!-- 占位用于对齐 -->
<view class="form-right">
<button class="btn-submit" @click="handleSubmit">提交</button>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from "vue"
import { ref, reactive, onMounted } from "vue"
import { getSystemConfig, saveSystemConfig } from "@/services/admin/systemConfigService.uts"
const currentTab = ref(0)
const tabs = [
{ name: "基础配置" }, { name: "分享配置" }, { name: "LOGO配置" },
{ name: "自定义JS" }, { name: "地图配置" }, { name: "备案配置" },
{ name: "模块配置" }, { name: "远程登录配置" }, { name: "WAF配置" }
{ name: "模块配置" }, { name: "远程登录" }, { name: "WAF配置" }
]
const formData = ref({
const isLoading = ref(false)
const isSaving = ref(false)
const formData = reactive({
site_open: 1,
site_name: "CRMEB标准版",
site_url: "https://v5.crmeb.net",
site_name: "",
site_url: "",
msg_queue: 0,
contact_phone: "",
auth_key: "AO9azvBW9vEcOH7swklTM0RYRb6EB4RLWMSD88MnKTi8Vd6cjXVd",
auth_key: "",
share_img: "",
share_title: "CRMEB v5标准版",
share_desc: "完善的文档 全心而来!",
share_title: "",
share_desc: "",
login_logo: "",
small_logo: "",
big_logo: "",
mobile_js: "",
admin_js: "",
pc_js: "",
tencent_map_key: "SMJBZ-WCHK4-ZPZUA-DSIXI-XDDVQ-XWFX7",
filing_no: "陕ICP备14011498号-3",
icp_link: "https://beian.miit.gov.cn/",
module_config: ["秒杀", "砍价", "拼团"] as string[],
tencent_map_key: "",
filing_no: "",
icp_link: "",
module_config: [] as string[],
remote_login_url: "",
waf_type: 2,
waf_config: "/\\.\\.\\//\n/\\<\\?/\n/\\bor\\b.*=\\s*\\*/i\n/(select[\\s\\S]*?)(from|limit)/i\n/(union[\\s\\S]*?select)/i\n/(having\\s+updatexml|extractvalue)/i"
waf_type: 0,
waf_config: ""
})
const handleUpload = (field: string) => {
uni.showToast({ title: "选择文件: " + field, icon: "none" })
onMounted(() => {
loadConfig()
})
async function loadConfig() {
isLoading.value = true
try {
const res = await getSystemConfig('system_settings')
if (res != null) {
Object.assign(formData, res as any)
}
} finally {
isLoading.value = false
}
}
const handleSubmit = () => {
uni.showLoading({ title: "保存中..." })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: "保存成功", icon: "success" })
}, 800)
async function handleSubmit() {
isSaving.value = true
try {
const ok = await saveSystemConfig('system_settings', formData as UTSJSONObject, '系统全局配置项')
if (ok) {
uni.showToast({ title: "保存成功", icon: "success" })
} else {
uni.showToast({ title: "保存失败", icon: "none" })
}
} finally {
isSaving.value = false
}
}
const handleUpload = (field: string) => {
uni.showToast({ title: "上传功能开发中: " + field, icon: "none" })
}
</script>
<style scoped>
.settings-card {
box-shadow: 0 1px 4px rgba(0,21,41,0.08);
}
.admin-page { padding: 24px; background-color: #f0f2f5; min-height: 100vh; }
.settings-card { background: #fff; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
/* 核心修复:确保 Tabs 横向排列并且可以滑动 */
.tabs-container { margin-bottom: 30px; border-bottom: 1px solid #e8eaec; width: 100%; overflow: hidden; }
.tabs-container { margin-bottom: 30px; border-bottom: 1px solid #e8eaec; }
.tabs-scroll { width: 100%; white-space: nowrap; }
.tabs-bar { display: inline-flex; flex-direction: row; min-width: 100%; }
.tab-item {
display: inline-flex;
flex-direction: row;
align-items: center;
padding: 12px 24px;
font-size: 14px;
color: #515a6e;
position: relative;
cursor: pointer;
flex-shrink: 0;
}
.tab-item.active { color: #1890ff; font-weight: bold; }
.tabs-bar { display: inline-flex; flex-direction: row; }
.tab-item { padding: 12px 24px; cursor: pointer; position: relative; }
.tab-text { font-size: 14px; color: #515a6e; }
.tab-item.active .tab-text { color: #1890ff; font-weight: bold; }
.tab-line { position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background-color: #1890ff; }
.form-container { padding-left: 20px; }
.form-container { padding: 0 20px 40px; }
.loading-state { padding: 60px; text-align: center; color: #999; }
/* 核心修复:确保表单项横向排列 (Label left, Input right) */
.form-item { display: flex; flex-direction: row; margin-bottom: 25px; align-items: flex-start; }
.form-label { width: 140px; font-size: 14px; color: #303133; text-align: right; padding-right: 20px; padding-top: 8px; flex-shrink: 0; }
.form-right { flex: 1; display: flex; flex-direction: column; width: 100%; }
.form-right { flex: 1; display: flex; flex-direction: column; }
.form-input { border: 1px solid #dcdfe6; width: 450px; height: 32px; padding: 0 15px; border-radius: 4px; font-size: 14px; color: #606266; outline: none; transition: border-color .2s; }
.form-input:focus { border-color: #409eff; }
.form-input { border: 1px solid #dcdfe6; width: 100%; max-width: 450px; height: 32px; padding: 0 12px; border-radius: 4px; font-size: 14px; }
.form-textarea { border: 1px solid #dcdfe6; width: 100%; max-width: 550px; height: 100px; padding: 8px 12px; border-radius: 4px; font-size: 14px; }
.code-bg { background-color: #f5f7fa; }
.form-tip { font-size: 12px; color: #c0c4cc; margin-top: 8px; }
.form-textarea { border: 1px solid #dcdfe6; width: 550px; height: 120px; padding: 10px 15px; border-radius: 4px; font-size: 14px; color: #606266; line-height: 1.6; outline: none; }
.form-textarea:focus { border-color: #409eff; }
.radio-group { display: flex; flex-direction: row; gap: 20px; height: 32px; align-items: center; }
.radio-label { display: flex; flex-direction: row; align-items: center; font-size: 14px; color: #606266; }
.waf-textarea { height: 200px; }
.code-bg { background-color: #f5f7fa; font-family: "Lucida Console", Monaco, monospace; }
.upload-placeholder { width: 80px; height: 80px; border: 1px dashed #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #909399; cursor: pointer; }
.logo-preview { width: 100%; height: 100%; }
.form-tip { font-size: 12px; color: #c0c4cc; margin-top: 8px; line-height: 1.5; width: 550px; }
.radio-group, .checkbox-group { display: flex; flex-direction: row; align-items: center; min-height: 32px; }
.radio-label, .checkbox-label { display: flex; flex-direction: row; align-items: center; margin-right: 30px; font-size: 14px; color: #606266; cursor: pointer; }
.upload-placeholder { width: 80px; height: 80px; border: 1px dashed #dcdfe6; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #909399; cursor: pointer; transition: all .2s; }
.upload-placeholder:hover { border-color: #409eff; color: #409eff; }
.submit-section { display: flex; flex-direction: row; margin-top: 20px; padding-top: 10px; }
.btn-submit { background-color: #1890ff; color: #fff; width: 65px; height: 32px; line-height: 32px; font-size: 14px; border-radius: 4px; margin: 0; border: none; cursor: pointer; text-align: center; }
.btn-submit:active { background-color: #096dd9; }
</style>
.submit-section { display: flex; flex-direction: row; margin-top: 20px; }
.btn-submit { background-color: #1890ff; border: none; padding: 0 24px; height: 36px; border-radius: 4px; cursor: pointer; }
.btn-txt { color: #fff; font-size: 14px; }
.btn-submit[disabled] { opacity: 0.6; }
</style>

View File

@@ -1,81 +1,177 @@
<template>
<view class="page-container">
<view class="page-header">
<text class="page-title">数据概览</text>
<text class="page-subtitle">Component: StatisticIndex</text>
</view>
<view class="page-content">
<view class="placeholder-card">
<text class="placeholder-title">页面占位</text>
<text class="placeholder-desc">该功能模块正在开发中</text>
<text class="placeholder-info">当前采用 CRMEB 路由体系 1:1 映射</text>
<view class="admin-statistic">
<!-- 核心指标卡片:累计数据 -->
<view class="stats-grid">
<view class="stat-card border-shadow" v-for="(item, key) in totalStats" :key="key">
<view class="stat-icon-box" :class="item.color">
<text class="stat-ic">{{ item.icon }}</text>
</view>
<view class="stat-info">
<text class="stat-val">{{ item.value }}</text>
<text class="stat-label">{{ item.label }}</text>
</view>
</view>
</view>
<!-- 今日实时数据 -->
<view class="section-title mt-24">
<text class="title-txt">今日实时</text>
<text class="sub-txt">更新时间:{{ currentTime }}</text>
</view>
<view class="stats-grid-mini">
<view class="stat-mini-card border-shadow" v-for="(item, key) in todayStats" :key="key">
<text class="sm-label">{{ item.label }}</text>
<text class="sm-val">{{ item.value }}</text>
</view>
</view>
<!-- 待办任务提醒 -->
<view class="section-title mt-24">
<text class="title-txt">待办提醒</text>
</view>
<view class="pending-grid">
<view class="pending-card border-shadow" v-for="(item, key) in pendingStats" :key="key">
<view class="p-left">
<text class="p-label">{{ item.label }}</text>
<text class="p-count">{{ item.value }}</text>
</view>
<view class="p-right">
<text class="p-link" @click="handlePending(key)">去处理 ></text>
</view>
</view>
</view>
<!-- 图表占位 -->
<view class="chart-section mt-24 border-shadow">
<view class="chart-header">
<text class="chart-title">销售趋势概览</text>
</view>
<view class="chart-body">
<text class="chart-placeholder-txt">趋势图表正在对接中...</text>
</view>
</view>
<view v-if="isLoading" class="global-loading">
<text class="loading-txt">数据聚合中...</text>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
// TODO: 实现 数据概览 的具体功能
const loading = ref<boolean>(false)
const isLoading = ref(false)
const currentTime = ref('')
const totalStats = ref({
sales: { label: '总销售额', value: '0.00', icon: '💰', color: 'blue' },
orders: { label: '总订单数', value: '0', icon: '📦', color: 'orange' },
users: { label: '总用户数', value: '0', icon: '👥', color: 'green' },
products: { label: '总商品数', value: '0', icon: '🛒', color: 'pink' }
})
const todayStats = ref({
sales: { label: '今日成交额', value: '0.00' },
orders: { label: '今日订单数', value: '0' },
new_users: { label: '今日新增用户', value: '0' }
})
const pendingStats = ref({
delivery: { label: '待发货订单', value: '0' },
stock: { label: '库存预警商品', value: '0' },
extract: { label: '待审核提现', value: '0' }
})
onMounted(() => {
updateTime()
loadStats()
})
function updateTime() {
const now = new Date()
currentTime.value = `${now.getHours()}:${now.getMinutes().toString().padStart(2, '0')}`
}
async function loadStats() {
isLoading.value = true
try {
const { data, error } = await supa.rpc('rpc_admin_get_overall_stats', {} as any)
if (error == null && data != null) {
const res = data as UTSJSONObject
const totals = res.get('totals') as UTSJSONObject
const today = res.get('today') as UTSJSONObject
const pending = res.get('pending') as UTSJSONObject
totalStats.value.sales.value = (totals.getNumber('total_sales') ?? 0).toFixed(2)
totalStats.value.orders.value = (totals.getInt('total_orders') ?? 0).toString()
totalStats.value.users.value = (totals.getInt('total_users') ?? 0).toString()
totalStats.value.products.value = (totals.getInt('total_products') ?? 0).toString()
todayStats.value.sales.value = (today.getNumber('today_sales') ?? 0).toFixed(2)
todayStats.value.orders.value = (today.getInt('today_orders') ?? 0).toString()
todayStats.value.new_users.value = (today.getInt('today_new_users') ?? 0).toString()
pendingStats.value.delivery.value = (pending.getInt('pending_delivery') ?? 0).toString()
pendingStats.value.stock.value = (pending.getInt('stock_warning') ?? 0).toString()
pendingStats.value.extract.value = (pending.getInt('pending_extract') ?? 0).toString()
}
} catch (e) {
console.error('Failed to fetch stats', e)
} finally {
isLoading.value = false
}
}
function handlePending(type: string) {
uni.showToast({ title: '正在跳转: ' + type, icon: 'none' })
}
</script>
<style scoped lang="scss">
.page-container {
padding: 20px;
min-height: 100vh;
background: #f5f5f5;
}
.admin-statistic { padding: 24px; background-color: #f0f2f5; min-height: 100vh; }
.border-shadow { background-color: #fff; border-radius: 4px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.page-header {
margin-bottom: 20px;
}
.mt-24 { margin-top: 24px; }
.page-title {
display: block;
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
/* 核心指标卡片 */
.stats-grid { display: flex; flex-direction: row; gap: 20px; }
.stat-card { flex: 1; display: flex; flex-direction: row; align-items: center; padding: 24px; }
.stat-icon-box { width: 56px; height: 56px; border-radius: 28px; display: flex; align-items: center; justify-content: center; margin-right: 16px; }
.stat-icon-box.blue { background-color: #e6f7ff; color: #1890ff; }
.stat-icon-box.orange { background-color: #fff7e6; color: #ffa940; }
.stat-icon-box.green { background-color: #f6ffed; color: #52c41a; }
.stat-icon-box.pink { background-color: #fff0f6; color: #eb2f96; }
.stat-ic { font-size: 24px; }
.stat-info { display: flex; flex-direction: column; }
.stat-val { font-size: 24px; font-weight: bold; color: #303133; }
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
.page-subtitle {
display: block;
font-size: 14px;
color: #999;
}
/* 标题 */
.section-title { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.title-txt { font-size: 16px; font-weight: bold; color: #333; position: relative; padding-left: 12px; }
.title-txt::before { content: ''; position: absolute; left: 0; top: 2px; width: 4px; height: 16px; background-color: #1890ff; border-radius: 2px; }
.sub-txt { font-size: 12px; color: #999; }
.page-content {
background: #fff;
border-radius: 4px;
padding: 24px;
}
/* 今日实时 */
.stats-grid-mini { display: flex; flex-direction: row; gap: 16px; }
.stat-mini-card { flex: 1; padding: 20px; display: flex; flex-direction: column; }
.sm-label { font-size: 13px; color: #666; margin-bottom: 8px; }
.sm-val { font-size: 20px; font-weight: bold; color: #1890ff; }
.placeholder-card {
text-align: center;
padding: 60px 20px;
}
/* 待办提醒 */
.pending-grid { display: flex; flex-direction: row; gap: 16px; }
.pending-card { flex: 1; padding: 20px; display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.p-label { font-size: 13px; color: #666; display: block; margin-bottom: 4px; }
.p-count { font-size: 22px; font-weight: bold; color: #f5222d; }
.p-link { font-size: 12px; color: #1890ff; cursor: pointer; }
.placeholder-title {
display: block;
font-size: 18px;
font-weight: 600;
color: #666;
margin-bottom: 12px;
}
/* 图表占位 */
.chart-section { padding: 24px; height: 350px; display: flex; flex-direction: column; }
.chart-title { font-size: 15px; font-weight: bold; color: #333; }
.chart-body { flex: 1; display: flex; align-items: center; justify-content: center; }
.chart-placeholder-txt { font-size: 14px; color: #ccc; }
.placeholder-desc {
display: block;
font-size: 14px;
color: #999;
margin-bottom: 8px;
}
.placeholder-info {
display: block;
font-size: 12px;
color: #1890ff;
}
.global-loading { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(255,255,255,0.6); z-index: 100; display: flex; align-items: center; justify-content: center; }
.loading-txt { font-size: 14px; color: #1890ff; }
</style>