|
|
@@ -0,0 +1,371 @@
|
|
|
+<template>
|
|
|
+ <div class="app-sync-container">
|
|
|
+ <div class="page-header">
|
|
|
+ <el-page-header @back="goBack">
|
|
|
+ <template #content>
|
|
|
+ <span class="text-large font-600 mr-3"> 数据同步 </span>
|
|
|
+ </template>
|
|
|
+ <template #extra>
|
|
|
+ <div class="flex items-center">
|
|
|
+ <span v-if="app" class="app-name">应用: {{ app.app_name }}</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-page-header>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-card class="sync-card" v-loading="loading">
|
|
|
+ <template #header>
|
|
|
+ <div class="card-header">
|
|
|
+ <span>同步配置</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-form :model="form" label-width="120px">
|
|
|
+ <el-form-item label="同步范围">
|
|
|
+ <el-radio-group v-model="form.mode">
|
|
|
+ <el-radio label="ALL">所有用户</el-radio>
|
|
|
+ <el-radio label="SELECTED">选择用户</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <div v-if="form.mode === 'SELECTED'" class="user-selection-area">
|
|
|
+ <el-table
|
|
|
+ ref="userTableRef"
|
|
|
+ :data="users"
|
|
|
+ style="width: 100%"
|
|
|
+ border
|
|
|
+ @selection-change="handleSelectionChange"
|
|
|
+ >
|
|
|
+ <el-table-column type="selection" width="55" />
|
|
|
+ <el-table-column prop="name" label="姓名" width="120" />
|
|
|
+ <el-table-column prop="english_name" label="英文名" width="120" />
|
|
|
+ <el-table-column prop="mobile" label="手机号" width="150" />
|
|
|
+ <el-table-column prop="status" label="状态" width="100">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-tag :type="scope.row.status === 'ACTIVE' ? 'success' : 'info'">
|
|
|
+ {{ scope.row.status }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <div class="pagination-container">
|
|
|
+ <el-pagination
|
|
|
+ v-model:current-page="page"
|
|
|
+ v-model:page-size="pageSize"
|
|
|
+ :page-sizes="[10, 20, 50, 100]"
|
|
|
+ layout="total, sizes, prev, pager, next"
|
|
|
+ :total="total"
|
|
|
+ @size-change="fetchUsers"
|
|
|
+ @current-change="fetchUsers"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-form-item label="邮箱设置">
|
|
|
+ <el-checkbox v-model="form.init_email">初始化默认域名邮箱</el-checkbox>
|
|
|
+ <div v-if="form.init_email" class="email-domain-input">
|
|
|
+ <el-input
|
|
|
+ v-model="form.email_domain"
|
|
|
+ placeholder="请输入域名 (例如: @example.com)"
|
|
|
+ style="width: 300px"
|
|
|
+ >
|
|
|
+ <template #prepend>账号 (英文名) + </template>
|
|
|
+ </el-input>
|
|
|
+ <div class="form-tip">
|
|
|
+ 勾选后,将为没有映射邮箱的用户生成邮箱:english_name@domain
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item>
|
|
|
+ <el-button type="primary" @click="handlePreSync" :disabled="form.mode === 'SELECTED' && selectedUsers.length === 0">
|
|
|
+ 开始同步
|
|
|
+ </el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- Confirmation Dialog -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="confirmDialogVisible"
|
|
|
+ title="同步确认"
|
|
|
+ width="500px"
|
|
|
+ >
|
|
|
+ <div class="confirm-content">
|
|
|
+ <el-alert
|
|
|
+ title="安全验证"
|
|
|
+ type="warning"
|
|
|
+ :closable="false"
|
|
|
+ show-icon
|
|
|
+ style="margin-bottom: 20px"
|
|
|
+ >
|
|
|
+ <p>请确认同步信息并进行安全验证。</p>
|
|
|
+ </el-alert>
|
|
|
+
|
|
|
+ <div class="summary-item">
|
|
|
+ <span class="label">同步目标应用:</span>
|
|
|
+ <span class="value">{{ app?.app_name }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="summary-item">
|
|
|
+ <span class="label">同步用户数量:</span>
|
|
|
+ <span class="value highlight">{{ syncCount }} 人</span>
|
|
|
+ </div>
|
|
|
+ <div class="summary-item">
|
|
|
+ <span class="label">同步字段:</span>
|
|
|
+ <span class="value">
|
|
|
+ 账号 (英文名/手机号), 手机号, 状态
|
|
|
+ <span v-if="form.init_email">, 邮箱 ({{ form.email_domain }})</span>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-divider />
|
|
|
+
|
|
|
+ <el-form :model="securityForm" label-width="0">
|
|
|
+ <el-form-item>
|
|
|
+ <div class="verify-code-container">
|
|
|
+ <el-input v-model="securityForm.verificationCode" placeholder="请输入手机验证码" />
|
|
|
+ <el-button type="primary" :disabled="timer > 0" @click="sendCode">
|
|
|
+ {{ timer > 0 ? `${timer}s` : '发送验证码' }}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <span class="dialog-footer">
|
|
|
+ <el-button @click="confirmDialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="confirmSync" :loading="syncing">
|
|
|
+ 确认同步
|
|
|
+ </el-button>
|
|
|
+ </span>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, reactive, onMounted, computed } from 'vue'
|
|
|
+import { useRoute, useRouter } from 'vue-router'
|
|
|
+import { getApp, Application, syncAppUsersV2 } from '../../api/apps'
|
|
|
+import { getUsers, User } from '../../api/users'
|
|
|
+import { sendImportVerificationCode } from '../../api/mapping' // Reusing the send verification code API
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+
|
|
|
+const route = useRoute()
|
|
|
+const router = useRouter()
|
|
|
+const appId = Number(route.params.id)
|
|
|
+
|
|
|
+const app = ref<Application | null>(null)
|
|
|
+const loading = ref(false)
|
|
|
+const syncing = ref(false)
|
|
|
+
|
|
|
+// Form
|
|
|
+const form = reactive({
|
|
|
+ mode: 'ALL',
|
|
|
+ init_email: false,
|
|
|
+ email_domain: '@hnyunzhu.com'
|
|
|
+})
|
|
|
+
|
|
|
+// User Table Data
|
|
|
+const users = ref<User[]>([])
|
|
|
+const total = ref(0)
|
|
|
+const page = ref(1)
|
|
|
+const pageSize = ref(10)
|
|
|
+const selectedUsers = ref<User[]>([])
|
|
|
+
|
|
|
+// Security
|
|
|
+const confirmDialogVisible = ref(false)
|
|
|
+const securityForm = reactive({
|
|
|
+ verificationCode: ''
|
|
|
+})
|
|
|
+const timer = ref(0)
|
|
|
+
|
|
|
+// Computed
|
|
|
+const syncCount = computed(() => {
|
|
|
+ if (form.mode === 'ALL') return total.value // Approximation if we don't fetch all. Ideally backend tells us, but for now we use total.
|
|
|
+ return selectedUsers.value.length
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ if (!appId) {
|
|
|
+ ElMessage.error('Invalid App ID')
|
|
|
+ router.push({ name: 'AppList' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await fetchApp()
|
|
|
+ await fetchUsers()
|
|
|
+})
|
|
|
+
|
|
|
+const fetchApp = async () => {
|
|
|
+ try {
|
|
|
+ const res = await getApp(appId)
|
|
|
+ app.value = res.data
|
|
|
+ } catch (e) {
|
|
|
+ // handled by interceptor
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const fetchUsers = async () => {
|
|
|
+ loading.value = true
|
|
|
+ try {
|
|
|
+ // We only need to fetch users if we are displaying the table.
|
|
|
+ // But we also need 'total' for "ALL" mode estimation?
|
|
|
+ // Yes, fetchUsers gets total.
|
|
|
+ const res = await getUsers({
|
|
|
+ skip: (page.value - 1) * pageSize.value,
|
|
|
+ limit: pageSize.value
|
|
|
+ })
|
|
|
+ users.value = res.data.items
|
|
|
+ total.value = res.data.total
|
|
|
+ } catch (e) {
|
|
|
+ // error
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleSelectionChange = (val: User[]) => {
|
|
|
+ selectedUsers.value = val
|
|
|
+}
|
|
|
+
|
|
|
+const goBack = () => {
|
|
|
+ router.push({ name: 'AppList' })
|
|
|
+}
|
|
|
+
|
|
|
+const validateDomain = (domain: string) => {
|
|
|
+ // Allow optional starting @, then standard domain regex
|
|
|
+ // ^@? indicates optional @ at start
|
|
|
+ // [a-zA-Z0-9] starts the domain part
|
|
|
+ // (?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])? middle part
|
|
|
+ // \. dot
|
|
|
+ // [a-zA-Z]{2,} TLD
|
|
|
+ const regex = /^@?([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
|
|
+ return regex.test(domain)
|
|
|
+}
|
|
|
+
|
|
|
+const handlePreSync = () => {
|
|
|
+ if (form.init_email) {
|
|
|
+ if (!form.email_domain) {
|
|
|
+ ElMessage.warning('请输入邮箱域名')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!validateDomain(form.email_domain)) {
|
|
|
+ ElMessage.warning('邮箱域名格式不正确 (例如: @hnyunzhu.com)')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (form.mode === 'SELECTED' && selectedUsers.value.length === 0) {
|
|
|
+ ElMessage.warning('请选择要同步的用户')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ securityForm.verificationCode = ''
|
|
|
+ confirmDialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+const sendCode = async () => {
|
|
|
+ try {
|
|
|
+ await sendImportVerificationCode() // Using the existing endpoint which sends to current user
|
|
|
+ ElMessage.success('验证码已发送')
|
|
|
+ timer.value = 60
|
|
|
+ const interval = setInterval(() => {
|
|
|
+ timer.value--
|
|
|
+ if (timer.value <= 0) clearInterval(interval)
|
|
|
+ }, 1000)
|
|
|
+ } catch (e) {
|
|
|
+ // error
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const confirmSync = async () => {
|
|
|
+ if (!securityForm.verificationCode) {
|
|
|
+ ElMessage.warning('请输入验证码')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ syncing.value = true
|
|
|
+ try {
|
|
|
+ const userIds = form.mode === 'SELECTED' ? selectedUsers.value.map(u => u.id) : []
|
|
|
+
|
|
|
+ const res = await syncAppUsersV2(appId, {
|
|
|
+ mode: form.mode as 'ALL' | 'SELECTED',
|
|
|
+ user_ids: userIds,
|
|
|
+ init_email: form.init_email,
|
|
|
+ email_domain: form.email_domain,
|
|
|
+ verification_code: securityForm.verificationCode
|
|
|
+ })
|
|
|
+
|
|
|
+ ElMessage.success(res.data.message)
|
|
|
+ confirmDialogVisible.value = false
|
|
|
+ // Maybe go back or stay?
|
|
|
+ // User might want to see logs?
|
|
|
+ // Let's stay but refresh selection?
|
|
|
+ // Or go back to app list.
|
|
|
+ // Let's offer to go to logs or close.
|
|
|
+ // For now, simple success message.
|
|
|
+ } catch (e) {
|
|
|
+ // error
|
|
|
+ } finally {
|
|
|
+ syncing.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.app-sync-container {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+.page-header {
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+.sync-card {
|
|
|
+ max-width: 1000px;
|
|
|
+ margin: 0 auto;
|
|
|
+}
|
|
|
+.user-selection-area {
|
|
|
+ margin-top: 20px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+.pagination-container {
|
|
|
+ margin-top: 15px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+}
|
|
|
+.email-domain-input {
|
|
|
+ margin-top: 10px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+.form-tip {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ margin-top: 5px;
|
|
|
+}
|
|
|
+.confirm-content {
|
|
|
+ padding: 10px;
|
|
|
+}
|
|
|
+.summary-item {
|
|
|
+ margin-bottom: 10px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+}
|
|
|
+.summary-item .label {
|
|
|
+ font-weight: bold;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+.summary-item .value {
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+.summary-item .value.highlight {
|
|
|
+ color: #409EFF;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.verify-code-container {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|