Browse Source

b端

master
hx 2 weeks ago
parent
commit
f0e5de9a0f
  1. 9
      src/api/brand.js
  2. 17
      src/api/device/device.js
  3. 255
      src/components/device/PendingActivateDialog.vue
  4. 8
      src/lang/fence-messages.js
  5. 12
      src/lang/profile-messages.js
  6. 21
      src/layout/components/Navbar.vue
  7. 54
      src/layout/components/Sidebar/Logo.vue
  8. 91
      src/main.js
  9. 26
      src/permission.js
  10. 58
      src/router/index.js
  11. 4
      src/store/getters.js
  12. 4
      src/store/index.js
  13. 64
      src/store/modules/brand.js
  14. 125
      src/utils/brand.js
  15. 20
      src/utils/dynamicTitle.js
  16. 72
      src/utils/request.js
  17. 73
      src/utils/validate.js
  18. 29
      src/views/device/device/index.vue
  19. 327
      src/views/fence/device/index.vue
  20. 541
      src/views/login.vue
  21. 25
      src/views/system/user/components/ProfileSettingsCard.vue

9
src/api/brand.js

@ -0,0 +1,9 @@
import request from "@/utils/request";
export function getBrandConfig(businessNo) {
return request({
url: "/system/info/brand",
method: "get",
params: businessNo ? { businessNo } : {}
});
}

17
src/api/device/device.js

