feat: 新增电工 APP 账号开通与密码重置功能
- 新增后端重置密码接口及前端对应操作入口 - 新增补开电工 APP 账号的更新参数 - 新增 hasAccount 字段标识电工是否开通 APP 账号 - 新增数据库菜单及角色绑定支持密码重置权限 - 移除后台角色中错误的 WORKER 和 CUSTOMER 角色
This commit is contained in:
1 parent
6cf6a3d5b9
commit
f8117e33b6
9 files changed
+164
-18
No files matched your search
@@ -0,0 +1,27 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Client } = require('pg');
|
||||
|
||||
(async () => {
|
||||
const client = new Client({
|
||||
host: '222.243.55.42',
|
||||
port: 26532,
|
||||
database: 'xintong',
|
||||
user: 'postgres',
|
||||
password: '7F0d9T7SOroYqgXqtZ7tHL2SbnvUljHe'
|
||||
});
|
||||
await client.connect();
|
||||
const sql = fs.readFileSync(
|
||||
path.join(__dirname, 'database', 'migrations', '20261006_worker_password_manage.sql'),
|
||||
'utf8'
|
||||
);
|
||||
await client.query(sql);
|
||||
const menu = await client.query('SELECT id, parent_id, menu_name, menu_type, permission FROM sys_menu WHERE id = 57');
|
||||
const bind = await client.query('SELECT role_id, menu_id FROM sys_role_menu WHERE menu_id = 57');
|
||||
console.log('menu:', JSON.stringify(menu.rows));
|
||||
console.log('bind:', JSON.stringify(bind.rows));
|
||||
await client.end();
|
||||
})().catch((e) => {
|
||||
console.error('FAILED:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -43,6 +43,8 @@ export interface WorkerRecord {
|
||||
auditBy?: number;
|
||||
auditAt?: string;
|
||||
lastLoginAt?: string;
|
||||
// 是否已开通电工 APP 登录账号
|
||||
hasAccount?: boolean;
|
||||
certStatus?: WorkerCertStatus;
|
||||
certs?: WorkerCertSummary[];
|
||||
createdAt?: string;
|
||||
@@ -119,6 +121,9 @@ export const workerRegistryApi = {
|
||||
|
||||
audit: (id: number, data: WorkerAuditPayload) => request.put(`/workers/${id}/audit`, data),
|
||||
|
||||
// 重置 APP 登录密码(后端固定重置为手机号后 6 位)
|
||||
resetPassword: (id: number) => request.post(`/workers/${id}/reset-password`),
|
||||
|
||||
addCert: (id: number, data: WorkerCertPayload) => request.post(`/workers/${id}/certs`, data),
|
||||
|
||||
updateCert: (id: number, certId: number, data: WorkerCertPayload) =>
|
||||
|
||||
@@ -165,6 +165,19 @@
|
||||
<template #icon><AuditOutlined /></template>
|
||||
审核
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="canResetPassword"
|
||||
title="确定重置该电工的 APP 登录密码吗?"
|
||||
description="重置后密码为手机号后 6 位"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleResetPassword(record.id)"
|
||||
>
|
||||
<a-button type="link" size="small">
|
||||
<template #icon><KeyOutlined /></template>
|
||||
重置密码
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm
|
||||
v-if="canDelete"
|
||||
title="确定要删除该电工吗?"
|
||||
@@ -240,8 +253,11 @@
|
||||
<a-form-item label="服务评分" name="rating">
|
||||
<a-input-number v-model:value="formData.rating" :min="0" :max="5" :step="0.1" style="width: 160px" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="!isEdit" label="APP 登录账号">
|
||||
<div class="switch-line">
|
||||
<a-form-item label="APP 登录账号">
|
||||
<div v-if="isEdit && formData.hasAccount" class="switch-line">
|
||||
<span class="form-tip">该电工已开通 APP 登录账号,如需重置密码请使用列表中的「重置密码」。</span>
|
||||
</div>
|
||||
<div v-else class="switch-line">
|
||||
<a-switch v-model:checked="formData.withAccount" />
|
||||
<span class="form-tip">开启后同时创建电工 APP 登录密码,默认密码为手机号后 6 位</span>
|
||||
</div>
|
||||
@@ -381,6 +397,7 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
AuditOutlined,
|
||||
KeyOutlined,
|
||||
TeamOutlined,
|
||||
ClockCircleOutlined,
|
||||
WarningOutlined,
|
||||
@@ -425,6 +442,7 @@ const canCreate = computed(() => permissionStore.hasPermission('worker:create'))
|
||||
const canUpdate = computed(() => permissionStore.hasPermission('worker:update'));
|
||||
const canDelete = computed(() => permissionStore.hasPermission('worker:delete'));
|
||||
const canAudit = computed(() => permissionStore.hasPermission('worker:audit'));
|
||||
const canResetPassword = computed(() => permissionStore.hasPermission('worker:reset-password'));
|
||||
|
||||
// ==================== 列表数据 ====================
|
||||
const loading = ref(false);
|
||||
@@ -501,7 +519,7 @@ const columns = [
|
||||
{ title: '在岗状态', key: 'status', align: 'center' as const, width: 110 },
|
||||
{ title: '评分', key: 'rating', align: 'center' as const, width: 90 },
|
||||
{ title: '完成数', dataIndex: 'completedCount', key: 'completedCount', align: 'center' as const, width: 90 },
|
||||
{ title: '操作', key: 'action', width: 210, fixed: 'right' as const },
|
||||
{ title: '操作', key: 'action', width: 300, fixed: 'right' as const },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
@@ -529,6 +547,8 @@ const formData = reactive({
|
||||
status: 1,
|
||||
rating: 5,
|
||||
withAccount: false,
|
||||
// 编辑时用于判断该电工是否已开通 APP 账号
|
||||
hasAccount: false,
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
@@ -664,6 +684,8 @@ async function handleEdit(record: WorkerRecord) {
|
||||
formData.workerTitle = record.workerTitle || '';
|
||||
formData.status = record.status;
|
||||
formData.rating = Number(record.rating ?? 5);
|
||||
formData.hasAccount = record.hasAccount === true;
|
||||
formData.withAccount = false;
|
||||
|
||||
modalVisible.value = true;
|
||||
await loadCertRows(record.id);
|
||||
@@ -674,6 +696,7 @@ async function loadCertRows(workerId: number) {
|
||||
try {
|
||||
const res: any = await workerRegistryApi.getDetail(workerId);
|
||||
if (res.code === 200 && res.data) {
|
||||
formData.hasAccount = res.data.hasAccount === true;
|
||||
const certs: WorkerCert[] = res.data.certs || [];
|
||||
certRows.value = certs.map((c) => ({
|
||||
_key: certKeySeed++,
|
||||
@@ -700,6 +723,7 @@ function resetForm() {
|
||||
formData.status = 1;
|
||||
formData.rating = 5;
|
||||
formData.withAccount = false;
|
||||
formData.hasAccount = false;
|
||||
certRows.value = [];
|
||||
|
||||
if (formRef.value) {
|
||||
@@ -822,6 +846,8 @@ async function handleSubmit() {
|
||||
let res: any;
|
||||
if (isEdit.value && editId.value) {
|
||||
payload.status = formData.status;
|
||||
// 补开 APP 账号:仅对尚未开通的电工生效
|
||||
payload.withAccount = formData.withAccount;
|
||||
res = await workerRegistryApi.update(editId.value, payload);
|
||||
} else {
|
||||
payload.withAccount = formData.withAccount;
|
||||
@@ -880,6 +906,21 @@ async function handleDelete(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 重置密码 ====================
|
||||
async function handleResetPassword(id: number) {
|
||||
try {
|
||||
const res: any = await workerRegistryApi.resetPassword(id);
|
||||
if (res.code === 200) {
|
||||
message.success('密码已重置为手机号后 6 位');
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message || '重置密码失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('重置密码失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 审核 ====================
|
||||
function handleAudit(record: WorkerRecord) {
|
||||
auditRecord.value = record;
|
||||
|
||||
+14
-11
@@ -16,7 +16,7 @@
|
||||
-- - 显式重置序列,确保 id 不漂移
|
||||
--
|
||||
-- 内容:
|
||||
-- 1. 初始角色(ADMIN / SALESMAN / WORKER / CUSTOMER)
|
||||
-- 1. 初始角色(ADMIN / SALESMAN)
|
||||
-- 2. 初始菜单(顶级 6 + 系统管理子菜单 6 + 运营派单子菜单 13 等,共 50 条)
|
||||
-- 3. 初始字典(客户/设备/派单/支付等业务字典)
|
||||
-- 4. 管理员账户(admin / admin123456)
|
||||
@@ -26,21 +26,24 @@
|
||||
BEGIN;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 1. 初始角色(显式指定 id:1=ADMIN 2=SALESMAN 3=WORKER 4=CUSTOMER)
|
||||
-- 1. 初始角色(显式指定 id:1=ADMIN 2=SALESMAN)
|
||||
-- 后台仅存在系统用户(sys_user)角色;电工(worker 表)与客户(customer_account 表)
|
||||
-- 各自独立账号体系,不作为后台角色出现。
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_role (id, role_code, role_name, description, sort_order) VALUES
|
||||
(1, 'ADMIN', '运营管理员', '全部菜单权限,管理所有客户', 1),
|
||||
(2, 'SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2),
|
||||
(3, 'WORKER', '共享电工', 'APP 接单处理,无后台菜单', 3),
|
||||
(4, 'CUSTOMER', '客户', '小程序查看用电/报警/报修', 4)
|
||||
(2, 'SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
role_code = EXCLUDED.role_code,
|
||||
role_name = EXCLUDED.role_name,
|
||||
description = EXCLUDED.description,
|
||||
sort_order = EXCLUDED.sort_order;
|
||||
|
||||
-- 清理历史遗留的 APP 端角色(电工/客户不参与后台角色体系)
|
||||
DELETE FROM sys_role WHERE role_code IN ('WORKER', 'CUSTOMER');
|
||||
|
||||
-- 重置 sys_role 自增序列
|
||||
SELECT setval('sys_role_id_seq', 4, true);
|
||||
SELECT setval('sys_role_id_seq', 2, true);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2)
|
||||
@@ -165,7 +168,8 @@ INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort
|
||||
(43, 3, '新增电工', NULL, NULL, NULL, 7, 3, 'worker:create', 1),
|
||||
(44, 3, '编辑电工', NULL, NULL, NULL, 8, 3, 'worker:update', 1),
|
||||
(45, 3, '删除电工', NULL, NULL, NULL, 9, 3, 'worker:delete', 1),
|
||||
(46, 3, '审核电工', NULL, NULL, NULL, 10, 3, 'worker:audit', 1)
|
||||
(46, 3, '审核电工', NULL, NULL, NULL, 10, 3, 'worker:audit', 1),
|
||||
(57, 3, '重置电工密码', NULL, NULL, NULL, 11, 3, 'worker:reset-password', 1)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
menu_name = EXCLUDED.menu_name,
|
||||
@@ -254,14 +258,13 @@ INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1)
|
||||
ON CONFLICT (user_id, role_id) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 6. 角色-菜单绑定:ADMIN 拥有全部 46 条菜单,SALESMAN 拥有客户域+项目+统计报表
|
||||
-- WORKER、CUSTOMER 不绑定任何菜单
|
||||
-- 6. 角色-菜单绑定:ADMIN 拥有全部菜单,SALESMAN 拥有客户域+项目+统计报表
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11),
|
||||
(1, 12), (1, 13), (1, 14), (1, 15), (1, 16), (1, 17), (1, 18), (1, 19), (1, 20), (1, 21), (1, 22), (1, 23),
|
||||
(1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33), (1, 55), (1, 56),
|
||||
(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46),
|
||||
(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46), (1, 57),
|
||||
(2, 1), (2, 2), (2, 5),
|
||||
(2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23),
|
||||
(2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33), (2, 55), (2, 56)
|
||||
@@ -330,7 +333,7 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- 重置 sys_menu 自增序列
|
||||
SELECT setval('sys_menu_id_seq', 56, true);
|
||||
SELECT setval('sys_menu_id_seq', 57, true);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 移除后台角色中的「共享电工(WORKER)」「客户(CUSTOMER)」
|
||||
-- 背景:电工走 worker 表、客户走 customer_account 表,均为独立账号体系,
|
||||
-- 不存在对应的 sys_user,因此这两个角色挂在后台「用户管理」中是语义错误
|
||||
-- (选中后永远不会关联到任何后台用户)。后台角色仅保留 ADMIN / SALESMAN。
|
||||
-- 影响:sys_user_role / sys_role_menu 通过外键 ON DELETE CASCADE 自动清理。
|
||||
-- 幂等:按 role_code 删除;重复执行无副作用。
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM sys_role WHERE role_code IN ('WORKER', 'CUSTOMER');
|
||||
|
||||
-- 重置 sys_role 自增序列到当前最大 id,避免后续插入冲突
|
||||
SELECT setval('sys_role_id_seq', COALESCE((SELECT MAX(id) FROM sys_role), 1), true);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 电工登录密码维护
|
||||
-- 背景:worker 表已有 password_hash,但后台「电工登记管理」仅有新增/编辑/审核/删除,
|
||||
-- 无法为存量电工重置密码,也无法为当初未开通 APP 账号的电工补开账号。
|
||||
-- 本次新增按钮权限 worker:reset-password,用于控制「重置密码」操作入口。
|
||||
-- 幂等:菜单用 ON CONFLICT (id) DO UPDATE;角色绑定用 ON CONFLICT DO NOTHING。
|
||||
-- 注意:菜单 id 57,避开 34-56(34/36/51 为菜单项,其余为按钮/子项)。
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(57, 3, '重置电工密码', NULL, NULL, NULL, 11, 3, 'worker:reset-password', 1)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
menu_name = EXCLUDED.menu_name,
|
||||
menu_path = EXCLUDED.menu_path,
|
||||
component = EXCLUDED.component,
|
||||
icon = EXCLUDED.icon,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
menu_type = EXCLUDED.menu_type,
|
||||
permission = EXCLUDED.permission,
|
||||
visible = EXCLUDED.visible;
|
||||
|
||||
-- 角色绑定:ADMIN(role_id=1)拥有 57
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
(1, 57)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- 重置 sys_menu 自增序列,避免后续插入 id 冲突
|
||||
SELECT setval('sys_menu_id_seq', 57, true);
|
||||
|
||||
COMMIT;
|
||||
@@ -23,6 +23,11 @@ export class WorkerController {
|
||||
}
|
||||
@Del("/:id") async remove(@Param("id") id: string) { return this.workerService.remove(Number(id)); }
|
||||
|
||||
// 重置电工 APP 登录密码(重置为手机号后 6 位)
|
||||
@Post("/:id/reset-password") async resetPassword(@Param("id") id: string) {
|
||||
return this.workerService.resetPassword(Number(id));
|
||||
}
|
||||
|
||||
// 资质审核:操作人取自登录态
|
||||
@Put("/:id/audit") async audit(@Param("id") id: string, @Body() body: WorkerAuditDTO) {
|
||||
return this.workerService.audit(Number(id), body, this.ctx.state.user?.userId);
|
||||
|
||||
@@ -72,6 +72,8 @@ export class WorkerUpdateDTO {
|
||||
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) @Max(5) rating?: number;
|
||||
@IsOptional() @IsString() @MaxLength(128) certInfo?: string;
|
||||
// 为尚未开通 APP 账号的电工补开账号(默认密码为手机号后 6 位;已开通时忽略)
|
||||
@IsOptional() @IsBoolean() withAccount?: boolean;
|
||||
// 存在时整体覆盖该电工的全部证书
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs?: WorkerCertDTO[];
|
||||
}
|
||||
|
||||
@@ -104,10 +104,12 @@ export class WorkerService {
|
||||
};
|
||||
}
|
||||
|
||||
// 剔除敏感字段
|
||||
// 剔除敏感字段;额外返回是否已开通 APP 登录账号
|
||||
private cleanWorker(worker: Worker): any {
|
||||
const data = worker.toJSON ? worker.toJSON() : { ...worker };
|
||||
const hasAccount = !!data.passwordHash;
|
||||
delete data.passwordHash;
|
||||
data.hasAccount = hasAccount;
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -171,7 +173,6 @@ export class WorkerService {
|
||||
if (certStatus) {
|
||||
const rows = await this.workerModel.findAll({
|
||||
where,
|
||||
attributes: { exclude: ["passwordHash"] },
|
||||
order: [["rating", "DESC"]]
|
||||
});
|
||||
const certMap = await this.loadCertMap(rows.map((r) => Number(r.id)));
|
||||
@@ -195,7 +196,6 @@ export class WorkerService {
|
||||
// 常规分页:仅对当前页电工批量加载证书
|
||||
const { count, rows } = await this.workerModel.findAndCountAll({
|
||||
where,
|
||||
attributes: { exclude: ["passwordHash"] },
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["rating", "DESC"]],
|
||||
@@ -240,7 +240,7 @@ export class WorkerService {
|
||||
|
||||
// 详情:含完整证书数组
|
||||
async detail(id: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(id, { attributes: { exclude: ["passwordHash"] } });
|
||||
const worker = await this.workerModel.findByPk(id);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
@@ -310,6 +310,10 @@ export class WorkerService {
|
||||
if (dto.status !== undefined) payload.status = dto.status;
|
||||
if (dto.rating !== undefined) payload.rating = dto.rating;
|
||||
if (dto.certInfo !== undefined) payload.certInfo = dto.certInfo;
|
||||
// 补开 APP 账号:仅对尚未开通(无密码哈希)的电工生效,默认密码为手机号后 6 位
|
||||
if (dto.withAccount === true && !worker.passwordHash) {
|
||||
payload.passwordHash = await hashPassword(getDefaultPassword(dto.phone || worker.phone));
|
||||
}
|
||||
|
||||
await worker.update(payload);
|
||||
|
||||
@@ -326,6 +330,21 @@ export class WorkerService {
|
||||
return { code: 200, message: "更新电工成功", data: this.cleanWorker(worker) };
|
||||
}
|
||||
|
||||
// 重置 APP 登录密码:固定重置为手机号后 6 位;未开通账号时一并开通
|
||||
async resetPassword(id: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(id);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
if (!worker.phone) {
|
||||
return { code: 400, message: "该电工未登记手机号,无法重置密码", data: null };
|
||||
}
|
||||
|
||||
await worker.update({ passwordHash: await hashPassword(getDefaultPassword(worker.phone)) });
|
||||
|
||||
return { code: 200, message: "重置密码成功", data: this.cleanWorker(worker) };
|
||||
}
|
||||
|
||||
// 删除电工:存在派单记录则拒绝;否则级联删除证书并清理通知
|
||||
async remove(id: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(id);
|
||||
|
||||
Reference in new issue
Block a user