561 lines
17 KiB
Vue
561 lines
17 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 账号管理:对接 Jeecg /sys/user(主账登录账号)。
|
||
* 与员工岗位页分离——本页管后台登录,员工页管企微业务角色。
|
||
*/
|
||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||
import { message, Modal } from "ant-design-vue";
|
||
import {
|
||
changePassword,
|
||
deleteUser,
|
||
getAllRolesListNoByTenant,
|
||
getUserRoles,
|
||
listUsers,
|
||
mapUserToAccount,
|
||
roleTagFor,
|
||
saveOrUpdateUser,
|
||
type AccountRow,
|
||
} from "/@/views/inquiry/account/account.api";
|
||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||
import { usePermission } from "/@/hooks/web/usePermission";
|
||
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||
|
||
const { hasPermission } = usePermission();
|
||
/** 账号新建/编辑/删除/重置密码均属 edit 权限 */
|
||
const canEditAccount = () => hasPermission(InquiryBtnPerm.accountEdit);
|
||
|
||
const rows = ref<AccountRow[]>([]);
|
||
const loading = ref(false);
|
||
const total = ref(0);
|
||
const pageNo = ref(1);
|
||
const pageSize = ref(10);
|
||
const roleOptions = ref<{ value: string; label: string; tag: string; roleCode?: string }[]>([]);
|
||
const filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" });
|
||
const modalOpen = ref(false);
|
||
const editing = ref<AccountRow | null>(null);
|
||
const form = reactive({
|
||
username: "",
|
||
name: "",
|
||
wecomUserId: "",
|
||
roleId: "",
|
||
phone: "",
|
||
status: "active" as AccountRow["status"],
|
||
password: "",
|
||
/** 只看本账号看板数据(对齐原项目 dashboardSelfOnly) */
|
||
dashboardSelfOnly: false,
|
||
});
|
||
|
||
const modalTitle = computed(() => (editing.value ? "编辑账号" : "新建账号"));
|
||
const modalSub = computed(() =>
|
||
editing.value ? `正在编辑:${editing.value.username}` : "创建账号并绑定角色",
|
||
);
|
||
const modalOkText = computed(() => (editing.value ? "保存修改" : "创建账号"));
|
||
|
||
async function loadRoles() {
|
||
const res: any = await getAllRolesListNoByTenant({});
|
||
const list = Array.isArray(res) ? res : res?.records || res?.result || [];
|
||
roleOptions.value = (list as any[]).map((r, i) => ({
|
||
value: String(r.id),
|
||
label: r.roleName || r.roleCode || r.id,
|
||
tag: roleTagFor(i),
|
||
roleCode: String(r.roleCode || ""),
|
||
}));
|
||
if (!form.roleId && roleOptions.value.length) {
|
||
form.roleId =
|
||
roleOptions.value.find((r) => r.label.includes("运营"))?.value || roleOptions.value[0].value;
|
||
}
|
||
}
|
||
|
||
async function loadList() {
|
||
loading.value = true;
|
||
try {
|
||
if (!roleOptions.value.length) await loadRoles();
|
||
const kw = filters.keyword.trim();
|
||
// Jeecg QueryGenerator:字段值包 * 走 LIKE。多字段 AND 过严,按关键字形态选一个字段。
|
||
const params: Record<string, any> = {
|
||
pageNo: pageNo.value,
|
||
pageSize: pageSize.value,
|
||
};
|
||
if (kw) {
|
||
if (/[\u4e00-\u9fff]/.test(kw)) {
|
||
params.realname = `*${kw}*`;
|
||
} else if (/^\d{5,}$/.test(kw)) {
|
||
params.phone = `*${kw}*`;
|
||
} else {
|
||
params.username = `*${kw}*`;
|
||
}
|
||
}
|
||
if (filters.status === "active") params.status = 1;
|
||
if (filters.status === "disabled") params.status = 2;
|
||
|
||
const res: any = await listUsers(params);
|
||
const page = res?.records ? res : res?.result || {};
|
||
const records = page.records || [];
|
||
total.value = Number(page.total || records.length || 0);
|
||
const mapped: AccountRow[] = [];
|
||
for (const u of records as any[]) {
|
||
let roleId = "";
|
||
let roleLabel = "未绑定角色";
|
||
let roleTag = "tag-blue";
|
||
try {
|
||
const roles: any = await getUserRoles({ userid: u.id });
|
||
const ids = Array.isArray(roles) ? roles : roles?.result || [];
|
||
if (ids.length) {
|
||
roleId = String(ids[0]);
|
||
const hit = roleOptions.value.find((r) => r.value === roleId);
|
||
roleLabel = hit?.label || roleLabel;
|
||
roleTag = hit?.tag || roleTag;
|
||
// 用角色码辅助识别超管
|
||
if (hit?.roleCode === "admin" || (hit?.label || "").includes("超级管理员")) {
|
||
roleLabel = hit?.label || "超级管理员";
|
||
}
|
||
} else if (String(u.username || "").toLowerCase() === "admin") {
|
||
// 库里未绑角色时仍展示超管,并尝试选中 admin 角色以便保存时能补绑
|
||
const adminRole = roleOptions.value.find(
|
||
(r) => r.roleCode === "admin" || r.label.includes("超级管理员"),
|
||
);
|
||
if (adminRole) {
|
||
roleId = adminRole.value;
|
||
roleLabel = adminRole.label || "超级管理员";
|
||
roleTag = adminRole.tag;
|
||
} else {
|
||
roleLabel = "超级管理员";
|
||
roleTag = "tag-violet";
|
||
}
|
||
}
|
||
} catch {
|
||
/* 无角色仍展示账号 */
|
||
}
|
||
mapped.push(mapUserToAccount(u, roleId, roleLabel, roleTag));
|
||
}
|
||
rows.value = mapped;
|
||
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1);
|
||
if (pageNo.value > maxPage) {
|
||
pageNo.value = maxPage;
|
||
await loadList();
|
||
}
|
||
} catch (e: any) {
|
||
message.error(e?.message || "加载账号失败");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
let accountSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||
watch(
|
||
() => [filters.keyword, filters.status],
|
||
() => {
|
||
pageNo.value = 1;
|
||
if (accountSearchTimer) clearTimeout(accountSearchTimer);
|
||
accountSearchTimer = setTimeout(() => loadList(), 250);
|
||
},
|
||
);
|
||
|
||
function onPageChange() {
|
||
loadList();
|
||
}
|
||
|
||
onMounted(() => loadList());
|
||
|
||
function openCreate() {
|
||
editing.value = null;
|
||
Object.assign(form, {
|
||
username: "",
|
||
name: "",
|
||
wecomUserId: "",
|
||
roleId: roleOptions.value[0]?.value || "",
|
||
phone: "",
|
||
status: "active",
|
||
password: "",
|
||
dashboardSelfOnly: false,
|
||
});
|
||
modalOpen.value = true;
|
||
}
|
||
|
||
function openEdit(row: AccountRow) {
|
||
if (row.isSuperAdmin) {
|
||
message.warning("超级管理员不可在后台编辑");
|
||
return;
|
||
}
|
||
editing.value = row;
|
||
Object.assign(form, {
|
||
username: row.username,
|
||
name: row.name,
|
||
wecomUserId: row.wecomUserId || "",
|
||
roleId: row.roleId || roleOptions.value[0]?.value || "",
|
||
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
|
||
status: row.status,
|
||
password: "",
|
||
dashboardSelfOnly: Boolean(row.dashboardSelfOnly),
|
||
});
|
||
modalOpen.value = true;
|
||
}
|
||
|
||
async function save() {
|
||
if (editing.value?.isSuperAdmin) {
|
||
message.warning("超级管理员不可在后台编辑");
|
||
return;
|
||
}
|
||
if (!form.username.trim() || !form.name.trim()) {
|
||
message.warning("请填写账号与姓名");
|
||
return;
|
||
}
|
||
if (!form.wecomUserId.trim()) {
|
||
message.warning("请填写企业微信 ID");
|
||
return;
|
||
}
|
||
if (!editing.value && !form.password.trim()) {
|
||
message.warning("请填写初始密码");
|
||
return;
|
||
}
|
||
if (!form.roleId) {
|
||
message.warning("请选择角色");
|
||
return;
|
||
}
|
||
const payload: Record<string, any> = {
|
||
id: editing.value?.id,
|
||
username: form.username.trim(),
|
||
realname: form.name.trim(),
|
||
workNo: form.wecomUserId.trim(),
|
||
phone: form.phone.trim() || undefined,
|
||
status: form.status === "active" ? 1 : 2,
|
||
selectedroles: form.roleId,
|
||
dashboardSelfOnly: form.dashboardSelfOnly ? 1 : 0,
|
||
};
|
||
if (!editing.value) payload.password = form.password;
|
||
try {
|
||
await saveOrUpdateUser(payload, Boolean(editing.value));
|
||
message.success(editing.value ? "账号已更新" : "账号已创建");
|
||
modalOpen.value = false;
|
||
await loadList();
|
||
} catch (e: any) {
|
||
message.error(e?.message || "保存失败");
|
||
}
|
||
}
|
||
|
||
const resetOpen = ref(false);
|
||
const resetTarget = ref<AccountRow | null>(null);
|
||
const resetForm = reactive({ password: "", confirm: "" });
|
||
|
||
function resetPassword(row: AccountRow) {
|
||
resetTarget.value = row;
|
||
resetForm.password = "";
|
||
resetForm.confirm = "";
|
||
resetOpen.value = true;
|
||
}
|
||
|
||
function closeReset() {
|
||
resetOpen.value = false;
|
||
resetTarget.value = null;
|
||
}
|
||
|
||
async function confirmResetPassword() {
|
||
if (!resetForm.password.trim()) {
|
||
message.warning("请输入新密码");
|
||
return;
|
||
}
|
||
if (resetForm.password.length < 8) {
|
||
message.warning("密码至少 8 位");
|
||
return;
|
||
}
|
||
if (resetForm.password !== resetForm.confirm) {
|
||
message.warning("两次输入的密码不一致");
|
||
return;
|
||
}
|
||
if (!resetTarget.value) return;
|
||
try {
|
||
await changePassword({
|
||
username: resetTarget.value.username,
|
||
password: resetForm.password,
|
||
confirmpassword: resetForm.confirm,
|
||
});
|
||
message.success(`已重置 ${resetTarget.value.username} 的密码`);
|
||
closeReset();
|
||
} catch (e: any) {
|
||
message.error(e?.message || "重置密码失败");
|
||
}
|
||
}
|
||
|
||
function removeAccount(row: AccountRow) {
|
||
if (row.username === "admin") {
|
||
message.warning("系统管理员不可删除");
|
||
return;
|
||
}
|
||
Modal.confirm({
|
||
title: `删除账号 ${row.username}?`,
|
||
okType: "danger",
|
||
async onOk() {
|
||
try {
|
||
await deleteUser({ id: row.id }, () => undefined);
|
||
message.success("已删除");
|
||
await loadList();
|
||
} catch (e: any) {
|
||
message.error(e?.message || "删除失败");
|
||
}
|
||
},
|
||
});
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Teleport defer to="#page-topbar-actions">
|
||
<div class="account-topbar-actions">
|
||
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
|
||
<ProtoIcon name="download" :size="14" />导出
|
||
</button>
|
||
<button v-if="canEditAccount()" type="button" class="btn btn-primary" @click="openCreate">
|
||
<ProtoIcon name="plus" :size="14" />新建账号
|
||
</button>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<div class="section-card is-list-page">
|
||
<div class="toolbar">
|
||
<div class="search">
|
||
<ProtoIcon class="search-icon" name="search" :size="16" />
|
||
<input v-model="filters.keyword" placeholder="搜索账号 / 姓名 / 手机号..." />
|
||
</div>
|
||
<select v-model="filters.status">
|
||
<option value="">状态</option>
|
||
<option value="active">启用</option>
|
||
<option value="disabled">禁用</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>账号</th>
|
||
<th>姓名</th>
|
||
<th>绑定角色</th>
|
||
<th>数据范围</th>
|
||
<th>手机号</th>
|
||
<th>最后登录</th>
|
||
<th>状态</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-if="loading">
|
||
<td colspan="8" style="text-align: center; color: #94a3b8">加载中…</td>
|
||
</tr>
|
||
<tr v-else-if="!rows.length">
|
||
<td colspan="8" style="text-align: center; color: #94a3b8">暂无账号</td>
|
||
</tr>
|
||
<tr v-for="row in rows" :key="row.id">
|
||
<td class="account-username">{{ row.username }}</td>
|
||
<td>{{ row.name }}</td>
|
||
<td>
|
||
<span class="tag" :class="row.roleTag">{{ row.roleLabel }}</span>
|
||
</td>
|
||
<td>
|
||
<!-- 与编辑弹窗「数据范围」同源:dashboardSelfOnly -->
|
||
<span
|
||
class="tag"
|
||
:class="row.dashboardSelfOnly ? 'tag-amber' : 'tag-teal'"
|
||
:title="
|
||
row.dashboardSelfOnly
|
||
? '分析看板仅统计本账号企微归属的工单'
|
||
: '分析看板可看权限内全部数据'
|
||
"
|
||
>
|
||
{{ row.dashboardSelfOnly ? "只看自己数据" : "全部数据" }}
|
||
</span>
|
||
</td>
|
||
<td>{{ row.phoneMasked }}</td>
|
||
<td>{{ row.lastLogin }}</td>
|
||
<td>
|
||
<span class="tag" :class="row.status === 'active' ? 'tag-green' : 'tag-rose'">
|
||
{{ row.status === "active" ? "启用" : "禁用" }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div class="account-row-actions">
|
||
<button
|
||
v-if="canEditAccount() && !row.isSuperAdmin"
|
||
class="link-action"
|
||
type="button"
|
||
@click="openEdit(row)"
|
||
>
|
||
编辑
|
||
</button>
|
||
<!-- 超管只隐藏编辑/删除;重置密码仍可用。不展示「不可编辑」占位文案 -->
|
||
<button
|
||
v-if="canEditAccount()"
|
||
class="link-action link-muted"
|
||
type="button"
|
||
@click="resetPassword(row)"
|
||
>
|
||
重置密码
|
||
</button>
|
||
<button
|
||
v-if="canEditAccount() && !row.isSuperAdmin"
|
||
class="link-action link-danger"
|
||
type="button"
|
||
@click="removeAccount(row)"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<AdminPagination
|
||
v-model:page-no="pageNo"
|
||
v-model:page-size="pageSize"
|
||
:total="total"
|
||
unit="个账号"
|
||
:disabled="loading"
|
||
@change="onPageChange"
|
||
/>
|
||
</div>
|
||
|
||
<AdminModal
|
||
v-model:open="modalOpen"
|
||
:title="modalTitle"
|
||
:subtitle="modalSub"
|
||
size="md"
|
||
:ok-text="modalOkText"
|
||
@ok="save"
|
||
>
|
||
<div class="form-row">
|
||
<label class="lbl">账号 <span class="req">*</span></label>
|
||
<input
|
||
v-model="form.username"
|
||
class="field field-mono"
|
||
type="text"
|
||
placeholder="英文字母、数字、下划线"
|
||
:readonly="Boolean(editing)"
|
||
:class="{ 'is-readonly': Boolean(editing) }"
|
||
/>
|
||
</div>
|
||
<div v-if="!editing" class="form-row">
|
||
<label class="lbl">密码 <span class="req">*</span></label>
|
||
<input
|
||
v-model="form.password"
|
||
class="field"
|
||
type="password"
|
||
placeholder="至少 8 位,含字母与数字"
|
||
/>
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="lbl">姓名 <span class="req">*</span></label>
|
||
<input v-model="form.name" class="field" type="text" placeholder="账号持有人姓名" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="lbl">企业微信 ID <span class="req">*</span></label>
|
||
<input
|
||
v-model="form.wecomUserId"
|
||
class="field field-mono"
|
||
type="text"
|
||
placeholder="写入工号字段,用于推送关联"
|
||
/>
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="lbl">手机号</label>
|
||
<input v-model="form.phone" class="field" type="tel" />
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="lbl">绑定角色 <span class="req">*</span></label>
|
||
<select v-model="form.roleId" class="field">
|
||
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="lbl">数据范围</label>
|
||
<label class="account-self-only">
|
||
<input v-model="form.dashboardSelfOnly" type="checkbox" />
|
||
<span>只看自己数据</span>
|
||
<em>勾选后,分析看板仅统计本账号企微归属的工单</em>
|
||
</label>
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="lbl">状态</label>
|
||
<div class="status-toggle">
|
||
<button type="button" :class="{ 'is-selected': form.status === 'active' }" @click="form.status = 'active'">
|
||
启用
|
||
</button>
|
||
<button
|
||
type="button"
|
||
:class="{ 'is-selected': form.status === 'disabled' }"
|
||
@click="form.status = 'disabled'"
|
||
>
|
||
禁用
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</AdminModal>
|
||
|
||
<AdminModal
|
||
v-model:open="resetOpen"
|
||
title="重置密码"
|
||
:subtitle="resetTarget ? `账号:${resetTarget.username}(${resetTarget.name})` : ''"
|
||
size="sm"
|
||
ok-text="确认重置"
|
||
@ok="confirmResetPassword"
|
||
>
|
||
<div class="form-row">
|
||
<label class="lbl">新密码 <span class="req">*</span></label>
|
||
<input
|
||
v-model="resetForm.password"
|
||
class="field"
|
||
type="password"
|
||
placeholder="至少 8 位,含字母与数字"
|
||
autocomplete="new-password"
|
||
/>
|
||
</div>
|
||
<div class="form-row">
|
||
<label class="lbl">确认密码 <span class="req">*</span></label>
|
||
<input
|
||
v-model="resetForm.confirm"
|
||
class="field"
|
||
type="password"
|
||
placeholder="再次输入新密码"
|
||
autocomplete="new-password"
|
||
/>
|
||
</div>
|
||
</AdminModal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.account-topbar-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.account-username {
|
||
font-family: ui-monospace, monospace;
|
||
font-weight: 800;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.account-row-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.account-self-only {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: #0f172a;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.account-self-only em {
|
||
flex: 1 1 100%;
|
||
font-style: normal;
|
||
font-size: 12px;
|
||
color: #64748b;
|
||
}
|
||
</style>
|