Files
inquiry_robot/inquiry-backend/src/views/inquiry/account/index.vue
T

561 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>