From fd89c3164057da244389d0d04215a09a24e688ca Mon Sep 17 00:00:00 2001 From: andy <594580820@qq.com> Date: Mon, 3 Aug 2026 15:35:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E4=B8=BA=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AE=8C=E6=95=B4=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=BC=8F=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增响应式工具函数与全局移动端样式,统一适配≤768px屏幕 - 添加移动端底部导航组件,替代桌面端侧边栏菜单 - 为经销商、客户、报备列表页新增移动端卡片视图替换表格布局 - 调整所有页面的表单、弹窗与布局适配移动端尺寸 - 优化移动端viewport配置,适配全面屏安全区域 - 修复密码哈希工具,在非安全上下文下回退使用js-sha256库 - 更新依赖包,新增@vueuse/core与js-sha256 --- frontend/index.html | 2 +- frontend/package.json | 2 + frontend/pnpm-lock.yaml | 42 ++++++++ frontend/src/components/MobileTabBar.vue | 76 +++++++++++++++ frontend/src/composables/useResponsive.ts | 17 ++++ frontend/src/main.ts | 1 + frontend/src/styles/mobile.scss | 44 +++++++++ frontend/src/styles/variables.scss | 25 +++++ frontend/src/utils/crypto.ts | 55 ++++++----- frontend/src/views/Customer.vue | 92 ++++++++++++++++- frontend/src/views/Dashboard.vue | 22 ++++- frontend/src/views/Dealer.vue | 89 ++++++++++++++++- frontend/src/views/Layout.vue | 65 ++++++++++-- frontend/src/views/Login.vue | 13 ++- frontend/src/views/Report.vue | 114 ++++++++++++++++++++-- frontend/src/views/SystemConfig.vue | 16 ++- 16 files changed, 613 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/MobileTabBar.vue create mode 100644 frontend/src/composables/useResponsive.ts create mode 100644 frontend/src/styles/mobile.scss create mode 100644 frontend/src/styles/variables.scss diff --git a/frontend/index.html b/frontend/index.html index 715ac80..f81f1a3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + 泊云智销通 diff --git a/frontend/package.json b/frontend/package.json index 3917c12..9f4b464 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a06fc9c..e1f25be 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/components/MobileTabBar.vue b/frontend/src/components/MobileTabBar.vue new file mode 100644 index 0000000..b069353 --- /dev/null +++ b/frontend/src/components/MobileTabBar.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/frontend/src/composables/useResponsive.ts b/frontend/src/composables/useResponsive.ts new file mode 100644 index 0000000..93f9ffc --- /dev/null +++ b/frontend/src/composables/useResponsive.ts @@ -0,0 +1,17 @@ +import { useMediaQuery } from '@vueuse/core' + +/** + * 响应式断点判断(与 styles/variables.scss 的媒体查询保持一致)。 + * - isMobile : ≤768px 手机竖屏 + * - isTablet : 769–1024 平板 + * - isPC : ≥1025px 桌面(现有 PC 行为) + * + * 返回值均为 Ref,可在模板中直接 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 } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index aa806e3..d1e79f5 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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' diff --git a/frontend/src/styles/mobile.scss b/frontend/src/styles/mobile.scss new file mode 100644 index 0000000..72363a5 --- /dev/null +++ b/frontend/src/styles/mobile.scss @@ -0,0 +1,44 @@ +@use './variables.scss' as *; + +/* ============================================================ + * 移动端全局兜底样式 + * 仅在 ≤768px 生效,PC(≥1025px)零影响。 + * 处理无法在组件内逐个绑定的命令式弹层(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; + } +} diff --git a/frontend/src/styles/variables.scss b/frontend/src/styles/variables.scss new file mode 100644 index 0000000..b6aa8f0 --- /dev/null +++ b/frontend/src/styles/variables.scss @@ -0,0 +1,25 @@ +// 断点常量 —— 与 composables/useResponsive.ts 的媒体查询保持一致 +// 改动断点时两处需同步。 +$bp-mobile: 768px; +$bp-tablet: 1024px; + +/// ≤768px 移动端 +@mixin mobile { + @media (max-width: $bp-mobile) { + @content; + } +} + +/// 769–1024 平板 +@mixin tablet { + @media (min-width: 769px) and (max-width: $bp-tablet) { + @content; + } +} + +/// ≤1024px(移动 + 平板) +@mixin mobile-and-tablet { + @media (max-width: $bp-tablet) { + @content; + } +} diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts index ae92651..4d28a1d 100644 --- a/frontend/src/utils/crypto.ts +++ b/frontend/src/utils/crypto.ts @@ -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 { + 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 { + if (hasSubtleCrypto()) { + return subtleSha256Hex(input) + } + return sha256(input) +} + /** * 使用 SHA-256 算法哈希密码 * @param password 明文密码 - * @returns 哈希后的十六进制字符串 + * @returns 哈希后的十六进制字符串(小写) */ export async function hashPassword(password: string): Promise { 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 { * 简单的加盐哈希(可选,用于增强安全性) * @param password 明文密码 * @param salt 盐值 - * @returns 哈希后的十六进制字符串 + * @returns 哈希后的十六进制字符串(小写) */ export async function hashPasswordWithSalt(password: string, salt: string): Promise { 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('密码加密失败') diff --git a/frontend/src/views/Customer.vue b/frontend/src/views/Customer.vue index 5b4e8bc..9a3d54a 100644 --- a/frontend/src/views/Customer.vue +++ b/frontend/src/views/Customer.vue @@ -17,7 +17,7 @@ - + @@ -49,7 +49,7 @@ - + @@ -84,6 +84,31 @@ + +
+
+
+ {{ row.name }} + 可报备 + 保护中 + 已失效 +
+
联系电话{{ row.phone || '-' }}
+
所属行业{{ getIndustryLabel(row.industry) }}
+
地址{{ row.address || '-' }}
+ +
创建时间{{ row.createdAt }}
+
+ 编辑 + 删除 +
+
+ +
+ @@ -105,7 +130,7 @@ @@ -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(90) +const { isMobile } = useResponsive() const loading = ref(false) const tableData = ref([]) @@ -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; + } +} diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 7bf1f6b..b5b925c 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -15,7 +15,7 @@ --> - +
@@ -28,7 +28,7 @@
- +
@@ -41,7 +41,7 @@
- +
@@ -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; + } +} diff --git a/frontend/src/views/Dealer.vue b/frontend/src/views/Dealer.vue index db80bdc..bd8cb7c 100644 --- a/frontend/src/views/Dealer.vue +++ b/frontend/src/views/Dealer.vue @@ -12,7 +12,7 @@ - + @@ -35,7 +35,7 @@ - + @@ -57,13 +57,36 @@ + + +
+
+
+ {{ row.name }} + + {{ row.status === 1 ? '启用' : '禁用' }} + +
+
账号{{ row.code }}
+
联系人{{ row.contactPerson || '-' }}
+
联系电话{{ row.contactPhone || '-' }}
+
邮箱{{ row.email || '-' }}
+
创建时间{{ row.createdAt }}
+
+ 编辑 + 重置密码 + 删除 +
+
+ +
@@ -110,7 +133,7 @@ @@ -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([]) @@ -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; + } +} diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 474c2ac..57836ba 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -1,7 +1,7 @@ - + @@ -40,7 +40,7 @@ - + @@ -102,6 +102,36 @@ + +
+
+
+ {{ row.schoolName }} + 待审核 + 已通过 + 已驳回 + 已失效 + 已作废 +
+
经销商{{ row.dealerName || '-' }}
+
所属产品{{ row.product || '-' }}
+
项目类型{{ row.projectType || '-' }}
+
负责人{{ row.contactPerson || '-' }}
+
负责人电话{{ row.contactPhone || '-' }}
+
报备说明{{ row.description || '-' }}
+
保护期截止{{ formatDate(row.protectEndDate) }}
+
创建时间{{ row.createdAt }}
+
+ 查看 + 进展 + 审核 + 编辑 + 撤回 +
+
+ +
+ - + - + @@ -207,7 +237,7 @@ - + @@ -270,8 +300,8 @@ - - + + {{ currentReport?.dealerName || '-' }} @@ -330,7 +360,7 @@ - +