feat: 新增电工 APP 账号开通与密码重置功能

- 新增后端重置密码接口及前端对应操作入口
- 新增补开电工 APP 账号的更新参数
- 新增 hasAccount 字段标识电工是否开通 APP 账号
- 新增数据库菜单及角色绑定支持密码重置权限
- 移除后台角色中错误的 WORKER 和 CUSTOMER 角色
This commit is contained in:
andyesfly committed 2026-10-11 15:07:45 +08:00
1 parent 6cf6a3d5b9
commit f8117e33b6
9 files changed
+164 -18

No files matched your search

+27
View File
@@ -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);
});
+5
View File
@@ -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) =>
+44 -3
View File
@@ -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
View File
@@ -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);
+2
View File
@@ -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[];
}
+23 -4
View File
@@ -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);