@ -8,6 +8,14 @@ export function listDevice(query) {
})
}
export function listPendingActivateDevice(query) {
return request({
url: '/device/device/pending-activate/list',
method: 'get',
params: query
})
}
export function getDevice(id) {
return request({
url: '/device/device/' + id,
@ -124,13 +132,14 @@ export function getBatchNo() {
})
}
export function batchActivateDevice(ids) {
export function batchActivateDevice(payload) {
const requestPayload = Array.isArray(payload)
? { ids: payload }
: (payload || { ids: [] })
return request({
url: '/device/device/activate/batch',
method: 'put',
data: {
ids: ids
}
data: requestPayload
})
}

255
src/components/device/PendingActivateDialog.vue

@ -0,0 +1,255 @@
<template>
<el-dialog
title="待激活设备"
:visible.sync="dialogVisible"
width="980px"
append-to-body
@close="handleClose"
>
<el-form
ref="queryForm"
:model="queryParams"
:inline="true"
label-position="top"
class="page-query-form claim-query-form"
>
<el-form-item :label="$t('device.query.orderCode')" prop="orderCode">
<el-input
v-model.trim="queryParams.orderCode"
:placeholder="$t('device.placeholder.orderCode')"
clearable
size="small"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item :label="$t('device.query.model')" prop="model">
<el-input
v-model.trim="queryParams.model"
:placeholder="$t('device.placeholder.model')"
clearable
size="small"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item :label="$t('device.query.sn')" prop="sn">
<el-input
v-model.trim="queryParams.sn"
:placeholder="$t('device.placeholder.sn')"
clearable
size="small"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item class="page-query-actions">
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">
{{ $t("common.search") }}
</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">{{ $t("common.reset") }}</el-button>
</el-form-item>
</el-form>
<el-table
ref="pendingActivateTable"
v-loading="loading"
:data="deviceList"
border
height="420"
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center" :reserve-selection="true" />
<el-table-column :label="$t('device.table.orderCode')" prop="orderCode" min-width="180" align="center" />
<el-table-column :label="$t('device.table.model')" prop="model" min-width="180" align="center" />
<el-table-column :label="$t('device.table.sn')" prop="sn" min-width="220" align="center" />
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="handlePageChange"
/>
<div slot="footer" class="dialog-footer">
<span class="page-footer-meta">已选择设备{{ selectedIds.length }} </span>
<el-button @click="handleClose">{{ $t("common.cancel") }}</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
批量激活
</el-button>
</div>
</el-dialog>
</template>
<script>
import { batchActivateDevice, listPendingActivateDevice } from "@/api/device/device";
function createDefaultQueryParams() {
return {
orderCode: "",
model: "",
sn: "",
pageNum: 1,
pageSize: 10,
};
}
export default {
name: "PendingActivateDialog",
props: {
visible: {
type: Boolean,
default: false,
},
},
data() {
return {
loading: false,
submitLoading: false,
total: 0,
queryParams: createDefaultQueryParams(),
deviceList: [],
selectedIds: [],
selectedDeviceMap: {},
};
},
computed: {
dialogVisible: {
get() {
return this.visible;
},
set(value) {
this.$emit("update:visible", value);
},
},
},
watch: {
visible(value) {
if (value) {
this.initializeDialog();
}
},
},
methods: {
async initializeDialog() {
this.resetSelection();
this.queryParams = createDefaultQueryParams();
this.fetchDeviceList();
},
buildRequestQueryParams() {
return {
pageNum: this.queryParams.pageNum,
pageSize: this.queryParams.pageSize,
orderCode: this.queryParams.orderCode || undefined,
model: this.queryParams.model || undefined,
sn: this.queryParams.sn || undefined,
};
},
fetchDeviceList() {
this.loading = true;
listPendingActivateDevice(this.buildRequestQueryParams())
.then((response) => {
const data = response && response.data ? response.data : {};
this.deviceList = Array.isArray(data.list) ? data.list : [];
this.total = Number(data.total) || 0;
this.$nextTick(() => {
this.syncSelection();
});
})
.catch(() => {
this.deviceList = [];
this.total = 0;
})
.finally(() => {
this.loading = false;
});
},
handleQuery() {
this.queryParams.pageNum = 1;
this.fetchDeviceList();
},
resetQuery() {
this.queryParams = createDefaultQueryParams();
if (this.$refs.queryForm) {
this.$refs.queryForm.resetFields();
}
this.resetSelection();
this.fetchDeviceList();
},
handlePageChange() {
this.fetchDeviceList();
},
handleSelectionChange(selection) {
const currentPageIds = this.deviceList.map((item) => item.id);
currentPageIds.forEach((id) => {
if (this.selectedDeviceMap[id]) {
this.$delete(this.selectedDeviceMap, id);
}
});
selection.forEach((item) => {
this.$set(this.selectedDeviceMap, item.id, item);
});
this.selectedIds = Object.keys(this.selectedDeviceMap).map((id) => Number(id));
},
resetSelection() {
this.selectedDeviceMap = {};
this.selectedIds = [];
if (this.$refs.pendingActivateTable) {
this.$refs.pendingActivateTable.clearSelection();
}
},
syncSelection() {
if (!this.$refs.pendingActivateTable) {
return;
}
this.$refs.pendingActivateTable.clearSelection();
this.deviceList.forEach((row) => {
if (this.selectedDeviceMap[row.id]) {
this.$refs.pendingActivateTable.toggleRowSelection(row, true);
}
});
},
handleSubmit() {
if (!this.selectedIds.length) {
this.$message.warning("请至少选择一台设备");
return;
}
this.$confirm(`确认激活已选中的 ${this.selectedIds.length} 台设备吗?`, this.$t("common.tips"), {
confirmButtonText: this.$t("common.confirm"),
cancelButtonText: this.$t("common.cancel"),
type: "warning",
})
.then(() => {
this.submitLoading = true;
return batchActivateDevice({
ids: this.selectedIds,
pendingActivation: 1,
pending_activation: 1,
});
})
.then(() => {
this.$message.success("批量激活成功");
this.$emit("success");
this.handleClose();
})
.catch(() => {})
.finally(() => {
this.submitLoading = false;
});
},
handleClose() {
this.queryParams = createDefaultQueryParams();
this.deviceList = [];
this.total = 0;
this.resetSelection();
this.dialogVisible = false;
},
},
};
</script>
<style scoped>
.claim-query-form {
margin-bottom: 12px;
}
</style>

8
src/lang/fence-messages.js

@ -136,7 +136,7 @@ const fenceZh = {
overview: { online: '监控设备', normalDevice: '正常设备', alertDevice: '告警设备', activeFence: '启用围栏', openAlarm: '未处理告警', todayAlarm: '今日告警' },
query: { keyword: '关键字', fenceName: '围栏名称', status: '状态', shapeType: '图形类型', alarmType: '告警类型', handleStatus: '处理状态', alarmLevel: '告警级别', alarmTime: '告警时间' },
placeholder: { keyword: '请输入围栏名称或备注', fenceName: '请输入围栏名称', monitorKeyword: '请输入设备 SN / 名称 / 地址', alarmKeyword: '请输入告警内容', alarmSn: '请输入设备 SN', alarmFenceName: '请输入围栏名称', alarmDeviceName: '请输入设备名称', alarmTimeStart: '开始时间', alarmTimeEnd: '结束时间' },
button: { add: '新增围栏', edit: '编辑', remove: '删除', scan: '执行扫描', refresh: '刷新', refreshAll: '刷新全局', save: '保存', handle: '处理告警', locate: '定位', more: '更多', preview: '预览', resetEditor: '重置编辑器', selectDevice: '选择设备', markNormal: '设为正常', expandMap: '放大地图', restoreMap: '还原地图' },
button: { add: '新增围栏', addLocateTip: '新增围栏前点击可定位', edit: '编辑', remove: '删除', scan: '执行扫描', refresh: '刷新', refreshAll: '刷新全局', save: '保存', handle: '处理告警', locate: '定位', more: '更多', preview: '预览', resetEditor: '重置编辑器', selectDevice: '选择设备', markNormal: '设为正常', expandMap: '放大地图', restoreMap: '还原地图' },
status: { enabled: '启用', disabled: '停用' },
shape: { circle: '圆形', rect: '矩形', polygon: '多边形' },
alarmRule: { enter: '进入', exit: '离开', inside: '范围内', outside: '范围外', boundary: '边界', exitRecommended: '离开告警(防丢推荐)', enterExit: '进入+离开', enterOnly: '仅进入' },
@ -150,7 +150,7 @@ const fenceZh = {
form: { addTitle: '新增围栏', editTitle: '编辑围栏', name: '围栏名称', shapeType: '几何类型', alarmRules: '告警策略', scheduleType: '生效时间', scheduleStart: '开始时间', scheduleEnd: '结束时间', alarmCountdownMinutes: '告警倒计时(小时)', remark: '围栏备注', centerLat: '中心纬度', centerLng: '中心经度', radiusMeter: '半径(米)', geomWkt: '几何 WKT', shapeDataJson: '图形 JSON', deviceIds: '绑定设备', devicePlaceholder: '请选择需要监控的设备', deviceSearchPlaceholder: '搜索 SN / 设备名称 / 地址', timePlaceholder: 'HH:mm:ss' },
dialog: { deviceTitle: '选择绑定设备', deviceHint: '仅显示当前企业下已启用的设备,双击行可快速选择。', selectedCount: '已选择 {count} 台设备', alarmLocateTitle: '设备位置定位' },
table: { name: '围栏名称', shape: '图形', rules: '告警策略', status: '状态', actions: '操作', sn: '设备 SN', alias: '设备名称', lat: '纬度', lng: '经度', coordinates: '经纬度', address: '位置地址', locationTime: '更新时间', fenceStatus: '围栏状态', lastAlarm: '最近告警', alarmMessage: '告警内容', alarmTime: '告警时间', alarmType: '告警类型', alarmLevel: '告警级别' },
message: { loadFailed: '围栏工作台加载失败', mapConfigLoadFailed: '地图配置加载失败', nameRequired: '请先填写围栏名称', ruleRequired: '请至少选择一项告警规则', circleRequired: '圆形围栏需要中心点和半径', shapeRequired: '请先在地图上绘制围栏图形', shapeSynced: '图形已同步到表单', saveSuccess: '保存成功', removeSuccess: '删除成功', confirmRemove: '确认删除选中的围栏吗?', selectFence: '请先选择围栏', handleSuccess: '告警处理成功', markNormalSuccess: '设备状态已设为正常', markNormalFailed: '当前状态无法设为正常', noMonitorDevice: '当前没有可监控设备', noFenceData: '当前没有围栏数据', noAlarmData: '当前没有告警数据', scanSuccessDetail: '扫描完成,设备 {deviceCount} 个,判定 {evaluatedCount} 条,新增告警 {alarmCount} 条。', alarmLocationMissing: '当前告警缺少经纬度,无法定位。', devicePointLoadFailed: '设备坐标加载失败,已先展示围栏' }
message: { loadFailed: '围栏工作台加载失败', mapConfigLoadFailed: '地图配置加载失败', nameRequired: '请先填写围栏名称', ruleRequired: '请至少选择一项告警规则', deviceRequired: '请至少选择一台绑定设备', circleRequired: '圆形围栏需要中心点和半径', shapeRequired: '请先在地图上绘制围栏图形', shapeSynced: '图形已同步到表单', saveSuccess: '保存成功', removeSuccess: '删除成功', confirmRemove: '确认删除选中的围栏吗?', selectFence: '请先选择围栏', handleSuccess: '告警处理成功', markNormalSuccess: '设备状态已设为正常', markNormalFailed: '当前状态无法设为正常', noMonitorDevice: '当前没有可监控设备', noFenceData: '当前没有围栏数据', noAlarmData: '当前没有告警数据', scanSuccessDetail: '扫描完成,设备 {deviceCount} 个,判定 {evaluatedCount} 条,新增告警 {alarmCount} 条。', alarmLocationMissing: '当前告警缺少经纬度,无法定位。', devicePointLoadFailed: '设备坐标加载失败,已先展示围栏' }
}
const fenceEn = {
@ -172,7 +172,7 @@ const fenceEn = {
overview: { online: 'Monitored Devices', normalDevice: 'Normal Devices', alertDevice: 'Alert Devices', activeFence: 'Active Fences', openAlarm: 'Open Alarms', todayAlarm: 'Today Alarms' },
query: { keyword: 'Keyword', fenceName: 'Fence Name', status: 'Status', shapeType: 'Shape', alarmType: 'Alarm Type', handleStatus: 'Handle Status', alarmLevel: 'Alarm Level', alarmTime: 'Alarm Time' },
placeholder: { keyword: 'Enter fence name or remark', fenceName: 'Enter fence name', monitorKeyword: 'Enter device SN / name / address', alarmKeyword: 'Enter alarm content', alarmSn: 'Enter device SN', alarmFenceName: 'Enter fence name', alarmDeviceName: 'Enter device name', alarmTimeStart: 'Start time', alarmTimeEnd: 'End time' },
button: { add: 'Add Fence', edit: 'Edit', remove: 'Delete', scan: 'Run Scan', refresh: 'Refresh', refreshAll: 'Refresh All', save: 'Save', handle: 'Handle', locate: 'Locate', more: 'More', preview: 'Preview', resetEditor: 'Reset Editor', selectDevice: 'Select Devices', markNormal: 'Set Normal', expandMap: 'Expand Map', restoreMap: 'Restore Map' },
button: { add: 'Add Fence', addLocateTip: 'Click to locate before adding a fence', edit: 'Edit', remove: 'Delete', scan: 'Run Scan', refresh: 'Refresh', refreshAll: 'Refresh All', save: 'Save', handle: 'Handle', locate: 'Locate', more: 'More', preview: 'Preview', resetEditor: 'Reset Editor', selectDevice: 'Select Devices', markNormal: 'Set Normal', expandMap: 'Expand Map', restoreMap: 'Restore Map' },
status: { enabled: 'Enabled', disabled: 'Disabled' },
shape: { circle: 'Circle', rect: 'Rectangle', polygon: 'Polygon' },
alarmRule: { enter: 'Enter', exit: 'Exit', inside: 'Inside', outside: 'Outside', boundary: 'Boundary', exitRecommended: 'Exit Alert (Recommended)', enterExit: 'Enter + Exit', enterOnly: 'Enter Only' },
@ -186,7 +186,7 @@ const fenceEn = {
form: { addTitle: 'Add Fence', editTitle: 'Edit Fence', name: 'Fence Name', shapeType: 'Geometry Type', alarmRules: 'Alarm Rules', scheduleType: 'Schedule', scheduleStart: 'Start Time', scheduleEnd: 'End Time', alarmCountdownMinutes: 'Alarm Countdown (hour)', remark: 'Fence Remark', centerLat: 'Center Lat', centerLng: 'Center Lng', radiusMeter: 'Radius (m)', geomWkt: 'Geometry WKT', shapeDataJson: 'Shape JSON', deviceIds: 'Bind Devices', devicePlaceholder: 'Select devices', deviceSearchPlaceholder: 'Search SN / device name / address', timePlaceholder: 'HH:mm:ss' },
dialog: { deviceTitle: 'Select Devices', deviceHint: 'Only active devices in the current business are shown. Double-click a row to select it quickly.', selectedCount: '{count} devices selected', alarmLocateTitle: 'Device Location' },
table: { name: 'Fence Name', shape: 'Shape', rules: 'Rules', status: 'Status', actions: 'Actions', sn: 'Device SN', alias: 'Device Name', lat: 'Latitude', lng: 'Longitude', coordinates: 'Coordinates', address: 'Address', locationTime: 'Update Time', fenceStatus: 'Fence Status', lastAlarm: 'Last Alarm', alarmMessage: 'Alarm Message', alarmTime: 'Alarm Time', alarmType: 'Alarm Type', alarmLevel: 'Alarm Level' },
message: { loadFailed: 'Failed to load fence workspace', mapConfigLoadFailed: 'Failed to load map config', nameRequired: 'Fence name is required', ruleRequired: 'Select at least one alarm rule', circleRequired: 'Circle fence needs center and radius', shapeRequired: 'Draw the fence shape on the map first', shapeSynced: 'The shape has been synced to the form', saveSuccess: 'Saved successfully', removeSuccess: 'Deleted successfully', confirmRemove: 'Delete the selected fences?', selectFence: 'Select fences first', handleSuccess: 'Alarm handled successfully', markNormalSuccess: 'State has been set to normal', markNormalFailed: 'Unable to set current state to normal', noMonitorDevice: 'No device available', noFenceData: 'No fence data', noAlarmData: 'No alarm data', scanSuccessDetail: 'Scan finished: {deviceCount} devices, {evaluatedCount} evaluations, {alarmCount} new alarms.', alarmLocationMissing: 'This alarm has no coordinates and cannot be located.', devicePointLoadFailed: 'Failed to load device coordinates, fence is shown first.' }
message: { loadFailed: 'Failed to load fence workspace', mapConfigLoadFailed: 'Failed to load map config', nameRequired: 'Fence name is required', ruleRequired: 'Select at least one alarm rule', deviceRequired: 'Select at least one bound device', circleRequired: 'Circle fence needs center and radius', shapeRequired: 'Draw the fence shape on the map first', shapeSynced: 'The shape has been synced to the form', saveSuccess: 'Saved successfully', removeSuccess: 'Deleted successfully', confirmRemove: 'Delete the selected fences?', selectFence: 'Select fences first', handleSuccess: 'Alarm handled successfully', markNormalSuccess: 'State has been set to normal', markNormalFailed: 'Unable to set current state to normal', noMonitorDevice: 'No device available', noFenceData: 'No fence data', noAlarmData: 'No alarm data', scanSuccessDetail: 'Scan finished: {deviceCount} devices, {evaluatedCount} evaluations, {alarmCount} new alarms.', alarmLocationMissing: 'This alarm has no coordinates and cannot be located.', devicePointLoadFailed: 'Failed to load device coordinates, fence is shown first.' }
}

12
src/lang/profile-messages.js

@ -12,6 +12,7 @@ const profileMessages = {
nickName: "昵称",
googleKey: "谷歌地图 Key",
gaodeKey: "高德地图 Key",
maptilerKey: "MapTiler Key",
gaodeSecurityKey: "高德安全密钥",
oldPassword: "旧密码",
newPassword: "新密码",
@ -20,6 +21,7 @@ const profileMessages = {
placeholder: {
googleKey: "请输入谷歌地图 Key",
gaodeKey: "请输入高德地图 Key",
maptilerKey: "Enter MapTiler Key",
gaodeSecurityKey: "请输入高德安全密钥",
oldPassword: "请输入旧密码",
newPassword: "请输入新密码",
@ -60,6 +62,7 @@ const profileMessages = {
nickName: "Nickname",
googleKey: "Google Maps Key",
gaodeKey: "Amap Key",
maptilerKey: "MapTiler Key",
gaodeSecurityKey: "Amap Security Key",
oldPassword: "Old Password",
newPassword: "New Password",
@ -68,6 +71,7 @@ const profileMessages = {
placeholder: {
googleKey: "Enter Google Maps Key",
gaodeKey: "Enter Amap Key",
maptilerKey: "Enter MapTiler Key",
gaodeSecurityKey: "Enter Amap Security Key",
oldPassword: "Enter old password",
newPassword: "Enter new password",
@ -108,6 +112,7 @@ const profileMessages = {
nickName: "Псевдоним",
googleKey: "Ключ Google Maps",
gaodeKey: "Ключ Amap",
maptilerKey: "MapTiler Key",
gaodeSecurityKey: "Ключ безопасности Amap",
oldPassword: "Старый пароль",
newPassword: "Новый пароль",
@ -116,6 +121,7 @@ const profileMessages = {
placeholder: {
googleKey: "Введите ключ Google Maps",
gaodeKey: "Введите ключ Amap",
maptilerKey: "Enter MapTiler Key",
gaodeSecurityKey: "Введите ключ безопасности Amap",
oldPassword: "Введите старый пароль",
newPassword: "Введите новый пароль",
@ -156,6 +162,7 @@ const profileMessages = {
nickName: "Surnom",
googleKey: "Cle Google Maps",
gaodeKey: "Cle Amap",
maptilerKey: "MapTiler Key",
gaodeSecurityKey: "Cle de securite Amap",
oldPassword: "Ancien mot de passe",
newPassword: "Nouveau mot de passe",
@ -164,6 +171,7 @@ const profileMessages = {
placeholder: {
googleKey: "Saisissez la cle Google Maps",
gaodeKey: "Saisissez la cle Amap",
maptilerKey: "Enter MapTiler Key",
gaodeSecurityKey: "Saisissez la cle de securite Amap",
oldPassword: "Saisissez l'ancien mot de passe",
newPassword: "Saisissez le nouveau mot de passe",
@ -204,6 +212,7 @@ const profileMessages = {
nickName: "Apodo",
googleKey: "Clave de Google Maps",
gaodeKey: "Clave de Amap",
maptilerKey: "MapTiler Key",
gaodeSecurityKey: "Clave de seguridad de Amap",
oldPassword: "Contrasena anterior",
newPassword: "Nueva contrasena",
@ -212,6 +221,7 @@ const profileMessages = {
placeholder: {
googleKey: "Ingrese la clave de Google Maps",
gaodeKey: "Ingrese la clave de Amap",
maptilerKey: "Enter MapTiler Key",
gaodeSecurityKey: "Ingrese la clave de seguridad de Amap",
oldPassword: "Ingrese la contrasena anterior",
newPassword: "Ingrese la nueva contrasena",
@ -252,6 +262,7 @@ const profileMessages = {
nickName: "Apelido",
googleKey: "Chave do Google Maps",
gaodeKey: "Chave da Amap",
maptilerKey: "MapTiler Key",
gaodeSecurityKey: "Chave de seguranca da Amap",
oldPassword: "Senha antiga",
newPassword: "Nova senha",
@ -260,6 +271,7 @@ const profileMessages = {
placeholder: {
googleKey: "Digite a chave do Google Maps",
gaodeKey: "Digite a chave da Amap",
maptilerKey: "Enter MapTiler Key",
gaodeSecurityKey: "Digite a chave de seguranca da Amap",
oldPassword: "Digite a senha antiga",
newPassword: "Digite a nova senha",

21
src/layout/components/Navbar.vue

@ -1,4 +1,4 @@
<template>
<template>
<div class="navbar">
<hamburger id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
@ -6,15 +6,11 @@
<top-nav v-if="topNav" id="topmenu-container" class="topmenu-container" />
<div class="right-menu">
<template v-if="device!=='mobile'">
<template v-if="device !== 'mobile'">
<search id="header-search" class="right-menu-item" />
<screenfull id="screenfull" class="right-menu-item hover-effect" />
<!-- <el-tooltip :content="$t('navbar.layoutSize')" effect="dark" placement="bottom">
<size-select id="size-select" class="right-menu-item hover-effect" />
</el-tooltip> -->
<el-dropdown class="right-menu-item hover-effect lang-menu" trigger="click" @command="changeLanguage">
<span class="lang-trigger">
<i class="el-icon-connection"></i>
@ -31,7 +27,6 @@
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<el-dropdown class="avatar-container right-menu-item hover-effect" trigger="hover">
@ -67,6 +62,7 @@ import Search from '@/components/HeaderSearch'
import RuoYiGit from '@/components/RuoYi/Git'
import RuoYiDoc from '@/components/RuoYi/Doc'
import { getLanguage, setLanguage, languageOptions } from '@/utils/language'
import { getBrandedLoginPath } from '@/utils/brand'
export default {
emits: ['setLayout'],
@ -90,7 +86,8 @@ export default {
'sidebar',
'avatar',
'device',
'nickName'
'nickName',
'brandBusinessNo'
]),
setting: {
get() {
@ -110,7 +107,7 @@ export default {
toggleSideBar() {
this.$store.dispatch('app/toggleSideBar')
},
setLayout(event) {
setLayout() {
this.$emit('setLayout')
},
logout() {
@ -120,7 +117,7 @@ export default {
type: 'warning'
}).then(() => {
this.$store.dispatch('LogOut').then(() => {
location.href = '/index'
location.href = getBrandedLoginPath(this.brandBusinessNo)
})
}).catch(() => {})
},
@ -150,7 +147,7 @@ export default {
float: left;
cursor: pointer;
transition: background .3s;
-webkit-tap-highlight-color:transparent;
-webkit-tap-highlight-color: transparent;
&:hover {
background: rgba(0, 0, 0, .025)
@ -221,7 +218,7 @@ export default {
border-radius: 50%;
}
.user-nickname{
.user-nickname {
position: relative;
bottom: 10px;
font-size: 14px;

54
src/layout/components/Sidebar/Logo.vue

@ -1,5 +1,5 @@
<template>
<div class="sidebar-logo-container" :class="{'collapse':collapse}" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
<template>
<div class="sidebar-logo-container" :class="{ collapse }" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
<transition name="sidebarLogoFade">
<router-link
:key="collapse ? 'collapse' : 'expand'"
@ -8,9 +8,9 @@
:style="logoLinkStyle"
to="/"
>
<img v-if="logo" :src="logo" class="sidebar-logo" />
<img v-if="logoSrc" :src="logoSrc" class="sidebar-logo" @error="handleLogoError" />
<h1
v-if="!collapse || !logo"
v-if="!collapse || !logoSrc"
class="sidebar-title"
:title="logoTitle"
:style="logoTitleStyle"
@ -25,14 +25,7 @@
<script>
import logoImg from '@/assets/logo/logo.png'
import variables from '@/assets/styles/variables.scss'
const SIDEBAR_TITLE_MAP = {
'zh-CN': '客户 GeoTag管理系统',
'en-US': 'GeoTag Customer Management System',
'fr-FR': 'Systeme de gestion client GeoTag',
'es-ES': 'Sistema de gestion de clientes GeoTag',
'pt-BR': 'Sistema de gestao de clientes GeoTag'
}
import { resolveBrandAsset } from '@/utils/brand'
export default {
name: 'SidebarLogo',
@ -42,6 +35,13 @@ export default {
required: true
}
},
data() {
return {
defaultTitle: process.env.VUE_APP_TITLE,
defaultLogo: logoImg,
logoLoadError: false
}
},
computed: {
variables() {
return variables
@ -49,8 +49,8 @@ export default {
sideTheme() {
return this.$store.state.settings.sideTheme
},
currentLanguage() {
return this.$store.state.settings.language || 'zh-CN'
currentBrand() {
return this.$store.getters.brand || {}
},
logoLinkStyle() {
if (this.collapse) {
@ -93,18 +93,27 @@ export default {
}
},
logoTitle() {
const directTitle = SIDEBAR_TITLE_MAP[this.currentLanguage]
if (directTitle) {
return directTitle
if (this.currentBrand.appSystemName) {
return this.currentBrand.appSystemName
}
const translated = this.$t('app.sidebarTitle')
return translated && translated !== 'app.sidebarTitle' ? translated : this.title
return translated && translated !== 'app.sidebarTitle' ? translated : this.defaultTitle
},
logoSrc() {
if (this.logoLoadError) {
return this.defaultLogo
}
return resolveBrandAsset(this.currentBrand.appLogoUrl) || this.defaultLogo
}
},
data() {
return {
title: process.env.VUE_APP_TITLE,
logo: logoImg
watch: {
'currentBrand.appLogoUrl'() {
this.logoLoadError = false
}
},
methods: {
handleLogoError() {
this.logoLoadError = true
}
}
}
@ -144,6 +153,7 @@ export default {
width: 32px;
height: 32px;
vertical-align: middle;
object-fit: contain;
}
& .sidebar-title {

91
src/main.js

@ -1,7 +1,5 @@
import Vue from "vue";
import Vue from "vue";
import Cookies from "js-cookie";
import Element from "element-ui";
import ElementLocale from "element-ui/lib/locale";
import elementZhCN from "element-ui/lib/locale/lang/zh-CN";
@ -12,17 +10,41 @@ import elementEs from "element-ui/lib/locale/lang/es";
import elementPtBr from "element-ui/lib/locale/lang/pt-br";
import "./assets/styles/element-variables.scss";
import "@/assets/styles/index.scss"; // global css
import "@/assets/styles/ruoyi.scss"; // ruoyi css
import "@/assets/styles/index.scss";
import "@/assets/styles/ruoyi.scss";
import App from "./App";
import store from "./store";
import router from "./router";
import directive from "./directive"; // directive
import plugins from "./plugins"; // plugins
import directive from "./directive";
import plugins from "./plugins";
import { download } from "@/utils/ruoyi";
import { math, warmupMathjs } from "@/utils/math.js";
import I18nPlugin, { t as i18nT } from "@/lang";
import { getLanguage } from "@/utils/language";
import { applyBrandPresentation } from "@/utils/brand";
import "./assets/icons";
import "./permission";
import { getDicts } from "@/api/system/dict/data";
import { getConfigKey } from "@/api/system/config";
import {
parseTime,
resetForm,
addDateRange,
selectDictLabel,
selectDictLabels,
handleTree,
addSESDateRange,
handleCopy,
formatAddress,
} from "@/utils/ruoyi";
import Pagination from "@/components/Pagination";
import RightToolbar from "@/components/RightToolbar";
import Editor from "@/components/Editor";
import FileUpload from "@/components/FileUpload";
import ImageUpload from "@/components/ImageUpload";
import ImagePreview from "@/components/ImagePreview";
import DictTag from "@/components/DictTag";
import DictData from "@/components/DictData";
const ELEMENT_LOCALE_MAP = {
"zh-CN": elementZhCN,
@ -52,38 +74,7 @@ Vue.prototype.msgError = function (msg) {
Vue.prototype.msgInfo = function (msg) {
this.$message.info(msg);
};
import "./assets/icons"; // icon
import "./permission"; // permission control
import { getDicts } from "@/api/system/dict/data";
import { getConfigKey } from "@/api/system/config";
import {
parseTime,
resetForm,
addDateRange,
selectDictLabel,
selectDictLabels,
handleTree,
addSESDateRange,
handleCopy,
formatAddress,
} from "@/utils/ruoyi";
// 分页组件
import Pagination from "@/components/Pagination";
// 自定义表格工具组件
import RightToolbar from "@/components/RightToolbar";
// 富文本组件
import Editor from "@/components/Editor";
// 文件上传组件
import FileUpload from "@/components/FileUpload";
// 图片上传组件
import ImageUpload from "@/components/ImageUpload";
// 图片预览组件
import ImagePreview from "@/components/ImagePreview";
// 字典标签组件
import DictTag from "@/components/DictTag";
// 字典数据组件
import DictData from "@/components/DictData";
// 全局方法挂载
Vue.prototype.getDicts = getDicts;
Vue.prototype.getConfigKey = getConfigKey;
Vue.prototype.parseTime = parseTime;
@ -96,7 +87,7 @@ Vue.prototype.handleTree = handleTree;
Vue.prototype.addSESDateRange = addSESDateRange;
Vue.prototype.handleCopy = handleCopy;
Vue.prototype.formatAddress = formatAddress;
// 全局组件挂载
Vue.component("DictTag", DictTag);
Vue.component("Pagination", Pagination);
Vue.component("RightToolbar", RightToolbar);
@ -110,17 +101,8 @@ Vue.use(plugins);
Vue.use(I18nPlugin);
DictData.install();
/**
* If you don't want to use mock-server
* you want to use MockJs for mock api
* you can execute: mockXHR()
*
* Currently MockJs will be used in the production environment,
* please remove it before going online! ! !
*/
Vue.use(Element, {
size: Cookies.get("size") || "medium", // set element-ui default size
size: Cookies.get("size") || "medium",
});
Vue.config.productionTip = false;
@ -128,9 +110,12 @@ Vue.config.productionTip = false;
if (typeof document !== "undefined") {
document.documentElement.setAttribute("lang", currentLanguage);
const appTitle = i18nT("app.sidebarTitle");
document.title = appTitle && appTitle !== "app.sidebarTitle"
? appTitle
: process.env.VUE_APP_TITLE;
applyBrandPresentation({
brand: store.state.brand.current,
fallbackTitle: appTitle && appTitle !== "app.sidebarTitle" ? appTitle : process.env.VUE_APP_TITLE,
pageTitle: store.state.settings.title,
dynamicTitle: store.state.settings.dynamicTitle,
});
}
const app = new Vue({

26
src/permission.js

@ -1,4 +1,4 @@
import router from "./router";
import router from "./router";
import store from "./store";
import { Message } from "element-ui";
import NProgress from "nprogress";
@ -6,27 +6,32 @@ import "nprogress/nprogress.css";
import { getToken } from "@/utils/auth";
import { isPathMatch } from "@/utils/validate";
import { isRelogin } from "@/utils/request";
import { extractBusinessNoFromPath, getBrandedLoginPath } from "@/utils/brand";
NProgress.configure({ showSpinner: false });
const whiteList = ["/login", "/register"];
const whiteList = ["/login", "/:businessNo/login", "/register"];
const HOME_PATH = "/index";
const isWhiteList = (path) => {
return whiteList.some((pattern) => isPathMatch(pattern, path));
};
const isWhiteList = (path) => whiteList.some((pattern) => isPathMatch(pattern, path));
const isLoginPath = (path) => isPathMatch("/login", path) || isPathMatch("/:businessNo/login", path);
const hasHomeRoute = (routes) => {
return Array.isArray(routes) && routes.some((route) => route && route.path === "/" && route.redirect === HOME_PATH);
};
const resolveLoginPath = (path) => {
const businessNo = extractBusinessNoFromPath(path) || store.getters.brandBusinessNo;
return getBrandedLoginPath(businessNo);
};
router.beforeEach((to, from, next) => {
NProgress.start();
if (getToken()) {
if (to.meta.title) {
store.dispatch("settings/setTitle", to.meta.title);
}
if (to.path === "/login") {
if (isLoginPath(to.path)) {
next({ path: HOME_PATH, replace: true });
NProgress.done();
return;
@ -39,6 +44,8 @@ router.beforeEach((to, from, next) => {
isRelogin.show = true;
store.dispatch("GetInfo").then(() => {
isRelogin.show = false;
return store.dispatch("brand/loadCurrentBrand").catch(() => null);
}).then(() => {
store.dispatch("GenerateRoutes").then((accessRoutes) => {
router.addRoutes(accessRoutes);
if (!hasHomeRoute(accessRoutes) && to.path !== "/no-permission") {
@ -58,7 +65,7 @@ router.beforeEach((to, from, next) => {
.catch(() => {})
.finally(() => {
Message.error(errorText);
next({ path: "/login", replace: true });
next({ path: resolveLoginPath(to.path), replace: true });
});
});
return;
@ -70,10 +77,11 @@ router.beforeEach((to, from, next) => {
if (isWhiteList(to.path)) {
next();
} else {
const loginPath = resolveLoginPath(to.path);
if (to.path === "/no-permission") {
next("/login");
next(loginPath);
} else {
next(`/login?redirect=${encodeURIComponent(to.fullPath)}`);
next(`${loginPath}?redirect=${encodeURIComponent(to.fullPath)}`);
}
NProgress.done();
}

58
src/router/index.js

@ -1,34 +1,10 @@
import Vue from 'vue'
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
/* Layout */
import Layout from '@/layout'
/**
* Note: 路由配置项
*
* hidden: true // 当设置 true 的时候该路由不会再侧边栏出现 如401,login等页面,或者如一些编辑页面/edit/1
* alwaysShow: true // 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式--如组件页面
* // 只有一个时,会将那个子路由当做根路由显示在侧边栏--如引导页面
* // 若你想不管路由下面的 children 声明的个数都显示你的根路由
* // 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则,一直显示根路由
* redirect: noRedirect // 当设置 noRedirect 的时候该路由在面包屑导航中不可被点击
* name:'router-name' // 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
* query: '{"id": 1, "name": "ry"}' // 访问路由的默认传递参数
* roles: ['admin', 'common'] // 访问路由的角色权限
* permissions: ['a:a:a', 'b:b:b'] // 访问路由的菜单权限
* meta : {
noCache: true // 如果设置为true,则不会被 <keep-alive> 缓存(默认 false)
title: 'title' // 设置该路由在侧边栏和面包屑中展示的名字
icon: 'svg-name' // 设置该路由的图标,对应路径src/assets/icons/svg
breadcrumb: false // 如果设置为false,则不会在breadcrumb面包屑中显示
activeMenu: '/system/user' // 当路由设置了该属性,则会高亮相对应的侧边栏。
}
*/
// 公共路由
export const constantRoutes = [
{
path: '/redirect',
@ -46,6 +22,11 @@ export const constantRoutes = [
component: () => import('@/views/login'),
hidden: true
},
{
path: '/:businessNo/login',
component: () => import('@/views/login'),
hidden: true
},
{
path: '/register',
component: () => import('@/views/register'),
@ -66,19 +47,6 @@ export const constantRoutes = [
component: () => import('@/views/error/noPermission'),
hidden: true
},
// {
// path: '',
// component: Layout,
// redirect: 'index',
// children: [
// {
// path: 'index',
// component: () => import('@/views/index'),
// name: 'Index',
// meta: { title: '首页', icon: 'dashboard', affix: true }
// }
// ]
// },
{
path: '/user',
component: Layout,
@ -108,9 +76,7 @@ export const constantRoutes = [
}
]
// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
{
path: '/system/dict-data',
component: Layout,
@ -138,24 +104,22 @@ export const dynamicRoutes = [
meta: { title: '调度日志', activeMenu: '/monitor/job' }
}
]
},
}
]
// 防止连续点击多次路由报错
let routerPush = Router.prototype.push
let routerReplace = Router.prototype.replace
// push
Router.prototype.push = function push(location) {
return routerPush.call(this, location).catch(err => err)
}
// replace
Router.prototype.replace = function push(location) {
Router.prototype.replace = function replace(location) {
return routerReplace.call(this, location).catch(err => err)
}
export default new Router({
mode: 'history', // 去掉url中的#
mode: 'history',
scrollBehavior: () => ({ y: 0 }),
routes: constantRoutes
})

4
src/store/getters.js

@ -1,8 +1,10 @@
const getters = {
const getters = {
sidebar: state => state.app.sidebar,
size: state => state.app.size,
device: state => state.app.device,
dict: state => state.dict.dict,
brand: state => state.brand.current,
brandBusinessNo: state => state.brand.current.businessNo,
visitedViews: state => state.tagsView.visitedViews,
cachedViews: state => state.tagsView.cachedViews,
token: state => state.user.token,

4
src/store/index.js

@ -1,6 +1,7 @@
import Vue from 'vue'
import Vue from 'vue'
import Vuex from 'vuex'
import app from './modules/app'
import brand from './modules/brand'
import dict from './modules/dict'
import user from './modules/user'
import tagsView from './modules/tagsView'
@ -13,6 +14,7 @@ Vue.use(Vuex)
const store = new Vuex.Store({
modules: {
app,
brand,
dict,
user,
tagsView,

64
src/store/modules/brand.js

@ -0,0 +1,64 @@
import { getBrandConfig } from "@/api/brand";
import {
applyBrandPresentation,
clearPersistedBrand,
getDefaultBrand,
normalizeBrandConfig,
persistBrand,
readPersistedBrand
} from "@/utils/brand";
const state = {
current: readPersistedBrand()
};
const mutations = {
SET_BRAND(state, brand) {
state.current = normalizeBrandConfig(brand);
},
CLEAR_BRAND(state) {
state.current = getDefaultBrand();
}
};
function syncBrandPresentation(brand, rootState) {
applyBrandPresentation({
brand,
fallbackTitle: process.env.VUE_APP_TITLE,
pageTitle: rootState.settings.title,
dynamicTitle: rootState.settings.dynamicTitle
});
}
function saveBrand(commit, rootState, brand) {
const normalized = normalizeBrandConfig(brand);
commit("SET_BRAND", normalized);
persistBrand(normalized);
syncBrandPresentation(normalized, rootState);
return normalized;
}
const actions = {
loadBrandByBusinessNo({ commit, rootState }, businessNo) {
return getBrandConfig(businessNo).then((response) => saveBrand(commit, rootState, response));
},
loadCurrentBrand({ commit, rootState }) {
return getBrandConfig().then((response) => saveBrand(commit, rootState, response));
},
setBrand({ commit, rootState }, brand) {
return Promise.resolve(saveBrand(commit, rootState, brand));
},
clearBrand({ commit, rootState }) {
commit("CLEAR_BRAND");
clearPersistedBrand();
syncBrandPresentation(getDefaultBrand(), rootState);
return Promise.resolve();
}
};
export default {
namespaced: true,
state,
mutations,
actions
};

125
src/utils/brand.js

@ -0,0 +1,125 @@
const BRAND_STORAGE_KEY = "geotag-customer-brand";
const DEFAULT_BRAND = {
businessNo: "",
appLogoUrl: "",
loginSystemName: "",
appSystemName: "",
loginLeftImageUrl: "",
faviconUrl: "",
copyrightText: ""
};
function safeDecodeURIComponent(value) {
if (!value) {
return "";
}
try {
return decodeURIComponent(value);
} catch (error) {
return value;
}
}
export function getDefaultBrand() {
return { ...DEFAULT_BRAND };
}
export function normalizeBrandConfig(payload = {}) {
const source = payload && payload.data ? payload.data : payload;
return {
...DEFAULT_BRAND,
businessNo: source.businessNo || source.business_no || "",
appLogoUrl: source.appLogoUrl || source.app_logo_url || "",
loginSystemName: source.loginSystemName || source.login_system_name || "",
appSystemName: source.appSystemName || source.app_system_name || "",
loginLeftImageUrl: source.loginLeftImageUrl || source.login_left_image_url || "",
faviconUrl: source.faviconUrl || source.favicon_url || "",
copyrightText: source.copyrightText || source.copyright_text || ""
};
}
export function resolveBrandAsset(url) {
if (!url) {
return "";
}
if (/^(https?:)?\/\//.test(url) || /^data:/.test(url)) {
return url;
}
if (url.startsWith("/")) {
return `${process.env.VUE_APP_BASE_API}${url}`;
}
return url;
}
export function extractBusinessNoFromPath(path) {
if (!path || path === "/login") {
return "";
}
const match = path.match(/^\/([^/]+)\/login\/?$/);
return match ? safeDecodeURIComponent(match[1]) : "";
}
export function getBrandedLoginPath(businessNo) {
return businessNo ? `/${encodeURIComponent(businessNo)}/login` : "/login";
}
export function persistBrand(brand) {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(BRAND_STORAGE_KEY, JSON.stringify(normalizeBrandConfig(brand)));
}
export function readPersistedBrand() {
if (typeof window === "undefined") {
return getDefaultBrand();
}
const raw = window.localStorage.getItem(BRAND_STORAGE_KEY);
if (!raw) {
return getDefaultBrand();
}
try {
return normalizeBrandConfig(JSON.parse(raw));
} catch (error) {
window.localStorage.removeItem(BRAND_STORAGE_KEY);
return getDefaultBrand();
}
}
export function clearPersistedBrand() {
if (typeof window === "undefined") {
return;
}
window.localStorage.removeItem(BRAND_STORAGE_KEY);
}
function getDefaultFaviconHref() {
const baseUrl = process.env.BASE_URL || "/";
return `${baseUrl}favicon.ico`;
}
export function applyBrandFavicon(iconUrl) {
if (typeof document === "undefined") {
return;
}
const href = iconUrl ? resolveBrandAsset(iconUrl) : getDefaultFaviconHref();
let link = document.querySelector("link[rel*='icon']");
if (!link) {
link = document.createElement("link");
link.rel = "shortcut icon";
document.head.appendChild(link);
}
link.type = "image/x-icon";
link.href = href;
}
export function applyBrandPresentation({ brand, fallbackTitle, pageTitle = "", dynamicTitle = false } = {}) {
if (typeof document === "undefined") {
return;
}
const normalized = normalizeBrandConfig(brand);
const baseTitle = normalized.appSystemName || fallbackTitle || process.env.VUE_APP_TITLE;
document.title = dynamicTitle && pageTitle ? `${pageTitle} - ${baseTitle}` : baseTitle;
applyBrandFavicon(normalized.faviconUrl);
}

20
src/utils/dynamicTitle.js

@ -1,6 +1,7 @@
import store from '@/store'
import store from '@/store'
import defaultSettings from '@/settings'
import { t as i18nT } from '@/lang'
import { applyBrandPresentation } from '@/utils/brand'
function resolveAppTitle() {
const translated = i18nT('app.sidebarTitle')
@ -9,16 +10,11 @@ function resolveAppTitle() {
: defaultSettings.title
}
/**
* 动态修改标题
*/
export function useDynamicTitle() {
const appTitle = resolveAppTitle()
if (store.state.settings.dynamicTitle) {
document.title = store.state.settings.title
? (store.state.settings.title + ' - ' + appTitle)
: appTitle
} else {
document.title = appTitle
}
applyBrandPresentation({
brand: store.state.brand.current,
fallbackTitle: resolveAppTitle(),
pageTitle: store.state.settings.title,
dynamicTitle: store.state.settings.dynamicTitle
})
}

72
src/utils/request.js

@ -8,24 +8,19 @@ import { tansParams } from "@/utils/ruoyi";
import { getAcceptLanguage } from "@/utils/language";
import cache from "@/plugins/cache";
import { t } from "@/lang";
import { getBrandedLoginPath } from "@/utils/brand";
// 是否显示重新登录
export let isRelogin = { show: false };
axios.defaults.headers["Content-Type"] = "application/json;charset=utf-8";
// 创建 axios 实例
const service = axios.create({
// axios 中请求配置有 baseURL 选项,表示请求 URL 公共部分
baseURL: process.env.VUE_APP_BASE_API,
// 超时
timeout: 10000,
});
// request 拦截器
service.interceptors.request.use(
(config) => {
// 接口密码加密
const encryptUrls = [
"/merchant/merchantInfo",
"/merchant/merchantInfo/updateMerchantPwdAdmin",
@ -51,18 +46,12 @@ service.interceptors.request.use(
});
}
// 是否需要设置 token
const isToken = (config.headers || {}).isToken === false;
// 是否需要防止数据重复提交
const isRepeatSubmit = (config.headers || {}).repeatSubmit === false;
if (getToken() && !isToken) {
config.headers["Authorization"] = "Bearer " + getToken();
}
console.log(`语言:${getAcceptLanguage()}`)
console.log("navigator.language =", navigator.language);
console.log("navigator.languages =", navigator.languages);
console.log("localStorage.language =", localStorage.getItem("language"));
config.headers["Accept-Language"] = getAcceptLanguage();
const clientTimeZone =
@ -71,8 +60,7 @@ console.log("localStorage.language =", localStorage.getItem("language"));
Intl.DateTimeFormat().resolvedOptions().timeZone) ||
"Asia/Shanghai";
config.headers["X-Timezone"] = clientTimeZone;
console.log(`时区:${clientTimeZone}`)
// get 请求映射 params 参数
if (config.method === "get" && config.params) {
let url = config.url + "?" + tansParams(config.params);
url = url.slice(0, -1);
@ -80,50 +68,33 @@ console.log("localStorage.language =", localStorage.getItem("language"));
config.url = url;
}
if (
!isRepeatSubmit &&
(config.method === "post" || config.method === "put")
) {
if (!isRepeatSubmit && (config.method === "post" || config.method === "put")) {
const requestObj = {
url: config.url,
data:
typeof config.data === "object"
? JSON.stringify(config.data)
: config.data,
data: typeof config.data === "object" ? JSON.stringify(config.data) : config.data,
time: new Date().getTime(),
};
const requestSize = Object.keys(JSON.stringify(requestObj)).length; // 请求数据大小
const limitSize = 5 * 1024 * 1024; // 限制存放数据 5M
const requestSize = JSON.stringify(requestObj).length;
const limitSize = 5 * 1024 * 1024;
if (requestSize >= limitSize) {
console.warn(
`[${config.url}]: ` +
"请求数据大小超出允许的 5M 限制,无法进行防重复提交验证。"
);
console.warn(`[${config.url}]: request data size exceeds 5MB, skip repeat-submit check.`);
return config;
}
const sessionObj = cache.session.getJSON("sessionObj");
if (
sessionObj === undefined ||
sessionObj === null ||
sessionObj === ""
) {
if (sessionObj === undefined || sessionObj === null || sessionObj === "") {
cache.session.setJSON("sessionObj", requestObj);
} else {
const s_url = sessionObj.url; // 请求地址
const s_data = sessionObj.data; // 请求数据
const s_time = sessionObj.time; // 请求时间
const interval = 1000; // 间隔时间(ms),小于此时间视为重复提交
if (
s_data === requestObj.data &&
requestObj.time - s_time < interval &&
s_url === requestObj.url
) {
const sUrl = sessionObj.url;
const sData = sessionObj.data;
const sTime = sessionObj.time;
const interval = 1000;
if (sData === requestObj.data && requestObj.time - sTime < interval && sUrl === requestObj.url) {
const message = "数据正在处理,请勿重复提交";
console.warn(`[${s_url}]: ` + message);
console.warn(`[${sUrl}]: ` + message);
return Promise.reject(new Error(message));
}
@ -139,19 +110,12 @@ console.log("localStorage.language =", localStorage.getItem("language"));
}
);
// 响应拦截器
service.interceptors.response.use(
(res) => {
// 未设置状态码则默认成功状态
const code = res.data.code || 200;
// 获取错误信息(优先后端返回,支持 i18n)
const msg = res.data.msg || errorCode[code] || errorCode["default"];
// 二进制数据则直接返回
if (
res.request.responseType === "blob" ||
res.request.responseType === "arraybuffer"
) {
if (res.request.responseType === "blob" || res.request.responseType === "arraybuffer") {
return res.data;
}
@ -170,7 +134,7 @@ service.interceptors.response.use(
.then(() => {
isRelogin.show = false;
store.dispatch("LogOut").then(() => {
location.href = "/index";
location.href = getBrandedLoginPath(store.getters.brandBusinessNo);
});
})
.catch(() => {
@ -213,4 +177,4 @@ service.interceptors.response.use(
}
);
export default service;
export default service;

73
src/utils/validate.js

@ -1,20 +1,23 @@
/**
* 路径匹配器
* @param {string} pattern
* @param {string} path
* @returns {Boolean}
*/
export function isPathMatch(pattern, path) {
const regexPattern = pattern.replace(/\//g, '\\/').replace(/\*\*/g, '.*').replace(/\*/g, '[^\\/]*')
const regex = new RegExp(`^${regexPattern}$`)
export function isPathMatch(pattern, path) {
const patternSegments = pattern.split('/').map((segment) => {
if (!segment) {
return ''
}
if (segment === '**') {
return '.*'
}
if (segment === '*') {
return '[^/]+'
}
if (segment.startsWith(':')) {
return '[^/]+'
}
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
})
const regex = new RegExp(`^${patternSegments.join('\\/')}$`)
return regex.test(path)
}
/**
* 判断value字符串是否为空
* @param {string} value
* @returns {Boolean}
*/
export function isEmpty(value) {
if (value == null || value == "" || value == undefined || value == "undefined") {
return true
@ -22,90 +25,48 @@ export function isEmpty(value) {
return false
}
/**
* 判断url是否是http或https
* @param {string} url
* @returns {Boolean}
*/
export function isHttp(url) {
return url.indexOf('http://') !== -1 || url.indexOf('https://') !== -1
}
/**
* 判断path是否为外链
* @param {string} path
* @returns {Boolean}
*/
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path)
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validUsername(str) {
const valid_map = ['admin', 'editor']
return valid_map.indexOf(str.trim()) >= 0
}
/**
* @param {string} url
* @returns {Boolean}
*/
export function validURL(url) {
const reg = /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/
return reg.test(url)
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validLowerCase(str) {
const reg = /^[a-z]+$/
return reg.test(str)
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validUpperCase(str) {
const reg = /^[A-Z]+$/
return reg.test(str)
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validAlphabets(str) {
const reg = /^[A-Za-z]+$/
return reg.test(str)
}
/**
* @param {string} email
* @returns {Boolean}
*/
export function validEmail(email) {
const reg = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
return reg.test(email)
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function isString(str) {
return typeof str === 'string' || str instanceof String
}
/**
* @param {Array} arg
* @returns {Boolean}
*/
export function isArray(arg) {
if (typeof Array.isArray === 'undefined') {
return Object.prototype.toString.call(arg) === '[object Array]'

29
src/views/device/device/index.vue

@ -80,6 +80,9 @@
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-s-claim" size="mini" @click="handleClaimDevice" v-hasPermi="['device:device:claim:batch']">{{ $t("device.button.claim") }}</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="el-icon-tickets" size="mini" v-hasPermi="['device:device:activate:batch']" @click="handlePendingActivateDialog">待激活设备</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="success" plain icon="el-icon-circle-check" size="mini" :disabled="multiple" v-hasPermi="['device:device:activate:batch']" @click="handleBatchActivate">{{ $t("device.button.batchEnable") }}</el-button>
</el-col>
@ -320,6 +323,7 @@
/>
</el-dialog>
<DeviceClaimDialog :visible.sync="claimDeviceOpen" @success="handleClaimSuccess" />
<PendingActivateDialog :visible.sync="pendingActivateOpen" @success="handlePendingActivateSuccess" />
<el-dialog :title="$t('device.dialog.assign.title')" :visible.sync="assignDeviceOpen" width="1200px" append-to-body @close="handleAssignDialogClose">
<UserSelector ref="userSelector" selection-mode @select="handleAssignUserSelect" />
<div slot="footer" class="dialog-footer">
@ -475,8 +479,9 @@ import {
assignBusinessUserDevices
} from "@/api/business/businessUser";
//
import BusinessSelect from "@/components/business/BusinessSelect"; //
import BusinessSelect from "@/components/business/BusinessSelect";
import DeviceClaimDialog from "@/components/device";
import PendingActivateDialog from "@/components/device/PendingActivateDialog";
import DeviceTrajectoryDialog from "@/components/device/TrajectoryDialog";
import UserSelector from "@/components/user";
@ -525,6 +530,7 @@ export default {
components: {
BusinessSelect,
DeviceClaimDialog,
PendingActivateDialog,
DeviceTrajectoryDialog,
UserSelector,
},
@ -601,6 +607,8 @@ export default {
selectedAssignUsers: [],
//
claimDeviceOpen: false,
//
pendingActivateOpen: false,
//
importOpen: false,
//
@ -756,13 +764,13 @@ export default {
id: null,
sn: null,
mac: null,
orderCode: null, //
orderCode: null,
privateKey: null,
batchNo: null,
hashid: null,
model: null,
bindBusinessId: null,
remark: null, //
remark: null,
locateUpdateTime: null,
lastLat: null,
lastLng: null,
@ -869,6 +877,10 @@ export default {
handleClaimDevice() {
this.claimDeviceOpen = true;
},
/** 打开待激活设备弹窗 */
handlePendingActivateDialog() {
this.pendingActivateOpen = true;
},
/** 打开分配设备弹窗 */
handleAssignDevice() {
if (!this.ids.length) {
@ -956,7 +968,11 @@ export default {
type: "warning",
})
.then(() => {
return batchActivateDevice(this.ids);
return batchActivateDevice({
ids: this.ids,
pendingActivation: 1,
pending_activation: 1,
});
})
.then(() => {
this.$message.success(this.$t("device.message.batchEnableSuccess"));
@ -1082,6 +1098,11 @@ export default {
this.claimDeviceOpen = false;
this.getList();
},
/** 待激活设备激活成功回调 */
handlePendingActivateSuccess() {
this.pendingActivateOpen = false;
this.getList();
},
/** 新增按钮操作 */
handleAdd() {
this.reset();

327
src/views/fence/device/index.vue

@ -253,7 +253,7 @@
/>
</div>
<div class="span-2">
<label>{{ $t('fenceDevice.form.deviceIds') }}</label>
<label class="form-label--required">{{ $t('fenceDevice.form.deviceIds') }}</label>
<div class="device-picker-row">
<el-button size="small" @click="openDeviceSelector">{{ $t('fenceDevice.button.selectDevice') }}</el-button>
</div>
@ -266,9 +266,11 @@
<label>{{ $t('fenceDevice.form.scheduleEnd') }}</label>
<el-time-picker v-model="fenceForm.scheduleEndTime" value-format="HH:mm:ss" format="HH:mm:ss" size="small" :placeholder="$t('fenceDevice.form.timePlaceholder')" />
</div>
</div>
<div class="fence-actions-row">
<el-button size="mini" type="primary" @click="startCreateFence">{{ $t('fenceDevice.button.add') }}</el-button>
</div>
<div class="fence-actions-row">
<el-tooltip effect="dark" :content="$t('fenceDevice.button.addLocateTip')" placement="top">
<el-button size="mini" type="primary" @click="startCreateFence">{{ $t('fenceDevice.button.add') }}</el-button>
</el-tooltip>
<el-button size="mini" @click="resetEditor">{{ $t('fenceDevice.button.resetEditor') }}</el-button>
<el-button size="mini" type="success" @click="submitFenceForm">{{ $t('fenceDevice.button.save') }}</el-button>
</div>
@ -518,7 +520,7 @@ const FOCUS_ZOOM = 14
const MAP_SERIES_COLORS = ['#2563eb', '#16a34a', '#d97706', '#7c3aed', '#0f766e', '#db2777']
const MAPTILER_TILE_URL = 'https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key='
const GOOGLE_TILE_URL = 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}'
const AMAP_TILE_URL = 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&style=7&x={x}&y={y}&z={z}'
const AMAP_TILE_URL = 'https://wprd0{s}.is.autonavi.com/appmaptile?style=7&x={x}&y={y}&z={z}'
function clone(obj) {
return JSON.parse(JSON.stringify(obj))
@ -597,6 +599,7 @@ export default {
editorDeviceOptions: [],
currentFenceId: null,
editorFocusedFence: null,
editorFenceDeviceStatusMap: {},
fenceForm: this.getDefaultFenceForm(),
handleDialogVisible: false,
selectedAlarmId: null,
@ -620,6 +623,8 @@ export default {
editorDrawControl: null,
editorPulseWaveSets: [],
editorPulseTimer: null,
editorPreferredCenter: null,
editorLocateRequestSeq: 0,
resizeTimer: null,
selectedDeviceRequestSeq: 0,
alarmLocateDialogVisible: false,
@ -750,8 +755,26 @@ export default {
}
},
selectedDevice() {
return this.monitorDevices.find(item => item.deviceId === this.selectedDeviceId)
|| null
if (!this.selectedDeviceId) {
return null
}
const selectedFenceId = this.normalizeFenceId(this.selectedMonitorFenceId)
if (selectedFenceId) {
const groups = this.fenceGroupedMonitorDevices || []
for (let i = 0; i < groups.length; i++) {
const devices = (groups[i] && groups[i].devices) || []
for (let j = 0; j < devices.length; j++) {
const row = devices[j]
if (!this.isSameDeviceId(row && row.deviceId, this.selectedDeviceId)) {
continue
}
if (this.isSameFenceId(row && row._groupFenceId, selectedFenceId)) {
return row
}
}
}
}
return this.monitorDevices.find(item => this.isSameDeviceId(item && item.deviceId, this.selectedDeviceId)) || null
},
selectedFenceDevices() {
const selectedIds = new Set((this.fenceForm.deviceIds || []).map(id => this.normalizeDeviceId(id)))
@ -1609,6 +1632,51 @@ export default {
this.editorDeviceOptions = rows
return rows
},
buildFenceDeviceStatusKey(fenceId, deviceId) {
const normalizedFenceId = this.normalizeFenceId(fenceId)
const normalizedDeviceId = this.normalizeDeviceId(deviceId)
if (!normalizedFenceId || !normalizedDeviceId) {
return ''
}
return `${normalizedFenceId}_${normalizedDeviceId}`
},
async loadEditorFenceDeviceStatuses(fence, deviceIds) {
const normalizedFenceId = this.normalizeFenceId(fence && fence.id)
const normalizedFenceName = String((fence && fence.fenceName) || '').trim()
const normalizedDeviceIds = this.normalizeDeviceIds(deviceIds)
this.editorFenceDeviceStatusMap = {}
if (!normalizedFenceId || !normalizedFenceName || !normalizedDeviceIds.length) {
return {}
}
const response = await listFenceMonitorDevices({
fenceName: normalizedFenceName,
pageNum: 1,
pageSize: Math.max(normalizedDeviceIds.length, 100)
})
const rows = this.normalizeRows(response)
const nextStatusMap = {}
rows.forEach(row => {
const rowDeviceId = this.normalizeDeviceId(row && row.deviceId)
if (!rowDeviceId || !normalizedDeviceIds.some(id => this.isSameDeviceId(id, rowDeviceId))) {
return
}
const statuses = this.parseFenceStatuses(row && row.fenceStatuses)
const matchedStatus = statuses.find(status =>
this.isSameFenceId(status && status.id, normalizedFenceId) ||
String((status && status.name) || '').trim() === normalizedFenceName
)
const statusText = String((matchedStatus && matchedStatus.status) || '').trim().toLowerCase()
if (!statusText) {
return
}
const statusKey = this.buildFenceDeviceStatusKey(normalizedFenceId, rowDeviceId)
if (statusKey) {
nextStatusMap[statusKey] = statusText
}
})
this.editorFenceDeviceStatusMap = nextStatusMap
return nextStatusMap
},
collectDeviceRowsByIds(deviceIds) {
const used = new Set()
return (deviceIds || []).map(id => {
@ -1766,13 +1834,18 @@ export default {
this.loadAlarmList({ pageNum: 1, pageSize: this.alarmPageSize })
},
async startCreateFence() {
const locateRequestSeq = ++this.editorLocateRequestSeq
this.editorFocusedFence = null
await this.switchTab('fence')
this.resetEditor(true)
this.editorNotice = this.$t('fenceDevice.editor.createHint')
this.locateEditorOnCreate(locateRequestSeq)
},
async startEditFence(row) {
this.editorLocateRequestSeq += 1
this.editorFocusedFence = null
this.editorPreferredCenter = null
this.editorFenceDeviceStatusMap = {}
await this.switchTab('fence')
const response = await getFence(row.id)
const data = this.normalizeObject(response)
@ -1797,6 +1870,10 @@ export default {
if (this.fenceForm.deviceIds.length) {
try {
await this.loadEditorMapDeviceOptions(this.fenceForm.deviceIds)
await this.loadEditorFenceDeviceStatuses({
id: this.currentFenceId,
fenceName: this.fenceForm.fenceName
}, this.fenceForm.deviceIds)
this.renderEditorMap()
} catch (error) {
this.$message.warning((error && error.message) || this.$t('fenceDevice.message.devicePointLoadFailed'))
@ -1805,6 +1882,8 @@ export default {
},
resetEditor(renderMap = true) {
this.editorFocusedFence = null
this.editorPreferredCenter = null
this.editorFenceDeviceStatusMap = {}
this.fenceForm = this.getDefaultFenceForm()
this.editorDeviceOptions = []
this.currentFenceId = null
@ -1812,6 +1891,52 @@ export default {
if (this.editorDrawGroup) this.editorDrawGroup.clearLayers()
if (renderMap) this.renderEditorMap()
},
locateEditorOnCreate(requestSeq) {
this.resolveCurrentLocation().then(async center => {
if (!center) {
return
}
if (requestSeq !== this.editorLocateRequestSeq) {
return
}
if (this.activeTab !== 'fence' || this.currentFenceId || this.editorFocusedFence) {
return
}
if (this.collectEditorBounds().length) {
return
}
this.editorPreferredCenter = center
await this.ensureEditorMap()
if (!this.editorMap || this.collectEditorBounds().length) {
return
}
this.editorMap.setView(center, FOCUS_ZOOM)
}).catch(() => {})
},
resolveCurrentLocation() {
if (typeof window === 'undefined' || !window.navigator || !window.navigator.geolocation) {
return Promise.resolve(null)
}
return new Promise(resolve => {
window.navigator.geolocation.getCurrentPosition(
position => {
const lat = Number(position && position.coords && position.coords.latitude)
const lng = Number(position && position.coords && position.coords.longitude)
if (!this.hasCoordinatePair(lat, lng)) {
resolve(null)
return
}
resolve([lat, lng])
},
() => resolve(null),
{
enableHighAccuracy: true,
timeout: 8000,
maximumAge: 60000
}
)
})
},
async submitFenceForm() {
if (!this.fenceForm.fenceName) {
this.$message.warning(this.$t('fenceDevice.message.nameRequired'))
@ -1821,6 +1946,10 @@ export default {
this.$message.warning(this.$t('fenceDevice.message.ruleRequired'))
return
}
if (!this.normalizeDeviceIds(this.fenceForm.deviceIds).length) {
this.$message.warning(this.$t('fenceDevice.message.deviceRequired'))
return
}
if (!this.validateShape()) return
const payload = clone(this.fenceForm)
payload.scheduleType = this.normalizeScheduleTypeValue(payload.scheduleType)
@ -2203,9 +2332,7 @@ export default {
const selectedLat = this.resolveOptionLat(this.selectedDevice)
const selectedLng = this.resolveOptionLng(this.selectedDevice)
if (this.selectedDevice && this.hasCoordinatePair(selectedLat, selectedLng)) {
const markerColor = this.selectedDevice.lastAlarmLevel
? this.getAlarmLevelColor(this.selectedDevice.lastAlarmLevel)
: '#ef4444'
const markerColor = this.resolveMonitorMapPointColor(this.selectedDevice)
const coreMarker = this.monitorLeaflet.circleMarker([selectedLat, selectedLng], {
radius: 10,
color: '#ffffff',
@ -2306,13 +2433,16 @@ export default {
const lat = this.resolveOptionLat(item)
const lng = this.resolveOptionLng(item)
if (!this.hasCoordinatePair(lat, lng)) return
const seriesColor = this.getSeriesColor(index)
pulsePoints.push({ lat: Number(lat), lng: Number(lng), color: seriesColor })
const markerColor = this.getFenceMapDeviceColor(item, targetFence, {
focused: isFocusedMode,
index
})
pulsePoints.push({ lat: Number(lat), lng: Number(lng), color: markerColor })
const marker = this.editorLeaflet.circleMarker([lat, lng], {
radius: 7,
color: '#ffffff',
weight: 2,
fillColor: seriesColor,
fillColor: markerColor,
fillOpacity: 0.95
})
marker.bindTooltip(item.alias || item.sn || '-', { direction: 'top' })
@ -2458,12 +2588,50 @@ export default {
return
}
}
if (map === this.editorMap && Array.isArray(this.editorPreferredCenter) && this.editorPreferredCenter.length === 2) {
const lat = Number(this.editorPreferredCenter[0])
const lng = Number(this.editorPreferredCenter[1])
if (!this.currentFenceId && !this.editorFocusedFence && this.hasCoordinatePair(lat, lng)) {
map.setView([lat, lng], FOCUS_ZOOM)
return
}
}
map.setView(DEFAULT_CENTER, DEFAULT_ZOOM)
},
getSeriesColor(index) {
if (!Number.isFinite(index) || index < 0) return MAP_SERIES_COLORS[0]
return MAP_SERIES_COLORS[index % MAP_SERIES_COLORS.length]
},
getFenceMapDeviceColor(item, fence, options = {}) {
const fallbackColor = this.getSeriesColor(options.index)
if (!options.focused || !fence) {
return fallbackColor
}
const status = this.resolveFenceDevicePreviewStatus(item, fence)
if (status === 'outside') {
return '#ef4444'
}
if (status === 'inside') {
return '#22c55e'
}
return fallbackColor
},
resolveFenceDevicePreviewStatus(item, fence) {
const statusKey = this.buildFenceDeviceStatusKey(fence && fence.id, this.resolveDeviceOptionId(item))
const mappedStatus = String(this.editorFenceDeviceStatusMap[statusKey] || '').trim().toLowerCase()
if (mappedStatus === 'inside') {
return 'inside'
}
if (mappedStatus === 'outside' || mappedStatus === 'boundary') {
return 'outside'
}
const lat = this.resolveOptionLat(item)
const lng = this.resolveOptionLng(item)
if (!this.hasCoordinatePair(lat, lng) || !fence) {
return ''
}
return this.isPointInsideFence(Number(lat), Number(lng), fence) ? 'inside' : 'outside'
},
getFenceSeriesStyle(index, overrides = {}) {
const color = this.getSeriesColor(index)
const fillColor = overrides.fillColor || overrides.color || color
@ -2485,6 +2653,8 @@ export default {
if (!row || !row.id) {
return
}
this.editorLocateRequestSeq += 1
this.editorPreferredCenter = null
await this.switchTab('fence')
const response = await getFence(row.id)
const detailData = this.normalizeObject(response)
@ -2501,6 +2671,7 @@ export default {
geomWkt: (detailData && detailData.geomWkt) || (row && row.geomWkt) || '',
deviceIds: focusedDeviceIds
}
this.editorFenceDeviceStatusMap = {}
this.editorDeviceOptions = []
this.editorNotice = this.$t('fenceDevice.editor.previewHint', { name: this.editorFocusedFence.fenceName || '-' })
await this.$nextTick()
@ -2509,6 +2680,7 @@ export default {
if (focusedDeviceIds.length) {
try {
await this.loadEditorMapDeviceOptions(focusedDeviceIds)
await this.loadEditorFenceDeviceStatuses(this.editorFocusedFence, focusedDeviceIds)
this.renderEditorMap()
} catch (error) {
this.$message.warning((error && error.message) || this.$t('fenceDevice.message.devicePointLoadFailed'))
@ -2805,6 +2977,102 @@ export default {
return null
}
},
isPointInsideFence(lat, lng, fence) {
const circle = this.circlePayload(fence)
if (circle) {
return this.isPointInsideCircle(lat, lng, circle)
}
const normalizedShapeType = this.normalizeFenceShapeType(fence && fence.shapeType)
const bounds = this.rectPayload(fence)
if (bounds && normalizedShapeType === 'rect') {
return this.isPointInsideRect(lat, lng, bounds)
}
const points = this.polygonPayload(fence)
if (points.length) {
return this.isPointInsidePolygon(lat, lng, points)
}
if (bounds) {
return this.isPointInsideRect(lat, lng, bounds)
}
return false
},
isPointInsideCircle(lat, lng, circle) {
if (!circle || !Array.isArray(circle.center) || circle.center.length < 2 || !Number.isFinite(circle.radius)) {
return false
}
const centerLat = Number(circle.center[0])
const centerLng = Number(circle.center[1])
const radiusMeter = Number(circle.radius)
if (!Number.isFinite(centerLat) || !Number.isFinite(centerLng) || !Number.isFinite(radiusMeter)) {
return false
}
return this.calculateDistanceMeters(lat, lng, centerLat, centerLng) <= radiusMeter
},
isPointInsideRect(lat, lng, bounds) {
if (!Array.isArray(bounds) || bounds.length < 2) {
return false
}
const southWest = bounds[0] || []
const northEast = bounds[1] || []
const minLat = Math.min(Number(southWest[0]), Number(northEast[0]))
const maxLat = Math.max(Number(southWest[0]), Number(northEast[0]))
const minLng = Math.min(Number(southWest[1]), Number(northEast[1]))
const maxLng = Math.max(Number(southWest[1]), Number(northEast[1]))
if (![minLat, maxLat, minLng, maxLng].every(Number.isFinite)) {
return false
}
return lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng
},
isPointInsidePolygon(lat, lng, points) {
if (!Array.isArray(points) || points.length < 3) {
return false
}
let inside = false
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const current = points[i] || []
const previous = points[j] || []
const currentLat = Number(current[0])
const currentLng = Number(current[1])
const previousLat = Number(previous[0])
const previousLng = Number(previous[1])
if (![currentLat, currentLng, previousLat, previousLng].every(Number.isFinite)) {
continue
}
if (this.isPointOnLineSegment(lat, lng, previousLat, previousLng, currentLat, currentLng)) {
return true
}
const intersects = ((currentLat > lat) !== (previousLat > lat)) &&
(lng < ((previousLng - currentLng) * (lat - currentLat)) / ((previousLat - currentLat) || Number.EPSILON) + currentLng)
if (intersects) {
inside = !inside
}
}
return inside
},
isPointOnLineSegment(pointLat, pointLng, startLat, startLng, endLat, endLng) {
const cross = (pointLng - startLng) * (endLat - startLat) - (pointLat - startLat) * (endLng - startLng)
if (Math.abs(cross) > 1e-9) {
return false
}
const dot = (pointLat - startLat) * (endLat - startLat) + (pointLng - startLng) * (endLng - startLng)
if (dot < 0) {
return false
}
const lengthSquared = Math.pow(endLat - startLat, 2) + Math.pow(endLng - startLng, 2)
return dot <= lengthSquared
},
calculateDistanceMeters(lat1, lng1, lat2, lng2) {
const toRadians = value => Number(value) * Math.PI / 180
const earthRadius = 6371000
const dLat = toRadians(lat2 - lat1)
const dLng = toRadians(lng2 - lng1)
const startLat = toRadians(lat1)
const endLat = toRadians(lat2)
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(startLat) * Math.cos(endLat) * Math.sin(dLng / 2) * Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return earthRadius * c
},
extendBounds(bounds, layer) {
if (!layer || !layer.getBounds) return
const layerBounds = layer.getBounds()
@ -2866,6 +3134,38 @@ export default {
getAlarmLevelColor(type) {
return type === 'critical' ? '#dc2626' : type === 'warning' ? '#d97706' : '#059669'
},
resolveMonitorMapPointColor(item) {
const statusType = this.resolveMonitorMapStatusType(item)
if (statusType === 'alert') {
return '#ef4444'
}
if (statusType === 'normal') {
return '#22c55e'
}
return '#22c55e'
},
resolveMonitorMapStatusType(item) {
const groupStatus = String((item && item._groupFenceStatus) || '').trim().toLowerCase()
if (groupStatus === 'outside' || groupStatus === 'boundary') {
return 'alert'
}
if (groupStatus === 'inside') {
return 'normal'
}
const selectedFenceId = this.normalizeFenceId(this.selectedMonitorFenceId)
const statuses = this.parseFenceStatuses(item && item.fenceStatuses)
if (selectedFenceId && statuses.length) {
const matchedStatus = statuses.find(status => this.isSameFenceId(status && status.id, selectedFenceId))
const matchedType = String((matchedStatus && matchedStatus.status) || '').trim().toLowerCase()
if (matchedType === 'outside' || matchedType === 'boundary') {
return 'alert'
}
if (matchedType === 'inside') {
return 'normal'
}
}
return this.resolveMonitorDeviceStatusType(item)
},
resolveMonitorDeviceStatusType(item) {
const groupStatus = String((item && item._groupFenceStatus) || '').trim().toLowerCase()
if (groupStatus === 'outside') return 'alert'
@ -3222,6 +3522,7 @@ export default {
.fence-content-split { display: grid; grid-template-columns: minmax(480px, 0.92fr) minmax(640px, 1.08fr); min-height: 0; height: 100%; }
.form-grid, .alert-filter-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; align-items: end; }
.form-grid label, .alert-filter-grid label { display: block; margin-bottom: 4px; font-size: 12px; color: #6b7280; }
.form-label--required::before { content: '*'; color: #ef4444; margin-right: 4px; }
.alert-filter-grid--alarm { grid-template-columns: repeat(4, minmax(240px, 1fr)); gap: 14px 16px; padding: 14px 16px 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f8fafc; }
.alert-filter-grid--alarm label { margin-bottom: 6px; color: #475569; font-weight: 600; }
.span-2 { grid-column: span 2; }

541
src/views/login.vue

@ -1,186 +1,129 @@
<template>
<div class="login">
<!-- 左侧动态背景区?-->
<div class="login-left" v-once>
<!-- 网格背景 -->
<div class="grid-overlay"></div>
<!-- 地图网格?SVG -->
<svg class="map-grid" viewBox="0 0 100 100" preserveAspectRatio="none">
<line x1="20" y1="0" x2="20" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="40" y1="0" x2="40" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="60" y1="0" x2="60" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="80" y1="0" x2="80" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="0" y1="20" x2="100" y2="20" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="0" y1="40" x2="100" y2="40" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="0" y1="60" x2="100" y2="60" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="0" y1="80" x2="100" y2="80" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
</svg>
<!-- 地图轮廓 SVG -->
<svg class="map-contours" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid slice">
<path d="M100,100 Q200,50 300,100 T500,100 T700,150" stroke="rgba(255,255,255,0.4)" fill="none" stroke-width="1.5"/>
<path d="M50,200 Q150,150 250,200 T450,200 T650,250 T750,200" stroke="rgba(255,255,255,0.3)" fill="none" stroke-width="1"/>
<path d="M100,300 Q250,250 400,300 T700,350" stroke="rgba(255,255,255,0.35)" fill="none" stroke-width="1"/>
<path d="M50,400 Q200,350 350,400 T550,400 T750,450" stroke="rgba(255,255,255,0.25)" fill="none" stroke-width="1"/>
<path d="M100,500 Q300,450 500,500 T700,500" stroke="rgba(255,255,255,0.3)" fill="none" stroke-width="1"/>
<path d="M200,50 Q350,100 500,50 T800,100" stroke="rgba(76,175,80,0.3)" fill="none" stroke-width="1"/>
<path d="M0,150 Q200,200 400,150 T800,200" stroke="rgba(76,175,80,0.25)" fill="none" stroke-width="1"/>
</svg>
<!-- 连接?SVG -->
<svg class="connection-lines" viewBox="0 0 100 100" preserveAspectRatio="none">
<line x1="12" y1="15" x2="50" y2="50" stroke="rgba(76,175,80,0.6)" stroke-width="0.15"/>
<line x1="78" y1="25" x2="50" y2="50" stroke="rgba(33,150,243,0.6)" stroke-width="0.15"/>
<line x1="18" y1="55" x2="50" y2="50" stroke="rgba(255,193,7,0.6)" stroke-width="0.15"/>
<line x1="70" y1="70" x2="50" y2="50" stroke="rgba(76,175,80,0.6)" stroke-width="0.15"/>
<line x1="88" y1="40" x2="50" y2="50" stroke="rgba(255,87,34,0.6)" stroke-width="0.15"/>
<line x1="35" y1="82" x2="50" y2="50" stroke="rgba(33,150,243,0.6)" stroke-width="0.15"/>
<line x1="5" y1="30" x2="50" y2="50" stroke="rgba(156,39,176,0.6)" stroke-width="0.15"/>
<line x1="85" y1="85" x2="50" y2="50" stroke="rgba(76,175,80,0.6)" stroke-width="0.15"/>
<line x1="12" y1="15" x2="78" y2="25" stroke="rgba(255,255,255,0.2)" stroke-width="0.05" stroke-dasharray="0.5,0.5"/>
<line x1="18" y1="55" x2="70" y2="70" stroke="rgba(255,255,255,0.2)" stroke-width="0.05" stroke-dasharray="0.5,0.5"/>
</svg>
<!-- 十字准星 SVG -->
<svg class="crosshair" width="600" height="600" viewBox="0 0 200 200">
<circle cx="100" cy="100" r="90" fill="none" stroke="rgba(76,175,80,0.2)" stroke-width="1" stroke-dasharray="5,3"/>
<circle cx="100" cy="100" r="80" fill="none" stroke="rgba(76,175,80,0.15)" stroke-width="0.5"/>
<line x1="100" y1="20" x2="100" y2="80" stroke="rgba(76,175,80,0.3)" stroke-width="0.5"/>
<line x1="100" y1="120" x2="100" y2="180" stroke="rgba(76,175,80,0.3)" stroke-width="0.5"/>
<line x1="20" y1="100" x2="80" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.5"/>
<line x1="120" y1="100" x2="180" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.5"/>
<path d="M 30 30 L 30 50 M 30 30 L 50 30" stroke="rgba(76,175,80,0.4)" stroke-width="1" fill="none"/>
<path d="M 170 30 L 170 50 M 170 30 L 150 30" stroke="rgba(76,175,80,0.4)" stroke-width="1" fill="none"/>
<path d="M 30 170 L 30 150 M 30 170 L 50 170" stroke="rgba(76,175,80,0.4)" stroke-width="1" fill="none"/>
<path d="M 170 170 L 170 150 M 170 170 L 150 170" stroke="rgba(76,175,80,0.4)" stroke-width="1" fill="none"/>
</svg>
<!-- 脉冲波纹 -->
<div class="ripple"></div>
<div class="ripple"></div>
<div class="ripple"></div>
<div class="ripple"></div>
<!-- 定位?-->
<div class="location-pins">
<div class="pin">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
</div>
<div class="pin">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
</div>
<div class="pin">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
</div>
<div class="pin">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
</div>
<div class="pin">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
</div>
<div class="pin">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
</div>
<div class="pin">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
<div class="login-left">
<template v-if="hasCustomLeftImage">
<img class="login-left-image" :src="resolvedLoginLeftImage" alt="login-left" @error="handleLeftImageError">
<div class="login-left-image-overlay"></div>
</template>
<template v-else>
<div class="grid-overlay"></div>
<svg class="map-grid" viewBox="0 0 100 100" preserveAspectRatio="none">
<line x1="20" y1="0" x2="20" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="40" y1="0" x2="40" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="60" y1="0" x2="60" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="80" y1="0" x2="80" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="0" y1="20" x2="100" y2="20" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="0" y1="40" x2="100" y2="40" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="0" y1="60" x2="100" y2="60" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
<line x1="0" y1="80" x2="100" y2="80" stroke="rgba(76,175,80,0.3)" stroke-width="0.1"/>
</svg>
<svg class="map-contours" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid slice">
<path d="M100,100 Q200,50 300,100 T500,100 T700,150" stroke="rgba(255,255,255,0.4)" fill="none" stroke-width="1.5"/>
<path d="M50,200 Q150,150 250,200 T450,200 T650,250 T750,200" stroke="rgba(255,255,255,0.3)" fill="none" stroke-width="1"/>
<path d="M100,300 Q250,250 400,300 T700,350" stroke="rgba(255,255,255,0.35)" fill="none" stroke-width="1"/>
<path d="M50,400 Q200,350 350,400 T550,400 T750,450" stroke="rgba(255,255,255,0.25)" fill="none" stroke-width="1"/>
<path d="M100,500 Q300,450 500,500 T700,500" stroke="rgba(255,255,255,0.3)" fill="none" stroke-width="1"/>
<path d="M200,50 Q350,100 500,50 T800,100" stroke="rgba(76,175,80,0.3)" fill="none" stroke-width="1"/>
<path d="M0,150 Q200,200 400,150 T800,200" stroke="rgba(76,175,80,0.25)" fill="none" stroke-width="1"/>
</svg>
<svg class="connection-lines" viewBox="0 0 100 100" preserveAspectRatio="none">
<line x1="12" y1="15" x2="50" y2="50" stroke="rgba(76,175,80,0.6)" stroke-width="0.15"/>
<line x1="78" y1="25" x2="50" y2="50" stroke="rgba(33,150,243,0.6)" stroke-width="0.15"/>
<line x1="18" y1="55" x2="50" y2="50" stroke="rgba(255,193,7,0.6)" stroke-width="0.15"/>
<line x1="70" y1="70" x2="50" y2="50" stroke="rgba(76,175,80,0.6)" stroke-width="0.15"/>
<line x1="88" y1="40" x2="50" y2="50" stroke="rgba(255,87,34,0.6)" stroke-width="0.15"/>
<line x1="35" y1="82" x2="50" y2="50" stroke="rgba(33,150,243,0.6)" stroke-width="0.15"/>
<line x1="5" y1="30" x2="50" y2="50" stroke="rgba(156,39,176,0.6)" stroke-width="0.15"/>
<line x1="85" y1="85" x2="50" y2="50" stroke="rgba(76,175,80,0.6)" stroke-width="0.15"/>
<line x1="12" y1="15" x2="78" y2="25" stroke="rgba(255,255,255,0.2)" stroke-width="0.05" stroke-dasharray="0.5,0.5"/>
<line x1="18" y1="55" x2="70" y2="70" stroke="rgba(255,255,255,0.2)" stroke-width="0.05" stroke-dasharray="0.5,0.5"/>
</svg>
<svg class="crosshair" width="600" height="600" viewBox="0 0 200 200">
<circle cx="100" cy="100" r="90" fill="none" stroke="rgba(76,175,80,0.2)" stroke-width="1" stroke-dasharray="5,3"/>
<circle cx="100" cy="100" r="80" fill="none" stroke="rgba(76,175,80,0.15)" stroke-width="0.5"/>
<line x1="100" y1="20" x2="100" y2="80" stroke="rgba(76,175,80,0.3)" stroke-width="0.5"/>
<line x1="100" y1="120" x2="100" y2="180" stroke="rgba(76,175,80,0.3)" stroke-width="0.5"/>
<line x1="20" y1="100" x2="80" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.5"/>
<line x1="120" y1="100" x2="180" y2="100" stroke="rgba(76,175,80,0.3)" stroke-width="0.5"/>
<path d="M 30 30 L 30 50 M 30 30 L 50 30" stroke="rgba(76,175,80,0.4)" stroke-width="1" fill="none"/>
<path d="M 170 30 L 170 50 M 170 30 L 150 30" stroke="rgba(76,175,80,0.4)" stroke-width="1" fill="none"/>
<path d="M 30 170 L 30 150 M 30 170 L 50 170" stroke="rgba(76,175,80,0.4)" stroke-width="1" fill="none"/>
<path d="M 170 170 L 170 150 M 170 170 L 150 170" stroke="rgba(76,175,80,0.4)" stroke-width="1" fill="none"/>
</svg>
<div class="ripple"></div>
<div class="ripple"></div>
<div class="ripple"></div>
<div class="ripple"></div>
<div class="location-pins">
<div class="pin" v-for="index in 8" :key="index">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
</div>
</div>
<div class="pin">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
<div class="orbit-ring"></div>
<div class="orbit-ring"></div>
<div class="orbit-ring"></div>
<div class="orbit-ring"></div>
<div ref="particles" class="particles"></div>
<div class="center-marker">
<svg class="marker-icon" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="48" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="1"/>
<circle cx="50" cy="50" r="40" fill="none" stroke="rgba(76,175,80,0.2)" stroke-width="1.5"/>
<path d="M50 10 C32 10 20 24 20 42 C20 65 50 90 50 90 C80 65 80 42 80 42 C80 24 68 10 50 10 Z"
fill="rgba(76,175,80,0.85)"
stroke="rgba(255,255,255,0.7)"
stroke-width="2.5"/>
<circle cx="50" cy="38" r="12" fill="rgba(255,255,255,0.95)"/>
<circle cx="50" cy="38" r="7" fill="rgba(76,175,80,1)"/>
<circle cx="50" cy="38" r="18" fill="none" stroke="rgba(76,175,80,0.6)" stroke-width="2">
<animate attributeName="r" from="18" to="35" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.6" to="0" dur="2s" repeatCount="indefinite"/>
</circle>
<circle cx="50" cy="38" r="18" fill="none" stroke="rgba(76,175,80,0.4)" stroke-width="1.5">
<animate attributeName="r" from="18" to="45" dur="2s" begin="0.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.4" to="0" dur="2s" begin="0.5s" repeatCount="indefinite"/>
</circle>
<circle cx="50" cy="38" r="18" fill="none" stroke="rgba(76,175,80,0.3)" stroke-width="1">
<animate attributeName="r" from="18" to="55" dur="2s" begin="1s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.3" to="0" dur="2s" begin="1s" repeatCount="indefinite"/>
</circle>
</svg>
</div>
</div>
<!-- 装饰圆环 -->
<div class="orbit-ring"></div>
<div class="orbit-ring"></div>
<div class="orbit-ring"></div>
<div class="orbit-ring"></div>
<!-- 粒子效果 -->
<div ref="particles" class="particles"></div>
<!-- 中央超大定位标记 -->
<div class="center-marker">
<svg class="marker-icon" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="48" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="1"/>
<circle cx="50" cy="50" r="40" fill="none" stroke="rgba(76,175,80,0.2)" stroke-width="1.5"/>
<path d="M50 10 C32 10 20 24 20 42 C20 65 50 90 50 90 C50 90 80 65 80 42 C80 24 68 10 50 10 Z"
fill="rgba(76,175,80,0.85)"
stroke="rgba(255,255,255,0.7)"
stroke-width="2.5"/>
<circle cx="50" cy="38" r="12" fill="rgba(255,255,255,0.95)"/>
<circle cx="50" cy="38" r="7" fill="rgba(76,175,80,1)"/>
<circle cx="50" cy="38" r="18" fill="none" stroke="rgba(76,175,80,0.6)" stroke-width="2">
<animate attributeName="r" from="18" to="35" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.6" to="0" dur="2s" repeatCount="indefinite"/>
</circle>
<circle cx="50" cy="38" r="18" fill="none" stroke="rgba(76,175,80,0.4)" stroke-width="1.5">
<animate attributeName="r" from="18" to="45" dur="2s" begin="0.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.4" to="0" dur="2s" begin="0.5s" repeatCount="indefinite"/>
</circle>
<circle cx="50" cy="38" r="18" fill="none" stroke="rgba(76,175,80,0.3)" stroke-width="1">
<animate attributeName="r" from="18" to="55" dur="2s" begin="1s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.3" to="0" dur="2s" begin="1s" repeatCount="indefinite"/>
</circle>
</svg>
</div>
</template>
</div>
<!-- 右侧登录表单区域 -->
<div class="login-right">
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form">
<h3 class="title">{{ $t("login.title") }}</h3>
<h3 class="title">{{ loginTitle }}</h3>
<el-form-item prop="username">
<el-input v-model="loginForm.username" type="text" auto-complete="off" :placeholder="$t('login.usernamePlaceholder')">
<svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon" />
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" auto-complete="off" :placeholder="$t('login.passwordPlaceholder')"
@keyup.enter.native="handleLogin">
<el-input v-model="loginForm.password" type="password" auto-complete="off" :placeholder="$t('login.passwordPlaceholder')" @keyup.enter.native="handleLogin">
<svg-icon slot="prefix" icon-class="password" class="el-input__icon input-icon" />
</el-input>
</el-form-item>
<el-form-item prop="code">
<el-input v-model="loginForm.code" auto-complete="off" :placeholder="$t('login.googleCodePlaceholder')" style="width: 100%"
@keyup.enter.native="handleLogin">
<el-input v-model="loginForm.code" auto-complete="off" :placeholder="$t('login.googleCodePlaceholder')" style="width: 100%" @keyup.enter.native="handleLogin">
<svg-icon slot="prefix" icon-class="validCode" class="el-input__icon input-icon" />
</el-input>
<!-- <div class="login-code">
<img :src="codeUrl" @click="getCode" class="login-code-img"/>
</div> -->
</el-form-item>
<!-- 人机验证 -->
<!-- <el-form-item prop='validateCode'>
<el-row :span="24">
<el-col :span="24">
<reCaptcha :sitekey="key" @getValidateCode='getValidateCode' v-model="loginForm.validateCode"></reCaptcha>
</el-col>
</el-row>
</el-form-item> -->
<!-- <el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">记住密码</el-checkbox> -->
<el-form-item style="width:100%;">
<el-button :loading="loading" size="medium" type="primary" style="width:100%;"
@click.native.prevent="handleLogin">
<span v-if="!loading">{{ $t("login.login") }}</span>
<span v-else>{{ $t("login.loggingIn") }}</span>
<el-button :loading="loading" size="medium" type="primary" style="width:100%;" @click.native.prevent="handleLogin">
<span v-if="!loading">{{ $t('login.login') }}</span>
<span v-else>{{ $t('login.loggingIn') }}</span>
</el-button>
</el-form-item>
<div class="login-form-lang">
@ -203,51 +146,53 @@
</div>
</el-form>
<!-- 底部 -->
<div class="el-login-footer">
<span class="login-footer-text">Copyright © 2026 GeoTag All Rights Reserved</span>
<span class="login-footer-text">{{ footerText }}</span>
</div>
</div>
</div>
</template>
<script>
import Cookies from "js-cookie";
import Cookies from 'js-cookie'
import { encrypt, decrypt } from '@/utils/jsencrypt'
import { getLanguage, setLanguage, languageOptions } from '@/utils/language'
import { extractBusinessNoFromPath, resolveBrandAsset } from '@/utils/brand'
const DEFAULT_COPYRIGHT_TEXT = 'Copyright © 2026 GeoTag All Rights Reserved'
export default {
name: "Login",
name: 'Login',
data() {
var checkCode = (rule, value, callback) => {
const checkCode = (rule, value, callback) => {
if (value == false) {
callback(new Error(this.$t("login.humanVerifyRequired")));
callback(new Error(this.$t('login.humanVerifyRequired')))
} else {
callback();
callback()
}
};
}
return {
codeUrl: "",
cookiePassword: "",
codeUrl: '',
cookiePassword: '',
key: '6LcBoGUaAAAAABUnZINfh4j6FgqpQR-yHakZepIR',
languageOptions: languageOptions,
languageOptions,
loginForm: {
username: "",
password: "",
username: '',
password: '',
rememberMe: false,
code: "",
uuid: "",
code: '',
uuid: '',
getValidateCode: false
},
loginRules: {
username: [
{ required: true, trigger: "blur", message: this.$t("login.usernameRequired") }
{ required: true, trigger: 'blur', message: this.$t('login.usernameRequired') }
],
password: [
{ required: true, trigger: "blur", message: this.$t("login.passwordRequired") }
{ required: true, trigger: 'blur', message: this.$t('login.passwordRequired') }
],
code: [
{ required: true, trigger: "change", message: this.$t("login.codeRequired") }
{ required: true, trigger: 'change', message: this.$t('login.codeRequired') }
],
validateCode: [
{ validator: checkCode, trigger: 'change' }
@ -256,85 +201,123 @@ export default {
loading: false,
redirect: undefined,
particleTask: null,
particleTaskType: ""
};
particleTaskType: '',
leftImageLoadError: false
}
},
computed: {
currentLanguage() {
return this.$store.state.settings.language || getLanguage()
},
currentBrand() {
return this.$store.getters.brand || {}
},
loginTitle() {
return this.currentBrand.loginSystemName || this.$t('login.title')
},
footerText() {
return this.currentBrand.copyrightText || DEFAULT_COPYRIGHT_TEXT
},
resolvedLoginLeftImage() {
if (this.leftImageLoadError) {
return ''
}
return resolveBrandAsset(this.currentBrand.loginLeftImageUrl)
},
hasCustomLeftImage() {
return !!this.resolvedLoginLeftImage
}
},
watch: {
$route: {
handler: function (route) {
this.redirect = route.query && route.query.redirect;
handler(route) {
this.redirect = route.query && route.query.redirect
this.loadBrandConfig(route)
},
immediate: true
},
hasCustomLeftImage() {
this.syncLoginVisuals()
},
'currentBrand.loginLeftImageUrl'() {
this.leftImageLoadError = false
}
},
created() {
// this.getCookie();
},
mounted() {
this.scheduleParticles();
this.syncLoginVisuals()
},
beforeDestroy() {
this.cancelParticleTask();
this.clearParticles();
this.cancelParticleTask()
this.clearParticles()
},
methods: {
loadBrandConfig(route) {
const businessNo = extractBusinessNoFromPath(route.path) || route.params.businessNo || ''
if (!businessNo) {
this.syncLoginVisuals()
return
}
this.leftImageLoadError = false
this.$store.dispatch('brand/loadBrandByBusinessNo', businessNo).catch(() => {
this.syncLoginVisuals()
})
},
syncLoginVisuals() {
this.cancelParticleTask()
this.clearParticles()
if (!this.hasCustomLeftImage) {
this.scheduleParticles()
}
},
scheduleParticles() {
const run = () => this.$nextTick(() => this.initParticles());
if (typeof window !== "undefined" && typeof window.requestIdleCallback === "function") {
this.particleTaskType = "idle";
this.particleTask = window.requestIdleCallback(run, { timeout: 180 });
return;
const run = () => this.$nextTick(() => this.initParticles())
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
this.particleTaskType = 'idle'
this.particleTask = window.requestIdleCallback(run, { timeout: 180 })
return
}
this.particleTaskType = "timeout";
this.particleTask = window.setTimeout(run, 32);
this.particleTaskType = 'timeout'
this.particleTask = window.setTimeout(run, 32)
},
cancelParticleTask() {
if (typeof window === "undefined" || this.particleTask == null) {
return;
if (typeof window === 'undefined' || this.particleTask == null) {
return
}
if (this.particleTaskType === "idle" && typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(this.particleTask);
if (this.particleTaskType === 'idle' && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(this.particleTask)
} else {
window.clearTimeout(this.particleTask);
window.clearTimeout(this.particleTask)
}
this.particleTask = null;
this.particleTaskType = "";
this.particleTask = null
this.particleTaskType = ''
},
initParticles() {
const particlesContainer = this.$refs.particles;
if (!particlesContainer || particlesContainer.childElementCount > 0) {
return;
const particlesContainer = this.$refs.particles
if (!particlesContainer || particlesContainer.childElementCount > 0 || this.hasCustomLeftImage) {
return
}
const fragment = document.createDocumentFragment();
const fragment = document.createDocumentFragment()
for (let i = 0; i < 40; i++) {
const particle = document.createElement('span');
particle.className = 'particle';
particle.style.left = Math.random() * 100 + '%';
particle.style.animationDelay = Math.random() * 25 + 's';
particle.style.animationDuration = (20 + Math.random() * 10) + 's';
particle.style.width = (2 + Math.random() * 3) + 'px';
particle.style.height = particle.style.width;
fragment.appendChild(particle);
const particle = document.createElement('span')
particle.className = 'particle'
particle.style.left = `${Math.random() * 100}%`
particle.style.animationDelay = `${Math.random() * 25}s`
particle.style.animationDuration = `${20 + Math.random() * 10}s`
particle.style.width = `${2 + Math.random() * 3}px`
particle.style.height = particle.style.width
fragment.appendChild(particle)
}
particlesContainer.appendChild(fragment);
particlesContainer.appendChild(fragment)
},
clearParticles() {
const particlesContainer = this.$refs.particles;
const particlesContainer = this.$refs.particles
if (particlesContainer) {
particlesContainer.textContent = "";
particlesContainer.textContent = ''
}
},
icoCreate(icoUrl) {
var link = document.querySelector("link[rel*='icon']") || document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = icoUrl
document.getElementsByTagName('head')[0].appendChild(link);
handleLeftImageError() {
this.leftImageLoadError = true
this.syncLoginVisuals()
},
changeLanguage(lang) {
const normalized = setLanguage(lang)
@ -348,40 +331,42 @@ export default {
this.loginForm.validateCode = value
},
getCookie() {
const username = Cookies.get("username");
const password = Cookies.get("password");
const username = Cookies.get('username')
const password = Cookies.get('password')
const rememberMe = Cookies.get('rememberMe')
this.loginForm = {
username: username === undefined ? this.loginForm.username : username,
password: password === undefined ? this.loginForm.password : decrypt(password),
rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)
};
}
},
handleLogin() {
this.$refs.loginForm.validate(valid => {
if (valid) {
this.loading = true;
if (this.loginForm.rememberMe) {
Cookies.set("username", this.loginForm.username, { expires: 30 });
Cookies.set("password", encrypt(this.loginForm.password), { expires: 30 });
Cookies.set('rememberMe', this.loginForm.rememberMe, { expires: 30 });
} else {
Cookies.remove("username");
Cookies.remove("password");
Cookies.remove('rememberMe');
}
this.$store.dispatch("Login", this.loginForm).then(() => {
const targetPath = this.redirect && this.redirect !== "/no-permission" ? this.redirect : "/index";
this.$router.push({ path: targetPath }).catch(() => { });
}).catch(() => {
this.loading = false;
});
this.$refs.loginForm.validate((valid) => {
if (!valid) {
return
}
});
this.loading = true
if (this.loginForm.rememberMe) {
Cookies.set('username', this.loginForm.username, { expires: 30 })
Cookies.set('password', encrypt(this.loginForm.password), { expires: 30 })
Cookies.set('rememberMe', this.loginForm.rememberMe, { expires: 30 })
} else {
Cookies.remove('username')
Cookies.remove('password')
Cookies.remove('rememberMe')
}
this.$store.dispatch('Login', this.loginForm).then(() => {
return this.$store.dispatch('brand/loadCurrentBrand').catch(() => null)
}).then(() => {
const targetPath = this.redirect && this.redirect !== '/no-permission' ? this.redirect : '/index'
this.$router.push({ path: targetPath }).catch(() => {})
}).catch(() => {
this.loading = false
})
})
}
}
};
}
</script>
<style rel="stylesheet/scss" lang="scss">
@ -391,7 +376,6 @@ export default {
height: 100%;
}
/* 左侧动态背景区�?*/
.login-left {
flex: 0 0 60%;
position: relative;
@ -400,20 +384,32 @@ export default {
contain: layout paint;
}
/* 网格背景 */
.login-left-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.login-left-image-overlay {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(10, 25, 41, 0.18) 0%, rgba(13, 60, 97, 0.38) 55%, rgba(1, 87, 155, 0.18) 100%);
}
.grid-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
background-image:
linear-gradient(rgba(76,175,80,0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(76,175,80,0.08) 1px, transparent 1px);
background-size: 60px 60px;
}
/* 地图轮廓 */
.map-contours {
position: absolute;
top: 50%;
@ -424,7 +420,6 @@ export default {
opacity: 0.15;
}
/* 定位点动�?*/
.location-pins {
position: absolute;
top: 0;
@ -461,7 +456,6 @@ export default {
50% { transform: scale(1.15); opacity: 1; }
}
/* 连接�?*/
.connection-lines {
position: absolute;
top: 0;
@ -471,7 +465,6 @@ export default {
opacity: 0.2;
}
/* 中央大定位标�?*/
.center-marker {
position: absolute;
top: 50%;
@ -493,7 +486,6 @@ export default {
50% { transform: translateY(-20px); }
}
/* 装饰圆环 */
.orbit-ring {
position: absolute;
top: 50%;
@ -516,7 +508,6 @@ export default {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
/* 粒子效果 */
.particles {
position: absolute;
top: 0;
@ -545,7 +536,6 @@ export default {
100% { transform: translateY(-100vh) rotate(720deg); opacity: 0; }
}
/* 十字准星 */
.crosshair {
position: absolute;
top: 50%;
@ -554,7 +544,6 @@ export default {
pointer-events: none;
}
/* 地图网格�?*/
.map-grid {
position: absolute;
top: 0;
@ -564,7 +553,6 @@ export default {
opacity: 0.1;
}
/* 脉冲波纹 */
.ripple {
position: absolute;
top: 50%;
@ -595,7 +583,6 @@ export default {
}
}
/* 右侧登录表单区域 */
.login-right {
flex: 0 0 40%;
display: flex;
@ -653,23 +640,6 @@ export default {
cursor: pointer;
}
.login-tip {
font-size: 13px;
text-align: center;
color: #bfbfbf;
}
.login-code {
width: 33%;
height: 38px;
float: right;
img {
cursor: pointer;
vertical-align: middle;
}
}
.el-login-footer {
height: 40px;
line-height: 40px;
@ -681,24 +651,28 @@ export default {
font-family: Arial;
font-size: 12px;
letter-spacing: 1px;
padding: 0 18px;
box-sizing: border-box;
}
.login-footer-text {
pointer-events: none;
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login-code-img {
height: 38px;
}
/* 响应�?*/
@media (max-width: 1024px) {
.login-left {
flex: 0 0 50%;
}
.login-right {
flex: 0 0 50%;
}
.login-form {
width: 350px;
}
@ -708,13 +682,14 @@ export default {
.login-left {
display: none;
}
.login-right {
flex: 0 0 100%;
}
.login-form {
width: 90%;
max-width: 400px;
}
}
</style>

25
src/views/system/user/components/ProfileSettingsCard.vue

@ -32,14 +32,7 @@
<el-input v-model="profileForm.nickName" maxlength="30" />
</el-form-item>
</el-col>
<el-col v-if="canEditMapConfig" :span="12">
<el-form-item :label="$t('profile.form.googleKey')">
<el-input
v-model="profileForm.googleKey"
:placeholder="$t('profile.placeholder.googleKey')"
/>
</el-form-item>
</el-col>
<el-col :span="12" />
</el-row>
<el-row v-if="canEditMapConfig" :gutter="20">
@ -51,6 +44,17 @@
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('profile.form.maptilerKey')">
<el-input
v-model="profileForm.maptilerKey"
:placeholder="$t('profile.placeholder.maptilerKey')"
/>
</el-form-item>
</el-col>
</el-row>
<el-row v-if="canEditMapConfig" :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('profile.form.gaodeSecurityKey')">
<el-input
@ -59,6 +63,7 @@
/>
</el-form-item>
</el-col>
<el-col :span="12" />
</el-row>
<el-form-item>
@ -131,7 +136,7 @@ function createDefaultProfileForm() {
businessName: "",
gaodeKey: "",
gaodeSecurityKey: "",
googleKey: "",
maptilerKey: "",
canEditBusinessConfig: false,
};
}
@ -218,7 +223,7 @@ export default {
if (this.canEditMapConfig) {
payload.gaodeKey = this.profileForm.gaodeKey;
payload.gaodeSecurityKey = this.profileForm.gaodeSecurityKey;
payload.googleKey = this.profileForm.googleKey;
payload.maptilerKey = this.profileForm.maptilerKey;
}
this.profileSaving = true;
updateUserProfile(payload)

Loading…
Cancel
Save