Implement student roster login gating

This commit is contained in:
2026-07-07 18:45:07 +08:00
parent 4f11d8632c
commit 29f25dd767
8 changed files with 318 additions and 21 deletions

View File

@@ -13,6 +13,7 @@ import type {
DashboardStats,
KnowledgeItem,
ModelItem,
UserImportResult,
} from "./types/api";
const admin = ref<AdminProfile | null>(null);
@@ -40,6 +41,17 @@ const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; conten
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
]);
const studentForm = reactive({
phone: "",
name: "",
nickname: "",
status: 1,
dailyChatLimit: 100,
expiredAt: "",
});
const studentImportText = ref("手机号,姓名,昵称\n13800000001,张三,三三\n13800000002,李四,");
const studentImportResult = ref<UserImportResult | null>(null);
const knowledgeForm = reactive({
name: "",
feishuSpaceId: "",
@@ -241,6 +253,59 @@ async function saveUser(row: AdminUser) {
await loadCurrentMenu();
}
async function createStudent() {
if (!studentForm.phone || !studentForm.name) {
ElMessage.error("请填写手机号和姓名");
return;
}
await api.createUser({
phone: studentForm.phone,
name: studentForm.name,
nickname: studentForm.nickname || null,
status: studentForm.status,
dailyChatLimit: studentForm.dailyChatLimit,
expiredAt: studentForm.expiredAt || null,
});
ElMessage.success("学员已添加");
resetStudentForm();
await loadCurrentMenu();
}
async function importStudents() {
const students = parseStudentImportText(studentImportText.value);
if (!students.length) {
ElMessage.error("请粘贴学员数据");
return;
}
studentImportResult.value = await api.importUsers(students);
ElMessage.success(
`导入完成:新增 ${studentImportResult.value.created},更新 ${studentImportResult.value.updated},失败 ${studentImportResult.value.failed}`,
);
await loadCurrentMenu();
}
function parseStudentImportText(text: string) {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((line, index) => !(index === 0 && line.includes("手机号")))
.map((line) => {
const [phone = "", name = "", nickname = ""] = line.split(/,|\t/).map((item) => item.trim());
return {
phone,
name,
nickname: nickname || null,
status: 1,
dailyChatLimit: 100,
};
});
}
function resetStudentForm() {
Object.assign(studentForm, { phone: "", name: "", nickname: "", status: 1, dailyChatLimit: 100, expiredAt: "" });
}
async function deleteUser(row: AdminUser) {
if (!window.confirm(`确认删除用户 ${row.phone} 吗?删除后前台将无法继续使用该账号。`)) return;
await api.deleteUser(row.id);
@@ -659,9 +724,55 @@ function buildChatQuery() {
<template v-if="activeMenu === 'users'">
<div class="page-head inline">
<div><h2>用户管理</h2><p>查询用户启用禁用和调整额度</p></div>
<div><h2>用户管理</h2><p>维护学员名单只有名单内启用学员可以登录用户端</p></div>
<el-input v-model="userKeyword" placeholder="手机号或姓名" clearable @change="loadCurrentMenu" />
</div>
<section class="student-tools">
<div class="student-tool-panel">
<h3>手动添加学员</h3>
<div class="student-form-grid">
<el-input v-model="studentForm.phone" placeholder="手机号" />
<el-input v-model="studentForm.name" placeholder="姓名" />
<el-input v-model="studentForm.nickname" placeholder="昵称(可选)" />
<el-input-number v-model="studentForm.dailyChatLimit" :min="0" />
<el-date-picker
v-model="studentForm.expiredAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="有效期(可选)"
/>
<div class="form-switch-row">
<span>启用</span>
<el-switch v-model="studentForm.status" :active-value="1" :inactive-value="0" />
</div>
</div>
<div class="actions compact">
<el-button type="primary" @click="createStudent">添加学员</el-button>
<el-button @click="resetStudentForm">清空</el-button>
</div>
</div>
<div class="student-tool-panel">
<h3>批量导入学员</h3>
<el-input
v-model="studentImportText"
type="textarea"
:rows="5"
resize="none"
placeholder="每行一个学员:手机号,姓名,昵称"
/>
<div class="actions compact">
<el-button type="primary" @click="importStudents">导入学员</el-button>
<span v-if="studentImportResult" class="import-result">
新增 {{ studentImportResult.created }}更新 {{ studentImportResult.updated }}失败 {{ studentImportResult.failed }}
</span>
</div>
<div v-if="studentImportResult?.failures.length" class="import-failures">
<span v-for="failure in studentImportResult.failures.slice(0, 5)" :key="`${failure.row}-${failure.phone}`">
{{ failure.row }} {{ failure.phone || '-' }}{{ failure.reason }}
</span>
</div>
</div>
</section>
<el-table :data="users" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="phone" label="手机号" width="150" />

View File

@@ -10,6 +10,7 @@ import type {
DashboardStats,
KnowledgeItem,
ModelItem,
UserImportResult,
} from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api";
@@ -73,6 +74,10 @@ export const api = {
profile: () => request<AdminProfile>("/admin/profile"),
dashboard: () => request<DashboardStats>("/admin/dashboard"),
users: (keyword = "") => request<AdminUser[]>(`/admin/user/list?keyword=${encodeURIComponent(keyword)}`),
createUser: (payload: Record<string, unknown>) =>
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
importUsers: (students: Record<string, unknown>[]) =>
request<UserImportResult>("/admin/user/import", { method: "POST", body: JSON.stringify({ students }) }),
updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),

View File

@@ -185,6 +185,58 @@ textarea {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.student-tools {
display: grid;
grid-template-columns: minmax(520px, 1fr) minmax(420px, 0.9fr);
gap: 14px;
margin-bottom: 16px;
}
.student-tool-panel {
padding: 16px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #ffffff;
}
.student-tool-panel h3 {
margin: 0 0 12px;
font-size: 16px;
}
.student-form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
align-items: center;
}
.student-form-grid .el-input,
.student-form-grid .el-input-number,
.student-form-grid .el-date-editor {
width: 100%;
}
.actions.compact {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.import-result {
color: #40524b;
font-size: 13px;
}
.import-failures {
display: grid;
gap: 4px;
margin-top: 8px;
color: #b42318;
font-size: 12px;
}
.knowledge-form {
display: grid;
grid-template-columns: minmax(160px, 1.1fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(180px, 1.2fr) 100px 150px;

View File

@@ -32,6 +32,19 @@ export interface AdminUser {
createdAt?: string | null;
}
export interface UserImportFailure {
row: number;
phone: string;
reason: string;
}
export interface UserImportResult {
created: number;
updated: number;
failed: number;
failures: UserImportFailure[];
}
export interface KnowledgeItem {
id: number;
name: string;