andy_dev_2026 #1

Merged
andy merged 5 commits from andy_dev_2026 into main 2026-08-03 09:31:15 +00:00
16 changed files with 613 additions and 62 deletions
Showing only changes of commit fd89c31640 - Show all commits

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
<title>泊云智销通</title>
</head>
<body>

View File

@ -11,10 +11,12 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"@vueuse/core": "^14.4.0",
"axios": "^1.6.0",
"bcryptjs": "^3.0.3",
"dayjs": "^1.11.0",
"element-plus": "^2.5.0",
"js-sha256": "^1.0.0",
"pinia": "^2.1.0",
"pinia-plugin-persistedstate": "^3.2.0",
"vue": "^3.4.0",

View File

@ -11,6 +11,9 @@ importers:
'@element-plus/icons-vue':
specifier: ^2.3.0
version: 2.3.2(vue@3.5.27(typescript@5.9.3))
'@vueuse/core':
specifier: ^14.4.0
version: 14.4.0(vue@3.5.27(typescript@5.9.3))
axios:
specifier: ^1.6.0
version: 1.13.2
@ -23,6 +26,9 @@ importers:
element-plus:
specifier: ^2.5.0
version: 2.13.1(vue@3.5.27(typescript@5.9.3))
js-sha256:
specifier: ^1.0.0
version: 1.0.0
pinia:
specifier: ^2.1.0
version: 2.3.1(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
@ -529,6 +535,9 @@ packages:
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@typescript-eslint/eslint-plugin@6.21.0':
resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==}
engines: {node: ^16.0.0 || >=18.0.0}
@ -644,12 +653,25 @@ packages:
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
'@vueuse/core@14.4.0':
resolution: {integrity: sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@10.11.1':
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
'@vueuse/metadata@14.4.0':
resolution: {integrity: sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==}
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
'@vueuse/shared@14.4.0':
resolution: {integrity: sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==}
peerDependencies:
vue: ^3.5.0
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@ -1015,6 +1037,9 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
js-sha256@1.0.0:
resolution: {integrity: sha512-Bqxf6ENUzYIMzuELCmRNrJOVbjKH1oMgbfYJBKVr/W1Xf9fazpahqCbb24v1pR7XV1isuqhM+w9KWpK7zCyUQw==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@ -1699,6 +1724,8 @@ snapshots:
'@types/web-bluetooth@0.0.20': {}
'@types/web-bluetooth@0.0.21': {}
'@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -1880,8 +1907,17 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/core@14.4.0(vue@3.5.27(typescript@5.9.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.4.0
'@vueuse/shared': 14.4.0(vue@3.5.27(typescript@5.9.3))
vue: 3.5.27(typescript@5.9.3)
'@vueuse/metadata@10.11.1': {}
'@vueuse/metadata@14.4.0': {}
'@vueuse/shared@10.11.1(vue@3.5.27(typescript@5.9.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.27(typescript@5.9.3))
@ -1889,6 +1925,10 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/shared@14.4.0(vue@3.5.27(typescript@5.9.3))':
dependencies:
vue: 3.5.27(typescript@5.9.3)
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@ -2309,6 +2349,8 @@ snapshots:
isexe@2.0.0: {}
js-sha256@1.0.0: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1

View File

@ -0,0 +1,76 @@
<template>
<nav class="mobile-tab-bar">
<router-link
v-for="item in menus"
:key="item.path"
:to="item.path"
class="tab-item"
:class="{ active: isActive(item.path) }"
>
<el-icon><component :is="item.icon" /></el-icon>
<span class="tab-label">{{ item.label }}</span>
</router-link>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { HomeFilled, Document, Shop, Setting } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const userStore = useUserStore()
// Layout admin
const allMenus = [
{ path: '/dashboard', label: '首页', icon: HomeFilled, adminOnly: false },
{ path: '/report', label: '报备', icon: Document, adminOnly: false },
{ path: '/dealer', label: '经销商', icon: Shop, adminOnly: true },
{ path: '/system-config', label: '设置', icon: Setting, adminOnly: true }
]
const menus = computed(() => allMenus.filter((m) => !m.adminOnly || userStore.isAdmin))
const isActive = (path: string) => route.path === path
</script>
<style scoped>
.mobile-tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
height: 56px;
background-color: #fff;
border-top: 1px solid #e6e6e6;
/* 兼容 iPhone 底部安全区 */
padding-bottom: env(safe-area-inset-bottom);
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #909399;
text-decoration: none;
font-size: 12px;
}
.tab-item .el-icon {
font-size: 20px;
}
.tab-item.active {
color: #409eff;
}
.tab-label {
line-height: 1;
}
</style>

View File

@ -0,0 +1,17 @@
import { useMediaQuery } from '@vueuse/core'
/**
* styles/variables.scss
* - isMobile : 768px
* - isTablet : 7691024
* - isPC : 1025px PC
*
* Ref<boolean> v-if
*/
export function useResponsive() {
const isMobile = useMediaQuery('(max-width: 768px)')
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
const isPC = useMediaQuery('(min-width: 1025px)')
return { isMobile, isTablet, isPC }
}

View File

@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/mobile.scss'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'

View File

@ -0,0 +1,44 @@
@use './variables.scss' as *;
/* ============================================================
* 移动端全局兜底样式
* 仅在 768px 生效PC1025px零影响
* 处理无法在组件内逐个绑定的命令式弹层ElMessageBox
* 与通用触摸/溢出问题
* ========================================================= */
@include mobile {
// 防止页面横向溢出宽表格/固定宽度元素
html,
body {
overflow-x: hidden;
}
// 命令式确认框ElMessageBox.confirm无法逐个绑宽度统一兜底
.el-message-box {
width: 86% !important;
max-width: 86vw !important;
}
// 通知/消息更贴顶避免遮挡底部 Tab
.el-message {
min-width: auto !important;
max-width: 92vw !important;
}
// 分页器触摸热区加大
.el-pagination {
.btn-prev,
.btn-next,
.el-pager li {
min-width: 32px;
height: 32px;
line-height: 32px;
}
}
// 表单标签更紧凑
.el-form-item__label {
font-size: 14px;
}
}

View File

@ -0,0 +1,25 @@
// 断点常量 composables/useResponsive.ts 的媒体查询保持一致
// 改动断点时两处需同步
$bp-mobile: 768px;
$bp-tablet: 1024px;
/// 768px 移动端
@mixin mobile {
@media (max-width: $bp-mobile) {
@content;
}
}
/// 7691024 平板
@mixin tablet {
@media (min-width: 769px) and (max-width: $bp-tablet) {
@content;
}
}
/// 1024px移动 + 平板
@mixin mobile-and-tablet {
@media (max-width: $bp-tablet) {
@content;
}
}

View File

@ -1,39 +1,44 @@
/**
*
* 使 Web Crypto API SHA-256
* SHA-256 hex
*
* HTTPS localhost使 Web Crypto API
* HTTP + /IP 访 crypto.subtle undefined
* 退 JS js-sha256
* SHA-256 hex
*/
import { sha256 } from 'js-sha256'
/**
* ArrayBuffer
*/
function stringToArrayBuffer(str: string): ArrayBuffer {
const encoder = new TextEncoder()
return encoder.encode(str).buffer
/** 当前环境是否可用原生 Web Crypto即处于安全上下文 */
function hasSubtleCrypto(): boolean {
return typeof crypto !== 'undefined' && typeof crypto.subtle?.digest === 'function'
}
/**
* ArrayBuffer
*/
function arrayBufferToHex(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
/** 原生 Web Crypto SHA-256 → 小写 hex */
async function subtleSha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/** 统一入口:优先原生,回退纯 JS */
async function sha256Hex(input: string): Promise<string> {
if (hasSubtleCrypto()) {
return subtleSha256Hex(input)
}
return sha256(input)
}
/**
* 使 SHA-256
* @param password
* @returns
* @returns
*/
export async function hashPassword(password: string): Promise<string> {
if (!password) return ''
try {
// 使用 Web Crypto API 进行 SHA-256 哈希
const data = stringToArrayBuffer(password)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return arrayBufferToHex(hashBuffer)
return await sha256Hex(password)
} catch (error) {
console.error('密码哈希失败:', error)
throw new Error('密码加密失败')
@ -44,16 +49,12 @@ export async function hashPassword(password: string): Promise<string> {
*
* @param password
* @param salt
* @returns
* @returns
*/
export async function hashPasswordWithSalt(password: string, salt: string): Promise<string> {
if (!password) return ''
try {
const saltedPassword = password + salt
const data = stringToArrayBuffer(saltedPassword)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
return arrayBufferToHex(hashBuffer)
return await sha256Hex(password + salt)
} catch (error) {
console.error('密码哈希失败:', error)
throw new Error('密码加密失败')

View File

@ -17,7 +17,7 @@
</template>
<!-- 查询表单 -->
<el-form :inline="true" :model="queryForm" class="query-form">
<el-form :inline="!isMobile" :model="queryForm" class="query-form">
<el-form-item label="客户名称">
<el-input v-model="queryForm.name" placeholder="请输入客户名称" clearable />
</el-form-item>
@ -49,7 +49,7 @@
</el-form>
<!-- 数据表格 -->
<el-table :data="tableData" border stripe v-loading="loading">
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading">
<el-table-column prop="name" label="客户名称" />
<el-table-column prop="phone" label="联系电话" />
<el-table-column prop="address" label="地址" />
@ -84,6 +84,31 @@
</el-table-column>
</el-table>
<!-- 移动端卡片视图 -->
<div v-if="isMobile" class="mobile-card-list" v-loading="loading">
<div v-for="row in tableData" :key="row.id" class="mobile-card">
<div class="card-top">
<span class="card-title">{{ row.name }}</span>
<el-tag v-if="row.status === 0" type="success" size="small">可报备</el-tag>
<el-tag v-else-if="row.status === 1" type="warning" size="small">保护中</el-tag>
<el-tag v-else type="info" size="small">已失效</el-tag>
</div>
<div class="card-row"><span class="label">联系电话</span><span>{{ row.phone || '-' }}</span></div>
<div class="card-row"><span class="label">所属行业</span><span>{{ getIndustryLabel(row.industry) }}</span></div>
<div class="card-row"><span class="label">地址</span><span>{{ row.address || '-' }}</span></div>
<template v-if="row.status === 1">
<div class="card-row"><span class="label">当前经销商</span><span>{{ row.currentDealerName || '-' }}</span></div>
<div class="card-row"><span class="label">保护期截止</span><span>{{ row.protectEndDate ? formatDate(row.protectEndDate) : '-' }}</span></div>
</template>
<div class="card-row"><span class="label">创建时间</span><span>{{ row.createdAt }}</span></div>
<div class="card-actions">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</div>
</div>
<el-empty v-if="!loading && tableData.length === 0" description="暂无客户数据" />
</div>
<!-- 分页 -->
<el-config-provider :locale="zhCn">
<el-pagination
@ -91,12 +116,12 @@
v-model:page-size="queryForm.size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
prev-text="上一页"
next-text="下一页"
@size-change="fetchData"
@current-change="fetchData"
style="margin-top: 20px; justify-content: flex-end"
:style="{ marginTop: '20px', justifyContent: isMobile ? 'center' : 'flex-end' }"
/>
</el-config-provider>
</el-card>
@ -105,7 +130,7 @@
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
:width="isMobile ? '92%' : '600px'"
@close="handleDialogClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
@ -161,8 +186,10 @@ import { getConfigValue } from '@/api/system'
import { searchSchoolByName } from '@/api/school'
import type { Customer, CustomerForm } from '@/types'
import type { School } from '@/api/school'
import { useResponsive } from '@/composables/useResponsive'
const protectDays = ref<number>(90)
const { isMobile } = useResponsive()
const loading = ref(false)
const tableData = ref<Customer[]>([])
@ -383,4 +410,59 @@ onMounted(() => {
color: #909399;
margin-top: 4px;
}
/* ===== 移动端卡片视图 ===== */
.mobile-card-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.mobile-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 12px;
}
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.card-row {
display: flex;
font-size: 13px;
color: #606266;
line-height: 1.8;
}
.card-row .label {
width: 76px;
color: #909399;
flex-shrink: 0;
}
.card-actions {
display: flex;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
@media (max-width: 768px) {
.card-header {
flex-wrap: wrap;
gap: 10px;
}
}
</style>

View File

@ -15,7 +15,7 @@
</el-card>
</el-col>
-->
<el-col :span="userStore.isAdmin ? 8 : 12">
<el-col :xs="24" :sm="userStore.isAdmin ? 8 : 12">
<el-card class="stat-card" @click="handleReportCountClick">
<div class="stat-content">
<div class="stat-icon" style="background-color: #67c23a">
@ -28,7 +28,7 @@
</div>
</el-card>
</el-col>
<el-col :span="userStore.isAdmin ? 8 : 12">
<el-col :xs="24" :sm="userStore.isAdmin ? 8 : 12">
<el-card class="stat-card" @click="handlePendingClick">
<div class="stat-content">
<div class="stat-icon" style="background-color: #e6a23c">
@ -41,7 +41,7 @@
</div>
</el-card>
</el-col>
<el-col :span="8" v-if="userStore.isAdmin">
<el-col :xs="24" :sm="8" v-if="userStore.isAdmin">
<el-card class="stat-card" @click="handleDealerCountClick">
<div class="stat-content">
<div class="stat-icon" style="background-color: #f56c6c">
@ -184,4 +184,20 @@ onMounted(() => {
color: #606266;
line-height: 1.8;
}
@media (max-width: 768px) {
.welcome-card {
padding: 24px;
}
.stat-icon {
width: 48px;
height: 48px;
margin-right: 10px;
}
.stat-value {
font-size: 20px;
}
}
</style>

View File

@ -12,7 +12,7 @@
</template>
<!-- 查询表单 -->
<el-form :inline="true" :model="queryForm" class="query-form">
<el-form :inline="!isMobile" :model="queryForm" class="query-form">
<el-form-item label="经销商名称">
<el-input v-model="queryForm.name" placeholder="请输入经销商名称" clearable />
</el-form-item>
@ -35,7 +35,7 @@
</el-form>
<!-- 数据表格 -->
<el-table :data="tableData" border stripe v-loading="loading">
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading">
<el-table-column prop="name" label="经销商名称" />
<el-table-column prop="code" label="经销商账号" />
<el-table-column prop="contactPerson" label="联系人" />
@ -57,13 +57,36 @@
</template>
</el-table-column>
</el-table>
<!-- 移动端卡片视图 -->
<div v-if="isMobile" class="mobile-card-list" v-loading="loading">
<div v-for="row in tableData" :key="row.id" class="mobile-card">
<div class="card-top">
<span class="card-title">{{ row.name }}</span>
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</div>
<div class="card-row"><span class="label">账号</span><span>{{ row.code }}</span></div>
<div class="card-row"><span class="label">联系人</span><span>{{ row.contactPerson || '-' }}</span></div>
<div class="card-row"><span class="label">联系电话</span><span>{{ row.contactPhone || '-' }}</span></div>
<div class="card-row"><span class="label">邮箱</span><span>{{ row.email || '-' }}</span></div>
<div class="card-row"><span class="label">创建时间</span><span>{{ row.createdAt }}</span></div>
<div class="card-actions">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="warning" @click="handleResetPassword(row)">重置密码</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</div>
</div>
<el-empty v-if="!loading && tableData.length === 0" description="暂无经销商数据" />
</div>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
:width="isMobile ? '92%' : '600px'"
@close="handleDialogClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
@ -110,7 +133,7 @@
<el-dialog
v-model="resetPasswordDialogVisible"
title="重置密码"
width="500px"
:width="isMobile ? '92%' : '500px'"
@close="handleResetPasswordDialogClose"
>
<el-form ref="resetPasswordFormRef" :model="resetPasswordForm" :rules="resetPasswordRules" label-width="100px">
@ -149,6 +172,9 @@ import { getDealerList, createDealer, updateDealer, deleteDealer } from '@/api/d
import { resetPassword } from '@/api/auth'
import { hashPassword } from '@/utils/crypto'
import type { Dealer } from '@/types'
import { useResponsive } from '@/composables/useResponsive'
const { isMobile } = useResponsive()
const loading = ref(false)
const tableData = ref<Dealer[]>([])
@ -383,4 +409,59 @@ onMounted(() => {
.query-form {
margin-bottom: 20px;
}
/* ===== 移动端卡片视图 ===== */
.mobile-card-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.mobile-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 12px;
}
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.card-row {
display: flex;
font-size: 13px;
color: #606266;
line-height: 1.8;
}
.card-row .label {
width: 76px;
color: #909399;
flex-shrink: 0;
}
.card-actions {
display: flex;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
@media (max-width: 768px) {
.card-header {
flex-wrap: wrap;
gap: 10px;
}
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<el-container class="layout-container">
<el-aside width="200px">
<div class="logo">泊云智销通</div>
<el-aside v-if="!isMobile" width="200px">
<div class="logo">{{ APP_TITLE }}</div>
<el-menu
:default-active="activeMenu"
router
@ -34,13 +34,16 @@
<el-container>
<el-header>
<div class="header-content">
<div class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item>{{ currentPageTitle }}</el-breadcrumb-item>
</el-breadcrumb>
<div class="header-left">
<span class="mobile-logo">{{ APP_TITLE }}</span>
<div class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item>{{ currentPageTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
<div class="user-info">
<el-dropdown>
<el-dropdown :trigger="isMobile ? 'click' : 'hover'">
<span class="user-name">
<el-icon><Avatar /></el-icon>
{{ userStore.userInfo?.dealerName }}
@ -70,11 +73,14 @@
</el-main>
</el-container>
<!-- 移动端底部导航 -->
<MobileTabBar v-if="isMobile" />
<!-- 修改密码对话框 -->
<el-dialog
v-model="showChangePasswordDialog"
title="修改密码"
width="500px"
:width="isMobile ? '92%' : '500px'"
@close="handleDialogClose"
>
<el-form ref="formRef" :model="passwordForm" :rules="passwordRules" label-width="100px">
@ -118,10 +124,15 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem
import { useUserStore } from '@/stores/user'
import { changePassword } from '@/api/auth'
import { hashPassword } from '@/utils/crypto'
import { useResponsive } from '@/composables/useResponsive'
import MobileTabBar from '@/components/MobileTabBar.vue'
const APP_TITLE = '泊云智销通'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const { isMobile } = useResponsive()
const activeMenu = computed(() => route.path)
const currentPageTitle = computed(() => route.meta.title as string || '首页')
@ -244,6 +255,19 @@ const handleLogout = async () => {
align-items: center;
}
.header-left {
display: flex;
align-items: center;
}
/* 默认PC隐藏移动端 logo显示面包屑 */
.mobile-logo {
display: none;
font-size: 16px;
font-weight: bold;
color: #303133;
}
.user-name {
display: flex;
align-items: center;
@ -258,4 +282,29 @@ const handleLogout = async () => {
background-color: #f0f2f5;
padding: 20px;
}
/* ===== 移动端≤768px ===== */
@media (max-width: 768px) {
.el-header {
padding: 0 12px;
}
.breadcrumb {
display: none;
}
.mobile-logo {
display: inline;
}
.el-main {
padding: 12px;
/* 给底部 TabBar56px + 安全区)留白 */
padding-bottom: calc(56px + env(safe-area-inset-bottom));
}
.user-name .el-tag {
display: none;
}
}
</style>

View File

@ -89,7 +89,8 @@ const handleLogin = async () => {
}
.login-box {
width: 400px;
width: 90%;
max-width: 400px;
padding: 40px;
background: white;
border-radius: 10px;
@ -109,4 +110,14 @@ const handleLogin = async () => {
.login-btn {
width: 100%;
}
@media (max-width: 768px) {
.login-box {
padding: 24px;
}
.login-container {
padding: 16px;
}
}
</style>

View File

@ -17,7 +17,7 @@
</template>
<!-- 查询表单 -->
<el-form :inline="true" :model="queryForm" class="query-form">
<el-form :inline="!isMobile" :model="queryForm" class="query-form">
<el-form-item label="学校名称">
<el-input v-model="queryForm.customerName" placeholder="请输入学校名称" clearable />
</el-form-item>
@ -40,7 +40,7 @@
</el-form>
<!-- 数据表格 -->
<el-table :data="tableData" border stripe v-loading="loading">
<el-table v-if="!isMobile" :data="tableData" border stripe v-loading="loading">
<el-table-column prop="dealerName" label="经销商" v-if="userStore.isAdmin"/>
<el-table-column prop="schoolName" label="学校名称" />
<el-table-column prop="product" label="所属产品" show-overflow-tooltip />
@ -102,6 +102,36 @@
</el-table-column>
</el-table>
<!-- 移动端卡片视图 -->
<div v-if="isMobile" class="mobile-card-list" v-loading="loading">
<div v-for="row in tableData" :key="row.id" class="mobile-card">
<div class="card-top">
<span class="card-title">{{ row.schoolName }}</span>
<el-tag v-if="row.status === 0" type="warning" size="small">待审核</el-tag>
<el-tag v-else-if="row.status === 1" type="success" size="small">已通过</el-tag>
<el-tag v-else-if="row.status === 2" type="danger" size="small">已驳回</el-tag>
<el-tag v-else-if="row.status === 3" type="info" size="small">已失效</el-tag>
<el-tag v-else-if="row.status === 4" type="warning" size="small">已作废</el-tag>
</div>
<div v-if="userStore.isAdmin" class="card-row"><span class="label">经销商</span><span class="val">{{ row.dealerName || '-' }}</span></div>
<div class="card-row"><span class="label">所属产品</span><span class="val">{{ row.product || '-' }}</span></div>
<div class="card-row"><span class="label">项目类型</span><span class="val">{{ row.projectType || '-' }}</span></div>
<div class="card-row"><span class="label">负责人</span><span class="val">{{ row.contactPerson || '-' }}</span></div>
<div class="card-row"><span class="label">负责人电话</span><span class="val">{{ row.contactPhone || '-' }}</span></div>
<div class="card-row"><span class="label">报备说明</span><span class="val">{{ row.description || '-' }}</span></div>
<div class="card-row"><span class="label">保护期截止</span><span class="val">{{ formatDate(row.protectEndDate) }}</span></div>
<div class="card-row"><span class="label">创建时间</span><span class="val">{{ row.createdAt }}</span></div>
<div class="card-actions">
<el-button link type="primary" @click="handleView(row)">查看</el-button>
<el-button v-if="row.status === 1" link type="info" @click="handleShowProgress(row)">进展</el-button>
<el-button v-if="userStore.isAdmin && row.status === 0" link type="success" @click="handleAudit(row)">审核</el-button>
<el-button v-if="userStore.isAdmin && row.status === 1" link type="warning" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="!userStore.isAdmin && row.status === 0" link type="danger" @click="handleWithdraw(row)">撤回</el-button>
</div>
</div>
<el-empty v-if="!loading && tableData.length === 0" description="暂无报备数据" />
</div>
<!-- 分页 -->
<el-config-provider :locale="zhCn">
<el-pagination
@ -109,18 +139,18 @@
v-model:page-size="queryForm.size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
prev-text="上一页"
next-text="下一页"
@size-change="fetchData"
@current-change="fetchData"
style="margin-top: 20px; justify-content: flex-end"
:style="{ marginTop: '20px', justifyContent: isMobile ? 'center' : 'flex-end' }"
/>
</el-config-provider>
</el-card>
<!-- 提交报备对话框 -->
<el-dialog v-model="dialogVisible" title="提交报备" width="650px" @close="handleDialogClose">
<el-dialog v-model="dialogVisible" title="提交报备" :width="isMobile ? '92%' : '650px'" @close="handleDialogClose">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="学校" prop="schoolName">
<el-autocomplete
@ -183,7 +213,7 @@
</el-dialog>
<!-- 审核对话框 -->
<el-dialog v-model="auditDialogVisible" title="审核报备" width="600px">
<el-dialog v-model="auditDialogVisible" title="审核报备" :width="isMobile ? '92%' : '600px'">
<el-form :model="auditForm" label-width="100px">
<el-form-item label="审核结果">
<el-radio-group v-model="auditForm.approved">
@ -207,7 +237,7 @@
</el-dialog>
<!-- 编辑报备对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑报备" width="600px" @close="handleEditDialogClose">
<el-dialog v-model="editDialogVisible" title="编辑报备" :width="isMobile ? '92%' : '600px'" @close="handleEditDialogClose">
<el-form ref="editFormRef" :model="editFormData" :rules="editRules" label-width="100px">
<el-form-item label="学校名称">
<el-input :model-value="currentReport?.schoolName" disabled />
@ -270,8 +300,8 @@
</el-dialog>
<!-- 报备详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="报备详情" width="700px">
<el-descriptions :column="2" border>
<el-dialog v-model="detailDialogVisible" title="报备详情" :width="isMobile ? '92%' : '700px'">
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="经销商">
{{ currentReport?.dealerName || '-' }}
</el-descriptions-item>
@ -330,7 +360,7 @@
</el-dialog>
<!-- 进展记录对话框 -->
<el-dialog v-model="progressDialogVisible" title="报备进展记录" width="1000px" @close="handleProgressDialogClose">
<el-dialog v-model="progressDialogVisible" title="报备进展记录" :width="isMobile ? '92%' : '1000px'" :fullscreen="isMobile" @close="handleProgressDialogClose">
<el-card>
<template #header>
<div class="card-header">
@ -387,11 +417,13 @@ import { getProgressByReportId, createProgress, updateProgress } from '@/api/rep
import { searchSchoolByName } from '@/api/school'
import { getConfigValue } from '@/api/system'
import type { Report, ReportForm, ReportProgress, ReportProgressForm, ReportUpdateForm } from '@/types'
import { useResponsive } from '@/composables/useResponsive'
const route = useRoute()
const userStore = useUserStore()
const protectDays = ref<number>(90)
const { isMobile } = useResponsive()
const loading = ref(false)
const tableData = ref<Report[]>([])
@ -792,4 +824,66 @@ onMounted(() => {
:deep(.el-descriptions__content) {
color: #333;
}
/* ===== 移动端卡片视图 ===== */
.mobile-card-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.mobile-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 12px;
}
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.card-row {
display: flex;
align-items: flex-start;
font-size: 13px;
color: #606266;
line-height: 1.8;
}
.card-row .label {
width: 80px;
flex-shrink: 0;
color: #909399;
}
.card-row .val {
flex: 1;
word-break: break-all;
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
@media (max-width: 768px) {
.card-header {
flex-wrap: wrap;
gap: 10px;
}
}
</style>

View File

@ -20,7 +20,7 @@
修改配置后立即生效请谨慎操作保护期天数的修改仅影响新生成的报备
</el-alert>
<el-form :model="formData" label-width="150px">
<el-form :model="formData" :label-position="isMobile ? 'top' : 'right'" label-width="150px">
<el-divider content-position="left">报备配置</el-divider>
<el-form-item label="保护期天数">
@ -30,7 +30,7 @@
:max="365"
:step="1"
controls-position="right"
style="width: 200px"
:style="{ width: isMobile ? '100%' : '200px' }"
/>
<span style="margin-left: 10px; color: #909399"></span>
<div style="margin-top: 5px; color: #909399; font-size: 12px">
@ -51,7 +51,7 @@
<el-divider content-position="left">当前配置状态</el-divider>
<el-descriptions :column="2" border>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="当前保护期天数">
{{ configs.find(c => c.configKey === 'report.protect.days')?.configValue || 90 }}
</el-descriptions-item>
@ -72,6 +72,9 @@ import { ElMessage } from 'element-plus'
import { Check } from '@element-plus/icons-vue'
import { getAllConfigs, batchUpdateConfigs } from '@/api/system'
import type { SystemConfig } from '@/types'
import { useResponsive } from '@/composables/useResponsive'
const { isMobile } = useResponsive()
const configs = ref<SystemConfig[]>([])
const saving = ref(false)
@ -143,4 +146,11 @@ onMounted(() => {
justify-content: space-between;
align-items: center;
}
@media (max-width: 768px) {
.card-header {
flex-wrap: wrap;
gap: 10px;
}
}
</style>