更新
This commit is contained in:
1 parent
b6b528490c
commit
4ab63e77ed
27 files changed
+1282
-119
No files matched your search
@@ -80,3 +80,5 @@ go/*.pem
|
|||||||
.mimocode/bun.lock
|
.mimocode/bun.lock
|
||||||
|
|
||||||
docs/sql/
|
docs/sql/
|
||||||
|
|
||||||
|
go/update-server-outside.bat
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import request from '@/utils/request';
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
// 将所选租户用户与人员档案进行比对,有差异时同步人员信息
|
||||||
|
export function syncTenantUsersToEmployees(userIds) {
|
||||||
|
return request({
|
||||||
|
url: "/backend/syncTenantUsersToEmployees",
|
||||||
|
method: "post",
|
||||||
|
data: { user_ids: userIds },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
//获取所有用户信息
|
//获取所有用户信息
|
||||||
export function getAllUsers(params) {
|
export function getAllUsers(params) {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@@ -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="账号">
|
||||||
|
|||||||
@@ -9,6 +9,14 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
:disabled="!selectedUsers.length || syncingEmployees"
|
||||||
|
:loading="syncingEmployees"
|
||||||
|
@click="handleSyncEmployees"
|
||||||
|
>
|
||||||
|
同步员工{{ selectedUsers.length ? `(${selectedUsers.length})` : '' }}
|
||||||
|
</el-button>
|
||||||
<el-button type="primary" @click="handleAddUser">
|
<el-button type="primary" @click="handleAddUser">
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<Plus />
|
<Plus />
|
||||||
@@ -29,6 +37,15 @@
|
|||||||
<el-alert v-if="quotaFull" type="warning" show-icon :closable="false" class="quota-alert"
|
<el-alert v-if="quotaFull" type="warning" show-icon :closable="false" class="quota-alert"
|
||||||
title="用户数已达上限,新增账号前请联系平台管理员增购用户数" />
|
title="用户数已达上限,新增账号前请联系平台管理员增购用户数" />
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
title="人员联动提示:新增用户后系统会自动生成员工;存量用户请勾选后点击「同步员工」,系统会按当前租户比对,只补建或更新有差异的员工资料。"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<el-form inline>
|
<el-form inline>
|
||||||
<el-form-item label="租户筛选">
|
<el-form-item label="租户筛选">
|
||||||
@@ -40,7 +57,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 用户列表表格 -->
|
<!-- 用户列表表格 -->
|
||||||
<el-table :data="users" style="width: 100%" v-loading="loading">
|
<el-table
|
||||||
|
ref="userTableRef"
|
||||||
|
:data="users"
|
||||||
|
style="width: 100%"
|
||||||
|
v-loading="loading"
|
||||||
|
row-key="id"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="48" fixed="left" />
|
||||||
<el-table-column prop="id" label="ID" align="center" fixed="left" />
|
<el-table-column prop="id" label="ID" align="center" fixed="left" />
|
||||||
<el-table-column prop="name" label="姓名" min-width="120" align="center">
|
<el-table-column prop="name" label="姓名" min-width="120" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
@@ -101,9 +126,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import type { ElTable } from "element-plus";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { Plus, Refresh } from "@element-plus/icons-vue";
|
import { Plus, Refresh } from "@element-plus/icons-vue";
|
||||||
import { getTenantUsers, deleteUser } from "@/api/user";
|
import { getTenantUsers, deleteUser, syncTenantUsersToEmployees } from "@/api/user";
|
||||||
import { getTenantQuotaInfo } from "@/api/tenant";
|
import { getTenantQuotaInfo } from "@/api/tenant";
|
||||||
import { getAllRoles } from "@/api/role";
|
import { getAllRoles } from "@/api/role";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
@@ -153,6 +179,10 @@ const previewDialogRef = ref();
|
|||||||
|
|
||||||
// 编辑/密码对话框状态
|
// 编辑/密码对话框状态
|
||||||
const editDialogVisible = ref(false);
|
const editDialogVisible = ref(false);
|
||||||
|
const userTableRef = ref<InstanceType<typeof ElTable>>();
|
||||||
|
const selectedUsers = ref<User[]>([]);
|
||||||
|
const syncingEmployees = ref(false);
|
||||||
|
|
||||||
const passwordDialogVisible = ref(false);
|
const passwordDialogVisible = ref(false);
|
||||||
const previewDialogVisible = ref(false);
|
const previewDialogVisible = ref(false);
|
||||||
const editDialogTitle = ref("添加用户");
|
const editDialogTitle = ref("添加用户");
|
||||||
@@ -169,6 +199,49 @@ const quotaFull = computed(
|
|||||||
Number(quotaInfo.value.used) >= Number(quotaInfo.value.quota)
|
Number(quotaInfo.value.used) >= Number(quotaInfo.value.quota)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSelectionChange = (rows: User[]) => {
|
||||||
|
selectedUsers.value = rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncEmployees = async () => {
|
||||||
|
if (!selectedUsers.value.length || syncingEmployees.value) return;
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`将按当前租户核对所选 ${selectedUsers.value.length} 个用户的员工档案;只新增缺失记录或更新存在差异的资料。已配置的部门、职位等员工专属信息会保留。继续同步吗?`,
|
||||||
|
"同步员工",
|
||||||
|
{ confirmButtonText: "开始同步", cancelButtonText: "取消", type: "warning" }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncingEmployees.value = true;
|
||||||
|
try {
|
||||||
|
const res = await syncTenantUsersToEmployees(selectedUsers.value.map((user) => user.id));
|
||||||
|
if (res?.code !== 200) throw new Error(res?.msg || "同步失败");
|
||||||
|
const result = res.data || {};
|
||||||
|
const successCount =
|
||||||
|
Number(result.created || 0) + Number(result.updated || 0) + Number(result.unchanged || 0);
|
||||||
|
const failedCount = Number(result.failed || 0);
|
||||||
|
const summary = `同步完成:成功 ${successCount} 人,失败 ${failedCount} 人`;
|
||||||
|
const errors = Array.isArray(result.errors) ? result.errors.filter(Boolean) : [];
|
||||||
|
if (errors.length) {
|
||||||
|
await ElMessageBox.alert(errors.join("\n"), summary, {
|
||||||
|
confirmButtonText: "知道了",
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ElMessage.success(summary);
|
||||||
|
}
|
||||||
|
userTableRef.value?.clearSelection();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.response?.data?.msg || error?.message || "同步失败");
|
||||||
|
} finally {
|
||||||
|
syncingEmployees.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const fetchQuotaInfo = async () => {
|
const fetchQuotaInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getTenantQuotaInfo();
|
const res = await getTenantQuotaInfo();
|
||||||
@@ -226,6 +299,8 @@ const fetchUsers = async () => {
|
|||||||
}
|
}
|
||||||
const res = await getTenantUsers(selectedTenantId.value);
|
const res = await getTenantUsers(selectedTenantId.value);
|
||||||
users.value = res?.data?.list || [];
|
users.value = res?.data?.list || [];
|
||||||
|
selectedUsers.value = [];
|
||||||
|
userTableRef.value?.clearSelection();
|
||||||
total.value = Number(res?.data?.total || 0);
|
total.value = Number(res?.data?.total || 0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
users.value = [];
|
users.value = [];
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -906,6 +923,7 @@ func (c *BackendAiChatController) SendStream() {
|
|||||||
"tokens": totalUsage.total(),
|
"tokens": totalUsage.total(),
|
||||||
"duration_ms": durationMs,
|
"duration_ms": durationMs,
|
||||||
"model": useModel,
|
"model": useModel,
|
||||||
|
"provider_name": provider.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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,6 +1109,12 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:政策条款"},
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+26
-29
@@ -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">
|
||||||
|
<span class="form-brand-logo">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
<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="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="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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<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 @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">
|
||||||
|
<div class="icon-field">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="currentMenu.icon"
|
v-model="iconInput"
|
||||||
placeholder="例如:fas fa-tachometer-alt"
|
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">支持直接粘贴官网的 <i class="..."></i>,保存时会自动提取 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>
|
||||||
|
|
||||||
|
<p class="mock-title">欢迎登陆您的工作空间</p>
|
||||||
|
|
||||||
|
<!-- 全部关闭时提示已关闭 -->
|
||||||
|
<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>
|
||||||
<div class="mock-input"></div>
|
|
||||||
<div class="mock-input"></div>
|
|
||||||
<div class="mock-btn">登 录</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>
|
<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 {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #3973ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
color: #3973ff;
|
color: #3973ff;
|
||||||
font-weight: 600;
|
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>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user