This commit is contained in:
hero920103 committed 2026-11-13 20:26:16 +08:00
1 parent b6b528490c
commit 4ab63e77ed
28 files changed
+1858 -695

No files matched your search

+3 -1
View File
@@ -79,4 +79,6 @@ go/*.pem
.mimocode/package-lock.json .mimocode/package-lock.json
.mimocode/bun.lock .mimocode/bun.lock
docs/sql/ docs/sql/
go/update-server-outside.bat
+111 -102
View File
@@ -1,102 +1,111 @@
import request from '@/utils/request'; import request from '@/utils/request';
//获取所有用户信息 // 将所选租户用户与人员档案进行比对,有差异时同步人员信息
export function getAllUsers(params) { export function syncTenantUsersToEmployees(userIds) {
return request({ return request({
url: '/backend/getAllUsers', url: "/backend/syncTenantUsersToEmployees",
method: 'get', method: "post",
params, data: { user_ids: userIds },
}); });
} }
//获取租户用户 //获取所有用户信息
export function getTenantUsers(tenantId) { export function getAllUsers(params) {
return request({ return request({
url: `/backend/getTenantUsers/${tenantId}`, url: '/backend/getAllUsers',
method: 'get', method: 'get',
}); params,
} });
}
// 获取用户信息
// params.by === 'id':按 yz_system_tenant_user 主键精确查询(用户管理页传列表行 id 时必须携带); //获取租户用户
// 缺省时后端兼容 identity_id / 老 uid(个人中心等场景) export function getTenantUsers(tenantId) {
export function getUserInfo(userId, params) { return request({
return request({ url: `/backend/getTenantUsers/${tenantId}`,
url: `/backend/getUserInfo/${userId}`, method: 'get',
method: 'get', });
params, }
});
} // 获取用户信息
// params.by === 'id':按 yz_system_tenant_user 主键精确查询(用户管理页传列表行 id 时必须携带);
// 添加用户 // 缺省时后端兼容 identity_id / 老 uid(个人中心等场景)
export function addUser(data) { export function getUserInfo(userId, params) {
return request({ return request({
url: '/backend/addUser', url: `/backend/getUserInfo/${userId}`,
method: 'post', method: 'get',
data, params,
}); });
} }
// 编辑用户信息(params.by === 'id' 时后端按主键精确匹配) // 添加用户
export function editUser(userId, data, params) { export function addUser(data) {
return request({ return request({
url: `/backend/editUser/${userId}`, url: '/backend/addUser',
method: 'post', method: 'post',
data, data,
params, });
}); }
}
// 编辑用户信息(params.by === 'id' 时后端按主键精确匹配)
// 更新用户信息(编辑用户的别名) export function editUser(userId, data, params) {
export function updateUserInfo(userId, data) { return request({
return editUser(userId, data); url: `/backend/editUser/${userId}`,
} method: 'post',
data,
// 更新个人资料 params,
export function updateUserProfile(userId, data) { });
return editUser(userId, data); }
}
// 更新用户信息(编辑用户的别名)
// 绑定/更换手机号 export function updateUserInfo(userId, data) {
export function bindPhone(userId, phone) { return editUser(userId, data);
return editUser(userId, { phone }); }
}
// 更新个人资料
// 绑定/更换邮箱 export function updateUserProfile(userId, data) {
export function bindEmail(userId, email) { return editUser(userId, data);
return editUser(userId, { email }); }
}
// 绑定/更换手机号
// 上传并设置当前登录用户头像(一步到位:上传文件 + 写入认证中心头像) export function bindPhone(userId, phone) {
// formData 需包含 file 字段;返回 data.url 为头像地址 return editUser(userId, { phone });
export function uploadAvatar(formData) { }
return request({
url: '/backend/uploadavatar', // 绑定/更换邮箱
method: 'post', export function bindEmail(userId, email) {
data: formData, return editUser(userId, { email });
// 上传不设超时;不要手写 Content-Type,需浏览器生成 multipart boundary }
timeout: 0,
}); // 上传并设置当前登录用户头像(一步到位:上传文件 + 写入认证中心头像)
} // formData 需包含 file 字段;返回 data.url 为头像地址
export function uploadAvatar(formData) {
// 删除用户(params.by === 'id' 时后端按主键精确匹配) return request({
export function deleteUser(userId, params) { url: '/backend/uploadavatar',
return request({ method: 'post',
url: `/backend/deleteUser/${userId}`, data: formData,
method: 'delete', // 上传不设超时;不要手写 Content-Type,需浏览器生成 multipart boundary
params, timeout: 0,
}); });
} }
// 修改密码;byID=true 表示 userId 是用户管理列表行主键,后端按主键精确匹配 // 删除用户(params.by === 'id' 时后端按主键精确匹配)
export function changePassword(userId, data, byID = false) { export function deleteUser(userId, params) {
return request({ return request({
url: '/backend/changePassword', url: `/backend/deleteUser/${userId}`,
method: 'post', method: 'delete',
data: { params,
id: userId, });
password: data.newPassword, }
by: byID ? 'id' : undefined,
}, // 修改密码;byID=true 表示 userId 是用户管理列表行主键,后端按主键精确匹配
}); export function changePassword(userId, data, byID = false) {
} return request({
url: '/backend/changePassword',
method: 'post',
data: {
id: userId,
password: data.newPassword,
by: byID ? 'id' : undefined,
},
});
}
@@ -158,16 +158,20 @@
</template> </template>
</div> </div>
<div <div
v-if="msg.role === 'assistant' && (msg.tokens || msg.durationMs || msg.firstTokenMs)" v-if="msg.role === 'assistant' && (msg.tokens || msg.durationMs || msg.firstTokenMs || msg.model || msg.providerName || msg.provider_name)"
class="message-meta" class="message-meta"
> >
<span v-if="msg.firstTokenMs" class="meta-item">首字 {{ (msg.firstTokenMs / 1000).toFixed(1) }}s</span> <span v-if="msg.firstTokenMs" class="meta-item">首字 {{ (msg.firstTokenMs / 1000).toFixed(1) }}s</span>
<span v-if="msg.firstTokenMs" class="meta-dot">·</span> <span v-if="msg.firstTokenMs" class="meta-dot">·</span>
<span class="meta-item">{{ formatDuration(msg.durationMs) }}</span> <span v-if="msg.providerName || msg.provider_name || msg.model" class="meta-item">
<span class="meta-dot">·</span> {{ msg.providerName || msg.provider_name || '未知厂商' }}<template v-if="msg.model"> · {{ msg.model }}</template>
<span class="meta-item">{{ msg.tokens }} tokens</span> </span>
<span v-if="msg.model" class="meta-dot">·</span> <span v-if="msg.tokens || msg.durationMs" class="meta-dot">·</span>
<span v-if="msg.model" class="meta-item">{{ msg.model }}</span> <span v-if="msg.durationMs" class="meta-item">用时 {{ formatDuration(msg.durationMs) }}</span>
<span v-if="msg.durationMs && msg.tokens" class="meta-dot">·</span>
<span v-if="msg.tokens" class="meta-item">{{ formatTokenRate(msg.tokens, msg.durationMs) }} token/s</span>
<span v-if="msg.tokens" class="meta-dot">·</span>
<span v-if="msg.tokens" class="meta-item">{{ msg.tokens }} tokens</span>
</div> </div>
<div class="message-actions"> <div class="message-actions">
<el-tooltip content="复制" placement="top"> <el-tooltip content="复制" placement="top">
@@ -369,6 +373,17 @@ const currentModels = computed(() => {
return p && p.models ? p.models : [] return p && p.models ? p.models : []
}) })
const currentProvider = computed(() =>
providerList.value.find((item) => item.id === selectedProviderId.value) || null
)
function formatTokenRate(tokens, durationMs) {
const count = Number(tokens || 0)
const elapsed = Number(durationMs || 0)
if (!count || !elapsed) return '—'
return (count / (elapsed / 1000)).toFixed(1)
}
onMounted(() => { onMounted(() => {
fetchProviders() fetchProviders()
fetchDefaultPreset() fetchDefaultPreset()
@@ -475,7 +490,11 @@ async function fetchMessages(sessionId) {
try { try {
const res = await getChatMessageList(sessionId) const res = await getChatMessageList(sessionId)
if (res.code === 200) { if (res.code === 200) {
messages.value = res.data.list || [] messages.value = (res.data.list || []).map((msg) => ({
...msg,
durationMs: msg.durationMs ?? msg.duration_ms ?? 0,
providerName: msg.providerName || msg.provider_name || '',
}))
scrollToBottom() scrollToBottom()
} }
} catch (e) { } catch (e) {
@@ -748,9 +767,10 @@ async function doSend(content, images = []) {
scrollToBottom() scrollToBottom()
} else if (eventType === 'done') { } else if (eventType === 'done') {
aiMsg.id = parsed.message_id aiMsg.id = parsed.message_id
if (parsed.tokens) aiMsg.tokens = parsed.tokens if (parsed.tokens !== undefined) aiMsg.tokens = parsed.tokens
if (parsed.duration_ms) aiMsg.durationMs = parsed.duration_ms if (parsed.duration_ms !== undefined) aiMsg.durationMs = parsed.duration_ms
if (parsed.model) aiMsg.model = parsed.model if (parsed.model) aiMsg.model = parsed.model
aiMsg.providerName = parsed.provider_name || currentProvider.value?.name || ''
} else if (eventType === 'error') { } else if (eventType === 'error') {
streamError = new Error(parsed.error || 'AI调用失败') streamError = new Error(parsed.error || 'AI调用失败')
} }
@@ -322,7 +322,7 @@
> >
确认到账 确认到账
</el-button> </el-button>
<div class="toolbar-right"> <!-- <div class="toolbar-right">
<el-button <el-button
v-if="isEditable" v-if="isEditable"
type="primary" type="primary"
@@ -331,7 +331,7 @@
> >
{{ isDraft ? "提交审核" : "重新提交" }} {{ isDraft ? "提交审核" : "重新提交" }}
</el-button> </el-button>
</div> </div> -->
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
@@ -13,6 +13,9 @@
<el-form-item label="账号" prop="account"> <el-form-item label="账号" prop="account">
<el-input v-model="form.account" :disabled="isEdit" placeholder="留空时按组织设置自动生成" /> <el-input v-model="form.account" :disabled="isEdit" placeholder="留空时按组织设置自动生成" />
<span v-if="isEdit" style="font-size: 12px; color: #909399; margin-top: 4px; display: block">
账号与用户管理保持一致,不可直接修改
</span>
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item label="密码" prop="password">
@@ -3,7 +3,8 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h2>{{ labels.employeeLabel }}管理</h2> <h2>{{ labels.employeeLabel }}管理</h2>
<p>{{ labels.name }}与其它模块共用同一份{{ labels.employeeLabel }}数据</p> <p v-if="module === 'basic'">人员数据与「用户管理」自动匹配,由系统根据用户账号自动生成</p>
<p v-else>{{ labels.name }}与其它模块共用同一份{{ labels.employeeLabel }}数据</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<el-button :icon="Refresh" @click="loadData">刷新</el-button> <el-button :icon="Refresh" @click="loadData">刷新</el-button>
@@ -15,7 +16,15 @@
> >
批量调岗({{ selectedRows.length }}) 批量调岗({{ selectedRows.length }})
</el-button> </el-button>
<el-button type="primary" :icon="Plus" @click="editDialogRef?.open(null)"> <el-button
v-if="module === 'basic'"
type="primary"
plain
@click="goToUsers"
>
前往用户管理
</el-button>
<el-button type="primary" :icon="Plus" @click="handleAddEmployee">
新增{{ labels.employeeLabel }} 新增{{ labels.employeeLabel }}
</el-button> </el-button>
</div> </div>
@@ -89,7 +98,7 @@
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="viewDialogRef?.open(row)">查看</el-button> <el-button link type="primary" size="small" @click="viewDialogRef?.open(row)">查看</el-button>
<el-button link type="primary" size="small" @click="editDialogRef?.open(row)">编辑</el-button> <el-button link type="primary" size="small" @click="editDialogRef?.open(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="editDialogRef?.open(row, { copy: true })">复制</el-button> <el-button v-if="module !== 'basic'" link type="warning" size="small" @click="editDialogRef?.open(row, { copy: true })">复制</el-button>
<el-button link type="primary" size="small" @click="moveDialogRef?.open([row])">调岗</el-button> <el-button link type="primary" size="small" @click="moveDialogRef?.open([row])">调岗</el-button>
<el-button link type="warning" size="small" @click="handleResetPassword(row)">重置密码</el-button> <el-button link type="warning" size="small" @click="handleResetPassword(row)">重置密码</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button> <el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
@@ -141,6 +150,8 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Refresh, Search } from '@element-plus/icons-vue'; import { Plus, Refresh, Search } from '@element-plus/icons-vue';
import EmployeeEditDialog from './EmployeeEditDialog.vue'; import EmployeeEditDialog from './EmployeeEditDialog.vue';
@@ -167,6 +178,33 @@ const editDialogRef = ref();
const viewDialogRef = ref(); const viewDialogRef = ref();
const moveDialogRef = ref(); const moveDialogRef = ref();
const router = useRouter();
const goToUsers = () => {
router.push('/basicSettings/users');
};
const handleAddEmployee = () => {
if (props.module === 'basic') {
ElMessageBox.confirm(
'人员与「用户管理」保持一致,系统在添加用户后会自动生成对应人员档案。\n\n如需添加新人员,请前往「用户管理」添加用户账号。',
'新增人员说明',
{
confirmButtonText: '前往用户管理',
cancelButtonText: '取消',
type: 'info',
}
)
.then(() => {
router.push('/basicSettings/users');
})
.catch(() => {});
return;
}
editDialogRef.value?.open(null);
};
const rows = ref([]); const rows = ref([]);
const selectedRows = ref([]); const selectedRows = ref([]);
const loading = ref(false); const loading = ref(false);
@@ -1,6 +1,15 @@
<template> <template>
<el-drawer v-model="visible" :title="dialogTitle" width="500px"> <el-drawer v-model="visible" :title="dialogTitle" width="500px">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px"> <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-alert
v-if="isAdd"
type="info"
:closable="false"
show-icon
title="添加用户后,系统将自动在「人员管理」中生成对应员工档案,可在人员管理中配置部门与职位。"
style="margin-bottom: 16px"
/>
<div class="form-title">账号信息</div> <div class="form-title">账号信息</div>
<!-- 账号 --> <!-- 账号 -->
<el-form-item label="账号"> <el-form-item label="账号">
+442 -367
View File
@@ -1,367 +1,442 @@
<template> <template>
<div class="container-box"> <div class="container-box">
<div class="header-bar"> <div class="header-bar">
<h2> <h2>
用户管理 用户管理
<el-tag v-if="quotaInfo" size="small" :type="quotaFull ? 'danger' : 'info'" effect="plain" <el-tag v-if="quotaInfo" size="small" :type="quotaFull ? 'danger' : 'info'" effect="plain"
class="quota-tag"> class="quota-tag">
用户数 {{ quotaInfo.used }} / {{ quotaInfo.quota }} 用户数 {{ quotaInfo.used }} / {{ quotaInfo.quota }}
</el-tag> </el-tag>
</h2> </h2>
<div class="header-actions"> <div class="header-actions">
<el-button type="primary" @click="handleAddUser"> <el-button
<el-icon> type="success"
<Plus /> :disabled="!selectedUsers.length || syncingEmployees"
</el-icon> :loading="syncingEmployees"
添加用户 @click="handleSyncEmployees"
</el-button> >
<el-button @click="refresh"> 同步员工{{ selectedUsers.length ? `(${selectedUsers.length})` : '' }}
<el-icon> </el-button>
<Refresh /> <el-button type="primary" @click="handleAddUser">
</el-icon> <el-icon>
刷新 <Plus />
</el-button> </el-icon>
</div> 添加用户
</div> </el-button>
<el-button @click="refresh">
<el-divider></el-divider> <el-icon>
<Refresh />
<el-alert v-if="quotaFull" type="warning" show-icon :closable="false" class="quota-alert" </el-icon>
title="用户数已达上限,新增账号前请联系平台管理员增购用户数" /> 刷新
</el-button>
<div class="filter-bar"> </div>
<el-form inline> </div>
<el-form-item label="租户筛选">
<el-select v-model="selectedTenantId" style="width: 220px" disabled> <el-divider></el-divider>
<el-option :label="`当前租户(ID: ${selectedTenantId || '-'})`" :value="selectedTenantId" />
</el-select> <el-alert v-if="quotaFull" type="warning" show-icon :closable="false" class="quota-alert"
</el-form-item> title="用户数已达上限,新增账号前请联系平台管理员增购用户数" />
</el-form>
</div> <el-alert
type="info"
<!-- 用户列表表格 --> show-icon
<el-table :data="users" style="width: 100%" v-loading="loading"> :closable="false"
<el-table-column prop="id" label="ID" align="center" fixed="left" /> title="人员联动提示:新增用户后系统会自动生成员工;存量用户请勾选后点击「同步员工」,系统会按当前租户比对,只补建或更新有差异的员工资料。"
<el-table-column prop="name" label="姓名" min-width="120" align="center"> style="margin-bottom: 16px"
<template #default="scope"> />
<span class="name-link" @click="handlePreview(scope.row)">{{ scope.row.name }}</span>
</template>
</el-table-column> <div class="filter-bar">
<el-table-column prop="group_id" label="角色" align="center"> <el-form inline>
<template #default="scope"> <el-form-item label="租户筛选">
<el-tag :type="getRoleTagType(scope.row.group_id)" size="small"> <el-select v-model="selectedTenantId" style="width: 220px" disabled>
{{ getRoleTagText(roles, scope.row.group_id) }} <el-option :label="`当前租户(ID: ${selectedTenantId || '-'})`" :value="selectedTenantId" />
</el-tag> </el-select>
</template> </el-form-item>
</el-table-column> </el-form>
<el-table-column prop="account" label="账号" min-width="180" align="center" /> </div>
<el-table-column prop="phone" label="手机号" min-width="180" align="center" />
<el-table-column prop="qq" label="QQ" min-width="180" align="center" /> <!-- 用户列表表格 -->
<el-table-column prop="last_login_ip" label="最后登录IP" width="120" align="center" /> <el-table
<el-table-column prop="login_count" label="登陆次数" width="120" align="center" /> ref="userTableRef"
<el-table-column prop="status" label="状态" width="80" align="center"> :data="users"
<template #default="scope"> style="width: 100%"
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">{{ v-loading="loading"
scope.row.status === 1 ? "启用" : "禁用" row-key="id"
}}</el-tag> @selection-change="handleSelectionChange"
</template> >
</el-table-column> <el-table-column type="selection" width="48" fixed="left" />
<el-table-column label="操作" width="240" align="center" fixed="right"> <el-table-column prop="id" label="ID" align="center" fixed="left" />
<template #default="scope"> <el-table-column prop="name" label="姓名" min-width="120" align="center">
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button> <template #default="scope">
<el-button size="small" type="warning" @click="handleChangePassword(scope.row)"> <span class="name-link" @click="handlePreview(scope.row)">{{ scope.row.name }}</span>
修改密码 </template>
</el-button> </el-table-column>
<el-button v-if="scope.row.username !== 'admin' && scope.row.id !== 1" size="small" type="danger" <el-table-column prop="group_id" label="角色" align="center">
@click="handleDelete(scope.row)">删除</el-button> <template #default="scope">
</template> <el-tag :type="getRoleTagType(scope.row.group_id)" size="small">
</el-table-column> {{ getRoleTagText(roles, scope.row.group_id) }}
</el-table> </el-tag>
</template>
<!-- 分页 --> </el-table-column>
<div class="pagination-bar"> <el-table-column prop="account" label="账号" min-width="180" align="center" />
<el-pagination :current-page="page" :page-size="pageSize" :total="total" @current-change="handlePageChange" <el-table-column prop="phone" label="手机号" min-width="180" align="center" />
layout="total, prev, pager, next" /> <el-table-column prop="qq" label="QQ" min-width="180" align="center" />
</div> <el-table-column prop="last_login_ip" label="最后登录IP" width="120" align="center" />
<el-table-column prop="login_count" label="登陆次数" width="120" align="center" />
<!-- 编辑用户对话框组件 --> <el-table-column prop="status" label="状态" width="80" align="center">
<UserEditDialog ref="userEditRef" :modelValue="editDialogVisible" @update:modelValue="editDialogVisible = $event" <template #default="scope">
:is-edit="isEdit" @submit="handleEditSuccess" @close="editDialogVisible = false" /> <el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">{{
scope.row.status === 1 ? "启用" : "禁用"
<!-- 修改密码对话框组件 --> }}</el-tag>
<ChangePasswordDialog ref="changePasswordRef" :modelValue="passwordDialogVisible" </template>
@update:modelValue="passwordDialogVisible = $event" :user-id="currentUserId" @submit="handlePasswordChangeSuccess" </el-table-column>
@close="passwordDialogVisible = false" /> <el-table-column label="操作" width="240" align="center" fixed="right">
<template #default="scope">
<!-- 预览用户对话框组件 --> <el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<PreviewDialog ref="previewDialogRef" :modelValue="previewDialogVisible" <el-button size="small" type="warning" @click="handleChangePassword(scope.row)">
@update:modelValue="previewDialogVisible = $event" :user="currentUser" /> 修改密码
</div> </el-button>
</template> <el-button v-if="scope.row.username !== 'admin' && scope.row.id !== 1" size="small" type="danger"
@click="handleDelete(scope.row)">删除</el-button>
<script setup lang="ts"> </template>
import { ref, computed, onMounted } from "vue"; </el-table-column>
import { ElMessage, ElMessageBox } from "element-plus"; </el-table>
import { Plus, Refresh } from "@element-plus/icons-vue";
import { getTenantUsers, deleteUser } from "@/api/user"; <!-- 分页 -->
import { getTenantQuotaInfo } from "@/api/tenant"; <div class="pagination-bar">
import { getAllRoles } from "@/api/role"; <el-pagination :current-page="page" :page-size="pageSize" :total="total" @current-change="handlePageChange"
import { useAuthStore } from "@/stores/auth"; layout="total, prev, pager, next" />
import UserEditDialog from "./components/userEdit.vue"; </div>
import ChangePasswordDialog from "./components/changePassword.vue";
import PreviewDialog from "./components/preview.vue"; <!-- 编辑用户对话框组件 -->
<UserEditDialog ref="userEditRef" :modelValue="editDialogVisible" @update:modelValue="editDialogVisible = $event"
interface User { :is-edit="isEdit" @submit="handleEditSuccess" @close="editDialogVisible = false" />
id: number;
tid?: number; <!-- 修改密码对话框组件 -->
account: string; <ChangePasswordDialog ref="changePasswordRef" :modelValue="passwordDialogVisible"
name: string; @update:modelValue="passwordDialogVisible = $event" :user-id="currentUserId" @submit="handlePasswordChangeSuccess"
phone: string; @close="passwordDialogVisible = false" />
qq: string;
sex: number; <!-- 预览用户对话框组件 -->
group_id: number; <PreviewDialog ref="previewDialogRef" :modelValue="previewDialogVisible"
status: number; @update:modelValue="previewDialogVisible = $event" :user="currentUser" />
last_login_ip: string; </div>
login_count: number; </template>
create_time: string;
update_time: string; <script setup lang="ts">
} import { ref, computed, onMounted } from "vue";
import type { ElTable } from "element-plus";
interface Role { import { ElMessage, ElMessageBox } from "element-plus";
id: number; import { Plus, Refresh } from "@element-plus/icons-vue";
name: string; import { getTenantUsers, deleteUser, syncTenantUsersToEmployees } from "@/api/user";
status?: number; import { getTenantQuotaInfo } from "@/api/tenant";
rights?: string; import { getAllRoles } from "@/api/role";
create_time?: string; import { useAuthStore } from "@/stores/auth";
update_time?: string; import UserEditDialog from "./components/userEdit.vue";
} import ChangePasswordDialog from "./components/changePassword.vue";
import PreviewDialog from "./components/preview.vue";
const page = ref(1);
const pageSize = ref(10); interface User {
const total = ref(0); id: number;
tid?: number;
const users = ref<User[]>([]); account: string;
const roles = ref<Role[]>([]); name: string;
const loading = ref(false); phone: string;
const authStore = useAuthStore(); qq: string;
const selectedTenantId = ref<number>(Number(authStore.user.tid || 0)); sex: number;
group_id: number;
// 组件引用 status: number;
const userEditRef = ref(); last_login_ip: string;
const changePasswordRef = ref(); login_count: number;
const previewDialogRef = ref(); create_time: string;
update_time: string;
// 编辑/密码对话框状态 }
const editDialogVisible = ref(false);
const passwordDialogVisible = ref(false); interface Role {
const previewDialogVisible = ref(false); id: number;
const editDialogTitle = ref("添加用户"); name: string;
const isEdit = ref(false); status?: number;
const currentUserId = ref<number | undefined>(undefined); rights?: string;
const currentUser = ref<User | undefined>(undefined); create_time?: string;
update_time?: string;
// 用户数配额(由平台端配置:默认 20,可增购) }
const quotaInfo = ref<any>(null);
const quotaFull = computed( const page = ref(1);
() => const pageSize = ref(10);
!!quotaInfo.value && const total = ref(0);
Number(quotaInfo.value.quota) > 0 &&
Number(quotaInfo.value.used) >= Number(quotaInfo.value.quota) const users = ref<User[]>([]);
); const roles = ref<Role[]>([]);
const loading = ref(false);
const fetchQuotaInfo = async () => { const authStore = useAuthStore();
try { const selectedTenantId = ref<number>(Number(authStore.user.tid || 0));
const res = await getTenantQuotaInfo();
if (res?.code === 200) { // 组件引用
quotaInfo.value = res.data || null; const userEditRef = ref();
} const changePasswordRef = ref();
} catch { const previewDialogRef = ref();
quotaInfo.value = null;
} // 编辑/密码对话框状态
}; const editDialogVisible = ref(false);
const userTableRef = ref<InstanceType<typeof ElTable>>();
//刷新 const selectedUsers = ref<User[]>([]);
const refresh = async () => { const syncingEmployees = ref(false);
await fetchUsers();
fetchQuotaInfo(); const passwordDialogVisible = ref(false);
}; const previewDialogVisible = ref(false);
const editDialogTitle = ref("添加用户");
// 获取角色列表 const isEdit = ref(false);
const fetchRoles = async () => { const currentUserId = ref<number | undefined>(undefined);
try { const currentUser = ref<User | undefined>(undefined);
const res = await getAllRoles();
roles.value = res.data || []; // 用户数配额(由平台端配置:默认 20,可增购)
} catch (e) { const quotaInfo = ref<any>(null);
roles.value = []; const quotaFull = computed(
} () =>
}; !!quotaInfo.value &&
Number(quotaInfo.value.quota) > 0 &&
// 获取角色tag状态 Number(quotaInfo.value.used) >= Number(quotaInfo.value.quota)
function getRoleTagType(group_id: number): string { );
const typeMap: Record<number, string> = {
1: "primary", const handleSelectionChange = (rows: User[]) => {
2: "success", selectedUsers.value = rows;
3: "warning", };
4: "danger",
}; const handleSyncEmployees = async () => {
return typeMap[group_id] || "primary"; if (!selectedUsers.value.length || syncingEmployees.value) return;
} try {
await ElMessageBox.confirm(
// 获取角色tag文本 `将按当前租户核对所选 ${selectedUsers.value.length} 个用户的员工档案;只新增缺失记录或更新存在差异的资料。已配置的部门、职位等员工专属信息会保留。继续同步吗?`,
function getRoleTagText(roles: Role[] | undefined, group_id: number): string { "同步员工",
if (!roles || !Array.isArray(roles)) { { confirmButtonText: "开始同步", cancelButtonText: "取消", type: "warning" }
return "未知"; );
} } catch {
return roles.find((role) => role.id === group_id)?.name || "未知"; return;
} }
// 获取用户列表 syncingEmployees.value = true;
const fetchUsers = async () => { try {
loading.value = true; const res = await syncTenantUsersToEmployees(selectedUsers.value.map((user) => user.id));
try { if (res?.code !== 200) throw new Error(res?.msg || "同步失败");
if (!selectedTenantId.value) { const result = res.data || {};
users.value = []; const successCount =
total.value = 0; Number(result.created || 0) + Number(result.updated || 0) + Number(result.unchanged || 0);
return; const failedCount = Number(result.failed || 0);
} const summary = `同步完成:成功 ${successCount} 人,失败 ${failedCount} 人`;
const res = await getTenantUsers(selectedTenantId.value); const errors = Array.isArray(result.errors) ? result.errors.filter(Boolean) : [];
users.value = res?.data?.list || []; if (errors.length) {
total.value = Number(res?.data?.total || 0); await ElMessageBox.alert(errors.join("\n"), summary, {
} catch (e) { confirmButtonText: "知道了",
users.value = []; type: "warning",
total.value = 0; });
} finally { } else {
loading.value = false; ElMessage.success(summary);
} }
}; userTableRef.value?.clearSelection();
} catch (error: any) {
// 添加用户 ElMessage.error(error?.response?.data?.msg || error?.message || "同步失败");
const handleAddUser = () => { } finally {
// 用户数达到上限时不允许再新增,提示联系平台增购 syncingEmployees.value = false;
if (quotaFull.value) { }
ElMessageBox.alert( };
`当前租户用户数已达上限(${quotaInfo.value.used}/${quotaInfo.value.quota}),请联系平台管理员增购用户数。`,
"用户数已达上限",
{ type: "warning", confirmButtonText: "我知道了" } const fetchQuotaInfo = async () => {
).catch(() => {}); try {
return; const res = await getTenantQuotaInfo();
} if (res?.code === 200) {
isEdit.value = false; quotaInfo.value = res.data || null;
editDialogVisible.value = true; }
if (userEditRef.value) { } catch {
userEditRef.value.open(); quotaInfo.value = null;
} }
}; };
// 编辑用户 //刷新
const handleEdit = (user: User) => { const refresh = async () => {
isEdit.value = true; await fetchUsers();
editDialogVisible.value = true; fetchQuotaInfo();
if (userEditRef.value) { };
userEditRef.value.open(user);
} // 获取角色列表
}; const fetchRoles = async () => {
try {
// 预览用户 const res = await getAllRoles();
const handlePreview = (user: User) => { roles.value = res.data || [];
currentUser.value = user; } catch (e) {
previewDialogVisible.value = true; roles.value = [];
if (previewDialogRef.value) { }
previewDialogRef.value.open(user); };
}
}; // 获取角色tag状态
function getRoleTagType(group_id: number): string {
//修改密码 const typeMap: Record<number, string> = {
const handleChangePassword = async (user: User) => { 1: "primary",
changePasswordRef.value.open(user.id, user.account); 2: "success",
passwordDialogVisible.value = true; 3: "warning",
currentUserId.value = user.id; 4: "danger",
}; };
return typeMap[group_id] || "primary";
// 编辑成功回调 }
const handleEditSuccess = () => {
editDialogVisible.value = false; // 获取角色tag文本
ElMessage.success(isEdit.value ? "编辑成功" : "添加成功"); function getRoleTagText(roles: Role[] | undefined, group_id: number): string {
fetchUsers(); if (!roles || !Array.isArray(roles)) {
fetchQuotaInfo(); return "未知";
}; }
return roles.find((role) => role.id === group_id)?.name || "未知";
// 密码修改成功回调 }
const handlePasswordChangeSuccess = () => {
passwordDialogVisible.value = false; // 获取用户列表
ElMessage.success("密码修改成功"); const fetchUsers = async () => {
}; loading.value = true;
try {
// 分页改变 if (!selectedTenantId.value) {
const handlePageChange = (val: number) => { users.value = [];
page.value = val; total.value = 0;
fetchUsers(); return;
}; }
const res = await getTenantUsers(selectedTenantId.value);
// 删除用户 users.value = res?.data?.list || [];
const handleDelete = async (user: User) => { selectedUsers.value = [];
ElMessageBox.confirm("确认删除该用户?", "提示", { userTableRef.value?.clearSelection();
confirmButtonText: "确定", total.value = Number(res?.data?.total || 0);
cancelButtonText: "取消", } catch (e) {
type: "warning", users.value = [];
}).then(async () => { total.value = 0;
try { } finally {
await deleteUser(user.id, { by: "id" }); loading.value = false;
ElMessage.success("删除成功"); }
fetchUsers(); };
fetchQuotaInfo();
} catch (e) { // 添加用户
ElMessage.error("删除失败"); const handleAddUser = () => {
} // 用户数达到上限时不允许再新增,提示联系平台增购
}); if (quotaFull.value) {
}; ElMessageBox.alert(
`当前租户用户数已达上限(${quotaInfo.value.used}/${quotaInfo.value.quota}),请联系平台管理员增购用户数。`,
onMounted(async () => { "用户数已达上限",
selectedTenantId.value = Number(authStore.user.tid || 0); { type: "warning", confirmButtonText: "我知道了" }
fetchUsers(); ).catch(() => {});
fetchRoles(); return;
fetchQuotaInfo(); }
}); isEdit.value = false;
</script> editDialogVisible.value = true;
if (userEditRef.value) {
<style lang="less" scoped> userEditRef.value.open();
.filter-bar { }
margin-bottom: 12px; };
}
// 编辑用户
.quota-tag { const handleEdit = (user: User) => {
margin-left: 10px; isEdit.value = true;
vertical-align: middle; editDialogVisible.value = true;
} if (userEditRef.value) {
userEditRef.value.open(user);
.quota-alert { }
margin-bottom: 12px; };
}
// 预览用户
.card-header { const handlePreview = (user: User) => {
display: flex; currentUser.value = user;
justify-content: space-between; previewDialogVisible.value = true;
align-items: center; if (previewDialogRef.value) {
previewDialogRef.value.open(user);
span { }
font-size: 1.4rem; };
font-weight: 700;
} //修改密码
} const handleChangePassword = async (user: User) => {
changePasswordRef.value.open(user.id, user.account);
:deep(.el-alert__title) { passwordDialogVisible.value = true;
color: #f56c6c !important; currentUserId.value = user.id;
} };
.name-link { // 编辑成功回调
color: #3973ff; const handleEditSuccess = () => {
cursor: pointer; editDialogVisible.value = false;
text-decoration: none; ElMessage.success(isEdit.value ? "编辑成功" : "添加成功");
transition: color 0.3s; fetchUsers();
fetchQuotaInfo();
&:hover { };
color: #66b1ff;
text-decoration: underline; // 密码修改成功回调
} const handlePasswordChangeSuccess = () => {
} passwordDialogVisible.value = false;
</style> ElMessage.success("密码修改成功");
};
// 分页改变
const handlePageChange = (val: number) => {
page.value = val;
fetchUsers();
};
// 删除用户
const handleDelete = async (user: User) => {
ElMessageBox.confirm("确认删除该用户?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(async () => {
try {
await deleteUser(user.id, { by: "id" });
ElMessage.success("删除成功");
fetchUsers();
fetchQuotaInfo();
} catch (e) {
ElMessage.error("删除失败");
}
});
};
onMounted(async () => {
selectedTenantId.value = Number(authStore.user.tid || 0);
fetchUsers();
fetchRoles();
fetchQuotaInfo();
});
</script>
<style lang="less" scoped>
.filter-bar {
margin-bottom: 12px;
}
.quota-tag {
margin-left: 10px;
vertical-align: middle;
}
.quota-alert {
margin-bottom: 12px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 1.4rem;
font-weight: 700;
}
}
:deep(.el-alert__title) {
color: #f56c6c !important;
}
.name-link {
color: #3973ff;
cursor: pointer;
text-decoration: none;
transition: color 0.3s;
&:hover {
color: #66b1ff;
text-decoration: underline;
}
}
</style>
+11 -7
View File
@@ -15,7 +15,7 @@
// //
// 用法: // 用法:
// //
// deploy -host 10.31.100.2 -user root -dir /www/wwwroot/api.yunzer.cn -local . // deploy -host 212.64.112.158 -port 8822 -user root -dir /www/wwwroot/api.yunzer.cn -local .
package main package main
import ( import (
@@ -51,8 +51,8 @@ var defaultExcludeFiles = []string{
"*.exe", "*.log", "*.pid", "*.bak", "*.tmp", "*.pub", "*.exe", "*.log", "*.pid", "*.bak", "*.tmp", "*.pub",
"nohup.out", "output.log", "go.log", "nohup.out", "output.log", "go.log",
"test-output-*.txt", "migrate_*.csv", "test-output-*.txt", "migrate_*.csv",
"server", // 无扩展名的编译产物 "server", // 无扩展名的编译产物
"*_id_ed25519", // SSH 私钥:绝对不能传到服务器 "*_id_ed25519", // SSH 私钥:绝对不能传到服务器
".deploy_known_hosts", ".deploy_known_hosts",
} }
@@ -60,6 +60,7 @@ var defaultExcludeFiles = []string{
type options struct { type options struct {
host string host string
port string // ← 新增
user string user string
pass string pass string
keyFile string keyFile string
@@ -79,7 +80,8 @@ var errCancelled = errors.New("用户取消")
func main() { func main() {
var opt options var opt options
flag.StringVar(&opt.host, "host", "10.31.100.2", "服务器地址") flag.StringVar(&opt.host, "host", "212.64.112.158", "服务器地址")
flag.StringVar(&opt.port, "port", "22", "SSH 端口") // ← 新增
flag.StringVar(&opt.user, "user", "root", "SSH 用户名") flag.StringVar(&opt.user, "user", "root", "SSH 用户名")
flag.StringVar(&opt.pass, "pass", "", "SSH 密码(留空则读环境变量 DEPLOY_PASS)") flag.StringVar(&opt.pass, "pass", "", "SSH 密码(留空则读环境变量 DEPLOY_PASS)")
flag.StringVar(&opt.keyFile, "key", "", "SSH 私钥路径(指定后用密钥登录,忽略密码)") flag.StringVar(&opt.keyFile, "key", "", "SSH 私钥路径(指定后用密钥登录,忽略密码)")
@@ -137,7 +139,7 @@ func run(opt options) error {
} }
fmt.Printf("本地目录 : %s\n", localRoot) fmt.Printf("本地目录 : %s\n", localRoot)
fmt.Printf("远程目录 : %s@%s:%s\n", opt.user, opt.host, opt.dir) fmt.Printf("远程目录 : %s@%s:%s:%s\n", opt.user, opt.host, opt.port, opt.dir) // ← 输出带端口
client, err := dial(opt, localRoot) client, err := dial(opt, localRoot)
if err != nil { if err != nil {
@@ -583,10 +585,12 @@ func dial(opt options, localRoot string) (*ssh.Client, error) {
HostKeyCallback: hostKeyChecker(localRoot), HostKeyCallback: hostKeyChecker(localRoot),
Timeout: 20 * time.Second, Timeout: 20 * time.Second,
} }
addr := fmt.Sprintf("%s:%d", opt.host, 22)
// ← 关键:用 net.JoinHostPort 支持自定义端口(同时兼容 IPv6)
addr := net.JoinHostPort(opt.host, opt.port)
client, err := ssh.Dial("tcp", addr, cfg) client, err := ssh.Dial("tcp", addr, cfg)
if err != nil { if err != nil {
return nil, fmt.Errorf("连接服务器失败: %w", err) return nil, fmt.Errorf("连接服务器失败(%s): %w", addr, err)
} }
return client, nil return client, nil
} }
+58 -1
View File
@@ -224,6 +224,51 @@ func findBackendTenantUser(idOrUid uint64, jwtTid uint64, byID bool) (*models.Sy
// GetAllUsers 获取当前租户后台用户列表 // GetAllUsers 获取当前租户后台用户列表
// GET /backend/getAllUsers // GET /backend/getAllUsers
// SyncTenantUsersToEmployees 手动同步当前租户选中的用户到人员档案。
// POST /backend/syncTenantUsersToEmployees {"user_ids":[...]};服务层再次按 JWT tid 校验每个 ID。
func (c *BackendAdminUserController) SyncTenantUsersToEmployees() {
_, tid, ok := c.requireBackendIdentity()
if !ok {
return
}
if tid == 0 {
c.Data["json"] = map[string]interface{}{"code": 403, "msg": "登录态未绑定企业"}
_ = c.ServeJSON()
return
}
var request struct {
UserIDs []uint64 `json:"user_ids"`
}
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
var readErr error
body, readErr = io.ReadAll(c.Ctx.Request.Body)
if readErr != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "读取同步请求失败"}
_ = c.ServeJSON()
return
}
}
if err := json.Unmarshal(body, &request); err != nil || len(request.UserIDs) == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "请先选择要同步的用户"}
_ = c.ServeJSON()
return
}
if len(request.UserIDs) > 500 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "单次最多同步500个用户"}
_ = c.ServeJSON()
return
}
result, err := services.SyncSelectedTenantUsersToEmployees(tid, request.UserIDs)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "同步失败: " + err.Error()}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": result}
_ = c.ServeJSON()
}
func (c *BackendAdminUserController) GetAllUsers() { func (c *BackendAdminUserController) GetAllUsers() {
_, jwtTid, ok := c.requireBackendIdentity() _, jwtTid, ok := c.requireBackendIdentity()
if !ok { if !ok {
@@ -557,6 +602,8 @@ func (c *BackendAdminUserController) AddUser() {
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); e == nil { if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); e == nil {
syncTenantUserToAuth(&created, false) syncTenantUserToAuth(&created, false)
} }
// 自动生成员工并同步通讯录(先有用户,后自动生成员工)
_, _, _ = services.EnsureEmployeeForTenantUser(&created)
c.Data["json"] = map[string]interface{}{ c.Data["json"] = map[string]interface{}{
"code": 200, "code": 200,
@@ -687,6 +734,12 @@ func (c *BackendAdminUserController) EditUser() {
var edited models.SystemTenantUser var edited models.SystemTenantUser
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&edited); e == nil { if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&edited); e == nil {
syncTenantUserToAuth(&edited, false) syncTenantUserToAuth(&edited, false)
oldAccount := ""
if row.Account != nil {
oldAccount = *row.Account
}
_ = services.SyncEmployeeOnUserUpdate(row.Tid, oldAccount, &edited)
} }
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
@@ -729,6 +782,11 @@ func (c *BackendAdminUserController) DeleteUser() {
return return
} }
// 同步将员工状态置为离职(2)
if row.Account != nil {
_ = services.SyncEmployeeOnUserDelete(row.Tid, *row.Account)
}
// 双写:删除认证中心中的企业绑定 // 双写:删除认证中心中的企业绑定
removeTenantUserFromAuth(row) removeTenantUserFromAuth(row)
@@ -790,7 +848,6 @@ func (c *BackendAdminUserController) ChangePassword() {
_ = c.ServeJSON() _ = c.ServeJSON()
return return
} }
// 双写:同步新密码到统一认证中心(改密场景,显式允许更新身份层密码) // 双写:同步新密码到统一认证中心(改密场景,显式允许更新身份层密码)
var updated models.SystemTenantUser var updated models.SystemTenantUser
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&updated); e == nil { if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&updated); e == nil {
+58 -40
View File
@@ -167,7 +167,7 @@ const maxToolRounds = 6
// aiUsage 一次 AI 调用的 token 用量与真实模型 // aiUsage 一次 AI 调用的 token 用量与真实模型
type aiUsage struct { type aiUsage struct {
Model string `json:"model,omitempty"` // 接口实际返回的使用模型(如网关把 auto 解析后的真实模型) Model string `json:"model,omitempty"` // 接口实际返回的使用模型(如网关把 auto 解析后的真实模型)
PromptTokens int `json:"prompt_tokens"` PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"` CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"` TotalTokens int `json:"total_tokens"`
@@ -197,10 +197,10 @@ type usedMcpTool struct {
// aiToolUsage 一次 AI 工具循环调用的元信息:用了什么模型、调用了哪些 MCP 工具 // aiToolUsage 一次 AI 工具循环调用的元信息:用了什么模型、调用了哪些 MCP 工具
type aiToolUsage struct { type aiToolUsage struct {
Model string `json:"model"` // 实际使用的模型 Model string `json:"model"` // 实际使用的模型
Rounds int `json:"rounds"` // 工具循环轮数 Rounds int `json:"rounds"` // 工具循环轮数
Tools []usedMcpTool `json:"tools"` // 实际被调用的 MCP 工具(去重) Tools []usedMcpTool `json:"tools"` // 实际被调用的 MCP 工具(去重)
ToolCount int `json:"tool_count"` // 工具调用种类数 ToolCount int `json:"tool_count"` // 工具调用种类数
} }
// sanitizeToolName 工具名规范化为 OpenAI 允许的字符集 // sanitizeToolName 工具名规范化为 OpenAI 允许的字符集
@@ -260,8 +260,8 @@ func collectMcpTools(claims *jwtutil.Claims, enabled bool, serverIDs []uint64) (
if err != nil { if err != nil {
// 单个服务连接失败不阻断整体,跳过并记录到摘要(标记不可用) // 单个服务连接失败不阻断整体,跳过并记录到摘要(标记不可用)
summaries = append(summaries, mcpToolSummary{ summaries = append(summaries, mcpToolSummary{
ServerID: srv.ID, ServerID: srv.ID,
ServerName: srv.Name, ServerName: srv.Name,
Description: "连接失败:" + err.Error(), Description: "连接失败:" + err.Error(),
}) })
continue continue
@@ -446,8 +446,25 @@ func (c *BackendAiChatController) MessageList() {
c.chatJsonErr(500, 500, "查询失败: "+err.Error()) c.chatJsonErr(500, 500, "查询失败: "+err.Error())
return return
} }
providerName := ""
if session.ProviderID > 0 {
provider := models.BackendAiProvider{ID: session.ProviderID}
if models.Orm.Read(&provider) == nil {
providerName = provider.Name
}
}
responseList := make([]map[string]interface{}, 0, len(list))
for _, message := range list {
b, _ := json.Marshal(message)
var item map[string]interface{}
_ = json.Unmarshal(b, &item)
if message.Role == "assistant" && providerName != "" {
item["provider_name"] = providerName
}
responseList = append(responseList, item)
}
c.chatOk(map[string]interface{}{"list": list, "title": session.Title}) c.chatOk(map[string]interface{}{"list": responseList, "title": session.Title})
} }
// DeleteMessage DELETE /backend/ai/chat/message/:id // DeleteMessage DELETE /backend/ai/chat/message/:id
@@ -702,14 +719,14 @@ func (c *BackendAiChatController) Send() {
durationMs := int(time.Since(startTime).Milliseconds()) durationMs := int(time.Since(startTime).Milliseconds())
assistantMsg := models.BackendAiChatMessage{ assistantMsg := models.BackendAiChatMessage{
TenantID: fmt.Sprintf("%d", claims.TenantId), TenantID: fmt.Sprintf("%d", claims.TenantId),
SessionID: session.ID, SessionID: session.ID,
Role: "assistant", Role: "assistant",
Content: reply, Content: reply,
Tokens: totalUsage.total(), Tokens: totalUsage.total(),
DurationMs: durationMs, DurationMs: durationMs,
Model: useModel, Model: useModel,
CreateTime: time.Now(), CreateTime: time.Now(),
} }
_, _ = models.Orm.Insert(&assistantMsg) _, _ = models.Orm.Insert(&assistantMsg)
@@ -886,14 +903,14 @@ func (c *BackendAiChatController) SendStream() {
// 保存AI回复 // 保存AI回复
durationMs := int(time.Since(startTime).Milliseconds()) durationMs := int(time.Since(startTime).Milliseconds())
assistantMsg := models.BackendAiChatMessage{ assistantMsg := models.BackendAiChatMessage{
TenantID: fmt.Sprintf("%d", claims.TenantId), TenantID: fmt.Sprintf("%d", claims.TenantId),
SessionID: session.ID, SessionID: session.ID,
Role: "assistant", Role: "assistant",
Content: fullReply, Content: fullReply,
Tokens: totalUsage.total(), Tokens: totalUsage.total(),
DurationMs: durationMs, DurationMs: durationMs,
Model: useModel, Model: useModel,
CreateTime: time.Now(), CreateTime: time.Now(),
} }
_, _ = models.Orm.Insert(&assistantMsg) _, _ = models.Orm.Insert(&assistantMsg)
@@ -901,11 +918,12 @@ func (c *BackendAiChatController) SendStream() {
_, _ = models.Orm.Update(&session, "update_time") _, _ = models.Orm.Update(&session, "update_time")
writeJSON("done", map[string]interface{}{ writeJSON("done", map[string]interface{}{
"session_id": session.ID, "session_id": session.ID,
"message_id": assistantMsg.ID, "message_id": assistantMsg.ID,
"tokens": totalUsage.total(), "tokens": totalUsage.total(),
"duration_ms": durationMs, "duration_ms": durationMs,
"model": useModel, "model": useModel,
"provider_name": provider.Name,
}) })
} }
@@ -1044,10 +1062,10 @@ func callAITools(provider models.BackendAiProvider, model string, systemPrompt s
} }
var result struct { var result struct {
Content []struct { Content []struct {
Type string `json:"type"` Type string `json:"type"`
Text string `json:"text"` Text string `json:"text"`
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Input map[string]interface{} `json:"input"` Input map[string]interface{} `json:"input"`
} `json:"content"` } `json:"content"`
Usage *struct { Usage *struct {
@@ -1303,9 +1321,9 @@ func callAnthropicStreamTools(provider models.BackendAiProvider, model string, s
var text strings.Builder var text strings.Builder
var usage aiUsage var usage aiUsage
type toolUseAcc struct { type toolUseAcc struct {
ID string ID string
Name string Name string
Input strings.Builder Input strings.Builder
} }
acc := make(map[int]*toolUseAcc) acc := make(map[int]*toolUseAcc)
var order []int var order []int
@@ -1326,11 +1344,11 @@ func callAnthropicStreamTools(provider models.BackendAiProvider, model string, s
switch currentEvent { switch currentEvent {
case "content_block_start": case "content_block_start":
var block struct { var block struct {
Index int `json:"index"` Index int `json:"index"`
ContentBlock struct { ContentBlock struct {
Type string `json:"type"` Type string `json:"type"`
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} `json:"content_block"` } `json:"content_block"`
Usage *struct { Usage *struct {
InputTokens int `json:"input_tokens"` InputTokens int `json:"input_tokens"`
+39 -34
View File
@@ -10,6 +10,7 @@ import (
"server/models" "server/models"
"server/pkg/identity" "server/pkg/identity"
"server/pkg/jwtutil" "server/pkg/jwtutil"
"server/services"
"github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web" beego "github.com/beego/beego/v2/server/web"
@@ -39,15 +40,15 @@ func (c *BackendErpContactController) contactTenantID() (uint64, error) {
} }
type erpContactDTO struct { type erpContactDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
Tid uint64 `json:"tid"` Tid uint64 `json:"tid"`
EmployeeID uint64 `json:"employee_id"` EmployeeID uint64 `json:"employee_id"`
OrgID uint64 `json:"org_id"` OrgID uint64 `json:"org_id"`
OrgName string `json:"org_name"` OrgName string `json:"org_name"`
ContactType int8 `json:"contact_type"` ContactType int8 `json:"contact_type"`
ContactName string `json:"contact_name"` ContactName string `json:"contact_name"`
Gender int8 `json:"gender"` Gender int8 `json:"gender"`
Phone string `json:"phone"` Phone string `json:"phone"`
// 手机号可登记多个(有的人有多个号码):JSON 数组,最多 5 个;phone 保留为第一个 // 手机号可登记多个(有的人有多个号码):JSON 数组,最多 5 个;phone 保留为第一个
Mobiles string `json:"mobiles"` Mobiles string `json:"mobiles"`
WorkPhone string `json:"work_phone"` WorkPhone string `json:"work_phone"`
@@ -585,6 +586,10 @@ func (c *BackendErpContactController) contactDTO(row models.BackendErpContact) e
// --- Sync from Employee --- // --- Sync from Employee ---
func init() {
services.OnEmployeeCreatedHook = SyncContactOnEmployeeCreate
}
// SyncContactOnEmployeeCreate 员工创建后同步到通讯录(由组织架构控制器调用) // SyncContactOnEmployeeCreate 员工创建后同步到通讯录(由组织架构控制器调用)
func SyncContactOnEmployeeCreate(tid uint64, employeeID uint64, emp *models.BackendEmployee) { func SyncContactOnEmployeeCreate(tid uint64, employeeID uint64, emp *models.BackendEmployee) {
syncContactFromEmployee(tid, employeeID, emp) syncContactFromEmployee(tid, employeeID, emp)
@@ -765,13 +770,13 @@ func syncContactFromEmployee(tid uint64, employeeID uint64, emp *models.BackendE
// getEmployeeFileForContact 获取员工人事档案数据(用于通讯录同步) // getEmployeeFileForContact 获取员工人事档案数据(用于通讯录同步)
// 返回档案中与通讯录相关的字段 // 返回档案中与通讯录相关的字段
func getEmployeeFileForContact(employeeID uint64) struct { func getEmployeeFileForContact(employeeID uint64) struct {
WorkEmail string WorkEmail string
HouseholdAddress string HouseholdAddress string
CurrentAddress string CurrentAddress string
EmergencyContact string EmergencyContact string
EmergencyPhone string EmergencyPhone string
EmergencyRelation string EmergencyRelation string
EmploymentStatus int8 EmploymentStatus int8
} { } {
var file models.BackendEmployeeFile var file models.BackendEmployeeFile
err := models.Orm.QueryTable(new(models.BackendEmployeeFile)). err := models.Orm.QueryTable(new(models.BackendEmployeeFile)).
@@ -782,32 +787,32 @@ func getEmployeeFileForContact(employeeID uint64) struct {
if err != nil { if err != nil {
// 档案不存在,返回空值 // 档案不存在,返回空值
return struct { return struct {
WorkEmail string WorkEmail string
HouseholdAddress string HouseholdAddress string
CurrentAddress string CurrentAddress string
EmergencyContact string EmergencyContact string
EmergencyPhone string EmergencyPhone string
EmergencyRelation string EmergencyRelation string
EmploymentStatus int8 EmploymentStatus int8
}{} }{}
} }
return struct { return struct {
WorkEmail string WorkEmail string
HouseholdAddress string HouseholdAddress string
CurrentAddress string CurrentAddress string
EmergencyContact string EmergencyContact string
EmergencyPhone string EmergencyPhone string
EmergencyRelation string EmergencyRelation string
EmploymentStatus int8 EmploymentStatus int8
}{ }{
WorkEmail: file.WorkEmail, WorkEmail: file.WorkEmail,
HouseholdAddress: file.HouseholdAddress, HouseholdAddress: file.HouseholdAddress,
CurrentAddress: file.CurrentAddress, CurrentAddress: file.CurrentAddress,
EmergencyContact: file.EmergencyContact, EmergencyContact: file.EmergencyContact,
EmergencyPhone: file.EmergencyPhone, EmergencyPhone: file.EmergencyPhone,
EmergencyRelation: file.EmergencyRelation, EmergencyRelation: file.EmergencyRelation,
EmploymentStatus: file.EmploymentStatus, EmploymentStatus: file.EmploymentStatus,
} }
} }
@@ -982,4 +987,4 @@ func contactFirstNonEmptyPtr(values ...*string) *string {
} }
} }
return nil return nil
} }
+70 -17
View File
@@ -5,9 +5,12 @@ import (
"strconv" "strconv"
"strings" "strings"
"time"
"server/models" "server/models"
"server/pkg/identity" "server/pkg/identity"
"server/pkg/jwtutil" "server/pkg/jwtutil"
"server/services"
"github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web" beego "github.com/beego/beego/v2/server/web"
@@ -66,21 +69,21 @@ func defaultOrgSettings() orgSettings {
} }
type organizationDTO struct { type organizationDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
Tid uint64 `json:"tid"` Tid uint64 `json:"tid"`
TenantID uint64 `json:"tenant_id"` TenantID uint64 `json:"tenant_id"`
OrgName string `json:"org_name"` OrgName string `json:"org_name"`
OrgCode string `json:"org_code"` OrgCode string `json:"org_code"`
ParentID uint64 `json:"parent_id"` ParentID uint64 `json:"parent_id"`
ParentName string `json:"parent_name"` ParentName string `json:"parent_name"`
LeaderID uint64 `json:"leader_id"` LeaderID uint64 `json:"leader_id"`
LeaderName string `json:"leader_name"` LeaderName string `json:"leader_name"`
IsCompany int `json:"is_company"` IsCompany int `json:"is_company"`
Sort uint `json:"sort"` Sort uint `json:"sort"`
Status int8 `json:"status"` Status int8 `json:"status"`
Remark string `json:"remark"` Remark string `json:"remark"`
CreateTime string `json:"create_time"` CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"` UpdateTime string `json:"update_time"`
} }
type employeeDTO struct { type employeeDTO struct {
@@ -785,7 +788,7 @@ func (c *BackendOrganizationController) GetEmployee() {
return return
} }
cond := orm.NewCondition().And("tid", tid).And("delete_time__isnull", true) cond := orm.NewCondition().AndCond(orm.NewCondition().Or("tid", tid).Or("tid__isnull", true)).And("delete_time__isnull", true)
orgID, _ := c.GetUint64("org_id") orgID, _ := c.GetUint64("org_id")
if orgID == 0 { if orgID == 0 {
@@ -820,6 +823,29 @@ func (c *BackendOrganizationController) GetEmployee() {
return return
} }
// uk_account 为全局唯一索引,历史员工可能 tid 为空;只在当前用户账号可确认归属时认领这类历史记录。
var tenantUsers []models.SystemTenantUser
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("tid", tid).Filter("delete_time__isnull", true).All(&tenantUsers, "account")
ownedAccounts := make(map[string]struct{}, len(tenantUsers))
for _, user := range tenantUsers {
if user.Account != nil && strings.TrimSpace(*user.Account) != "" {
ownedAccounts[strings.TrimSpace(*user.Account)] = struct{}{}
}
}
filtered := make([]models.BackendEmployee, 0, len(rows))
for _, row := range rows {
if row.Tid != nil && *row.Tid > 0 && *row.Tid != int(tid) {
continue
}
if row.Tid == nil || *row.Tid == 0 {
if _, belongsToTenant := ownedAccounts[strings.TrimSpace(row.Account)]; !belongsToTenant {
continue
}
}
filtered = append(filtered, row)
}
rows = filtered
c.jsonOK(c.employeeDTOList(tid, rows)) c.jsonOK(c.employeeDTOList(tid, rows))
} }
@@ -909,6 +935,17 @@ func (c *BackendOrganizationController) CreateEmployee() {
c.jsonError(400, "账号已存在") c.jsonError(400, "账号已存在")
return return
} }
// 校验是否先有用户:必须在「用户管理」中已存在该账号(先有用户,后自动生成员工)
var tenantUser models.SystemTenantUser
tuErr := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("account", account).
Filter("delete_time__isnull", true).
One(&tenantUser)
if tuErr != nil {
c.jsonError(400, "该账号在「用户管理」中不存在,请先前往「用户管理」添加用户")
return
}
gender, hasGender := c.getIntValue(body, "gender", "sex") gender, hasGender := c.getIntValue(body, "gender", "sex")
status, hasStatus := c.getIntValue(body, "account_status", "status") status, hasStatus := c.getIntValue(body, "account_status", "status")
@@ -1072,13 +1109,19 @@ func (c *BackendOrganizationController) EditEmployee() {
// 同步到通讯录(字段优先级:人事档案 > 员工) // 同步到通讯录(字段优先级:人事档案 > 员工)
SyncContactOnEmployeeRefresh(tid, uint64(id)) SyncContactOnEmployeeRefresh(tid, uint64(id))
// 反向同步到租户用户表(保持人员与用户一致)
var updatedEmp models.BackendEmployee
if err := c.employeeQuery(tid).Filter("id", id).One(&updatedEmp); err == nil {
_ = services.SyncUserFromEmployee(tid, &updatedEmp)
}
c.jsonOK(nil) c.jsonOK(nil)
} }
// DeleteEmployee 员工离职/禁用:员工不做删除,只改账号状态。 // DeleteEmployee 员工离职/禁用:员工不做删除,只改账号状态。
// //
// 说明:员工属于企业数据,人事档案(yz_backend_employee_file)与通讯录 // 说明:员工属于企业数据,人事档案(yz_backend_employee_file)与通讯录
//(yz_backend_erp_contact)都以 employee_id 关联员工。一旦把员工软删除(写 delete_time), // (yz_backend_erp_contact)都以 employee_id 关联员工。一旦把员工软删除(写 delete_time),
// 档案页与通讯录就查不到对应的人,等于连带删掉了企业数据。 // 档案页与通讯录就查不到对应的人,等于连带删掉了企业数据。
// 因此这里只把账号状态置为"离职"(2),不写 delete_time,也不触碰档案与通讯录记录。 // 因此这里只把账号状态置为"离职"(2),不写 delete_time,也不触碰档案与通讯录记录。
// 启用/禁用请走 editEmployee 改 account_status(1 启用 / 0 禁用 / 2 离职)。 // 启用/禁用请走 editEmployee 改 account_status(1 启用 / 0 禁用 / 2 离职)。
@@ -1106,6 +1149,16 @@ func (c *BackendOrganizationController) DeleteEmployee() {
return return
} }
// 员工置为离职状态后,同步将对应用户的状态置为禁用(0),防止离职人员继续登录系统
var emp models.BackendEmployee
if err := c.employeeQuery(tid).Filter("id", id).One(&emp); err == nil && emp.Account != "" {
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("account", emp.Account).
Filter("delete_time__isnull", true).
Update(orm.Params{"status": int8(0), "update_time": time.Now()})
}
c.jsonOK(nil) c.jsonOK(nil)
} }
+5 -5
View File
@@ -4,11 +4,11 @@ import "time"
// BackendAiChatMessage AI聊天消息表 // BackendAiChatMessage AI聊天消息表
type BackendAiChatMessage struct { type BackendAiChatMessage struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"` ID uint64 `orm:"column(id);pk;auto" json:"id"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"` TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"`
SessionID uint64 `orm:"column(session_id)" json:"session_id"` SessionID uint64 `orm:"column(session_id)" json:"session_id"`
Role string `orm:"column(role);size(20)" json:"role"` // user / assistant Role string `orm:"column(role);size(20)" json:"role"` // user / assistant
Content string `orm:"column(content);type(text)" json:"content"` Content string `orm:"column(content);type(text)" json:"content"`
// Images 用户上传的图片(base64 dataURL 的 JSON 数组),仅 user 消息使用 // Images 用户上传的图片(base64 dataURL 的 JSON 数组),仅 user 消息使用
Images string `orm:"column(images);type(text)" json:"images"` Images string `orm:"column(images);type(text)" json:"images"`
// Tokens 本次响应消耗的 token 总数(0 表示未统计到) // Tokens 本次响应消耗的 token 总数(0 表示未统计到)
+1 -1
View File
@@ -60,7 +60,7 @@ var PlatformSiteSettingDefs = []PlatformSiteSettingDef{
// 备案信息 // 备案信息
{Tag: "icp", Title: "ICP 备案号", Remark: "如:苏ICP备2026000000号-1,常用于页脚 | group:备案信息"}, {Tag: "icp", Title: "ICP 备案号", Remark: "如:苏ICP备2026000000号-1,常用于页脚 | group:备案信息"},
{Tag: "policeicp", Title: "公安备案", Remark: "如:苏公网安备32010000000000号,常用于页脚 | group:备案信息"}, {Tag: "policeicp", Title: "公安备案", Remark: "如:苏公网安备32010000000000号,常用于页脚 | group:备案信息"},
{Tag: "copyright", Title: "版权信息", Remark: "如:© 2026 公司名称 版权所有 | group:备案信息"}, {Tag: "copyright", Title: "版权信息", Remark: "如:© 2026 云泽网 版权所有 | group:备案信息"},
// 政策条款 // 政策条款
{Tag: "privacy", Title: "隐私政策", Remark: "隐私政策链接,如 /page/privacy | group:政策条款"}, {Tag: "privacy", Title: "隐私政策", Remark: "隐私政策链接,如 /page/privacy | group:政策条款"},
+1
View File
@@ -163,6 +163,7 @@ func RegisterAuthRoutes() {
beego.Router("/backend/weather/now", &controllers.BackendWeatherController{}, "get:Now") beego.Router("/backend/weather/now", &controllers.BackendWeatherController{}, "get:Now")
// 用户接口 // 用户接口
beego.Router("/backend/syncTenantUsersToEmployees", &controllers.BackendAdminUserController{}, "post:SyncTenantUsersToEmployees")
beego.Router("/backend/getTenantUsers/:tid", &controllers.BackendAdminUserController{}, "get:GetTenantUsers") beego.Router("/backend/getTenantUsers/:tid", &controllers.BackendAdminUserController{}, "get:GetTenantUsers")
beego.Router("/backend/getAllUsers", &controllers.BackendAdminUserController{}, "get:GetAllUsers") beego.Router("/backend/getAllUsers", &controllers.BackendAdminUserController{}, "get:GetAllUsers")
beego.Router("/backend/getUserInfo/:id", &controllers.BackendAdminUserController{}, "get:GetUserInfo") beego.Router("/backend/getUserInfo/:id", &controllers.BackendAdminUserController{}, "get:GetUserInfo")
+379
View File
@@ -0,0 +1,379 @@
package services
import (
"fmt"
"server/models"
"strconv"
"strings"
"time"
"github.com/beego/beego/v2/client/orm"
)
// OnEmployeeCreatedHook 员工创建后的外部回调(由 controllers 包在初始化时注册,如同步通讯录)
var OnEmployeeCreatedHook func(tid uint64, employeeID uint64, emp *models.BackendEmployee)
// parseDate 解析日期字符串为 *time.Time
func parseDate(v string) *time.Time {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
if t, err := time.Parse("2006-01-02", v); err == nil {
return &t
}
if t, err := time.Parse("2006-01-02 15:04:05", v); err == nil {
return &t
}
return nil
}
// EnsureEmployeeForTenantUser 确保租户用户在 yz_backend_employee 中有对应的人员记录。
// 规则:先有用户,后自动生成员工,以 (tid, account) 严格匹配。
func EnsureEmployeeForTenantUser(tu *models.SystemTenantUser) (*models.BackendEmployee, bool, error) {
if tu == nil || tu.Account == nil {
return nil, false, nil
}
account := strings.TrimSpace(*tu.Account)
if account == "" {
return nil, false, nil
}
tid := int(tu.Tid)
if tid <= 0 {
return nil, false, nil
}
var emp models.BackendEmployee
// 员工表的 uk_account 是全局唯一索引(不是 tid+account 复合索引)。
// 因此先按账号查全表,避免已有的历史/其他租户同账号记录被误判为缺失后再 INSERT。
err := models.Orm.QueryTable(new(models.BackendEmployee)).
Filter("account", account).
One(&emp)
if err == nil && emp.Tid != nil && *emp.Tid > 0 && *emp.Tid != tid {
return nil, false, fmt.Errorf("员工账号 %s 已被其他租户使用,不能重复创建", account)
}
name := account
if tu.Name != nil && strings.TrimSpace(*tu.Name) != "" {
name = strings.TrimSpace(*tu.Name)
}
var birthday *time.Time
if tu.Birth != nil && strings.TrimSpace(*tu.Birth) != "" {
birthday = parseDate(strings.TrimSpace(*tu.Birth))
}
status := int8(1)
if tu.Status == 0 {
status = 0
}
var deptPtr *string
if tu.OrgID > 0 {
deptStr := strconv.FormatUint(tu.OrgID, 10)
deptPtr = &deptStr
}
if err == orm.ErrNoRows {
// 不存在 -> 自动创建新员工
newEmp := models.BackendEmployee{
Tid: &tid,
Account: account,
Name: name,
Gender: int8(tu.Sex),
Birthday: birthday,
Department: deptPtr,
Phone: tu.Phone,
Email: tu.Email,
AccountStatus: status,
}
id, insertErr := models.Orm.Insert(&newEmp)
if insertErr != nil {
return nil, false, insertErr
}
newEmp.ID = uint(id)
if OnEmployeeCreatedHook != nil {
OnEmployeeCreatedHook(tu.Tid, uint64(newEmp.ID), &newEmp)
}
return &newEmp, true, nil
} else if err != nil {
return nil, false, err
}
// 已存在 -> 只同步用户侧明确提供的基础资料;不覆盖部门、职位及其他员工专属字段。
update := orm.Params{}
if emp.Tid == nil || *emp.Tid <= 0 {
// 历史无租户归属的员工记录:被当前租户用户明确匹配后,补上租户归属。
update["tid"] = tid
}
if emp.DeleteTime != nil {
update["delete_time"] = nil
}
if emp.Name != name {
update["name"] = name
}
if emp.Gender != int8(tu.Sex) {
update["gender"] = int8(tu.Sex)
}
if birthday != nil && (emp.Birthday == nil || !emp.Birthday.Equal(*birthday)) {
update["birthday"] = birthday
}
if tu.Phone != nil && strings.TrimSpace(*tu.Phone) != "" && (emp.Phone == nil || *emp.Phone != *tu.Phone) {
update["phone"] = *tu.Phone
}
if tu.Email != nil && strings.TrimSpace(*tu.Email) != "" && (emp.Email == nil || *emp.Email != *tu.Email) {
update["email"] = *tu.Email
}
if tu.OrgID > 0 && (emp.Department == nil || *emp.Department == "") {
update["department"] = *deptPtr
}
if emp.AccountStatus != status && emp.AccountStatus != 2 {
update["account_status"] = status
}
if len(update) > 0 {
update["update_time"] = time.Now()
query := models.Orm.QueryTable(new(models.BackendEmployee)).Filter("id", emp.ID)
if emp.Tid != nil && *emp.Tid > 0 {
query = query.Filter("tid", tid)
}
if _, err := query.Update(update); err != nil {
return nil, false, err
}
}
return &emp, false, nil
}
// SyncSelectedTenantUsersToEmployees 对指定租户用户进行比对;仅对缺失或基础资料有差异的员工执行写入。
func SyncSelectedTenantUsersToEmployees(tid uint64, userIDs []uint64) (map[string]interface{}, error) {
counts := map[string]interface{}{"created": 0, "updated": 0, "unchanged": 0, "failed": 0, "errors": []string{}}
if tid == 0 || len(userIDs) == 0 {
return counts, nil
}
seen := make(map[uint64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID == 0 {
counts["failed"] = counts["failed"].(int) + 1
counts["errors"] = append(counts["errors"].([]string), "用户ID无效")
continue
}
if _, duplicate := seen[userID]; duplicate {
continue
}
seen[userID] = struct{}{}
var user models.SystemTenantUser
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", userID).
Filter("tid", tid).
Filter("delete_time__isnull", true).
One(&user)
if err != nil {
counts["failed"] = counts["failed"].(int) + 1
counts["errors"] = append(counts["errors"].([]string), fmt.Sprintf("用户记录 %d 不存在于当前租户或已删除", userID))
continue
}
differs := tenantUserEmployeeDiffers(&user)
_, created, err := EnsureEmployeeForTenantUser(&user)
if err != nil {
counts["failed"] = counts["failed"].(int) + 1
account := ""
if user.Account != nil {
account = strings.TrimSpace(*user.Account)
}
counts["errors"] = append(counts["errors"].([]string), fmt.Sprintf("用户 %s(ID %d)同步失败: %v", account, userID, err))
continue
}
if created {
counts["created"] = counts["created"].(int) + 1
} else if differs {
counts["updated"] = counts["updated"].(int) + 1
} else {
counts["unchanged"] = counts["unchanged"].(int) + 1
}
}
return counts, nil
}
func tenantUserEmployeeDiffers(user *models.SystemTenantUser) bool {
if user == nil || user.Account == nil {
return true
}
var employee models.BackendEmployee
if err := models.Orm.QueryTable(new(models.BackendEmployee)).
Filter("tid", int(user.Tid)).
Filter("account", strings.TrimSpace(*user.Account)).
Filter("delete_time__isnull", true).
One(&employee); err != nil {
return true
}
name := strings.TrimSpace(*user.Account)
if user.Name != nil && strings.TrimSpace(*user.Name) != "" {
name = strings.TrimSpace(*user.Name)
}
status := int8(1)
if user.Status == 0 {
status = 0
}
if employee.Name != name || employeeStatusDiffers(employee.AccountStatus, status) || int(employee.Gender) != int(user.Sex) {
return true
}
if user.Phone != nil && (employee.Phone == nil || *employee.Phone != *user.Phone) {
return true
}
if user.Email != nil && (employee.Email == nil || *employee.Email != *user.Email) {
return true
}
if user.Birth != nil && strings.TrimSpace(*user.Birth) != "" {
birthday := parseDate(*user.Birth)
if birthday != nil && (employee.Birthday == nil || !employee.Birthday.Equal(*birthday)) {
return true
}
}
return false
}
func employeeStatusDiffers(employeeStatus, userStatus int8) bool {
if employeeStatus == 2 {
return false // 离职员工由人事管理维护,不通过用户同步自动复职
}
return employeeStatus != userStatus
}
// SyncEmployeeOnUserUpdate 当用户在用户管理中被编辑时,同步修改对应员工
func SyncEmployeeOnUserUpdate(tid uint64, oldAccount string, tu *models.SystemTenantUser) error {
if tu == nil || tid == 0 {
return nil
}
acc := oldAccount
if acc == "" && tu.Account != nil {
acc = *tu.Account
}
acc = strings.TrimSpace(acc)
if acc == "" {
return nil
}
var emp models.BackendEmployee
err := models.Orm.QueryTable(new(models.BackendEmployee)).
Filter("tid", int(tid)).
Filter("account", acc).
One(&emp)
if err != nil {
// 员工不存在则自动创建
_, _, err = EnsureEmployeeForTenantUser(tu)
return err
}
changed := false
if tu.Account != nil && strings.TrimSpace(*tu.Account) != "" && emp.Account != strings.TrimSpace(*tu.Account) {
emp.Account = strings.TrimSpace(*tu.Account)
changed = true
}
if tu.Name != nil && strings.TrimSpace(*tu.Name) != "" && emp.Name != strings.TrimSpace(*tu.Name) {
emp.Name = strings.TrimSpace(*tu.Name)
changed = true
}
if tu.Sex > 0 && emp.Gender != int8(tu.Sex) {
emp.Gender = int8(tu.Sex)
changed = true
}
if tu.Birth != nil {
tb := parseDate(strings.TrimSpace(*tu.Birth))
if tb != nil && (emp.Birthday == nil || !emp.Birthday.Equal(*tb)) {
emp.Birthday = tb
changed = true
}
}
if tu.Phone != nil && (emp.Phone == nil || *emp.Phone != *tu.Phone) {
emp.Phone = tu.Phone
changed = true
}
if tu.Email != nil && (emp.Email == nil || *emp.Email != *tu.Email) {
emp.Email = tu.Email
changed = true
}
if tu.Status == 1 && emp.AccountStatus == 0 {
emp.AccountStatus = 1
changed = true
} else if tu.Status == 0 && emp.AccountStatus == 1 {
emp.AccountStatus = 0
changed = true
}
if changed {
emp.UpdateTime = time.Now()
_, err = models.Orm.Update(&emp)
return err
}
return nil
}
// SyncEmployeeOnUserDelete 当租户用户被删除时,将员工置为离职状态(2)
func SyncEmployeeOnUserDelete(tid uint64, account string) error {
account = strings.TrimSpace(account)
if tid == 0 || account == "" {
return nil
}
_, err := models.Orm.QueryTable(new(models.BackendEmployee)).
Filter("tid", int(tid)).
Filter("account", account).
Filter("delete_time__isnull", true).
Update(orm.Params{
"account_status": int8(2), // 2 = 离职
"update_time": time.Now(),
})
return err
}
// SyncUserFromEmployee 当在人员管理中编辑了员工的基本信息时,同步回用户表
func SyncUserFromEmployee(tid uint64, emp *models.BackendEmployee) error {
if emp == nil || tid == 0 {
return nil
}
account := strings.TrimSpace(emp.Account)
if account == "" {
return nil
}
update := orm.Params{
"update_time": time.Now(),
}
if emp.Name != "" {
update["name"] = emp.Name
}
if emp.Phone != nil {
update["phone"] = *emp.Phone
}
if emp.Email != nil {
update["email"] = *emp.Email
}
if emp.Gender > 0 {
update["sex"] = uint8(emp.Gender)
}
if emp.Birthday != nil {
update["birth"] = emp.Birthday.Format("2006-01-02")
}
if emp.Department != nil && *emp.Department != "" {
if orgId, err := strconv.ParseUint(*emp.Department, 10, 64); err == nil && orgId > 0 {
update["org_id"] = orgId
}
}
// 员工状态 1: 启用 -> 用户 1; 员工状态 0: 禁用 / 2: 离职 -> 用户 0 (禁用登录)
if emp.AccountStatus == 1 {
update["status"] = int8(1)
} else {
update["status"] = int8(0)
}
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("account", account).
Filter("delete_time__isnull", true).
Update(update)
return err
}
+31 -34
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<title>{{.LoginTitle}} - 统一认证中心</title> <title>温新云科技 - 统一认证中心 - 统一认证中心</title>
<style> <style>
:root { :root {
--primary: #3973ff; --primary: #3973ff;
@@ -212,18 +212,20 @@
to { opacity: 1; transform: none; } to { opacity: 1; transform: none; }
} }
/* 小屏时左侧品牌区隐藏,在表单上方展示紧凑品牌头 */ /* 表单上方品牌 Logo 区:显示在「登录」字样上方 */
.mobile-brand { display: none; align-items: center; gap: 12px; margin-bottom: 28px; } .form-brand { display: flex; align-items: center; margin-bottom: 24px; }
.mobile-brand .brand-img { height: 36px; max-width: 180px; } .form-brand-img { height: 42px; max-width: 240px; object-fit: contain; display: block; }
.mobile-brand .brand-logo { .form-brand-fallback { display: inline-flex; align-items: center; gap: 12px; }
.form-brand-logo {
width: 40px; height: 40px; width: 40px; height: 40px;
border-radius: 11px;
background: rgba(57, 115, 255, 0.10); background: rgba(57, 115, 255, 0.10);
border-color: rgba(57, 115, 255, 0.18); border: 1px solid rgba(57, 115, 255, 0.18);
display: flex; align-items: center; justify-content: center;
flex: 0 0 auto;
} }
.mobile-brand .brand-logo svg { stroke: var(--primary); } .form-brand-logo svg { width: 22px; height: 22px; stroke: var(--primary); }
.mobile-brand-text { display: flex; flex-direction: column; gap: 2px; } .form-brand-name { font-size: 16px; font-weight: 600; color: var(--text); }
.mobile-brand-name { font-size: 15px; font-weight: 600; }
.mobile-brand-sub { font-size: 12px; color: var(--muted); }
.form-foot { .form-foot {
position: absolute; position: absolute;
@@ -535,14 +537,13 @@
padding: 40px 24px 72px; padding: 40px 24px 72px;
} }
.form-inner { max-width: 440px; margin: 0 auto; } .form-inner { max-width: 440px; margin: 0 auto; }
.mobile-brand { display: flex; }
.panel-title { font-size: 26px; } .panel-title { font-size: 26px; }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="login-page"> <div class="login-page">
<!-- 左侧品牌区:背景图 / Logo / 主副标题由 platform「平台设置 → 登录页设置」提供 --> <!-- 左侧品牌区:背景图 / 主副标题由 platform「平台设置 → 登录页设置」提供 -->
<aside class="brand-panel{{if .LoginBg}} has-bg{{end}}"> <aside class="brand-panel{{if .LoginBg}} has-bg{{end}}">
{{if .LoginBg}} {{if .LoginBg}}
<img class="brand-bg" src="{{.LoginBg}}" alt="" /> <img class="brand-bg" src="{{.LoginBg}}" alt="" />
@@ -551,20 +552,16 @@
<div class="brand-inner"> <div class="brand-inner">
<div class="brand"> <div class="brand">
{{if .LoginLogo}}
<img class="brand-img" src="{{.LoginLogo}}" alt="{{.LoginTitle}}" />
{{else}}
<span class="brand-logo"> <span class="brand-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/> <path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
<path d="M9.2 12.2l2 2 3.6-4"/> <path d="M9.2 12.2l2 2 3.6-4"/>
</svg> </svg>
</span> </span>
<span class="brand-name">温新云统一认证中心</span> <span class="brand-name">统一认证中心</span>
{{end}}
</div> </div>
<h1 class="brand-title">{{.LoginTitle}}</h1> <h1 class="brand-title">温新云</h1>
<p class="brand-sub">{{.LoginSubtitle}}</p> <p class="brand-sub">{{.LoginSubtitle}}</p>
</div> </div>
@@ -573,28 +570,28 @@
<rect x="4" y="11" width="16" height="10" rx="2"/> <rect x="4" y="11" width="16" height="10" rx="2"/>
<path d="M8 11V8a4 4 0 018 0v3"/> <path d="M8 11V8a4 4 0 018 0v3"/>
</svg> </svg>
<span>© 2026 Yunzer · 连接已加密</span> <span>© 2026 WEIXIN TECH · 连接已加密</span>
</div> </div>
</aside> </aside>
<!-- 右侧表单区 --> <!-- 右侧表单区 -->
<main class="form-panel"> <main class="form-panel">
<div class="form-inner"> <div class="form-inner">
<div class="mobile-brand"> <!-- 品牌 Logo 区:显示在「登录」字样上方 -->
<div class="form-brand">
{{if .LoginLogo}} {{if .LoginLogo}}
<img class="brand-img" src="{{.LoginLogo}}" alt="{{.LoginTitle}}" /> <img class="form-brand-img" src="{{.LoginLogo}}" alt="温新云科技 - 统一认证中心" />
{{else}} {{else}}
<span class="brand-logo"> <div class="form-brand-fallback">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> <span class="form-brand-logo">
<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.2 12.2l2 2 3.6-4"/> <path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
</svg> <path d="M9.2 12.2l2 2 3.6-4"/>
</span> </svg>
</span>
<span class="form-brand-name">温新云统一认证中心</span>
</div>
{{end}} {{end}}
<span class="mobile-brand-text">
<span class="mobile-brand-name">温新云统一认证中心</span>
<span class="mobile-brand-sub">{{.LoginTitle}}</span>
</span>
</div> </div>
{{if eq .Step "tenant"}} {{if eq .Step "tenant"}}
<div class="panel-head"> <div class="panel-head">
@@ -633,8 +630,8 @@
<div class="back"><a class="link" onclick="logout()">不是我的账号?切换账号</a></div> <div class="back"><a class="link" onclick="logout()">不是我的账号?切换账号</a></div>
{{else}} {{else}}
<div class="panel-head"> <div class="panel-head">
<h1 class="panel-title">登录</h1> <h1 class="panel-title">欢迎登陆您的工作空间</h1>
<p class="panel-sub">请选择登录方式,进入您的工作空间</p> <!-- <p class="panel-sub"></p> -->
</div> </div>
<div class="error" id="error"> <div class="error" id="error">
@@ -799,7 +796,7 @@
</div> </div>
<div class="form-foot"> <div class="form-foot">
<span>© 2026 云泽 版权所有</span> <span>© 2027 云泽网 版权所有</span>
<button id="cacheClearBtn" class="cache-clear-btn" type="button" onclick="clearSiteCache()" title="仅清除本站在当前浏览器中的缓存数据">清除缓存</button> <button id="cacheClearBtn" class="cache-clear-btn" type="button" onclick="clearSiteCache()" title="仅清除本站在当前浏览器中的缓存数据">清除缓存</button>
</div> </div>
</main> </main>
+17
View File
@@ -21,6 +21,23 @@ const staticMainChildren = [
component: () => import("@/views/system/email/index.vue"), component: () => import("@/views/system/email/index.vue"),
meta: { requiresAuth: true, title: "邮箱管理" } meta: { requiresAuth: true, title: "邮箱管理" }
}, },
// 平台 AI 服务商配置与 Playground(设置页保留 /setting 兼容路径)
{
path: "/system/aisettings",
name: "PlatformAiSettings",
component: () => import("@/views/system/aisettings/index.vue"),
meta: { requiresAuth: true, title: "AI 管理" }
},
{
path: "/system/aisettings/setting",
redirect: "/system/aisettings"
},
{
path: "/system/aisettings/playground",
name: "PlatformAiPlayground",
component: () => import("@/views/system/aisettings/playground.vue"),
meta: { requiresAuth: true, title: "AI Playground" }
},
// 统一认证中心:接入应用(OIDC Client)注册管理 // 统一认证中心:接入应用(OIDC Client)注册管理
{ {
path: "/system/authClient", path: "/system/authClient",
+40
View File
@@ -0,0 +1,40 @@
/**
* 将 Font Awesome HTML 片段或普通 class 字符串统一转换为可存储的 class 字符串。
* 例如:<i class="fa-solid fa-water-ladder"></i> -> fa-solid fa-water-ladder
*/
export function normalizeFontAwesomeIcon(value) {
const input = String(value ?? "").trim();
if (!input) return "";
const classAttribute = input.match(/\bclass\s*=\s*(["'])(.*?)\1/i);
const source = classAttribute ? classAttribute[2] : input;
const tokens = source
.replace(/<\/?i\b[^>]*>/gi, " ")
.replace(/[<>"'`]/g, " ")
.replace(/\s+/g, " ")
.trim()
.split(" ")
.filter(Boolean);
// FA 图标家族/样式前缀不是图形本身。保留一个样式前缀和最后一个图标名,
// 这样旧值中意外累积的多个 fa-* 图标也不会同时渲染。
const styleClasses = new Set([
"fas", "far", "fab", "fal", "fad", "fat", "fass", "fasr", "fasl", "fasds",
"fa-solid", "fa-regular", "fa-brands", "fa-light", "fa-thin", "fa-duotone",
"fa-sharp", "fa-sharp-solid", "fa-sharp-regular", "fa-sharp-light", "fa-sharp-thin",
]);
const modifierClasses = new Set([
"fa-fw", "fa-ul", "fa-li", "fa-border", "fa-pull-left", "fa-pull-right",
"fa-spin", "fa-spin-pulse", "fa-pulse", "fa-fade", "fa-beat", "fa-beat-fade",
"fa-bounce", "fa-flip", "fa-shake", "fa-xs", "fa-sm", "fa-lg", "fa-xl", "fa-2xl",
]);
const styleClass = tokens.find((token) => styleClasses.has(token));
const iconClasses = tokens.filter(
(token) => token.startsWith("fa-") && !styleClasses.has(token) && !modifierClasses.has(token),
);
const iconClass = iconClasses.at(-1);
return [styleClass || (tokens.includes("fa") ? "fa" : ""), iconClass]
.filter(Boolean)
.join(" ");
}
@@ -82,17 +82,22 @@
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button> <div class="dialog-footer">
<el-button v-if="isEdit" type="danger" plain :loading="deleting" @click="handleDelete">删除配置</el-button>
<el-button v-if="isEdit" plain :loading="testing" @click="handleTest">测试连接</el-button>
<span class="dialog-footer__spacer"></span>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button> <el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<script setup> <script setup>
import { reactive, ref, computed } from "vue"; import { reactive, ref, computed } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue"; import { Plus } from "@element-plus/icons-vue";
import { createAiProvider, updateAiProvider } from "@/api/aisettings"; import { createAiProvider, updateAiProvider, testAiProvider, deleteAiProvider } from "@/api/aisettings";
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
@@ -118,6 +123,8 @@ const apiTypeTip = computed(() => API_TYPE_TIPS[form.api_type] || "");
const apiBasePlaceholder = computed(() => API_BASE_PLACEHOLDERS[form.api_type] || "https://..."); const apiBasePlaceholder = computed(() => API_BASE_PLACEHOLDERS[form.api_type] || "https://...");
const submitting = ref(false); const submitting = ref(false);
const testing = ref(false);
const deleting = ref(false);
const form = reactive({ const form = reactive({
name: "", name: "",
api_base: "", api_base: "",
@@ -150,6 +157,43 @@ const addModelRow = () => {
form.models.push({ id: "", name: "" }); form.models.push({ id: "", name: "" });
}; };
const handleTest = async () => {
if (!props.item?.id) return;
testing.value = true;
try {
const res = await testAiProvider({ id: props.item.id, model: form.default_model || form.models.find((m) => m.id)?.id || "" });
if (res?.code === 200) {
ElMessage.success(`连接成功 · ${res.data?.latency_ms ?? "-"} ms`);
} else {
ElMessage.error(res?.msg || "连接测试失败");
}
} finally {
testing.value = false;
}
};
const handleDelete = async () => {
if (!props.item?.id) return;
try {
await ElMessageBox.confirm(`确定删除「${props.item.name}」配置吗?`, "删除确认", { type: "warning" });
} catch {
return;
}
deleting.value = true;
try {
const res = await deleteAiProvider(props.item.id);
if (res?.code === 200) {
ElMessage.success("配置已删除");
emit("update:modelValue", false);
emit("saved");
} else {
ElMessage.error(res?.msg || "删除失败");
}
} finally {
deleting.value = false;
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!form.name.trim()) return ElMessage.warning("请填写配置名称"); if (!form.name.trim()) return ElMessage.warning("请填写配置名称");
if (!form.api_base.trim()) return ElMessage.warning("请填写 API 地址"); if (!form.api_base.trim()) return ElMessage.warning("请填写 API 地址");
@@ -197,6 +241,17 @@ const handleSubmit = async () => {
margin-top: 4px; margin-top: 4px;
} }
.dialog-footer {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
&__spacer {
flex: 1;
}
}
.models-table { .models-table {
width: 100%; width: 100%;
@@ -100,7 +100,7 @@ const loadList = async () => {
listLoading.value = true; listLoading.value = true;
try { try {
const res = await getAiProviderList(); const res = await getAiProviderList();
if (res?.code === 200) list.value = res.data || []; if (res?.code === 200) list.value = res.data?.list || res.data || [];
} finally { } finally {
listLoading.value = false; listLoading.value = false;
} }
@@ -68,6 +68,15 @@
>{{ s.label }}</el-button> >{{ s.label }}</el-button>
</div> </div>
<el-input
v-model="systemPrompt"
class="pg-system-prompt"
type="textarea"
:rows="2"
resize="vertical"
placeholder="系统提示词(可选):定义这个智能体的角色、目标和回答规则"
/>
<div class="pg-chat" ref="chatRef"> <div class="pg-chat" ref="chatRef">
<div v-if="!messages.length" class="pg-empty"> <div v-if="!messages.length" class="pg-empty">
选择服务商与模型后,输入场景问题开始测试对话效果 选择服务商与模型后,输入场景问题开始测试对话效果
@@ -126,7 +135,7 @@
</div> </div>
<div class="pg-input"> <div class="pg-input">
<el-tooltip content="上传附件(图片/PDF,直传模型不存储)" placement="top"> <el-tooltip content="上传图片(直传模型,不存储)" placement="top">
<el-button class="pg-attach-btn" @click="fileInputRef?.click()"> <el-button class="pg-attach-btn" @click="fileInputRef?.click()">
<el-icon><Paperclip /></el-icon> <el-icon><Paperclip /></el-icon>
</el-button> </el-button>
@@ -134,7 +143,7 @@
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file" type="file"
accept="image/*,.pdf" accept="image/*"
multiple multiple
style="display: none" style="display: none"
@change="handleFiles" @change="handleFiles"
@@ -184,6 +193,7 @@ const providerId = ref(null);
const model = ref(""); const model = ref("");
const messages = ref([]); const messages = ref([]);
const input = ref(""); const input = ref("");
const systemPrompt = ref("");
const sending = ref(false); const sending = ref(false);
const chatRef = ref(null); const chatRef = ref(null);
const fileInputRef = ref(null); const fileInputRef = ref(null);
@@ -195,9 +205,12 @@ const renamingText = ref("");
const MAX_FILE_SIZE = 10 * 1024 * 1024; const MAX_FILE_SIZE = 10 * 1024 * 1024;
const TYPE_LABELS = { deepseek: "DeepSeek", doubao: "豆包", codex: "Codex", custom: "自定义" }; const API_TYPES = {
const API_TYPES = { chat_completions: "Chat", responses: "Resp", anthropic: "Anthropic" }; chat_completions: "OpenAI 兼容",
const apiTypeLabel = (t) => TYPE_LABELS[t] || t; responses: "OpenAI Responses",
anthropic: "Anthropic Messages",
};
const apiTypeLabel = (t) => API_TYPES[t] || t || "OpenAI 兼容";
const currentProvider = computed(() => providers.value.find((p) => p.id === providerId.value)); const currentProvider = computed(() => providers.value.find((p) => p.id === providerId.value));
const modelOptions = computed(() => const modelOptions = computed(() =>
@@ -459,13 +472,17 @@ const handleFiles = async (e) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
e.target.value = ""; e.target.value = "";
for (const f of files) { for (const f of files) {
if (!f.type?.startsWith("image/")) {
ElMessage.warning(`${f.name} 不是支持的图片格式,已跳过`);
continue;
}
if (f.size > MAX_FILE_SIZE) { if (f.size > MAX_FILE_SIZE) {
ElMessage.warning(`${f.name} 超过 10MB,已跳过`); ElMessage.warning(`${f.name} 超过 10MB,已跳过`);
continue; continue;
} }
let dataUrl = await fileToDataURL(f); let dataUrl = await fileToDataURL(f);
const isImage = f.type?.startsWith("image/"); const isImage = true;
if (isImage) dataUrl = await compressImage(dataUrl); dataUrl = await compressImage(dataUrl);
// 上游网关一般限制请求体 ~4-10MB,base64 约膨胀 33%,超过 5MB 原始文件风险较高 // 上游网关一般限制请求体 ~4-10MB,base64 约膨胀 33%,超过 5MB 原始文件风险较高
if (f.size > 5 * 1024 * 1024 && !isImage) { if (f.size > 5 * 1024 * 1024 && !isImage) {
ElMessage.warning(`${f.name} 较大(${(f.size / 1024 / 1024).toFixed(1)}MB),上游可能拒绝,请尝试压缩后重试`); ElMessage.warning(`${f.name} 较大(${(f.size / 1024 / 1024).toFixed(1)}MB),上游可能拒绝,请尝试压缩后重试`);
@@ -519,6 +536,9 @@ const send = async () => {
const history = messages.value const history = messages.value
.filter((m) => !m._streaming && (m.content || m.attachments?.length)) .filter((m) => !m._streaming && (m.content || m.attachments?.length))
.map((m) => ({ role: m.role, content: buildApiContent(m) })); .map((m) => ({ role: m.role, content: buildApiContent(m) }));
if (systemPrompt.value.trim()) {
history.unshift({ role: "system", content: systemPrompt.value.trim() });
}
try { try {
await doStream(history, assistantMsg); await doStream(history, assistantMsg);
} catch (e) { } catch (e) {
@@ -720,6 +740,10 @@ onMounted(() => {
margin-bottom: 12px; margin-bottom: 12px;
} }
.pg-system-prompt {
margin-bottom: 12px;
}
.pg-chat { .pg-chat {
flex: 1; flex: 1;
min-height: 280px; min-height: 280px;
@@ -68,10 +68,19 @@
</el-form-item> </el-form-item>
<el-form-item label="图标" prop="icon"> <el-form-item label="图标" prop="icon">
<el-input <div class="icon-field">
v-model="currentMenu.icon" <el-input
placeholder="例如:fas fa-tachometer-alt" v-model="iconInput"
/> placeholder='输入 Font Awesome class 或 <i class="fa-solid fa-water-ladder"></i>'
@input="handleIconInput"
@blur="normalizeIconInput"
/>
<div class="icon-preview">
<i v-if="normalizedIcon" :class="normalizedIcon"></i>
<span v-if="!normalizedIcon">图标预览</span>
</div>
<div class="icon-tip">支持直接粘贴官网的 &lt;i class="..."&gt;&lt;/i&gt;,保存时会自动提取 class 内容。</div>
</div>
</el-form-item> </el-form-item>
<el-form-item label="排序" prop="sort"> <el-form-item label="排序" prop="sort">
@@ -136,6 +145,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
import { ElMessage, ElForm } from "element-plus"; import { ElMessage, ElForm } from "element-plus";
import { normalizeFontAwesomeIcon } from "@/utils/fontAwesomeIcon";
// 定义菜单数据类型 // 定义菜单数据类型
interface Menu { interface Menu {
@@ -182,6 +192,19 @@ const emit = defineEmits<{
// 表单引用 // 表单引用
const menuFormRef = ref<InstanceType<typeof ElForm>>(); const menuFormRef = ref<InstanceType<typeof ElForm>>();
const iconInput = ref("");
const normalizedIcon = computed(() => normalizeFontAwesomeIcon(iconInput.value));
const handleIconInput = (value: string) => {
const normalized = normalizeFontAwesomeIcon(value);
iconInput.value = normalized;
currentMenu.value.icon = normalized;
};
const normalizeIconInput = () => {
iconInput.value = normalizeFontAwesomeIcon(iconInput.value);
currentMenu.value.icon = iconInput.value;
};
// 当前操作的菜单 // 当前操作的菜单
const currentMenu = ref<Partial<Menu>>({ const currentMenu = ref<Partial<Menu>>({
@@ -252,7 +275,9 @@ watch(() => props.menu, (newMenu) => {
currentMenu.value = { currentMenu.value = {
...newMenu, ...newMenu,
pid: normalizedPid, pid: normalizedPid,
icon: normalizeFontAwesomeIcon(newMenu.icon),
}; };
iconInput.value = normalizeFontAwesomeIcon(newMenu.icon);
} else { } else {
// 重置表单 // 重置表单
currentMenu.value = { currentMenu.value = {
@@ -270,6 +295,7 @@ watch(() => props.menu, (newMenu) => {
type: 1, type: 1,
permission: '', permission: '',
}; };
iconInput.value = "";
} }
}, { immediate: true }); }, { immediate: true });
@@ -309,6 +335,7 @@ watch(() => props.visible, (newVisible) => {
type: 1, type: 1,
permission: '', permission: '',
}; };
iconInput.value = "";
} }
}); });
@@ -446,6 +473,7 @@ const handleSave = async () => {
// 解决后端时间字段问题:过滤掉不需要的字段 // 解决后端时间字段问题:过滤掉不需要的字段
const payload = { ...currentMenu.value }; const payload = { ...currentMenu.value };
payload.icon = normalizeFontAwesomeIcon(iconInput.value || currentMenu.value.icon);
// 再次确保 pid 是整数类型(双重保险) // 再次确保 pid 是整数类型(双重保险)
// 处理数组情况:如果 pid 是数组,取最后一个元素(el-cascader 返回的是路径数组) // 处理数组情况:如果 pid 是数组,取最后一个元素(el-cascader 返回的是路径数组)
@@ -503,4 +531,32 @@ const handleSave = async () => {
align-items: center; align-items: center;
gap: 14px; gap: 14px;
} }
.icon-field {
width: 100%;
}
.icon-preview {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
margin-top: 8px;
color: var(--el-text-color-secondary);
font-size: 13px;
i {
width: 20px;
text-align: center;
color: var(--el-color-primary);
font-size: 16px;
}
}
.icon-tip {
margin-top: 4px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
</style> </style>
@@ -198,6 +198,7 @@ import {
deleteMenu, deleteMenu,
} from "@/api/menu"; } from "@/api/menu";
import MenuEdit from "./components/edit.vue"; import MenuEdit from "./components/edit.vue";
import { normalizeFontAwesomeIcon } from "@/utils/fontAwesomeIcon";
// 定义菜单数据类型 // 定义菜单数据类型
interface Menu { interface Menu {
@@ -264,6 +265,9 @@ const fetchMenus = async () => {
const result = await getAllMenus(); const result = await getAllMenus();
if (result.code === 200) { if (result.code === 200) {
const sortedMenus = sortMenuTree(result.data || []); const sortedMenus = sortMenuTree(result.data || []);
getAllMenuRows(sortedMenus).forEach((menu) => {
menu.icon = normalizeFontAwesomeIcon(menu.icon);
});
menuTree.value = sortedMenus; menuTree.value = sortedMenus;
parentMenuOptions.value = [ parentMenuOptions.value = [
{ {
@@ -445,6 +449,7 @@ const handleMenuSave = async (menu: Partial<Menu>) => {
try { try {
// 解决后端时间字段问题:过滤掉不需要的字段 // 解决后端时间字段问题:过滤掉不需要的字段
const payload = { ...menu }; const payload = { ...menu };
payload.icon = normalizeFontAwesomeIcon(payload.icon);
// 确保 pid 是整数类型(后端要求必须是整数) // 确保 pid 是整数类型(后端要求必须是整数)
// 处理数组情况:如果 pid 是数组(el-cascader 返回的是路径数组),取最后一个元素 // 处理数组情况:如果 pid 是数组(el-cascader 返回的是路径数组),取最后一个元素
@@ -4,13 +4,14 @@
<el-card shadow="never" class="settings-card"> <el-card shadow="never" class="settings-card">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>统一登录页品牌</span> <span>统一登录页设置</span>
<span class="card-tip"> <span class="card-tip">
保存后用于统一认证中心登录页(/auth/login),未配置项使用默认文案 配置统一认证中心登录页(/auth/login)的品牌展示及开放的登录方式,保存后立即生效
</span> </span>
</div> </div>
</template> </template>
<!-- 品牌基本信息 -->
<el-form-item label="主标题" prop="login_title"> <el-form-item label="主标题" prop="login_title">
<el-input <el-input
v-model="form.login_title" v-model="form.login_title"
@@ -51,7 +52,7 @@
<el-icon v-else class="uploader-icon"><Plus /></el-icon> <el-icon v-else class="uploader-icon"><Plus /></el-icon>
</el-upload> </el-upload>
<div class="upload-meta"> <div class="upload-meta">
<p>显示在登录页左上角,建议透明背景 PNG / SVG,高度不小于 88px。</p> <p>显示在登录表单「登录」字样上方,建议透明背景 PNG / SVG,高度不小于 88px。</p>
<el-button v-if="form.login_logo" link type="danger" @click="form.login_logo = ''"> <el-button v-if="form.login_logo" link type="danger" @click="form.login_logo = ''">
移除 Logo 移除 Logo
</el-button> </el-button>
@@ -85,37 +86,129 @@
</div> </div>
</el-form-item> </el-form-item>
<el-divider content-position="left">效果预览</el-divider> <!-- 登录方式开关 -->
<el-divider content-position="left">登录方式开关</el-divider>
<el-form-item label="账号密码登录">
<el-switch v-model="form.login_pwd_enabled" active-value="1" inactive-value="0" />
<div class="form-tip">账号 + 密码登录。关闭后登录页不再显示该选项卡,接口侧同步拒绝。</div>
</el-form-item>
<el-form-item label="短信登录">
<el-switch v-model="form.login_sms_enabled" active-value="1" inactive-value="0" />
<div class="form-tip">手机号 + 短信验证码登录。关闭后登录页不再显示该选项卡。</div>
</el-form-item>
<el-form-item label="扫码登录">
<el-switch v-model="form.login_qr_enabled" active-value="1" inactive-value="0" />
<div class="form-tip">微信扫码登录(温新云小程序内确认)。关闭后登录页不再显示该选项卡。</div>
</el-form-item>
<el-alert
v-if="allDisabled"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 16px;"
title="当前已关闭全部登录方式,统一认证中心将提示「登录功能已关闭」,所有用户无法登录,请谨慎操作。"
/>
<el-divider content-position="left">效果预览(实时联动)</el-divider>
<div class="brand-preview"> <div class="brand-preview">
<!-- 左侧品牌区预览 -->
<div class="preview-left" :class="{ 'has-bg': !!form.login_bg }"> <div class="preview-left" :class="{ 'has-bg': !!form.login_bg }">
<img v-if="form.login_bg" :src="previewUrl(form.login_bg)" class="preview-bg" alt="" /> <img v-if="form.login_bg" :src="previewUrl(form.login_bg)" class="preview-bg" alt="" />
<span v-if="form.login_bg" class="preview-veil"></span> <span v-if="form.login_bg" class="preview-veil"></span>
<div class="preview-inner"> <div class="preview-inner">
<img <div class="preview-badge">
v-if="form.login_logo" <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
:src="previewUrl(form.login_logo)" <path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
class="preview-logo" <path d="M9.2 12.2l2 2 3.6-4"/>
alt="Logo" </svg>
/> <span>统一认证中心</span>
<div v-else class="preview-logo-fallback">温新云统一认证中心</div> </div>
<p class="preview-title">{{ form.login_title || "一次登录,全平台通行" }}</p> <p class="preview-title">{{ form.login_title || "一次登录,全平台通行" }}</p>
<p class="preview-sub"> <p class="preview-sub">
{{ form.login_subtitle || "基于 OIDC / OAuth 2.0 的统一身份底座" }} {{ form.login_subtitle || "基于 OIDC / OAuth 2.0 的统一身份底座" }}
</p> </p>
</div> </div>
</div> </div>
<!-- 右侧表单区预览:实时响应品牌与登录方式开关 -->
<div class="preview-right"> <div class="preview-right">
<p class="mock-title">登录</p> <!-- 登录字样上方的 Logo / 品牌标识 -->
<div class="mock-tabs"> <div class="mock-brand">
<span class="active">账号密码</span> <img
<span>短信登录</span> v-if="form.login_logo"
<span>扫码登录</span> :src="previewUrl(form.login_logo)"
class="preview-logo"
alt="Logo"
/>
<div v-else class="preview-logo-fallback">
<span class="mock-logo-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
<path d="M9.2 12.2l2 2 3.6-4"/>
</svg>
</span>
<span class="mock-logo-name">温新云统一认证中心</span>
</div>
</div> </div>
<div class="mock-input"></div>
<div class="mock-input"></div> <p class="mock-title">欢迎登陆您的工作空间</p>
<div class="mock-btn">登 录</div>
<div class="mock-third">微信登录 · QQ 登录</div> <!-- 全部关闭时提示已关闭 -->
<div v-if="allDisabled" class="mock-disabled">
<el-icon class="mock-disabled-icon"><Lock /></el-icon>
<p class="mock-disabled-title">登录功能已关闭</p>
<p class="mock-disabled-desc">已关闭全部登录方式,无法登录</p>
</div>
<!-- 至少开启一种登录方式时 -->
<template v-else>
<!-- 开启方式多于 1 种时展示选项卡切换,实时响应各开关 -->
<div v-if="showTabs" class="mock-tabs">
<span
v-if="pwdEnabled"
:class="{ active: currentActiveTab === 'account' }"
@click="currentActiveTab = 'account'"
>账号密码</span>
<span
v-if="smsEnabled"
:class="{ active: currentActiveTab === 'sms' }"
@click="currentActiveTab = 'sms'"
>短信登录</span>
<span
v-if="qrEnabled"
:class="{ active: currentActiveTab === 'qr' }"
@click="currentActiveTab = 'qr'"
>扫码登录</span>
</div>
<!-- 面板内容:按当前选中的登录方式切换 -->
<div v-if="currentActiveTab === 'account' || currentActiveTab === 'sms'" class="mock-panel">
<div class="mock-input">
<span class="mock-placeholder">{{ currentActiveTab === 'account' ? '手机号 / 邮箱 / 账号' : '手机号' }}</span>
</div>
<div class="mock-input">
<span class="mock-placeholder">{{ currentActiveTab === 'account' ? '密码' : '短信验证码' }}</span>
</div>
<div class="mock-btn">登 录</div>
</div>
<!-- 扫码登录面板 -->
<div v-else-if="currentActiveTab === 'qr'" class="mock-qr-panel">
<div class="mock-qr-box">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3 3h8v8H3V3zm2 2v4h4V5H5zm8-2h8v8h-8V3zm2 2v4h4V5h-4zM3 13h8v8H3v-8zm2 2v4h4v-4H5zm13-2h3v2h-3v-2zm-5 0h2v3h-2v-3zm2 3h2v2h-2v-2zm-2 2h2v3h-2v-3zm5-2h3v5h-2v-3h-1v-2zm0-3h1v2h-1v-2zm-3 5h2v2h-2v-2z"/>
</svg>
</div>
<p class="mock-qr-text">微信扫码登录</p>
</div>
<div class="mock-third">微信登录 · QQ 登录</div>
</template>
</div> </div>
</div> </div>
</el-card> </el-card>
@@ -129,9 +222,9 @@
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue"; import { Plus, Lock } from "@element-plus/icons-vue";
import { uploadToWebsiteMaterial } from "@/utils/websiteUpload"; import { uploadToWebsiteMaterial } from "@/utils/websiteUpload";
import { getLoginBrand, saveLoginBrand } from "@/api/sitesettings"; import { getLoginBrand, saveLoginBrand } from "@/api/sitesettings";
@@ -147,11 +240,60 @@ const form = reactive({
login_subtitle: "", login_subtitle: "",
login_logo: "", login_logo: "",
login_bg: "", login_bg: "",
login_pwd_enabled: "1",
login_sms_enabled: "1",
login_qr_enabled: "1",
}); });
// 服务端快照,用于“重置”丢弃未保存的改动 // 服务端快照,用于“重置”丢弃未保存的改动
let snapshot = { ...form }; let snapshot = { ...form };
// 开关状态实时计算
const pwdEnabled = computed(() => form.login_pwd_enabled !== "0");
const smsEnabled = computed(() => form.login_sms_enabled !== "0");
const qrEnabled = computed(() => form.login_qr_enabled !== "0");
const allDisabled = computed(
() => !pwdEnabled.value && !smsEnabled.value && !qrEnabled.value
);
const enabledCount = computed(() => {
let count = 0;
if (pwdEnabled.value) count++;
if (smsEnabled.value) count++;
if (qrEnabled.value) count++;
return count;
});
// 开启方式多于1种时才展示 tab 栏,与登录页实际渲染逻辑保持一致
const showTabs = computed(() => enabledCount.value > 1);
// 预览中当前选中的 tab
const currentActiveTab = ref("account");
watch(
[pwdEnabled, smsEnabled, qrEnabled],
() => {
const isCurrentValid =
(currentActiveTab.value === "account" && pwdEnabled.value) ||
(currentActiveTab.value === "sms" && smsEnabled.value) ||
(currentActiveTab.value === "qr" && qrEnabled.value);
if (!isCurrentValid) {
if (pwdEnabled.value) {
currentActiveTab.value = "account";
} else if (smsEnabled.value) {
currentActiveTab.value = "sms";
} else if (qrEnabled.value) {
currentActiveTab.value = "qr";
} else {
currentActiveTab.value = "";
}
}
},
{ immediate: true }
);
const previewUrl = (url) => { const previewUrl = (url) => {
const value = String(url || "").trim().replace(/\\/g, "/"); const value = String(url || "").trim().replace(/\\/g, "/");
if (!value) return ""; if (!value) return "";
@@ -168,6 +310,9 @@ const loadConfig = async () => {
form.login_subtitle = res.data.login_subtitle || ""; form.login_subtitle = res.data.login_subtitle || "";
form.login_logo = res.data.login_logo || ""; form.login_logo = res.data.login_logo || "";
form.login_bg = res.data.login_bg || ""; form.login_bg = res.data.login_bg || "";
form.login_pwd_enabled = res.data.login_pwd_enabled === "0" ? "0" : "1";
form.login_sms_enabled = res.data.login_sms_enabled === "0" ? "0" : "1";
form.login_qr_enabled = res.data.login_qr_enabled === "0" ? "0" : "1";
snapshot = { ...form }; snapshot = { ...form };
} else { } else {
ElMessage.error(res?.msg || "获取登录页配置失败"); ElMessage.error(res?.msg || "获取登录页配置失败");
@@ -214,6 +359,9 @@ const handleSubmit = async () => {
login_subtitle: String(form.login_subtitle || "").trim(), login_subtitle: String(form.login_subtitle || "").trim(),
login_logo: String(form.login_logo || "").trim(), login_logo: String(form.login_logo || "").trim(),
login_bg: String(form.login_bg || "").trim(), login_bg: String(form.login_bg || "").trim(),
login_pwd_enabled: form.login_pwd_enabled,
login_sms_enabled: form.login_sms_enabled,
login_qr_enabled: form.login_qr_enabled,
}); });
if (res?.code === 200) { if (res?.code === 200) {
ElMessage.success("保存成功,统一登录页已同步更新"); ElMessage.success("保存成功,统一登录页已同步更新");
@@ -337,8 +485,8 @@ onMounted(loadConfig);
.brand-preview { .brand-preview {
display: flex; display: flex;
width: 100%; width: 100%;
max-width: 720px; max-width: 740px;
height: 260px; min-height: 290px;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--el-border-color-lighter); border: 1px solid var(--el-border-color-lighter);
@@ -380,18 +528,29 @@ onMounted(loadConfig);
max-width: 100%; max-width: 100%;
} }
.preview-logo { .preview-badge {
height: 26px; display: inline-flex;
max-width: 140px; align-items: center;
object-fit: contain; gap: 6px;
margin-bottom: 18px; font-size: 11px;
font-weight: 600;
margin-bottom: 14px;
padding: 3px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #fff;
svg {
width: 13px;
height: 13px;
}
} }
.preview-logo-fallback { .preview-left.has-bg .preview-badge {
font-size: 13px; background: rgba(57, 115, 255, 0.12);
font-weight: 600; border-color: rgba(57, 115, 255, 0.18);
margin-bottom: 18px; color: #3973ff;
opacity: 0.9;
} }
.preview-title { .preview-title {
@@ -415,54 +574,197 @@ onMounted(loadConfig);
.preview-right { .preview-right {
flex: 0 0 42%; flex: 0 0 42%;
background: #fff; background: #fff;
padding: 26px 24px; padding: 20px 22px;
display: flex;
flex-direction: column;
justify-content: center;
}
.mock-brand {
margin-bottom: 8px;
display: flex;
align-items: center;
}
.preview-logo {
height: 24px;
max-width: 130px;
object-fit: contain;
display: block;
}
.preview-logo-fallback {
display: inline-flex;
align-items: center;
gap: 6px;
}
.mock-logo-icon {
width: 20px;
height: 20px;
border-radius: 6px;
background: rgba(57, 115, 255, 0.12);
color: #3973ff;
display: inline-flex;
align-items: center;
justify-content: center;
svg {
width: 12px;
height: 12px;
}
}
.mock-logo-name {
font-size: 11px;
font-weight: 600;
color: #1f2b48;
} }
.mock-title { .mock-title {
margin: 0 0 12px; margin: 0 0 10px;
font-size: 18px; font-size: 17px;
font-weight: 700; font-weight: 700;
color: #1f2b48; color: #1f2b48;
} }
.mock-tabs { .mock-tabs {
display: flex; display: flex;
gap: 10px; gap: 12px;
margin-bottom: 12px; margin-bottom: 10px;
padding-bottom: 8px; padding-bottom: 6px;
border-bottom: 1px solid #eef2fa; border-bottom: 1px solid #eef2fa;
font-size: 9px; font-size: 9.5px;
color: #8a94ad; color: #8a94ad;
.active { span {
color: #3973ff; cursor: pointer;
font-weight: 600; position: relative;
transition: color 0.2s;
&:hover {
color: #3973ff;
}
&.active {
color: #3973ff;
font-weight: 600;
&::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -7px;
height: 2px;
background: #3973ff;
border-radius: 1px;
}
}
} }
} }
.mock-panel {
display: flex;
flex-direction: column;
}
.mock-input { .mock-input {
height: 24px; height: 24px;
border: 1px solid #e3e9f5; border: 1px solid #e3e9f5;
border-radius: 6px; border-radius: 6px;
margin-bottom: 8px; margin-bottom: 6px;
background: #fbfcff; background: #fbfcff;
padding: 0 8px;
display: flex;
align-items: center;
}
.mock-placeholder {
font-size: 9px;
color: #b0b8c8;
line-height: 24px;
user-select: none;
} }
.mock-btn { .mock-btn {
height: 28px; height: 26px;
margin-top: 14px; margin-top: 6px;
border-radius: 6px; border-radius: 6px;
background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%); background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%);
color: #fff; color: #fff;
font-size: 11px; font-size: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
letter-spacing: 2px; letter-spacing: 2px;
} }
.mock-qr-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4px 0;
}
.mock-qr-box {
width: 58px;
height: 58px;
background: #f8fafc;
border: 1px dashed #c0c8d6;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #4a5568;
svg {
width: 40px;
height: 40px;
}
}
.mock-qr-text {
margin: 4px 0 0;
font-size: 9px;
color: #8a94ad;
}
.mock-disabled {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 14px 8px;
text-align: center;
background: #fafbfe;
border: 1px dashed #e3e9f5;
border-radius: 8px;
margin: 4px 0;
}
.mock-disabled-icon {
font-size: 22px;
color: #e6a23c;
margin-bottom: 4px;
}
.mock-disabled-title {
margin: 0 0 2px;
font-size: 11px;
font-weight: 600;
color: #1f2b48;
}
.mock-disabled-desc {
margin: 0;
font-size: 9px;
color: #a3acbf;
}
.mock-third { .mock-third {
margin-top: 10px; margin-top: 8px;
text-align: center; text-align: center;
font-size: 9px; font-size: 9px;
color: #a3acbf; color: #a3acbf;
@@ -32,15 +32,10 @@
/> />
</el-tab-pane> </el-tab-pane>
<!-- 登录页设置(统一认证中心 /auth/login) --> <!-- 登录页设置(统一认证中心 /auth/login:品牌配置 + 登录方式开关) -->
<el-tab-pane label="登录页设置" name="loginBrand"> <el-tab-pane label="登录页设置" name="loginBrand">
<loginBrandSettings v-if="activeTab === 'loginBrand'" /> <loginBrandSettings v-if="activeTab === 'loginBrand'" />
</el-tab-pane> </el-tab-pane>
<!-- 登录方式开关(账号密码 / 短信 / 扫码) -->
<el-tab-pane label="登录方式" name="loginMethods">
<loginSettings v-if="activeTab === 'loginMethods'" />
</el-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
</div> </div>
@@ -52,7 +47,6 @@ import platformSettings from "./components/platformSettings.vue";
import storageSettings from "./components/storageSettings.vue"; import storageSettings from "./components/storageSettings.vue";
import notificationSettings from "./components/notificationSettings.vue"; import notificationSettings from "./components/notificationSettings.vue";
import loginBrandSettings from "./components/loginBrandSettings.vue"; import loginBrandSettings from "./components/loginBrandSettings.vue";
import loginSettings from "./components/loginSettings.vue";
const activeTab = ref("platform"); const activeTab = ref("platform");
</script> </script>
@@ -11,7 +11,7 @@
</el-form-item> </el-form-item>
<el-form-item label="版权信息" prop="copyright"> <el-form-item label="版权信息" prop="copyright">
<el-input v-model="form.copyright" placeholder="如:© 2026 公司名称 版权所有" /> <el-input v-model="form.copyright" placeholder="如:© 2026 云泽网 版权所有" />
<TagTip tag="{yz:copyright}" desc="常用于页脚" /> <TagTip tag="{yz:copyright}" desc="常用于页脚" />
</el-form-item> </el-form-item>