소스 검색

feat(web): add s8 operator binding config UI

Turn /aidop/s8/config/roles into a tabbed page. The new "操作员账号绑定"
tab lets admins bind, rebind, and unbind sys-user accounts to S8
employees, surfacing BOUND/UNBOUND status and rejecting picks of
already-bound accounts in the picker. The legacy role permission CRUD
moves to a second tab so existing data stays reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
YY968XX 4 달 전
부모
커밋
99f859dc9c

+ 28 - 0
Web/src/views/aidop/s8/api/s8ConfigApi.ts

@@ -4,6 +4,24 @@ function unwrap<T>(res: { data: T }): T {
 	return res.data;
 }
 
+export interface S8OperatorBindingRow {
+	employeeId: number;
+	empCode: string;
+	name?: string | null;
+	factoryRefId: number;
+	sysUserId?: number | null;
+	sysUserName?: string | null;
+	bindStatus: 'BOUND' | 'UNBOUND';
+}
+
+export interface S8ConfigSysUserRow {
+	id: number;
+	account?: string | null;
+	realName?: string | null;
+	status: number;
+	alreadyBoundEmployeeId?: number | null;
+}
+
 export interface S8ExceptionTypeConfigRow {
 	id: number;
 	tenantId: number;
@@ -63,6 +81,16 @@ export const s8ConfigApi = {
 		service.get('/api/aidop/s8/config/data-sources', { params }).then(unwrap),
 	importRolesFromSys: (params?: { tenantId?: number; factoryId?: number }) =>
 		service.post('/api/aidop/s8/config/roles/import-from-sys', null, { params }).then(unwrap),
+	operatorBindings: {
+		list: (params?: { factoryRefId?: number; bindStatus?: 'BOUND' | 'UNBOUND' | ''; keyword?: string }) =>
+			service.get<S8OperatorBindingRow[]>('/api/aidop/s8/config/operator-bindings', { params }).then(unwrap),
+		bind: (body: { employeeId: number; sysUserId: number; factoryRefId: number }) =>
+			service.post<S8OperatorBindingRow>('/api/aidop/s8/config/operator-bindings', body).then(unwrap),
+		unbind: (employeeId: number) =>
+			service.delete(`/api/aidop/s8/config/operator-bindings/${employeeId}`).then(unwrap),
+		sysUsers: (params?: { keyword?: string; excludeEmployeeId?: number }) =>
+			service.get<S8ConfigSysUserRow[]>('/api/aidop/s8/config/sys-users', { params }).then(unwrap),
+	},
 	exceptionTypes: {
 		list: (params?: { tenantId?: number; factoryId?: number; domainCode?: string; enabledOnly?: boolean }) =>
 			service.get<S8ExceptionTypeConfigRow[]>('/api/aidop/s8/config/exception-types', { params }).then(unwrap),

+ 223 - 0
Web/src/views/aidop/s8/config/S8OperatorBindingTab.vue

@@ -0,0 +1,223 @@
+<script setup lang="ts" name="aidopS8OperatorBindingTab">
+import { computed, onMounted, reactive, ref } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import { s8ConfigApi, type S8OperatorBindingRow, type S8ConfigSysUserRow } from '../api/s8ConfigApi';
+
+const filters = reactive({
+	factoryRefId: 1,
+	bindStatus: '' as '' | 'BOUND' | 'UNBOUND',
+	keyword: '',
+});
+
+const loading = ref(false);
+const rows = ref<S8OperatorBindingRow[]>([]);
+
+async function loadRows() {
+	loading.value = true;
+	try {
+		rows.value = (await s8ConfigApi.operatorBindings.list({
+			factoryRefId: filters.factoryRefId || undefined,
+			bindStatus: filters.bindStatus || undefined,
+			keyword: filters.keyword?.trim() || undefined,
+		})) as S8OperatorBindingRow[];
+	} catch {
+		// 拦截器已 toast;这里吞掉避免 Uncaught (in promise)
+	} finally {
+		loading.value = false;
+	}
+}
+
+const dialogVisible = ref(false);
+const dialogTarget = ref<S8OperatorBindingRow | null>(null);
+const dialogForm = reactive<{ sysUserId: number | null }>({ sysUserId: null });
+const sysUserOptions = ref<S8ConfigSysUserRow[]>([]);
+const sysUserKeyword = ref('');
+const sysUserLoading = ref(false);
+
+async function loadSysUsers() {
+	if (!dialogTarget.value) return;
+	sysUserLoading.value = true;
+	try {
+		sysUserOptions.value = (await s8ConfigApi.operatorBindings.sysUsers({
+			keyword: sysUserKeyword.value?.trim() || undefined,
+			excludeEmployeeId: dialogTarget.value.employeeId,
+		})) as S8ConfigSysUserRow[];
+	} catch {
+		// silent — interceptor toast already shown
+	} finally {
+		sysUserLoading.value = false;
+	}
+}
+
+function openBindDialog(row: S8OperatorBindingRow) {
+	dialogTarget.value = row;
+	dialogForm.sysUserId = row.sysUserId ?? null;
+	sysUserKeyword.value = '';
+	sysUserOptions.value = [];
+	dialogVisible.value = true;
+	loadSysUsers();
+}
+
+function isOptionDisabled(opt: S8ConfigSysUserRow): boolean {
+	if (!dialogTarget.value) return false;
+	if (opt.alreadyBoundEmployeeId == null) return false;
+	return opt.alreadyBoundEmployeeId !== dialogTarget.value.employeeId;
+}
+
+async function submitBind() {
+	if (!dialogTarget.value) return;
+	if (!dialogForm.sysUserId) {
+		ElMessage.warning('请选择系统账号');
+		return;
+	}
+	try {
+		await s8ConfigApi.operatorBindings.bind({
+			employeeId: dialogTarget.value.employeeId,
+			sysUserId: dialogForm.sysUserId,
+			factoryRefId: dialogTarget.value.factoryRefId,
+		});
+		ElMessage.success('绑定成功');
+		dialogVisible.value = false;
+		await loadRows();
+	} catch {
+		// 拦截器已弹出业务 4xx 文案
+	}
+}
+
+async function confirmUnbind(row: S8OperatorBindingRow) {
+	try {
+		await ElMessageBox.confirm(
+			`解绑后员工 ${row.name || row.empCode} 不能承担处理人/检验人,直到重新绑定。是否继续?`,
+			'解绑确认',
+			{ type: 'warning', confirmButtonText: '解绑', cancelButtonText: '取消' },
+		);
+	} catch {
+		return; // 用户取消
+	}
+	try {
+		await s8ConfigApi.operatorBindings.unbind(row.employeeId);
+		ElMessage.success('已解绑');
+		await loadRows();
+	} catch {
+		// 拦截器已 toast
+	}
+}
+
+const stats = computed(() => {
+	const total = rows.value.length;
+	const bound = rows.value.filter((r) => r.bindStatus === 'BOUND').length;
+	return { total, bound, unbound: total - bound };
+});
+
+onMounted(loadRows);
+</script>
+
+<template>
+	<div class="operator-binding-tab">
+		<el-form :inline="true" class="filter-bar">
+			<el-form-item label="工厂 ID">
+				<el-input-number v-model="filters.factoryRefId" :min="1" :step="1" :controls="false" style="width: 120px" />
+			</el-form-item>
+			<el-form-item label="绑定状态">
+				<el-select v-model="filters.bindStatus" placeholder="全部" clearable style="width: 140px">
+					<el-option label="全部" value="" />
+					<el-option label="已绑定" value="BOUND" />
+					<el-option label="未绑定" value="UNBOUND" />
+				</el-select>
+			</el-form-item>
+			<el-form-item label="工号 / 姓名">
+				<el-input v-model="filters.keyword" placeholder="empCode / name" clearable style="width: 200px" @keyup.enter="loadRows" />
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" @click="loadRows">查询</el-button>
+				<el-button @click="filters.bindStatus = ''; filters.keyword = ''; loadRows()">重置</el-button>
+			</el-form-item>
+		</el-form>
+
+		<div class="stats-bar">
+			<el-tag>共 {{ stats.total }}</el-tag>
+			<el-tag type="success">已绑定 {{ stats.bound }}</el-tag>
+			<el-tag type="warning">未绑定 {{ stats.unbound }}</el-tag>
+		</div>
+
+		<el-table v-loading="loading" :data="rows" stripe size="small" style="width: 100%">
+			<el-table-column prop="empCode" label="工号" width="160" />
+			<el-table-column prop="name" label="姓名" width="160" />
+			<el-table-column label="绑定账号" min-width="200">
+				<template #default="{ row }">
+					<span v-if="row.bindStatus === 'BOUND'">{{ row.sysUserName }} <span class="muted">({{ row.sysUserId }})</span></span>
+					<span v-else class="muted">—</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="状态" width="120">
+				<template #default="{ row }">
+					<el-tag v-if="row.bindStatus === 'BOUND'" type="success">已绑定</el-tag>
+					<el-tag v-else type="warning">未绑定</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column label="操作" width="200">
+				<template #default="{ row }">
+					<el-button v-if="row.bindStatus === 'UNBOUND'" type="primary" size="small" @click="openBindDialog(row)">绑定</el-button>
+					<template v-else>
+						<el-button size="small" @click="openBindDialog(row)">换绑</el-button>
+						<el-button type="danger" size="small" @click="confirmUnbind(row)">解绑</el-button>
+					</template>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<el-dialog v-model="dialogVisible" :title="`绑定系统账号 - ${dialogTarget?.name ?? ''}`" width="520px">
+			<el-form label-width="100px">
+				<el-form-item label="员工">
+					<span>{{ dialogTarget?.empCode }} / {{ dialogTarget?.name }}</span>
+				</el-form-item>
+				<el-form-item label="系统账号" required>
+					<el-select
+						v-model="dialogForm.sysUserId"
+						filterable
+						remote
+						:remote-method="(q: string) => { sysUserKeyword = q; loadSysUsers(); }"
+						:loading="sysUserLoading"
+						placeholder="搜索 account / realName"
+						style="width: 100%"
+					>
+						<el-option
+							v-for="opt in sysUserOptions"
+							:key="opt.id"
+							:value="opt.id"
+							:label="`${opt.realName ?? ''}(${opt.account ?? ''})`"
+							:disabled="isOptionDisabled(opt)"
+						>
+							<div style="display:flex;justify-content:space-between;align-items:center">
+								<span>{{ opt.realName }} <span class="muted">({{ opt.account }})</span></span>
+								<el-tag v-if="isOptionDisabled(opt)" size="small" type="info">已被绑定</el-tag>
+							</div>
+						</el-option>
+					</el-select>
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="dialogVisible = false">取消</el-button>
+				<el-button type="primary" @click="submitBind">确定</el-button>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<style scoped>
+.operator-binding-tab {
+	padding: 8px 0;
+}
+.filter-bar {
+	margin-bottom: 8px;
+}
+.stats-bar {
+	display: flex;
+	gap: 8px;
+	margin-bottom: 8px;
+}
+.muted {
+	color: #999;
+	font-size: 12px;
+}
+</style>

+ 44 - 12
Web/src/views/aidop/s8/config/S8RolePermissionConfigPage.vue

@@ -2,8 +2,11 @@
 import { ref } from 'vue';
 import { ElMessage } from 'element-plus';
 import S8CrudConfigPage from '../components/config/S8CrudConfigPage.vue';
+import S8OperatorBindingTab from './S8OperatorBindingTab.vue';
 import { s8ConfigApi } from '../api/s8ConfigApi';
 
+const activeTab = ref<'binding' | 'roles'>('binding');
+
 const columns = [
 	{ key: 'roleCode', label: '角色编码', width: 180 },
 	{ key: 'permissionCodes', label: '权限编码' },
@@ -36,16 +39,45 @@ async function syncFromSys(reload: () => void) {
 </script>
 
 <template>
-	<S8CrudConfigPage
-		title="角色权限配置"
-		subtitle="S8 / 配置 / 角色权限"
-		endpoint="/api/aidop/s8/config/roles"
-		:columns="columns"
-		:fields="fields"
-		:build-default="buildDefault"
-	>
-		<template #toolbar-extra="{ reload }">
-			<el-button :loading="syncing" @click="syncFromSys(reload)">同步系统角色</el-button>
-		</template>
-	</S8CrudConfigPage>
+	<div class="s8-role-config-page">
+		<div class="page-header">
+			<h2>角色权限配置</h2>
+			<div class="subtitle">S8 / 配置 / 角色权限</div>
+		</div>
+		<el-tabs v-model="activeTab" class="role-config-tabs">
+			<el-tab-pane label="操作员账号绑定" name="binding">
+				<S8OperatorBindingTab />
+			</el-tab-pane>
+			<el-tab-pane label="角色权限(旧版)" name="roles">
+				<S8CrudConfigPage
+					title="角色权限配置"
+					subtitle="S8 / 配置 / 角色权限(旧版逗号串字段)"
+					endpoint="/api/aidop/s8/config/roles"
+					:columns="columns"
+					:fields="fields"
+					:build-default="buildDefault"
+				>
+					<template #toolbar-extra="{ reload }">
+						<el-button :loading="syncing" @click="syncFromSys(reload)">同步系统角色</el-button>
+					</template>
+				</S8CrudConfigPage>
+			</el-tab-pane>
+		</el-tabs>
+	</div>
 </template>
+
+<style scoped>
+.s8-role-config-page {
+	padding: 16px;
+}
+.page-header {
+	margin-bottom: 12px;
+}
+.page-header h2 {
+	margin: 0;
+}
+.page-header .subtitle {
+	color: #909399;
+	font-size: 12px;
+}
+</style>