443 lines
13 KiB
Vue
443 lines
13 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";
|
||
|
||
const rows = ref<AccountRow[]>([]);
|
||
const loading = ref(false);
|
||
const roleOptions = ref<{ value: string; label: string; tag: 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: "",
|
||
});
|
||
|
||
const filtered = computed(() =>
|
||
rows.value.filter((r) => {
|
||
const kw = filters.keyword.trim().toLowerCase();
|
||
if (kw && !`${r.username}${r.name}${r.wecomUserId}${r.phoneMasked}`.toLowerCase().includes(kw)) return false;
|
||
if (filters.status && r.status !== filters.status) return false;
|
||
return true;
|
||
}),
|
||
);
|
||
|
||
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),
|
||
}));
|
||
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 res: any = await listUsers({ pageNo: 1, pageSize: 200 });
|
||
const records = res?.records || res?.result?.records || [];
|
||
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;
|
||
}
|
||
} catch {
|
||
/* 无角色仍展示账号 */
|
||
}
|
||
mapped.push(mapUserToAccount(u, roleId, roleLabel, roleTag));
|
||
}
|
||
rows.value = mapped;
|
||
} catch (e: any) {
|
||
message.error(e?.message || "加载账号失败");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => loadList());
|
||
|
||
function openCreate() {
|
||
editing.value = null;
|
||
Object.assign(form, {
|
||
username: "",
|
||
name: "",
|
||
wecomUserId: "",
|
||
roleId: roleOptions.value[0]?.value || "",
|
||
phone: "",
|
||
status: "active",
|
||
password: "",
|
||
});
|
||
modalOpen.value = true;
|
||
}
|
||
|
||
function openEdit(row: AccountRow) {
|
||
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: "",
|
||
});
|
||
modalOpen.value = true;
|
||
}
|
||
|
||
async function save() {
|
||
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,
|
||
};
|
||
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 || "删除失败");
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
watch(
|
||
() => filters.keyword,
|
||
() => {
|
||
/* 本地过滤即可,账号量通常很小 */
|
||
},
|
||
);
|
||
</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 type="button" class="btn btn-primary" @click="openCreate">
|
||
<ProtoIcon name="plus" :size="14" />新建账号
|
||
</button>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<div class="section-card">
|
||
<div class="toolbar">
|
||
<div class="search">
|
||
<ProtoIcon class="search-icon" name="search" :size="16" />
|
||
<input v-model="filters.keyword" placeholder="搜索账号、姓名、企微ID、手机号..." />
|
||
</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>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-if="loading">
|
||
<td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td>
|
||
</tr>
|
||
<tr v-else-if="!filtered.length">
|
||
<td colspan="7" style="text-align: center; color: #94a3b8">暂无账号</td>
|
||
</tr>
|
||
<tr v-for="row in filtered" :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>{{ 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 class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
||
<button class="link-action link-muted" type="button" @click="resetPassword(row)">
|
||
重置密码
|
||
</button>
|
||
<button
|
||
v-if="row.username !== 'admin'"
|
||
class="link-action link-danger"
|
||
type="button"
|
||
@click="removeAccount(row)"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="pagination">
|
||
<span>
|
||
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个账号
|
||
</span>
|
||
<div class="pages">
|
||
<button class="is-active" type="button">1</button>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
<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;
|
||
}
|
||
</style>
|