Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-10-12 14:20:26 +08:00
9 changed files with 726 additions and 42 deletions
+62
View File
@@ -0,0 +1,62 @@
import request from '@/axios';
export const getList = (current, size, params) => {
return request({
url: '/blade-system/mp-user/list',
method: 'get',
params: {
...params,
current,
size,
},
});
};
export const getDetail = id => {
return request({
url: '/blade-system/mp-user/detail',
method: 'get',
params: {
id,
},
});
};
export const submit = row => {
return request({
url: '/blade-system/mp-user/submit',
method: 'post',
data: row,
});
};
export const remove = ids => {
return request({
url: '/blade-system/mp-user/remove',
method: 'post',
params: {
ids,
},
});
};
export const changeStatus = (id, status) => {
return request({
url: '/blade-system/mp-user/change-status',
method: 'post',
params: {
id,
status,
},
});
};
export const resetPassword = userIds => {
return request({
url: '/blade-system/mp-user/reset-password',
method: 'post',
params: {
userIds,
},
});
};
+203
View File
@@ -0,0 +1,203 @@
const identityDic = [
{ label: '司机', value: 'driver' },
{ label: '管理员', value: 'admin' },
];
const statusDic = [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 },
];
const validatePhone = (rule, value, callback) => {
const phone = String(value || '').trim();
if (!phone) {
callback(new Error('请输入手机号'));
return;
}
if (!/^1\d{10}$/.test(phone)) {
callback(new Error('请输入正确的手机号'));
return;
}
callback();
};
const validateRemark = (rule, value, callback) => {
if (String(value || '').length > 200) {
callback(new Error('备注不能超过200字'));
} else {
callback();
}
};
export default {
height: 'auto',
calcHeight: 22,
dialogWidth: 760,
labelPosition: 'right',
labelWidth: 'auto',
tip: false,
searchBtnText: '查询',
emptyBtnText: '重置',
saveBtnText: '提交',
updateBtnText: '提交',
searchShow: true,
searchIcon: true,
searchLabelWidth: 160,
searchMenuSpan: 24,
searchIndex: 4,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
viewBtn: false,
delBtn: false,
editBtn: false,
addBtn: false,
selection: true,
dialogClickModal: false,
menuWidth: 280,
column: [
{
label: '手机号',
prop: 'phone',
minWidth: 130,
search: true,
searchOrder: 4,
searchSpan: 6,
searchPlaceholder: '请输入',
maxlength: 11,
showWordLimit: true,
order: 100,
rules: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ validator: validatePhone, trigger: 'blur' },
],
},
{
label: '账号',
prop: 'account',
minWidth: 130,
search: true,
searchOrder: 3,
searchSpan: 6,
searchPlaceholder: '请输入',
maxlength: 45,
order: 90,
placeholder: '默认与手机号相同',
},
{
label: '姓名',
prop: 'realName',
minWidth: 100,
search: true,
searchOrder: 2,
searchSpan: 6,
searchPlaceholder: '请输入',
maxlength: 10,
showWordLimit: true,
order: 80,
},
{
label: '昵称',
prop: 'name',
minWidth: 100,
maxlength: 20,
showWordLimit: true,
order: 70,
},
{
label: '小程序身份',
prop: 'identities',
type: 'select',
multiple: true,
dataType: 'string',
dicData: identityDic,
minWidth: 140,
search: true,
searchOrder: 1,
searchSpan: 6,
searchPlaceholder: '请选择',
order: 60,
rules: [{ required: true, message: '请选择小程序身份', trigger: 'change' }],
},
{
label: '性别',
prop: 'sex',
type: 'select',
dicUrl: '/blade-system/dict/dictionary?code=sex',
props: {
label: 'dictValue',
value: 'dictKey',
},
dataType: 'number',
minWidth: 80,
hide: true,
order: 50,
},
{
label: 'openid',
prop: 'openid',
minWidth: 180,
overHidden: true,
addDisplay: false,
editDisplay: true,
order: 40,
placeholder: '可清空以解绑微信',
},
{
label: '状态',
prop: 'status',
type: 'select',
dicData: statusDic,
dataType: 'number',
minWidth: 90,
search: true,
searchOrder: 0,
searchSpan: 6,
addDisplay: false,
editDisplay: false,
value: 1,
order: 30,
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
span: 24,
minRows: 2,
maxlength: 200,
showWordLimit: true,
hide: true,
order: 20,
rules: [{ validator: validateRemark, trigger: 'blur' }],
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
addDisplay: false,
editDisplay: false,
viewDisplay: true,
order: 10,
},
{
label: '更新人',
prop: 'updateUserName',
minWidth: 100,
addDisplay: false,
editDisplay: false,
viewDisplay: true,
order: 5,
},
{
label: '更新时间',
prop: 'updateTime',
minWidth: 170,
addDisplay: false,
editDisplay: false,
viewDisplay: true,
order: 1,
},
],
};
+2 -1
View File
@@ -39,6 +39,7 @@ export const userOption = safe => {
label: '账号名',
prop: 'account',
slot: true,
minWidth: 180,
search: true,
searchOrder: 2,
searchPlaceholder: '支持模糊搜索',
@@ -96,7 +97,7 @@ export const userOption = safe => {
{
label: '账号名',
prop: 'account',
span: 6,
span: 12,
rules: [
{
required: true,
+41 -7
View File
@@ -139,9 +139,23 @@ const CERT_SNAPSHOT_GROUPS = {
{
title: '从业资格证信息',
fields: [
{ key: 'qualificationType', label: '从业资格认证类型' },
{ key: 'qualificationNo', label: '从业资格证号' },
{ key: 'qualificationUrl', label: '从业资格证照片', type: TYPE_IMAGE },
{ key: 'qualificationValidEnd', label: '从业资格证有效期止', type: TYPE_DATE },
{
key: 'qualificationFront',
label: '从业资格证封面页',
type: TYPE_IMAGE,
legacyKeys: ['qualificationUrl'],
},
{ key: 'qualificationBack', label: '从业资格证内容页', type: TYPE_IMAGE },
{ key: 'qualificationValidStart', label: '从业资格证有效期起', type: TYPE_DATE },
{
key: 'qualificationValidEnd',
label: '从业资格证有效期止',
type: TYPE_DATE,
longTermKey: 'qualificationLongTerm',
},
{ key: 'qualificationLongTerm', label: '从业资格证长期有效', type: TYPE_LONG_TERM },
],
},
],
@@ -268,9 +282,28 @@ const normalizeDate = value => {
return text;
};
/**
* 取字段值,兼容字段改名前的旧键
*
* 后端把某字段改名后,改名之前提交的记录里存的仍是旧键(例:从业资格证封面页
* 由 `qualificationUrl` 改为 `qualificationFront`)。只读新键会得到空,
* 审核页就把已填的封面页显示成「—」,误导审核员以为对方没传。
* 新旧键都存在时以新键为准。
*/
const readFieldValue = (snapshot, field) => {
if (!snapshot) return undefined;
const value = snapshot[field.key];
if (!isEmptyValue(value)) return value;
const legacyKeys = field.legacyKeys || [];
for (let i = 0; i < legacyKeys.length; i += 1) {
if (!isEmptyValue(snapshot[legacyKeys[i]])) return snapshot[legacyKeys[i]];
}
return value;
};
/** 比较用的归一化取值:展示相同即视为未变更 */
const canonicalOf = (snapshot, field) => {
const value = snapshot ? snapshot[field.key] : undefined;
const value = readFieldValue(snapshot, field);
if (field.type === TYPE_LONG_TERM) {
if (isEmptyValue(value)) return '';
return isLongTermOn(value) ? '1' : '0';
@@ -286,7 +319,7 @@ const canonicalOf = (snapshot, field) => {
/** 展示文案:日期型受「长期有效」影响,图片型返回 URL 由组件渲染缩略图 */
const textOf = (snapshot, field) => {
const value = snapshot ? snapshot[field.key] : undefined;
const value = readFieldValue(snapshot, field);
if (field.type === TYPE_IMAGE) {
return isEmptyValue(value) ? '' : String(value);
}
@@ -323,11 +356,11 @@ const buildRow = (field, pendingSnapshot, effectiveSnapshot, hasBaseline) => {
label: field.label,
type,
effective: {
raw: effectiveSnapshot ? effectiveSnapshot[field.key] : undefined,
raw: readFieldValue(effectiveSnapshot, target),
text: textOf(effectiveSnapshot, target),
},
pending: {
raw: pendingSnapshot ? pendingSnapshot[field.key] : undefined,
raw: readFieldValue(pendingSnapshot, target),
text: textOf(pendingSnapshot, target),
},
changed: Boolean(changeType),
@@ -363,7 +396,8 @@ export const buildSnapshotDiff = (bizType, pendingJson, effectiveJson) => {
const groups = groupsConfig.map(group => {
const rows = group.fields.map(field => {
knownKeys.push(field.key);
// 旧键已由所属字段并入展示,不能再作为独立字段出现,否则同一张封面页会出现两行
knownKeys.push(field.key, ...(field.legacyKeys || []));
return buildRow(field, pending.value, effective.value, hasBaseline);
});
return {
+321
View File
@@ -0,0 +1,321 @@
<template>
<basic-container class="mp-user-page">
<avue-crud
:option="option"
:table-loading="loading"
:data="data"
v-model:page="page"
v-model="form"
ref="crud"
:permission="permissionList"
:before-open="beforeOpen"
@row-save="rowSave"
@row-update="rowUpdate"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
type="primary"
v-if="permission.mp_user_add"
@click="$refs.crud.rowAdd()"
>
新增
</el-button>
<el-button
type="primary"
plain
v-if="permission.mp_user_reset"
@click="handleReset"
>
密码重置
</el-button>
<el-button
type="danger"
plain
v-if="permission.mp_user_delete"
@click="handleDelete"
>
批量删除
</el-button>
</template>
<template #identities="{ row }">
{{ row.identitiesName || formatIdentities(row.identities) }}
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'primary' : 'info'" class="status-text">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
<template #updateUserName="{ row }">
{{ formatUpdateUserName(row) }}
</template>
<template #menu="{ row, index }">
<el-link
type="primary"
v-if="permission.mp_user_view"
@click="$refs.crud.rowView(row, index)"
>
查看
</el-link>
<el-link
type="primary"
v-if="permission.mp_user_edit"
@click="$refs.crud.rowEdit(row, index)"
>
编辑
</el-link>
<el-link
type="primary"
v-if="permission.mp_user_status"
@click="handleStatus(row)"
>
{{ row.status === 1 ? '停用' : '启用' }}
</el-link>
<el-link
type="danger"
v-if="permission.mp_user_delete"
@click="rowDel(row)"
>
删除
</el-link>
</template>
</avue-crud>
</basic-container>
</template>
<script>
import { h } from 'vue';
import { getList, getDetail, submit, remove, changeStatus, resetPassword } from '@/api/system/mpUser';
import option from '@/option/system/mpUser';
import { mapGetters } from 'vuex';
import { formatUpdateUserName } from '@/utils/audit';
const IDENTITY_LABEL = {
driver: '司机',
admin: '管理员',
};
export default {
data() {
return {
form: {},
query: {},
loading: true,
page: {
pageSize: 10,
currentPage: 1,
total: 0,
},
selectionList: [],
option,
data: [],
};
},
computed: {
...mapGetters(['permission']),
permissionList() {
return {
addBtn: this.validData(this.permission.mp_user_add, false),
viewBtn: this.validData(this.permission.mp_user_view, false),
delBtn: this.validData(this.permission.mp_user_delete, false),
editBtn: this.validData(this.permission.mp_user_edit, false),
};
},
ids() {
return this.selectionList.map(item => item.id).join(',');
},
},
methods: {
formatUpdateUserName,
formatIdentities(value) {
if (!value) return '';
return String(value)
.split(',')
.map(item => IDENTITY_LABEL[item] || item)
.filter(Boolean)
.join(',');
},
normalizeSubmitRow(row = {}) {
const payload = { ...row };
if (Array.isArray(payload.identities)) {
payload.identities = payload.identities.filter(Boolean).join(',');
}
if (payload.phone) {
payload.phone = String(payload.phone).trim();
}
if (!payload.account && payload.phone) {
payload.account = payload.phone;
}
delete payload.password;
delete payload.identitiesName;
delete payload.sexName;
delete payload.createUserName;
delete payload.updateUserName;
return payload;
},
beforeOpen(done, type) {
if (['edit', 'view'].includes(type) && this.form.id) {
getDetail(this.form.id).then(res => {
const detail = res.data.data || {};
if (detail.identities && typeof detail.identities === 'string') {
detail.identities = detail.identities.split(',').filter(Boolean);
}
this.form = detail;
done();
});
return;
}
if (type === 'add') {
this.form = {
status: 1,
identities: ['driver'],
};
}
done();
},
rowSave(row, done, loading) {
submit(this.normalizeSubmitRow(row)).then(
() => {
this.onLoad(this.page);
this.$message.success('操作成功');
done();
},
() => loading()
);
},
rowUpdate(row, index, done, loading) {
submit(this.normalizeSubmitRow(row)).then(
() => {
this.onLoad(this.page);
this.$message.success('操作成功');
done();
},
() => loading()
);
},
rowDel(row) {
this.$confirm('确定删除该小程序用户?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => remove(row.id))
.then(() => {
this.onLoad(this.page);
this.$message.success('操作成功');
});
},
handleDelete() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('确定删除选中的小程序用户?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => remove(this.ids))
.then(() => {
this.onLoad(this.page);
this.$message.success('操作成功');
this.$refs.crud.toggleSelection();
});
},
handleStatus(row) {
const status = row.status === 1 ? 0 : 1;
const text = status === 1 ? '启用' : '停用';
this.$confirm(`是否${text}该小程序用户?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => changeStatus(row.id, status))
.then(() => {
this.$message.success('操作成功');
this.onLoad(this.page);
});
},
handleReset() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('确定将选中账号密码重置为随机密码?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => resetPassword(this.ids))
.then(res => {
this.showResetPassword(res.data.data);
this.$refs.crud.toggleSelection();
});
},
showResetPassword(passwordMap) {
const passwordList = Object.keys(passwordMap || {}).map(account =>
h('div', null, `${account}:${passwordMap[account]}`)
);
this.$alert(h('div', null, passwordList), '密码重置成功,请立即保存', {
confirmButtonText: '我已保存',
type: 'success',
});
},
searchReset() {
this.query = {};
this.onLoad(this.page);
},
searchChange(params, done) {
this.query = params;
this.page.currentPage = 1;
this.onLoad(this.page, params);
done();
},
selectionChange(list) {
this.selectionList = list;
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.query);
},
onLoad(page, params = {}) {
this.loading = true;
const query = {
...this.query,
...params,
};
if (Array.isArray(query.identities)) {
query.identities = query.identities.filter(Boolean).join(',');
}
getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data.data || {};
this.page.total = result.total || 0;
this.data = result.records || [];
})
.finally(() => {
this.loading = false;
});
},
},
};
</script>
<style lang="scss" scoped>
.mp-user-page {
:deep(.avue-crud__menu) {
min-height: auto;
}
}
</style>
+1 -1
View File
@@ -139,7 +139,7 @@
<section-card title="用户信息">
<el-form ref="userFormRef" :model="form" :rules="isUserViewMode ? {} : userRules" label-position="right" label-width="calc(6em + 24px)">
<el-row :gutter="18">
<el-col :span="6"><el-form-item label="账号名" prop="account"><el-input v-model="form.account" :disabled="isUserViewMode" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="账号名" prop="account"><el-input v-model="form.account" :disabled="isUserViewMode" /></el-form-item></el-col>
<el-col v-if="userDialogMode === 'add'" :span="6">
<el-form-item label="登录密码" prop="password">
<el-input
@@ -42,6 +42,12 @@
<el-descriptions-item label="认证类型">
{{ bizTypeLabel }}
</el-descriptions-item>
<el-descriptions-item label="审核人">
{{ detail.auditUserName || '-' }}
</el-descriptions-item>
<el-descriptions-item :label="auditTimeLabel">
{{ detail.auditTime ? formatCertDateTime(detail.auditTime) : '-' }}
</el-descriptions-item>
</el-descriptions>
</section-card>
@@ -149,6 +155,13 @@ export default {
statusTag() {
return certStatusTag(this.detail.status);
},
// 审核时间一栏按审核结论换标签:还在「审核中」的记录没有结论,不能写成通过或驳回时间
auditTimeLabel() {
const status = String(this.detail.status);
if (status === String(CERT_RECORD_STATUS.APPROVED)) return '审核通过时间';
if (status === String(CERT_RECORD_STATUS.REJECTED)) return '审核驳回时间';
return '审核时间';
},
// 只有「审核中」的记录可审;已通过/已驳回/已作废都是终态
auditable() {
return String(this.detail.status) === String(CERT_RECORD_STATUS.PENDING);
+31 -8
View File
@@ -1593,17 +1593,40 @@ export default {
const fullNation = `${nation}族`;
return this.nationOptions.includes(fullNation) ? fullNation : nation;
},
/**
* OCR / 手工录入日期归一化:对齐车辆侧与后端 FlexibleLocalDateDeserializer。
* 支持 yyyyMMdd、yyyy-MM-dd、2018.3.9、2030年05月20日及任意非数字分隔;无法识别返回空串。
*/
normalizeBirthday(value = '') {
const birthday = String(value || '').trim();
const compactMatch = birthday.match(/^(\d{4})(\d{2})(\d{2})$/);
if (compactMatch) {
const [, year, month, day] = compactMatch;
return `${year}-${month}-${day}`;
if (!birthday) return '';
if (/长期|永久/.test(birthday)) return '';
const looseMatch = birthday.match(/(\d{4})\D+(\d{1,2})\D+(\d{1,2})/);
if (looseMatch) {
return this.toValidDateText(looseMatch[1], looseMatch[2], looseMatch[3]);
}
const match = birthday.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?$/);
if (!match) return birthday;
const [, year, month, day] = match;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
const compact = birthday.replace(/\D/g, '');
if (compact.length === 8) {
return this.toValidDateText(compact.slice(0, 4), compact.slice(4, 6), compact.slice(6, 8));
}
return '';
},
toValidDateText(yearText, monthText, dayText) {
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return '';
}
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return '';
}
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
},
parseIdCardInfo(idCardNo = '') {
if (!/^\d{17}[\dX]$/.test(idCardNo)) {
+52 -25
View File
@@ -1174,37 +1174,59 @@ export default {
});
},
/**
* OCR 日期常为区间(如 2022-08-17至2024-01-05)或含无效日(如 01-65)。
* 提取可解析的合法日期;prefer=first 取登记类起始日,prefer=last 取有效期截止日。
* OCR 日期常为区间(如 2022-08-17至2024-01-05)、无分隔符(如 20260317)、
* 年月日/点分(2030年05月20日、2018.3.9)或任意非数字分隔。
* 对齐司机侧 normalizeBirthday / 后端 OcrFieldSupport:年与月、月与日之间允许任意非数字。
* prefer=first 取登记类起始日,prefer=last 取有效期截止日。
* 返回值统一为 YYYY-MM-DD,供 el-date-picker 展示与后端 LocalDate 反序列化。
*/
normalizeDate(value = '', prefer = 'first') {
const dateValue = String(value || '').trim();
if (!dateValue) return '';
const segments = dateValue.match(/\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/g) || [];
const sourceList = segments.length ? segments : [dateValue];
const candidates = sourceList
.map(item => {
const match = String(item)
.trim()
.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?$/);
if (!match) return '';
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return '';
if (/长期|永久/.test(dateValue)) return '';
const candidates = [];
const loosePattern = /(\d{4})\D+(\d{1,2})\D+(\d{1,2})/g;
let match = loosePattern.exec(dateValue);
while (match) {
const normalized = this.toValidDateText(match[1], match[2], match[3]);
if (normalized) {
candidates.push(normalized);
}
match = loosePattern.exec(dateValue);
}
if (!candidates.length) {
const compact = dateValue.replace(/\D/g, '');
if (compact.length === 8) {
const normalized = this.toValidDateText(
compact.slice(0, 4),
compact.slice(4, 6),
compact.slice(6, 8)
);
if (normalized) {
candidates.push(normalized);
}
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
})
.filter(Boolean);
}
}
if (!candidates.length) return '';
return prefer === 'last' ? candidates[candidates.length - 1] : candidates[0];
},
toValidDateText(yearText, monthText, dayText) {
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return '';
}
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return '';
}
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
},
normalizeVehicleDateFields(form = {}) {
const dateFields = [
['compulsoryScrapDate', 'first'],
@@ -1216,8 +1238,13 @@ export default {
['registrationDate', 'first'],
];
dateFields.forEach(([prop, prefer]) => {
if (form[prop] === undefined || form[prop] === null || form[prop] === '') return;
form[prop] = this.normalizeDate(form[prop], prefer);
if (form[prop] === undefined || form[prop] === null || form[prop] === '') {
form[prop] = null;
return;
}
const normalized = this.normalizeDate(form[prop], prefer);
// 无法识别或空串统一置 null,避免把 "" / "20260317" 原样提交给 LocalDate
form[prop] = normalized || null;
});
return form;
},