|
|
|
|
@ -6,18 +6,26 @@
|
|
|
|
|
<div class="kt-modal-card kt-modal-card--fullscreen">
|
|
|
|
|
<div class="kt-modal-header">
|
|
|
|
|
<h3 class="kt-modal-title">用户管理后台 <span class="kt-modal-title-en">USER MANAGEMENT</span></h3>
|
|
|
|
|
<button class="kt-close-btn" @click="$emit('close')" aria-label="关闭"><i class="fas fa-times" aria-hidden="true"></i></button>
|
|
|
|
|
<button class="kt-close-btn" @click="handleClose" aria-label="关闭"><i class="fas fa-times" aria-hidden="true"></i></button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="kt-modal-body">
|
|
|
|
|
<div class="kt-toolbar">
|
|
|
|
|
<div class="kt-search-group">
|
|
|
|
|
<i class="fas fa-search kt-search-icon" aria-hidden="true"></i>
|
|
|
|
|
<input type="text" placeholder="搜索用户..." class="kt-search-input" v-model="searchKeyword" @keyup.enter="fetchAdminData" />
|
|
|
|
|
<span class="kt-tip-text">💡点击表头排序</span>
|
|
|
|
|
<i class="fas fa-search kt-search-icon clickable" @click="handleSearch" aria-hidden="true" title="点击搜索"></i>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="搜索用户..."
|
|
|
|
|
class="kt-search-input"
|
|
|
|
|
v-model="searchKeyword"
|
|
|
|
|
@keyup.enter="handleSearch"
|
|
|
|
|
/>
|
|
|
|
|
<span class="kt-tip-text">💡支持 ID / 用户名 / 邮箱</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-btn-group">
|
|
|
|
|
<button class="kt-btn" @click="fetchAdminData">刷新</button>
|
|
|
|
|
<button class="kt-btn" @click="handleRefresh" :disabled="isRefreshing">
|
|
|
|
|
<i class="fas fa-sync-alt" :class="{ 'fa-spin': isRefreshing }"></i> 刷新
|
|
|
|
|
</button>
|
|
|
|
|
<button class="kt-btn kt-btn--primary" @click="openUserForm('create')">
|
|
|
|
|
<i class="fas fa-plus" aria-hidden="true"></i> 新建
|
|
|
|
|
</button>
|
|
|
|
|
@ -26,58 +34,70 @@
|
|
|
|
|
|
|
|
|
|
<div class="kt-table-wrapper">
|
|
|
|
|
<div class="kt-user-list">
|
|
|
|
|
<!-- 列表头:严格 5 列布局 -->
|
|
|
|
|
<div class="kt-list-header">
|
|
|
|
|
<span class="kt-sortable" @click="handleSort('user_id', $event)">ID <i :class="getSortIcon('user_id')" aria-hidden="true"></i></span>
|
|
|
|
|
<span class="kt-sortable" @click="handleSort('username', $event)">用户名 <i :class="getSortIcon('username')" aria-hidden="true"></i></span>
|
|
|
|
|
<span class="kt-sortable" @click="handleSort('email', $event)">邮箱 <i :class="getSortIcon('email')" aria-hidden="true"></i></span>
|
|
|
|
|
<span class="kt-sortable" @click="handleSort('role', $event)">角色 <i :class="getSortIcon('role')" aria-hidden="true"></i></span>
|
|
|
|
|
<span>操作</span>
|
|
|
|
|
<div class="kt-sortable" @click="handleSort('user_id', $event)">
|
|
|
|
|
<span>ID</span>
|
|
|
|
|
<i :class="getSortIcon('user_id')" aria-hidden="true"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-sortable" @click="handleSort('username', $event)">
|
|
|
|
|
<span>用户名</span>
|
|
|
|
|
<i :class="getSortIcon('username')" aria-hidden="true"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-sortable" @click="handleSort('email', $event)">
|
|
|
|
|
<span>邮箱</span>
|
|
|
|
|
<i :class="getSortIcon('email')" aria-hidden="true"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- 角色表头居中 -->
|
|
|
|
|
<div class="kt-sortable kt-col--center" @click="handleSort('role', $event)">
|
|
|
|
|
<span>角色</span>
|
|
|
|
|
<i :class="getSortIcon('role')" aria-hidden="true"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- 操作表头靠右 -->
|
|
|
|
|
<div class="kt-col--right">操作</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="sortedAdminUsers.length === 0" class="kt-empty-row">暂无数据</div>
|
|
|
|
|
|
|
|
|
|
<!-- 修改:遍历 sortedAdminUsers 而非 adminUsers -->
|
|
|
|
|
<div v-for="u in sortedAdminUsers" :key="u.user_id" class="kt-list-row">
|
|
|
|
|
<span class="kt-id-text">#{{ u.user_id }}</span>
|
|
|
|
|
<span class="kt-username">{{ u.username }}</span>
|
|
|
|
|
<span class="kt-email">{{ u.email }}</span>
|
|
|
|
|
<span>
|
|
|
|
|
<div v-if="paginatedUsers.length === 0" class="kt-empty-row">暂无数据</div>
|
|
|
|
|
|
|
|
|
|
<!-- 列表行:比例与 Header 严格一致 -->
|
|
|
|
|
<div v-for="u in paginatedUsers" :key="u.user_id" class="kt-list-row">
|
|
|
|
|
<div class="kt-col">#{{ u.user_id }}</div>
|
|
|
|
|
<div class="kt-col kt-username">{{ u.username }}</div>
|
|
|
|
|
<div class="kt-col kt-email">{{ u.email }}</div>
|
|
|
|
|
<div class="kt-col kt-col--center">
|
|
|
|
|
<span class="kt-role-tag" :class="'kt-role--' + getRoleClass(u.role)">
|
|
|
|
|
{{ formatRole(u.role) }}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
<div class="kt-actions">
|
|
|
|
|
<button class="kt-text-btn" @click="openUserForm('edit', u)">编辑</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-col kt-col--right kt-actions">
|
|
|
|
|
<button class="kt-text-btn kt-text-btn--edit" @click="openUserForm('edit', u)">编辑</button>
|
|
|
|
|
<button class="kt-text-btn kt-text-btn--danger" @click="handleDeleteUser(u)">删除</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="kt-pagination">
|
|
|
|
|
<button class="kt-page-btn" :disabled="currentPage <= 1" @click="changePage(-1)" aria-label="上一页">
|
|
|
|
|
<i class="fas fa-chevron-left" aria-hidden="true"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<span class="kt-page-info">第 {{ currentPage }} 页</span>
|
|
|
|
|
<button class="kt-page-btn" @click="changePage(1)" aria-label="下一页">
|
|
|
|
|
<i class="fas fa-chevron-right" aria-hidden="true"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="kt-page-btn" :disabled="currentPage <= 1" @click="changePage(-1)"><i class="fas fa-chevron-left"></i></button>
|
|
|
|
|
<span class="kt-page-info">第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
|
|
|
|
|
<button class="kt-page-btn" :disabled="currentPage >= totalPages" @click="changePage(1)"><i class="fas fa-chevron-right"></i></button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- D. 管理员 VIP 邀请码生成 (新增) -->
|
|
|
|
|
<!-- D. 管理员 VIP 邀请码生成 -->
|
|
|
|
|
<div v-else-if="subpageType === 'admin-vip'" class="kt-modal-wrapper">
|
|
|
|
|
<div class="kt-modal-card">
|
|
|
|
|
<div class="kt-modal-header">
|
|
|
|
|
<h3 class="kt-modal-title">邀请码管理 <span class="kt-modal-title-en">VIP CODES</span></h3>
|
|
|
|
|
<button class="kt-close-btn" @click="$emit('close')"><i class="fas fa-times"></i></button>
|
|
|
|
|
<button class="kt-close-btn" @click="handleClose"><i class="fas fa-times"></i></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-modal-body">
|
|
|
|
|
<div class="kt-form">
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">有效期 (天)</label>
|
|
|
|
|
<input type="number" v-model.number="vipGenForm.days" class="kt-form-input" />
|
|
|
|
|
<label class="kt-form-label">有效期 (天,整数)</label>
|
|
|
|
|
<input type="number" v-model.number="vipGenForm.days" class="kt-form-input" placeholder="例如: 30" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">生成数量 (1-10)</label>
|
|
|
|
|
@ -86,16 +106,10 @@
|
|
|
|
|
<div class="kt-form-actions">
|
|
|
|
|
<button class="kt-btn kt-btn--primary" @click="handleGenerateCodes">生成</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="generatedCodes.length > 0" class="kt-codes-result">
|
|
|
|
|
<p class="kt-codes-title">生成成功 (点击复制):</p>
|
|
|
|
|
<div class="kt-code-list">
|
|
|
|
|
<div
|
|
|
|
|
v-for="code in generatedCodes"
|
|
|
|
|
:key="code"
|
|
|
|
|
class="kt-code-item"
|
|
|
|
|
@click="copyCode(code)"
|
|
|
|
|
>
|
|
|
|
|
<div v-for="code in generatedCodes" :key="code" class="kt-code-item" @click="copyCode(code)">
|
|
|
|
|
{{ code }} <i class="fas fa-copy"></i>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@ -105,125 +119,93 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- E. VIP 升级/状态查看 (新增) -->
|
|
|
|
|
<!-- E. VIP 升级/状态查看 -->
|
|
|
|
|
<div v-else-if="subpageType === 'vip'" class="kt-modal-wrapper">
|
|
|
|
|
<div class="kt-modal-card">
|
|
|
|
|
<div class="kt-modal-header">
|
|
|
|
|
<h3 class="kt-modal-title">VIP 会员 <span class="kt-modal-title-en">PRIVILEGES</span></h3>
|
|
|
|
|
<button class="kt-close-btn" @click="$emit('close')"><i class="fas fa-times"></i></button>
|
|
|
|
|
<button class="kt-close-btn" @click="handleClose"><i class="fas fa-times"></i></button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="kt-modal-body">
|
|
|
|
|
<div class="kt-vip-benefits">
|
|
|
|
|
<div class="kt-benefit-item">
|
|
|
|
|
<i class="fas fa-bolt"></i>
|
|
|
|
|
<div>
|
|
|
|
|
<div class="kt-benefit-title">极速并发</div>
|
|
|
|
|
<div class="kt-benefit-desc">最大并发任务数提升至 10 个</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-benefit-item">
|
|
|
|
|
<i class="fas fa-database"></i>
|
|
|
|
|
<div>
|
|
|
|
|
<div class="kt-benefit-title">全库解锁</div>
|
|
|
|
|
<div class="kt-benefit-desc">解锁艺术品数据集防护权限</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-benefit-item">
|
|
|
|
|
<i class="fas fa-magic"></i>
|
|
|
|
|
<div>
|
|
|
|
|
<div class="kt-benefit-title">高级微调</div>
|
|
|
|
|
<div class="kt-benefit-desc">允许上传自定义图片进行微调</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-benefit-item"><i class="fas fa-bolt"></i><div><div class="kt-benefit-title">极速并发</div><div class="kt-benefit-desc">最大并发任务数提升至 10 个</div></div></div>
|
|
|
|
|
<div class="kt-benefit-item"><i class="fas fa-database"></i><div><div class="kt-benefit-title">全库解锁</div><div class="kt-benefit-desc">解锁艺术品数据集防护权限</div></div></div>
|
|
|
|
|
<div class="kt-benefit-item"><i class="fas fa-magic"></i><div><div class="kt-benefit-title">高级微调</div><div class="kt-benefit-desc">允许上传自定义图片进行微调</div></div></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="kt-divider"></div>
|
|
|
|
|
|
|
|
|
|
<div v-if="userStore.role === 'vip' || userStore.role === 'admin'" class="kt-vip-status active">
|
|
|
|
|
<i class="fas fa-crown"></i>
|
|
|
|
|
<span>尊贵的 VIP 用户</span>
|
|
|
|
|
<i class="fas fa-crown"></i><span>尊贵的 VIP 用户</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-else class="kt-form">
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">输入邀请码</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
v-model="vipUpgradeCode"
|
|
|
|
|
class="kt-form-input"
|
|
|
|
|
placeholder="VIP-XXXXXXXX"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-form-actions">
|
|
|
|
|
<button class="kt-btn kt-btn--primary" @click="handleUpgradeVip">立即激活</button>
|
|
|
|
|
<input type="text" v-model="vipUpgradeCode" class="kt-form-input" placeholder="VIP-XXXXXXXX"/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-form-actions"><button class="kt-btn kt-btn--primary" @click="handleUpgradeVip">立即激活</button></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- A/B. 普通弹窗 (修改密码/系统配置) -->
|
|
|
|
|
<!-- A/B. 普通弹窗 (修改密码/默认配置) -->
|
|
|
|
|
<div v-else class="kt-modal-wrapper">
|
|
|
|
|
<div class="kt-modal-card">
|
|
|
|
|
<div class="kt-modal-header">
|
|
|
|
|
<h3 class="kt-modal-title">{{ modalTitle }} <span class="kt-modal-title-en">{{ modalTitleEn }}</span></h3>
|
|
|
|
|
<button class="kt-close-btn" @click="$emit('close')" aria-label="关闭"><i class="fas fa-times" aria-hidden="true"></i></button>
|
|
|
|
|
<button class="kt-close-btn" @click="handleClose" aria-label="关闭"><i class="fas fa-times" aria-hidden="true"></i></button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="kt-modal-body">
|
|
|
|
|
<!-- A. 修改密码 -->
|
|
|
|
|
<div v-if="subpageType === 'password'" class="kt-form">
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">当前密码</label>
|
|
|
|
|
<input type="password" v-model="pwdForm.oldPassword" class="kt-form-input" />
|
|
|
|
|
<input type="password" v-model="pwdForm.oldPassword" class="kt-form-input" placeholder="输入当前密码" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">新密码</label>
|
|
|
|
|
<input type="password" v-model="pwdForm.newPassword" class="kt-form-input" />
|
|
|
|
|
<input type="password" v-model="pwdForm.newPassword" class="kt-form-input" placeholder="输入新密码" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">确认新密码</label>
|
|
|
|
|
<input type="password" v-model="pwdForm.confirmPassword" class="kt-form-input" />
|
|
|
|
|
<input type="password" v-model="pwdForm.confirmPassword" class="kt-form-input" placeholder="再次输入新密码" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-form-actions">
|
|
|
|
|
<button class="kt-btn kt-btn--primary" @click="submitPassword">确认修改</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- B. 默认配置 -->
|
|
|
|
|
<div v-else-if="subpageType === 'config'" class="kt-form">
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">默认防护对象</label>
|
|
|
|
|
<select v-model="configForm.data_type_id" class="kt-form-select" @change="onConfigDataTypeChange">
|
|
|
|
|
<option :value="1">通用人脸防护</option>
|
|
|
|
|
<option :value="2">通用艺术品防护</option>
|
|
|
|
|
</select>
|
|
|
|
|
<KtSelect v-model="configForm.data_type_id" :options="dataTypeOptions" @change="onConfigDataTypeChange"/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">默认扰动算法</label>
|
|
|
|
|
<select v-model="configForm.perturbation_configs_id" class="kt-form-select">
|
|
|
|
|
<option :value="null">不设置默认值</option>
|
|
|
|
|
<option
|
|
|
|
|
v-for="algo in filteredConfigAlgorithms"
|
|
|
|
|
:key="algo.id"
|
|
|
|
|
:value="algo.id"
|
|
|
|
|
>
|
|
|
|
|
{{ algo.method_name }}
|
|
|
|
|
</option>
|
|
|
|
|
</select>
|
|
|
|
|
<KtSelect v-model="configForm.perturbation_configs_id" :options="algoOptionsWithNone"/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">默认强度 (浮点数)</label>
|
|
|
|
|
<input type="number" step="0.1" v-model.number="configForm.perturbation_intensity" class="kt-form-input" />
|
|
|
|
|
<div class="kt-form-header">
|
|
|
|
|
<label class="kt-form-label" style="margin-bottom: 0;">默认强度</label>
|
|
|
|
|
<div class="kt-mode-toggle" v-if="configForm.perturbation_configs_id">
|
|
|
|
|
<span :class="{ active: !isCustomMode }" @click="toggleStrengthMode(false)">预设</span>
|
|
|
|
|
<span :class="{ active: isCustomMode }" @click="toggleStrengthMode(true)">自定义</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="configForm.perturbation_configs_id">
|
|
|
|
|
<div v-if="!isCustomMode" class="kt-strength-selector">
|
|
|
|
|
<div class="kt-strength-item" :class="{ active: configForm.perturbation_intensity === presetLow }" @click="configForm.perturbation_intensity = presetLow">低</div>
|
|
|
|
|
<div class="kt-strength-item" :class="{ active: configForm.perturbation_intensity === presetMid }" @click="configForm.perturbation_intensity = presetMid">中</div>
|
|
|
|
|
<div class="kt-strength-item" :class="{ active: configForm.perturbation_intensity === presetHigh }" @click="configForm.perturbation_intensity = presetHigh">高</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else><IntensitySlider v-model="configForm.perturbation_intensity" :min="sliderConfig.min" :max="sliderConfig.max" :step="sliderConfig.step" /></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else class="kt-tip-text">请先选择算法以设置强度</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="kt-form-actions">
|
|
|
|
|
<button class="kt-btn kt-btn--primary" @click="submitConfig">保存配置</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@ -240,23 +222,19 @@
|
|
|
|
|
<div class="kt-form">
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">用户名</label>
|
|
|
|
|
<input type="text" v-model="userForm.username" class="kt-form-input" :disabled="userModalMode === 'edit'" />
|
|
|
|
|
<input type="text" v-model="userForm.username" class="kt-form-input" :disabled="userModalMode === 'edit'" placeholder="输入用户名" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-form-group" v-if="userModalMode === 'create'">
|
|
|
|
|
<label class="kt-form-label">密码</label>
|
|
|
|
|
<input type="password" v-model="userForm.password" class="kt-form-input" />
|
|
|
|
|
<input type="password" v-model="userForm.password" class="kt-form-input" placeholder="输入密码" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">邮箱</label>
|
|
|
|
|
<input type="email" v-model="userForm.email" class="kt-form-input" />
|
|
|
|
|
<input type="email" v-model="userForm.email" class="kt-form-input" placeholder="输入邮箱" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-form-group">
|
|
|
|
|
<label class="kt-form-label">角色</label>
|
|
|
|
|
<select v-model="userForm.role" class="kt-form-select">
|
|
|
|
|
<option value="normal">普通用户 (Normal)</option>
|
|
|
|
|
<option value="vip">VIP用户</option>
|
|
|
|
|
<option value="admin">管理员 (Admin)</option>
|
|
|
|
|
</select>
|
|
|
|
|
<label class="kt-form-label">角色权限</label>
|
|
|
|
|
<KtSelect v-model="userForm.role" :options="roleOptions" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="kt-form-actions">
|
|
|
|
|
<button class="kt-btn" @click="showUserModal = false">取消</button>
|
|
|
|
|
@ -272,7 +250,8 @@
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { ref, computed, onMounted, reactive } from 'vue'
|
|
|
|
|
import { ref, computed, onMounted, reactive, watch, nextTick } from 'vue'
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
import {
|
|
|
|
|
authChangePassword, getUserConfig, updateUserConfig,
|
|
|
|
|
getAdminUserList, createAdminUser, updateAdminUser, deleteAdminUser
|
|
|
|
|
@ -280,312 +259,214 @@ import {
|
|
|
|
|
import { upgradeToVip } from '@/api/user'
|
|
|
|
|
import { generateVipCodes } from '@/api/admin'
|
|
|
|
|
import { useUserStore } from '@/stores/userStore'
|
|
|
|
|
import { ALGO_OPTIONS_Data, DATA_TYPE_MAP } from '@/utils/constants'
|
|
|
|
|
import { ALGO_OPTIONS_Data, DATA_TYPE_MAP, ALGO_MAP } from '@/utils/constants'
|
|
|
|
|
import modal from '@/utils/modal'
|
|
|
|
|
import KtSelect from '@/components/KtSelect.vue'
|
|
|
|
|
import IntensitySlider from '@/components/IntensitySlider.vue'
|
|
|
|
|
|
|
|
|
|
const props = defineProps(['subpageType'])
|
|
|
|
|
const emit = defineEmits(['close'])
|
|
|
|
|
const userStore = useUserStore()
|
|
|
|
|
const route = useRoute(); const router = useRouter(); const userStore = useUserStore()
|
|
|
|
|
|
|
|
|
|
// 状态支持路由参数与 Props 混合
|
|
|
|
|
const subpageType = computed(() => route.params.subpage || props.subpageType)
|
|
|
|
|
const handleClose = () => { if (route.params.subpage) router.push('/'); else emit('close') }
|
|
|
|
|
|
|
|
|
|
const modalTitle = computed(() => {
|
|
|
|
|
const map = { password: '修改密码', config: '系统配置', admin: '用户管理后台', 'admin-vip': '邀请码管理', vip: 'VIP会员' }
|
|
|
|
|
return map[props.subpageType] || ''
|
|
|
|
|
const map = { password: '修改密码', config: '默认配置', admin: '用户管理后台', 'admin-vip': '邀请码管理', vip: 'VIP会员' }
|
|
|
|
|
return map[subpageType.value] || ''
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const modalTitleEn = computed(() => {
|
|
|
|
|
const map = { password: 'CHANGE PASSWORD', config: 'SYSTEM CONFIG', admin: 'USER MANAGEMENT', 'admin-vip': 'VIP CODES', vip: 'MEMBERSHIP' }
|
|
|
|
|
return map[props.subpageType] || ''
|
|
|
|
|
const map = { password: 'CHANGE PASSWORD', config: 'DEFAULT CONFIGURATION', admin: 'USER MANAGEMENT', 'admin-vip': 'VIP CODES', vip: 'MEMBERSHIP' }
|
|
|
|
|
return map[subpageType.value] || ''
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// === VIP 升级逻辑 ===
|
|
|
|
|
const vipUpgradeCode = ref('')
|
|
|
|
|
const dataTypeOptions = [{ label: '通用人脸防护', value: 1 }, { label: '通用艺术品防护', value: 2 }]
|
|
|
|
|
const roleOptions = [{ label: '普通用户 (Normal)', value: 'normal' }, { label: 'VIP用户', value: 'vip' }, { label: '管理员 (Admin)', value: 'admin' }]
|
|
|
|
|
|
|
|
|
|
const vipUpgradeCode = ref('')
|
|
|
|
|
const handleUpgradeVip = async () => {
|
|
|
|
|
if (!vipUpgradeCode.value) return modal.warning('请输入邀请码')
|
|
|
|
|
try {
|
|
|
|
|
const res = await upgradeToVip({ vip_code: vipUpgradeCode.value })
|
|
|
|
|
await modal.success(res.message || '升级成功!')
|
|
|
|
|
if (res.user) {
|
|
|
|
|
userStore.updateUserInfo(res.user)
|
|
|
|
|
}
|
|
|
|
|
emit('close')
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e)
|
|
|
|
|
}
|
|
|
|
|
try { const res = await upgradeToVip({ vip_code: vipUpgradeCode.value }); await modal.success(res.message || '升级成功!'); if (res.user) userStore.updateUserInfo(res.user); handleClose() } catch (e) { console.error(e) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === Admin 邀请码生成逻辑 ===
|
|
|
|
|
const vipGenForm = ref({ days: 30, count: 1 })
|
|
|
|
|
const generatedCodes = ref([])
|
|
|
|
|
|
|
|
|
|
const vipGenForm = ref({ days: 30, count: 1 }); const generatedCodes = ref([])
|
|
|
|
|
const handleGenerateCodes = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await generateVipCodes({
|
|
|
|
|
expires_days: vipGenForm.value.days,
|
|
|
|
|
count: vipGenForm.value.count
|
|
|
|
|
})
|
|
|
|
|
generatedCodes.value = res.codes || []
|
|
|
|
|
modal.success(`成功生成 ${res.codes.length} 个邀请码`)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const copyCode = (code) => {
|
|
|
|
|
navigator.clipboard.writeText(code)
|
|
|
|
|
modal.info('已复制到剪贴板')
|
|
|
|
|
if (!Number.isInteger(vipGenForm.value.days) || vipGenForm.value.days <= 0) return modal.warning('请输入有效的正整数天数')
|
|
|
|
|
if (!Number.isInteger(vipGenForm.value.count) || vipGenForm.value.count <= 0) return modal.warning('生成数量必须为正整数')
|
|
|
|
|
try { const res = await generateVipCodes({ expires_days: vipGenForm.value.days, count: vipGenForm.value.count }); generatedCodes.value = res.codes || []; modal.success(`成功生成 ${res.codes.length} 个邀请码`) } catch (e) { console.error(e) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === Config Logic ===
|
|
|
|
|
const configForm = ref({ data_type_id: 1, perturbation_configs_id: null, perturbation_intensity: null })
|
|
|
|
|
const filteredConfigAlgorithms = computed(() => {
|
|
|
|
|
const typeStr = configForm.value.data_type_id === DATA_TYPE_MAP.ART ? 'art' : 'face'
|
|
|
|
|
return ALGO_OPTIONS_Data.filter(a => a.type === typeStr)
|
|
|
|
|
})
|
|
|
|
|
const onConfigDataTypeChange = () => {
|
|
|
|
|
const currentAlgoId = configForm.value.perturbation_configs_id
|
|
|
|
|
if (currentAlgoId) {
|
|
|
|
|
const algo = ALGO_OPTIONS_Data.find(a => a.id === currentAlgoId)
|
|
|
|
|
const newTypeStr = configForm.value.data_type_id === DATA_TYPE_MAP.ART ? 'art' : 'face'
|
|
|
|
|
if (algo && algo.type !== newTypeStr) configForm.value.perturbation_configs_id = null
|
|
|
|
|
const copyCode = async (code) => {
|
|
|
|
|
try { if (navigator.clipboard) { await navigator.clipboard.writeText(code); modal.info('已复制到剪贴板') } else throw new Error() } catch {
|
|
|
|
|
const t = document.createElement("textarea"); t.value = code; t.style.position = "fixed"; document.body.appendChild(t); t.select();
|
|
|
|
|
if (document.execCommand('copy')) modal.info('已复制到剪贴板'); document.body.removeChild(t)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const fetchConfig = async () => {
|
|
|
|
|
const res = await getUserConfig();
|
|
|
|
|
if (res?.config) {
|
|
|
|
|
configForm.value = {
|
|
|
|
|
data_type_id: res.config.data_type_id || 1,
|
|
|
|
|
perturbation_configs_id: res.config.perturbation_configs_id,
|
|
|
|
|
perturbation_intensity: res.config.perturbation_intensity
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const submitConfig = async () => { await updateUserConfig(configForm.value); modal.success('配置已保存'); emit('close') }
|
|
|
|
|
|
|
|
|
|
// === Password Logic ===
|
|
|
|
|
const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
|
|
|
|
const submitPassword = async () => {
|
|
|
|
|
if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) return modal.warning('两次输入密码不一致')
|
|
|
|
|
try {
|
|
|
|
|
await authChangePassword({ old_password: pwdForm.value.oldPassword, new_password: pwdForm.value.newPassword })
|
|
|
|
|
modal.success('密码修改成功'); emit('close')
|
|
|
|
|
} catch(e) { console.error(e) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// === Admin Logic ===
|
|
|
|
|
const adminUsers = ref([])
|
|
|
|
|
const searchKeyword = ref('')
|
|
|
|
|
const currentPage = ref(1)
|
|
|
|
|
const sortRules = ref([])
|
|
|
|
|
const showUserModal = ref(false)
|
|
|
|
|
const userModalMode = ref('create')
|
|
|
|
|
const userForm = reactive({ user_id: null, username: '', password: '', email: '', role: 'normal' })
|
|
|
|
|
|
|
|
|
|
const formatRole = (role) => { const map = { 'admin': '管理员', 'vip': 'VIP', 'normal': '普通' }; return map[role] || '普通' }
|
|
|
|
|
const getRoleClass = (role) => { const map = { 'admin': 'admin', 'vip': 'vip', 'normal': 'user' }; return map[role] || 'user' }
|
|
|
|
|
|
|
|
|
|
const fetchAdminData = async () => {
|
|
|
|
|
try {
|
|
|
|
|
// 依然发送排序请求给后端,以防后端实现了
|
|
|
|
|
const sortParam = sortRules.value.map(r => `${r.field}:${r.direction}`).join(',')
|
|
|
|
|
const res = await getAdminUserList({ page: currentPage.value, per_page: 20, q: searchKeyword.value, sort: sortParam })
|
|
|
|
|
if (res?.users) adminUsers.value = res.users
|
|
|
|
|
} catch (e) { console.error(e) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const changePage = (delta) => { currentPage.value += delta; fetchAdminData() }
|
|
|
|
|
|
|
|
|
|
// === 前端即时排序逻辑 ===
|
|
|
|
|
const sortedAdminUsers = computed(() => {
|
|
|
|
|
// 浅拷贝一份数据,避免直接修改原数组
|
|
|
|
|
const data = [...adminUsers.value]
|
|
|
|
|
const rule = sortRules.value[0] // 目前只取第一个排序规则
|
|
|
|
|
|
|
|
|
|
if (!rule) return data // 如果没有规则,返回原顺序
|
|
|
|
|
|
|
|
|
|
return data.sort((a, b) => {
|
|
|
|
|
let valA = a[rule.field]
|
|
|
|
|
let valB = b[rule.field]
|
|
|
|
|
|
|
|
|
|
// 特殊处理 ID (数字)
|
|
|
|
|
if (rule.field === 'user_id') {
|
|
|
|
|
return rule.direction === 'asc' ? valA - valB : valB - valA
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 默认字符串处理 (忽略大小写)
|
|
|
|
|
valA = String(valA || '').toLowerCase()
|
|
|
|
|
valB = String(valB || '').toLowerCase()
|
|
|
|
|
|
|
|
|
|
if (valA < valB) return rule.direction === 'asc' ? -1 : 1
|
|
|
|
|
if (valA > valB) return rule.direction === 'asc' ? 1 : -1
|
|
|
|
|
return 0
|
|
|
|
|
const configForm = ref({ data_type_id: 1, perturbation_configs_id: null, perturbation_intensity: null }); const isCustomMode = ref(false)
|
|
|
|
|
const algorithmSettings = computed(() => {
|
|
|
|
|
const algoId = configForm.value.perturbation_configs_id
|
|
|
|
|
if ([ALGO_MAP.SIMAC, ALGO_MAP.ASPL, ALGO_MAP.CAAT_PRO].includes(algoId)) return { min: 10, max: 16, step: 1, presets: { low: 10, mid: 12, high: 16 }, default: 12 }
|
|
|
|
|
if ([ALGO_MAP.GLAZE, ALGO_MAP.PID, ALGO_MAP.STYLE_PROTECTION, ALGO_MAP.QUICK].includes(algoId)) return { min: 0.01, max: 0.2, step: 0.01, presets: { low: 0.03, mid: 0.05, high: 0.1 }, default: 0.05 }
|
|
|
|
|
return { min: 0, max: 100, step: 1, presets: { low: 25, mid: 50, high: 75 }, default: 50 }
|
|
|
|
|
})
|
|
|
|
|
const sliderConfig = computed(() => ({ min: algorithmSettings.value.min, max: algorithmSettings.value.max, step: algorithmSettings.value.step }))
|
|
|
|
|
const presetLow = computed(() => algorithmSettings.value.presets.low), presetMid = computed(() => algorithmSettings.value.presets.mid), presetHigh = computed(() => algorithmSettings.value.presets.high)
|
|
|
|
|
watch(() => configForm.value.perturbation_configs_id, (n, o) => { if (o !== undefined && n !== o) { configForm.value.perturbation_intensity = algorithmSettings.value.default; isCustomMode.value = false } })
|
|
|
|
|
const toggleStrengthMode = (c) => { isCustomMode.value = c; if (!c) configForm.value.perturbation_intensity = algorithmSettings.value.presets.mid }
|
|
|
|
|
const filteredConfigAlgorithms = computed(() => ALGO_OPTIONS_Data.filter(a => a.type === (configForm.value.data_type_id === 2 ? 'art' : 'face')))
|
|
|
|
|
const algoOptionsWithNone = computed(() => [{ label: '不设置默认值', value: null }, ...filteredConfigAlgorithms.value.map(a => ({ label: a.method_name, value: a.id }))])
|
|
|
|
|
const onConfigDataTypeChange = () => { if (configForm.value.perturbation_configs_id) { if (ALGO_OPTIONS_Data.find(a => a.id === configForm.value.perturbation_configs_id)?.type !== (configForm.value.data_type_id === 2 ? 'art' : 'face')) configForm.value.perturbation_configs_id = null } }
|
|
|
|
|
const fetchConfig = async () => { const res = await getUserConfig(); if (res?.config) { configForm.value = { data_type_id: res.config.data_type_id || 1, perturbation_configs_id: res.config.perturbation_configs_id, perturbation_intensity: res.config.perturbation_intensity }; nextTick(() => { const p = algorithmSettings.value.presets; isCustomMode.value = !(res.config.perturbation_intensity === p.low || res.config.perturbation_intensity === p.mid || res.config.perturbation_intensity === p.high) }) } }
|
|
|
|
|
const submitConfig = async () => { await updateUserConfig(configForm.value); modal.success('配置已保存'); handleClose() }
|
|
|
|
|
|
|
|
|
|
const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' }); const submitPassword = async () => { if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) return modal.warning('两次输入不一致'); try { await authChangePassword({ old_password: pwdForm.value.oldPassword, new_password: pwdForm.value.newPassword }); modal.success('密码修改成功'); handleClose() } catch(e) {} }
|
|
|
|
|
|
|
|
|
|
const allAdminUsers = ref([]); const searchKeyword = ref(''); const currentPage = ref(1); const sortRules = ref([]); const isRefreshing = ref(false); const pageSize = 20
|
|
|
|
|
const totalPages = computed(() => Math.ceil(filteredUsers.value.length / pageSize) || 1)
|
|
|
|
|
const formatRole = (r) => ({ admin: '管理员', vip: 'VIP', normal: '普通' }[r] || '普通'), getRoleClass = (r) => ({ admin: 'admin', vip: 'vip', normal: 'user' }[r] || 'user')
|
|
|
|
|
const fetchAdminData = async (t = false) => { if (t) isRefreshing.value = true; try { const res = await getAdminUserList({ page: 1, per_page: 10000 }); if (res?.users) allAdminUsers.value = res.users; if (t) modal.success('已刷新') } catch(e) {} finally { if (t) setTimeout(() => isRefreshing.value = false, 600) } }
|
|
|
|
|
const filteredUsers = computed(() => { const k = searchKeyword.value.trim().toLowerCase(); return k ? allAdminUsers.value.filter(u => String(u.user_id).includes(k) || u.username?.toLowerCase().includes(k) || u.email?.toLowerCase().includes(k)) : allAdminUsers.value })
|
|
|
|
|
const sortedUsers = computed(() => {
|
|
|
|
|
const d = [...filteredUsers.value], r = sortRules.value[0]; if (!r) return d
|
|
|
|
|
return d.sort((a, b) => {
|
|
|
|
|
let vA = a[r.field], vB = b[r.field]; if (r.field === 'user_id') return r.direction === 'asc' ? vA - vB : vB - vA
|
|
|
|
|
vA = String(vA||'').toLowerCase(); vB = String(vB||'').toLowerCase(); return (vA < vB ? -1 : 1) * (r.direction === 'asc' ? 1 : -1)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
const paginatedUsers = computed(() => sortedUsers.value.slice((currentPage.value - 1) * pageSize, currentPage.value * pageSize))
|
|
|
|
|
const handleSearch = () => currentPage.value = 1, handleRefresh = () => fetchAdminData(true), changePage = (d) => { const n = currentPage.value + d; if (n >= 1 && n <= totalPages.value) currentPage.value = n }
|
|
|
|
|
const handleSort = (f, e) => { const i = sortRules.value.findIndex(r => r.field === f); if (i !== -1) { if (sortRules.value[i].direction === 'asc') sortRules.value[i].direction = 'desc'; else sortRules.value.splice(i, 1) } else { const n = { field: f, direction: 'asc' }; if (e.shiftKey) sortRules.value.push(n); else sortRules.value = [n] } }
|
|
|
|
|
const getSortIcon = (f) => { const r = sortRules.value.find(x => x.field === f); return r ? (r.direction === 'asc' ? 'fas fa-sort-up active' : 'fas fa-sort-down active') : 'fas fa-sort dim' }
|
|
|
|
|
|
|
|
|
|
const handleSort = (field, event) => {
|
|
|
|
|
const isMulti = event.shiftKey
|
|
|
|
|
const existingIndex = sortRules.value.findIndex(rule => rule.field === field)
|
|
|
|
|
|
|
|
|
|
if (existingIndex !== -1) {
|
|
|
|
|
const currentRule = sortRules.value[existingIndex]
|
|
|
|
|
// 切换方向:asc -> desc -> 移除
|
|
|
|
|
if (currentRule.direction === 'asc') currentRule.direction = 'desc'
|
|
|
|
|
else sortRules.value.splice(existingIndex, 1)
|
|
|
|
|
} else {
|
|
|
|
|
// 新增排序规则
|
|
|
|
|
const newRule = { field, direction: 'asc' }
|
|
|
|
|
if (isMulti) sortRules.value.push(newRule)
|
|
|
|
|
else sortRules.value = [newRule]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 触发一次 fetch,虽然主要靠前端排序,但保持逻辑完整
|
|
|
|
|
fetchAdminData()
|
|
|
|
|
}
|
|
|
|
|
const showUserModal = ref(false), userModalMode = ref('create'), userForm = reactive({ user_id: null, username: '', password: '', email: '', role: 'normal' })
|
|
|
|
|
const openUserForm = (m, u = null) => { userModalMode.value = m; if (m === 'edit' && u) Object.assign(userForm, { user_id: u.user_id, username: u.username, email: u.email, role: u.role, password: '' }); else Object.assign(userForm, { user_id: null, username: '', password: '', email: '', role: 'normal' }); showUserModal.value = true }
|
|
|
|
|
const submitUserForm = async () => { try { const p = { username: userForm.username, email: userForm.email, role: userForm.role }; if (userModalMode.value === 'create') { p.password = userForm.password; await createAdminUser(p) } else await updateAdminUser(userForm.user_id, { ...p, is_active: true }); showUserModal.value = false; fetchAdminData() } catch(e) {} }
|
|
|
|
|
const handleDeleteUser = async (u) => { if (await modal.confirm(`确定要删除用户 "${u.username}" 吗?`)) { try { await deleteAdminUser(u.user_id); modal.success('已删除'); fetchAdminData() } catch (e) { console.error(e) } } }
|
|
|
|
|
|
|
|
|
|
const getSortIcon = (field) => {
|
|
|
|
|
const rule = sortRules.value.find(r => r.field === field)
|
|
|
|
|
if (!rule) return 'fas fa-sort sort-icon dim'
|
|
|
|
|
return rule.direction === 'asc' ? 'fas fa-sort-up sort-icon active' : 'fas fa-sort-down sort-icon active'
|
|
|
|
|
}
|
|
|
|
|
onMounted(() => { if (subpageType.value === 'config') fetchConfig(); if (subpageType.value === 'admin') fetchAdminData() })
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
const openUserForm = (mode, user = null) => {
|
|
|
|
|
userModalMode.value = mode
|
|
|
|
|
if (mode === 'edit' && user) {
|
|
|
|
|
userForm.user_id = user.user_id; userForm.username = user.username; userForm.email = user.email; userForm.role = user.role; userForm.password = ''
|
|
|
|
|
} else {
|
|
|
|
|
userForm.user_id = null; userForm.username = ''; userForm.password = ''; userForm.email = ''; userForm.role = 'normal'
|
|
|
|
|
}
|
|
|
|
|
showUserModal.value = true
|
|
|
|
|
<style scoped>
|
|
|
|
|
/* Header 与 Row 比例严格同步 */
|
|
|
|
|
.kt-list-header, .kt-list-row {
|
|
|
|
|
display: grid;
|
|
|
|
|
/* ID(0.8) | 用户名(1.5) | 邮箱(2.5) | 角色(1) | 操作(1.2) */
|
|
|
|
|
grid-template-columns: 0.8fr 1.5fr 2.5fr 1fr 1.2fr;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const submitUserForm = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const payload = { username: userForm.username, email: userForm.email, role: userForm.role }
|
|
|
|
|
if (userModalMode.value === 'create') {
|
|
|
|
|
payload.password = userForm.password
|
|
|
|
|
await createAdminUser(payload)
|
|
|
|
|
modal.success('创建成功')
|
|
|
|
|
} else {
|
|
|
|
|
await updateAdminUser(userForm.user_id, { ...payload, is_active: true })
|
|
|
|
|
modal.success('更新成功')
|
|
|
|
|
}
|
|
|
|
|
showUserModal.value = false; fetchAdminData()
|
|
|
|
|
} catch (e) { console.error(e) }
|
|
|
|
|
.kt-list-header {
|
|
|
|
|
padding: 1rem 1.5rem;
|
|
|
|
|
background: var(--kt-muted);
|
|
|
|
|
font-family: var(--kt-font);
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
font-size: var(--kt-small);
|
|
|
|
|
color: var(--kt-muted-fg);
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.05em;
|
|
|
|
|
border-bottom: var(--kt-border-width) solid var(--kt-border);
|
|
|
|
|
position: sticky; top: 0; z-index: 10;
|
|
|
|
|
}
|
|
|
|
|
const handleDeleteUser = async (u) => {
|
|
|
|
|
const confirmed = await modal.confirm(`确定要删除用户 "${u.username}" 吗?此操作无法撤销。`)
|
|
|
|
|
if (!confirmed) return
|
|
|
|
|
try {
|
|
|
|
|
await deleteAdminUser(u.user_id)
|
|
|
|
|
modal.success('删除成功')
|
|
|
|
|
if (adminUsers.value.length === 1 && currentPage.value > 1) currentPage.value--; fetchAdminData()
|
|
|
|
|
} catch (e) { console.error(e) }
|
|
|
|
|
|
|
|
|
|
.kt-list-row {
|
|
|
|
|
padding: 1.5rem 1.5rem;
|
|
|
|
|
border-bottom: var(--kt-border-width) solid var(--kt-border);
|
|
|
|
|
transition: background var(--kt-transition-micro);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
if (props.subpageType === 'config') fetchConfig()
|
|
|
|
|
if (props.subpageType === 'admin') fetchAdminData()
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
.kt-list-row:hover { background: var(--kt-muted); }
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.kt-subpage-layout { width: 100%; height: 100%; padding: 2rem; display: flex; justify-content: center; align-items: center; overflow-y: auto; background: var(--kt-bg); }
|
|
|
|
|
.kt-modal-wrapper { display: flex; justify-content: center; align-items: center; width: 100%; max-width: 500px; }
|
|
|
|
|
/* 2. 排序标题布局:图标紧跟文字 */
|
|
|
|
|
.kt-sortable {
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
display: inline-flex; /* 紧凑布局 */
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
transition: color var(--kt-transition-micro);
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.kt-col--center { justify-content: center !important; text-align: center; }
|
|
|
|
|
.kt-sortable--center { justify-content: center; }
|
|
|
|
|
.kt-col--right { justify-content: flex-end !important; text-align: right; }
|
|
|
|
|
|
|
|
|
|
.kt-sortable i { font-size: 0.75rem; }
|
|
|
|
|
.active { color: var(--kt-accent); opacity: 1; }
|
|
|
|
|
.dim { opacity: 0.2; }
|
|
|
|
|
|
|
|
|
|
/* 3. 单元格文本处理 */
|
|
|
|
|
.kt-col { display: flex; align-items: center; min-height: 1.2em; }
|
|
|
|
|
.kt-username { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
|
|
.kt-email { color: var(--kt-muted-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
|
|
|
|
|
|
|
|
/* 4. 基础框架 */
|
|
|
|
|
.kt-subpage-layout { width: 100%; height: 100%; padding: 2rem; display: flex; justify-content: center; align-items: center; background: var(--kt-bg); }
|
|
|
|
|
.kt-modal-wrapper { width: 100%; max-width: 500px; }
|
|
|
|
|
.kt-modal-wrapper--wide { max-width: 90vw; }
|
|
|
|
|
.kt-modal-card { width: 100%; display: flex; flex-direction: column; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); overflow: hidden; }
|
|
|
|
|
.kt-modal-card--fullscreen { height: 80vh; max-height: 80vh; }
|
|
|
|
|
.kt-modal-header { flex: 0 0 auto; padding: 1.5rem 2rem; border-bottom: var(--kt-border-width) solid var(--kt-border); background: var(--kt-muted); display: flex; justify-content: space-between; align-items: center; }
|
|
|
|
|
.kt-modal-card { width: 100%; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.3); }
|
|
|
|
|
.kt-modal-card--fullscreen { height: 80vh; display: flex; flex-direction: column; }
|
|
|
|
|
.kt-modal-header { padding: 1.5rem 2rem; border-bottom: var(--kt-border-width) solid var(--kt-border); background: var(--kt-muted); display: flex; justify-content: space-between; align-items: center; }
|
|
|
|
|
.kt-modal-title { font-family: var(--kt-font); font-weight: 700; color: var(--kt-fg); font-size: var(--kt-body); text-transform: uppercase; letter-spacing: 0.02em; margin: 0; display: flex; align-items: baseline; gap: 1rem; }
|
|
|
|
|
.kt-modal-title-en { font-size: var(--kt-small); color: var(--kt-muted-fg); font-weight: 400; letter-spacing: 0.1em; }
|
|
|
|
|
|
|
|
|
|
/* 5. 按钮与交互 */
|
|
|
|
|
.kt-close-btn { width: 40px; height: 40px; background: transparent; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-muted-fg); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all var(--kt-transition-micro); }
|
|
|
|
|
.kt-close-btn:hover { background: var(--kt-fg); border-color: var(--kt-fg); color: var(--kt-bg); }
|
|
|
|
|
.kt-close-btn:active { transform: scale(0.95); }
|
|
|
|
|
.kt-modal-body { padding: 2rem; flex: 1; overflow-y: auto; min-height: 0; display: flex; flex-direction: column; gap: 1.5rem; }
|
|
|
|
|
.kt-form { display: flex; flex-direction: column; gap: 1.5rem; }
|
|
|
|
|
.kt-form-group { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
|
|
|
.kt-form-label { font-family: var(--kt-font); font-size: var(--kt-small); font-weight: 600; color: var(--kt-fg); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
|
|
|
.kt-form-input { width: 100%; padding: 0.75rem 1rem; font-family: var(--kt-font); font-size: var(--kt-small); background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-fg); outline: none; transition: border-color var(--kt-transition-micro); }
|
|
|
|
|
.kt-form-input:focus { border-color: var(--kt-accent); }
|
|
|
|
|
.kt-form-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
|
.kt-form-input::placeholder { color: var(--kt-muted-fg); }
|
|
|
|
|
.kt-form-select { width: 100%; padding: 0.75rem 2.5rem 0.75rem 1rem; font-family: var(--kt-font); font-size: var(--kt-small); background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-fg); cursor: pointer; appearance: none; outline: none; transition: border-color var(--kt-transition-micro); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23FAFAFA' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 1rem center; }
|
|
|
|
|
.kt-form-select:focus { border-color: var(--kt-accent); }
|
|
|
|
|
.kt-form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 0.5rem; }
|
|
|
|
|
.kt-btn { font-family: var(--kt-font); display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.75rem 1.5rem; background: transparent; color: var(--kt-fg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); cursor: pointer; transition: all var(--kt-transition-micro); font-size: var(--kt-small); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
|
|
|
.kt-btn:hover { background: var(--kt-muted); transform: scale(1.02); }
|
|
|
|
|
.kt-btn:active { transform: scale(0.98); }
|
|
|
|
|
.kt-btn--primary { background: var(--kt-accent); border-color: var(--kt-accent); color: var(--kt-accent-fg); }
|
|
|
|
|
.kt-btn--primary:hover { background: var(--kt-fg); border-color: var(--kt-fg); color: var(--kt-bg); }
|
|
|
|
|
.kt-btn-group { display: flex; gap: 0.75rem; }
|
|
|
|
|
.kt-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; }
|
|
|
|
|
.kt-search-group { display: flex; align-items: center; gap: 1rem; flex: 1; position: relative; }
|
|
|
|
|
.kt-search-icon { position: absolute; left: 1rem; color: var(--kt-muted-fg); font-size: var(--kt-small); }
|
|
|
|
|
.kt-search-input { width: 100%; max-width: 300px; padding: 0.75rem 1rem 0.75rem 2.5rem; font-family: var(--kt-font); font-size: var(--kt-small); background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-fg); outline: none; transition: border-color var(--kt-transition-micro); }
|
|
|
|
|
.kt-search-input:focus { border-color: var(--kt-accent); }
|
|
|
|
|
.kt-tip-text { font-family: var(--kt-font); font-size: clamp(0.625rem, 1vw, 0.75rem); color: var(--kt-muted-fg); }
|
|
|
|
|
.kt-table-wrapper { flex: 1; overflow-y: auto; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); background: var(--kt-bg); }
|
|
|
|
|
.kt-user-list { display: flex; flex-direction: column; }
|
|
|
|
|
.kt-list-header { display: grid; grid-template-columns: 0.8fr 1.5fr 2fr 1fr 1.2fr; gap: 1rem; padding: 1rem 1.5rem; background: var(--kt-muted); font-family: var(--kt-font); font-weight: 700; font-size: var(--kt-small); color: var(--kt-muted-fg); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: var(--kt-border-width) solid var(--kt-border); position: sticky; top: 0; z-index: 1; }
|
|
|
|
|
.kt-sortable { cursor: pointer; display: flex; align-items: center; gap: 0.5rem; transition: color var(--kt-transition-micro); }
|
|
|
|
|
.kt-sortable:hover { color: var(--kt-fg); }
|
|
|
|
|
.sort-icon { font-size: clamp(0.625rem, 1vw, 0.75rem); }
|
|
|
|
|
.sort-icon.dim { opacity: 0.3; }
|
|
|
|
|
.sort-icon.active { color: var(--kt-accent); opacity: 1; }
|
|
|
|
|
.kt-list-row { display: grid; grid-template-columns: 0.8fr 1.5fr 2fr 1fr 1.2fr; gap: 1rem; padding: 1rem 1.5rem; font-family: var(--kt-font); font-size: var(--kt-small); color: var(--kt-fg); border-bottom: var(--kt-border-width) solid var(--kt-border); align-items: center; transition: background var(--kt-transition-micro); }
|
|
|
|
|
.kt-list-row:hover { background: var(--kt-muted); }
|
|
|
|
|
.kt-list-row:last-child { border-bottom: none; }
|
|
|
|
|
.kt-id-text { color: var(--kt-muted-fg); font-weight: 600; }
|
|
|
|
|
.kt-username { font-weight: 600; }
|
|
|
|
|
.kt-email { color: var(--kt-muted-fg); word-break: break-all; }
|
|
|
|
|
.kt-empty-row { padding: 3rem; text-align: center; font-family: var(--kt-font); color: var(--kt-muted-fg); }
|
|
|
|
|
.kt-role-tag { display: inline-flex; align-items: center; padding: 0.25rem 0.75rem; font-family: var(--kt-font); font-size: clamp(0.625rem, 1vw, 0.75rem); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); }
|
|
|
|
|
.kt-role--admin { background: var(--kt-accent); color: var(--kt-accent-fg); border-color: var(--kt-accent); }
|
|
|
|
|
.kt-role--vip { background: transparent; color: var(--kt-accent); border-color: var(--kt-accent); }
|
|
|
|
|
.kt-role--user { background: var(--kt-muted); color: var(--kt-fg); border-color: var(--kt-border); }
|
|
|
|
|
.kt-actions { display: flex; gap: 0.75rem; }
|
|
|
|
|
.kt-text-btn { font-family: var(--kt-font); font-size: var(--kt-small); background: none; border: none; color: var(--kt-accent); cursor: pointer; padding: 0.25rem 0.5rem; border-radius: var(--kt-radius); transition: all var(--kt-transition-micro); font-weight: 600; }
|
|
|
|
|
.kt-text-btn:hover { background: var(--kt-muted); }
|
|
|
|
|
.kt-text-btn { font-weight: 700; color: var(--kt-muted-fg); background: none; border: none; cursor: pointer; transition: 0.2s; white-space: nowrap; }
|
|
|
|
|
.kt-text-btn--edit:hover { color: var(--kt-accent); }
|
|
|
|
|
.kt-text-btn--danger { color: #ef4444; }
|
|
|
|
|
.kt-text-btn--danger:hover { background: rgba(239, 68, 68, 0.1); }
|
|
|
|
|
.kt-pagination { display: flex; justify-content: center; align-items: center; gap: 1rem; padding: 1rem 0 0 0; }
|
|
|
|
|
.kt-page-btn { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; font-family: var(--kt-font); background: transparent; border: var(--kt-border-width) solid var(--kt-border); border-radius: var(--kt-radius); color: var(--kt-fg); cursor: pointer; transition: all var(--kt-transition-micro); }
|
|
|
|
|
.kt-page-btn:hover:not(:disabled) { background: var(--kt-muted); transform: scale(1.05); }
|
|
|
|
|
.kt-page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
|
|
|
.kt-page-info { font-family: var(--kt-font); font-size: var(--kt-small); color: var(--kt-muted-fg); }
|
|
|
|
|
.kt-sub-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(4px); display: flex; justify-content: center; align-items: center; z-index: 3000; }
|
|
|
|
|
.kt-sub-modal-card { width: 90%; max-width: 450px; }
|
|
|
|
|
|
|
|
|
|
/* VIP Styles */
|
|
|
|
|
.kt-vip-benefits { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1.5rem; }
|
|
|
|
|
.kt-benefit-item { display: flex; align-items: flex-start; gap: 1rem; padding: 1rem; background: var(--kt-muted); border-radius: var(--kt-radius); border: var(--kt-border-width) solid var(--kt-border); }
|
|
|
|
|
.kt-benefit-item i { color: var(--kt-accent); font-size: 1.25rem; margin-top: 0.2rem; }
|
|
|
|
|
.kt-benefit-title { font-family: var(--kt-font); font-weight: 700; color: var(--kt-fg); text-transform: uppercase; font-size: 0.9rem; margin-bottom: 0.25rem; }
|
|
|
|
|
.kt-benefit-desc { font-family: var(--kt-font); font-size: 0.8rem; color: var(--kt-muted-fg); }
|
|
|
|
|
.kt-divider { height: 1px; background: var(--kt-border); margin: 1rem 0; }
|
|
|
|
|
.kt-vip-status.active { display: flex; flex-direction: column; align-items: center; gap: 1rem; padding: 2rem; background: rgba(223, 225, 4, 0.1); border: 2px solid var(--kt-accent); border-radius: var(--kt-radius); color: var(--kt-accent); font-weight: 700; font-family: var(--kt-font); text-transform: uppercase; }
|
|
|
|
|
.kt-vip-status i { font-size: 3rem; }
|
|
|
|
|
.kt-codes-result { margin-top: 1.5rem; padding-top: 1.5rem; border-top: var(--kt-border-width) dashed var(--kt-border); }
|
|
|
|
|
.kt-codes-title { font-family: var(--kt-font); font-weight: 600; color: var(--kt-fg); margin-bottom: 0.75rem; }
|
|
|
|
|
.kt-code-list { display: grid; gap: 0.5rem; }
|
|
|
|
|
.kt-code-item { padding: 0.75rem; background: var(--kt-bg); border: var(--kt-border-width) solid var(--kt-border); font-family: monospace; color: var(--kt-accent); cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: all 0.2s; }
|
|
|
|
|
.kt-code-item:hover { border-color: var(--kt-accent); background: var(--kt-muted); }
|
|
|
|
|
.kt-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
|
|
|
|
|
.kt-btn { padding: 0.8rem 1.5rem; cursor: pointer; font-weight: 700; text-transform: uppercase; border: 2px solid var(--kt-border); background: none; color: var(--kt-fg); font-family: var(--kt-font); transition: 0.2s; }
|
|
|
|
|
.kt-btn--primary { background: var(--kt-accent); border-color: var(--kt-accent); color: #000; }
|
|
|
|
|
.kt-btn--primary:hover { background: var(--kt-fg); color: var(--kt-bg); border-color: var(--kt-fg); }
|
|
|
|
|
|
|
|
|
|
/* 确保块级堆叠 */
|
|
|
|
|
.kt-modal-body { padding: 2.5rem; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2rem; }
|
|
|
|
|
.kt-form { display: flex; flex-direction: column; gap: 1.5rem; width: 100%; }
|
|
|
|
|
.kt-form-group { display: block; width: 100%; margin-bottom: 0.5rem; } /* 强制块级 */
|
|
|
|
|
.kt-form-label { display: block; margin-bottom: 0.75rem; font-size: var(--kt-small); font-weight: 700; text-transform: uppercase; color: var(--kt-fg); letter-spacing: 0.05em; }
|
|
|
|
|
.kt-form-input {
|
|
|
|
|
display: block;
|
|
|
|
|
width: 100%;
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
border: var(--kt-border-width) solid var(--kt-border);
|
|
|
|
|
background: var(--kt-bg);
|
|
|
|
|
color: var(--kt-fg);
|
|
|
|
|
font-family: var(--kt-font);
|
|
|
|
|
transition: border-color 0.2s;
|
|
|
|
|
box-sizing: border-box; /* 必须包含 padding */
|
|
|
|
|
}
|
|
|
|
|
.kt-form-input:focus { border-color: var(--kt-accent); outline: none; }
|
|
|
|
|
.kt-form-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1rem; }
|
|
|
|
|
|
|
|
|
|
/* 7. 其他组件 */
|
|
|
|
|
.kt-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 0.5rem; }
|
|
|
|
|
.kt-search-group { position: relative; flex: 1; display: flex; align-items: center; gap: 1rem; }
|
|
|
|
|
.kt-search-input { width: 100%; max-width: 300px; padding: 0.75rem 1rem 0.75rem 2.5rem; border: var(--kt-border-width) solid var(--kt-border); background: var(--kt-bg); color: var(--kt-fg); }
|
|
|
|
|
.kt-search-icon { position: absolute; left: 1rem; color: var(--kt-muted-fg); }
|
|
|
|
|
.kt-table-wrapper { flex: 1; overflow-y: auto; border: var(--kt-border-width) solid var(--kt-border); }
|
|
|
|
|
.kt-role-tag { width: 80px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--kt-border); font-weight: 700; font-size: 0.7rem; text-transform: uppercase; }
|
|
|
|
|
.kt-role--admin { background: var(--kt-accent); color: #000; border-color: var(--kt-accent); }
|
|
|
|
|
.kt-role--vip { color: var(--kt-accent); border-color: var(--kt-accent); }
|
|
|
|
|
.kt-pagination { display: flex; justify-content: center; align-items: center; gap: 1.5rem; padding-top: 1rem; }
|
|
|
|
|
.kt-page-btn { width: 36px; height: 36px; border: 1px solid var(--kt-border); background: none; color: var(--kt-fg); cursor: pointer; }
|
|
|
|
|
.kt-form-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; min-height: 44px; }
|
|
|
|
|
.kt-mode-toggle { display: flex; gap: 0.5rem; background: var(--kt-muted); padding: 0.3rem; }
|
|
|
|
|
.kt-mode-toggle span { padding: 0.3rem 0.8rem; cursor: pointer; font-size: 0.7rem; font-weight: 700; }
|
|
|
|
|
.kt-mode-toggle span.active { background: var(--kt-bg); color: var(--kt-fg); }
|
|
|
|
|
.kt-strength-selector { display: flex; border: 1px solid var(--kt-border); }
|
|
|
|
|
.kt-strength-item { flex: 1; text-align: center; padding: 0.6rem; cursor: pointer; font-weight: 700; }
|
|
|
|
|
.kt-strength-item.active { background: var(--kt-accent); color: #000; }
|
|
|
|
|
.kt-sub-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 3000; display: flex; justify-content: center; align-items: center; backdrop-filter: blur(4px); }
|
|
|
|
|
.kt-sub-modal-card { width: 95%; max-width: 500px; height: auto !important; }
|
|
|
|
|
|
|
|
|
|
@media (max-width: 900px) {
|
|
|
|
|
.kt-subpage-layout { padding: 1rem; height: auto; min-height: 100%; align-items: flex-start; }
|
|
|
|
|
.kt-modal-wrapper { max-width: 100%; }
|
|
|
|
|
.kt-modal-wrapper--wide { max-width: 100%; }
|
|
|
|
|
.kt-modal-card--fullscreen { height: auto; max-height: none; }
|
|
|
|
|
.kt-list-header, .kt-list-row { grid-template-columns: 1fr 1fr; gap: 0.5rem; }
|
|
|
|
|
.kt-list-header span:nth-child(3), .kt-list-header span:nth-child(4), .kt-list-row span:nth-child(3), .kt-list-row span:nth-child(4) { display: none; }
|
|
|
|
|
.kt-toolbar { flex-direction: column; align-items: stretch; }
|
|
|
|
|
.kt-search-group { flex-direction: column; align-items: stretch; }
|
|
|
|
|
.kt-search-input { max-width: 100%; }
|
|
|
|
|
.kt-tip-text { display: none; }
|
|
|
|
|
.kt-sub-modal-card { max-width: 95%; }
|
|
|
|
|
.kt-btn-group { width: 100%; justify-content: stretch; }
|
|
|
|
|
.kt-btn-group .kt-btn { flex: 1; }
|
|
|
|
|
.kt-modal-card--fullscreen { height: auto; }
|
|
|
|
|
.kt-list-header { display: none; }
|
|
|
|
|
.kt-list-row { grid-template-columns: 1fr; padding: 1.5rem; gap: 0.5rem; }
|
|
|
|
|
.kt-col--right { justify-content: flex-start; }
|
|
|
|
|
.kt-actions { justify-content: flex-start; margin-top: 1rem; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.kt-close-btn:focus-visible, .kt-btn:focus-visible, .kt-form-input:focus-visible, .kt-form-select:focus-visible, .kt-text-btn:focus-visible, .kt-page-btn:focus-visible { outline: 2px solid var(--kt-accent); outline-offset: 2px; }
|
|
|
|
|
@media (prefers-reduced-motion: reduce) { .kt-close-btn, .kt-btn, .kt-list-row, .kt-text-btn, .kt-page-btn, .kt-form-input, .kt-form-select { transition: none; } .kt-btn:hover, .kt-page-btn:hover:not(:disabled) { transform: none; } }
|
|
|
|
|
</style>
|