5b8105ec2b
用户管理、菜单管理、不符合ASIN、数据去重总数据、查询ASIN、最低价ASIN、 商品类目、密钥管理共 8 个实体管理列表补齐两列。分组管理/店铺密钥/店铺管理 此前已带创建+修改时间,任务列表与统计报表(撞款监控、密钥用量、日志、 记录与版本)不含实体更新语义,均未改动。 关键点——时间列必须由数据库维护,否则新列是假的: 这些表的更新走 selectById → 改字段 → updateById,实体带着读出的旧 updated_at 一起写回。MySQL 规则是「UPDATE 显式给某列赋值时不触发该列的 ON UPDATE 自动更新」,不禁写就会把旧值写回去,更新时间永远冻结在首次写入 时刻。按 V125(biz_file_result)既有样板,给 7 个实体标注 @TableField(insertStrategy=NEVER, updateStrategy=NEVER)。 - V131:users / columns 补 updated_at(幂等 ADD COLUMN,仿 V125 写法)。 存量行被回填为迁移执行时刻,非真实历史变更时间(历史上无记录,无法还原) - 实体/VO:AdminUserEntity、PermissionMenuEntity、InvalidAsinDataEntity、 DedupeTotalDataEntity、ProductCategoryEntity、QueryAsinEntity、 SkipPriceAsinEntity 加/改写 updatedAt;AdminUserItemVo、PermissionMenuItemVo、 InvalidAsinDataItemVo、DedupeTotalDataItemVo 补 updatedAt; AdminUserSecretRowVo 补 createdAt(行级首次配置时间 = 三模块最早) - 查询ASIN/最低价ASIN 后端 VO 与前端 model 本就有两字段,仅补渲染 - 前端 8 页表格加列,同步修正空态/加载行的 colspan(手写表格,不同步会错位) - 测试:align-query-asin / align-skip-price 原断言「不允许有更新时间列」 (像素复刻旧版),按新需求改为断言两列存在;新增 e2e list-time-columns 覆盖 8 页表头与真实时间值渲染
828 lines
24 KiB
Vue
828 lines
24 KiB
Vue
<script setup lang="ts">
|
||
import { formatDateTime } from '@/utils/datetime'
|
||
/** 数据去重总数据(registry) · 像素复刻旧版 admin.html panel-dedupe-total-data(自绘:分组摘要 + 双行筛选 + 旧式表格/分页 + 编辑/导入弹窗 + 导出等待遮罩)。
|
||
* script 逻辑沿用现有 Vue 实现(查询/编辑/删除/导入轮询/导出秒表)。 */
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import {
|
||
fetchDedupeTotalList,
|
||
updateDedupeTotal,
|
||
deleteDedupeTotal,
|
||
fetchDedupeTotalGroups,
|
||
type DedupeGroupOption,
|
||
} from './dedupe-total-api.ts'
|
||
import type { DedupeTotalItem } from './dedupe-total-model.ts'
|
||
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
|
||
import { createDedupeTotalFilterState, toDedupeListParams } from './dedupe-total-filter.ts'
|
||
import {
|
||
fetchDedupeDeleteImportProgress,
|
||
fetchDedupeImportProgress,
|
||
startDedupeDeleteImport,
|
||
startDedupeImport,
|
||
} from './dedupe-import-api.ts'
|
||
import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './dedupe-import-model.ts'
|
||
import type { DedupeImportProgress } from './dedupe-import-model.ts'
|
||
import { toExportUrl } from './dedupe-total-export.ts'
|
||
import OldPagination from '@/components/OldPagination.vue'
|
||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||
|
||
const session = useAdminSessionStore()
|
||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||
|
||
const loading = ref(false)
|
||
const rows = ref<DedupeTotalItem[]>([])
|
||
const total = ref(0)
|
||
const page = ref(1)
|
||
const pageSize = ref(10)
|
||
const filter = reactive(createDedupeTotalFilterState())
|
||
const groups = ref<DedupeGroupOption[]>([])
|
||
/** 非超管仅有一个可访问分组时,编辑/导入弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||
const lockedGroupId = computed<number | null>(() => {
|
||
if (isSuperAdmin.value) return null
|
||
return groups.value.length === 1 ? groups.value[0].id : null
|
||
})
|
||
const jumpPage = ref('')
|
||
/** 顺序翻页游标:上一页响应给的 nextLastId;仅在"下一页"时使用,跳页/筛选时清空走 OFFSET。 */
|
||
const pageCursor = ref<number | null>(null)
|
||
/** 本次请求实际使用的游标(load 时决定) */
|
||
let pendingCursor: number | null = null
|
||
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||
|
||
const editVisible = ref(false)
|
||
const editSaving = ref(false)
|
||
const editTarget = ref<DedupeTotalItem | null>(null)
|
||
const editValue = ref('')
|
||
const editGroupId = ref<number | null>(null)
|
||
|
||
type ImportMode = 'add' | 'delete'
|
||
const importVisible = ref(false)
|
||
const importMode = ref<ImportMode>('add')
|
||
const importGroupId = ref<number | null>(null)
|
||
const importFile = ref<File | null>(null)
|
||
const importRunning = ref(false)
|
||
const importProgress = ref('')
|
||
|
||
const exporting = ref(false)
|
||
/** 导出等待遮罩与秒表(对齐 admin.js showDedupeTotalDataExportWait)。 */
|
||
const exportWaitVisible = ref(false)
|
||
const exportWaitSeconds = ref(0)
|
||
let exportWaitTimer: ReturnType<typeof setInterval> | null = null
|
||
|
||
function startExportWait(): void {
|
||
exportWaitSeconds.value = 0
|
||
exportWaitVisible.value = true
|
||
exportWaitTimer = setInterval(() => {
|
||
exportWaitSeconds.value += 1
|
||
}, 1000)
|
||
}
|
||
|
||
function stopExportWait(): void {
|
||
if (exportWaitTimer) {
|
||
clearInterval(exportWaitTimer)
|
||
exportWaitTimer = null
|
||
}
|
||
exportWaitVisible.value = false
|
||
}
|
||
|
||
async function load(): Promise<void> {
|
||
loading.value = true
|
||
try {
|
||
const result = await fetchDedupeTotalList(
|
||
toDedupeListParams(filter, page.value, pageSize.value, pendingCursor),
|
||
)
|
||
rows.value = result.items
|
||
total.value = result.total
|
||
if (result.page >= 1) page.value = result.page
|
||
// 本页响应回传的游标留作"下一页"用;空页则清空(没有更多)
|
||
pageCursor.value = result.nextLastId ?? null
|
||
pendingCursor = null
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function apply(): void {
|
||
page.value = 1
|
||
pageCursor.value = null
|
||
pendingCursor = null
|
||
void load()
|
||
}
|
||
|
||
function changePage(next: number): void {
|
||
if (next < 1 || next > totalPages.value) return
|
||
// 只有"顺序下一页"用 keyset 游标(避免深分页 offset);跳页/回退走 OFFSET,行为不变
|
||
pendingCursor = next === page.value + 1 ? pageCursor.value : null
|
||
page.value = next
|
||
void load()
|
||
}
|
||
|
||
function goJump(): void {
|
||
const n = Number.parseInt(jumpPage.value, 10)
|
||
if (Number.isNaN(n)) {
|
||
ElMessage.warning('请输入页码')
|
||
return
|
||
}
|
||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||
}
|
||
|
||
function changeSize(size: number) {
|
||
pageSize.value = size
|
||
page.value = 1
|
||
pageCursor.value = null
|
||
pendingCursor = null
|
||
void load()
|
||
}
|
||
|
||
function openEdit(row: DedupeTotalItem): void {
|
||
editTarget.value = row
|
||
editValue.value = row.dataValue
|
||
editGroupId.value = row.groupId ?? lockedGroupId.value
|
||
editVisible.value = true
|
||
}
|
||
|
||
async function saveEdit(): Promise<void> {
|
||
const value = editValue.value.trim()
|
||
const target = editTarget.value
|
||
if (!target) return
|
||
if (!value) {
|
||
ElMessage.warning('请填写总数据值')
|
||
return
|
||
}
|
||
if (!editGroupId.value) {
|
||
ElMessage.warning('请选择分组')
|
||
return
|
||
}
|
||
editSaving.value = true
|
||
try {
|
||
await updateDedupeTotal(target.id, value, editGroupId.value)
|
||
editVisible.value = false
|
||
ElMessage.success('保存成功')
|
||
await load()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||
} finally {
|
||
editSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function removeRow(row: DedupeTotalItem): Promise<void> {
|
||
// 对齐旧版:浏览器原生 confirm 确认后删除。
|
||
if (!window.confirm(`确定删除总数据“${row.dataValue}”吗?`)) return
|
||
try {
|
||
await deleteDedupeTotal(row.id)
|
||
ElMessage.success('删除成功')
|
||
await load()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||
}
|
||
}
|
||
|
||
function resetImport(): void {
|
||
importGroupId.value = lockedGroupId.value
|
||
importFile.value = null
|
||
importProgress.value = ''
|
||
importRunning.value = false
|
||
}
|
||
|
||
function openImportAdd(): void {
|
||
importMode.value = 'add'
|
||
resetImport()
|
||
importVisible.value = true
|
||
}
|
||
|
||
function openImportDelete(): void {
|
||
importMode.value = 'delete'
|
||
resetImport()
|
||
importVisible.value = true
|
||
}
|
||
|
||
function onPickFile(event: Event): void {
|
||
const input = event.target as HTMLInputElement
|
||
importFile.value = input.files?.[0] ?? null
|
||
}
|
||
|
||
function importValid(): boolean {
|
||
if (!importGroupId.value) {
|
||
ElMessage.warning('请先选择数据权限分组')
|
||
return false
|
||
}
|
||
if (!importFile.value) {
|
||
ElMessage.warning('请选择 Excel 文件')
|
||
return false
|
||
}
|
||
if (!isAllowedImportFile(importFile.value.name)) {
|
||
ElMessage.warning('仅支持 .xlsx/.xls 文件')
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
function sleep(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||
}
|
||
|
||
async function pollImport(poll: () => Promise<DedupeImportProgress>): Promise<DedupeImportProgress> {
|
||
for (let i = 0; i < 300; i += 1) {
|
||
const progress = await poll()
|
||
importProgress.value = importOutcomeText(progress)
|
||
if (importTaskFinished(progress)) return progress
|
||
await sleep(1000)
|
||
}
|
||
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
|
||
}
|
||
|
||
async function submitImport(): Promise<void> {
|
||
if (!importValid()) return
|
||
const file = importFile.value as File
|
||
const groupId = importGroupId.value as number
|
||
if (importMode.value === 'delete') {
|
||
if (!window.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?')) return
|
||
}
|
||
const mode = importMode.value
|
||
importRunning.value = true
|
||
importProgress.value = mode === 'add' ? '正在上传并解析文件…' : '正在上传删除清单…'
|
||
try {
|
||
const started =
|
||
mode === 'add' ? await startDedupeImport(file, groupId) : await startDedupeDeleteImport(file, groupId)
|
||
const poll = mode === 'add' ? () => fetchDedupeImportProgress(started) : () => fetchDedupeDeleteImportProgress(started)
|
||
const finished = await pollImport(poll)
|
||
importVisible.value = false
|
||
resetImport()
|
||
if (finished.status === 'success') {
|
||
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
|
||
await load()
|
||
} else {
|
||
ElMessage.error(importOutcomeText(finished))
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '导入失败')
|
||
} finally {
|
||
importRunning.value = false
|
||
}
|
||
}
|
||
|
||
async function doExport(): Promise<void> {
|
||
const { startDate, endDate } = filter
|
||
if (startDate && endDate && startDate > endDate) {
|
||
ElMessage.warning('开始日期不能晚于结束日期')
|
||
return
|
||
}
|
||
if (exporting.value) return
|
||
exporting.value = true
|
||
startExportWait()
|
||
try {
|
||
// 对齐 admin.js:fetch 流式下载,带筛选参数;失败响应(JSON)解析错误提示。
|
||
const response = await window.fetch(toExportUrl(filter), { credentials: 'include' })
|
||
const contentType = response.headers.get('content-type') || ''
|
||
if (!response.ok || contentType.includes('application/json')) {
|
||
const body = (await response.json().catch(() => ({}))) as { error?: string; message?: string; msg?: string }
|
||
throw new Error(body.error || body.message || body.msg || '导出失败')
|
||
}
|
||
const blob = await response.blob()
|
||
const disposition = response.headers.get('content-disposition') || ''
|
||
const matched = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)
|
||
const filename = matched ? decodeURIComponent(matched[1]) : 'dedupe-total-data.xlsx'
|
||
const url = URL.createObjectURL(blob)
|
||
const anchor = document.createElement('a')
|
||
anchor.href = url
|
||
anchor.download = filename
|
||
document.body.appendChild(anchor)
|
||
anchor.click()
|
||
anchor.remove()
|
||
URL.revokeObjectURL(url)
|
||
ElMessage.success('导出完成')
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '导出失败,请重试')
|
||
} finally {
|
||
stopExportWait()
|
||
exporting.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void load()
|
||
fetchDedupeTotalGroups().then((items) => {
|
||
groups.value = items
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="dedupe-view">
|
||
<section class="panel-box">
|
||
<div class="dedupe-panel-head">
|
||
<h3>ASIN列表</h3>
|
||
<div class="dedupe-head-actions">
|
||
<button class="btn" type="button" @click="openImportAdd">新增导入</button>
|
||
<button class="btn" type="button" @click="openImportDelete">删除导入</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="dedupe-filter-row">
|
||
<div class="form-group">
|
||
<label>ASIN</label>
|
||
<input v-model="filter.keyword" type="text" placeholder="输入 ASIN" @keyup.enter="apply" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>用户名(模糊搜索)</label>
|
||
<input v-model="filter.username" type="text" placeholder="输入用户名" @keyup.enter="apply" />
|
||
</div>
|
||
<div v-if="isSuperAdmin" class="form-group">
|
||
<label>分组</label>
|
||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||
</el-select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>国家</label>
|
||
<select v-model="filter.country">
|
||
<option value="">全部国家</option>
|
||
<option v-for="code in ASIN_COUNTRY_CODES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="dedupe-filter-actions">
|
||
<button class="btn" type="button" @click="apply">查询</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="dedupe-filter-date-row">
|
||
<div class="form-group">
|
||
<label>开始日期</label>
|
||
<input v-model="filter.startDate" type="date" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>结束日期</label>
|
||
<input v-model="filter.endDate" type="date" />
|
||
</div>
|
||
<div class="dedupe-filter-actions">
|
||
<button class="btn btn-secondary" type="button" :disabled="exporting" @click="doExport">
|
||
{{ exporting ? '导出中...' : '导出 XLSX' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-scroll dedupe-table-scroll">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>ASIN</th>
|
||
<th>国家</th>
|
||
<th>用户名</th>
|
||
<th v-if="isSuperAdmin">分组</th>
|
||
<th>创建时间</th>
|
||
<th>更新时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template v-if="rows.length">
|
||
<tr v-for="row in rows" :key="row.id">
|
||
<td>{{ row.id }}</td>
|
||
<td class="mono-cell">{{ row.dataValue }}</td>
|
||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||
<td>{{ row.username || '-' }}</td>
|
||
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
|
||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||
<td class="ops-cell">
|
||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
<tr v-else-if="loading">
|
||
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
|
||
</tr>
|
||
<tr v-else>
|
||
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无总数据</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||
</section>
|
||
|
||
<div v-if="editVisible" class="modal-mask" @click.self="editVisible = false">
|
||
<div class="modal">
|
||
<h3>编辑ASIN</h3>
|
||
<div class="form-group">
|
||
<label>ASIN</label>
|
||
<input v-model="editValue" type="text" placeholder="请输入总数据值" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>分组</label>
|
||
<select v-model="editGroupId" :disabled="lockedGroupId != null">
|
||
<option disabled :value="null">请选择分组</option>
|
||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||
</select>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn" type="button" :disabled="editSaving" @click="saveEdit">保存</button>
|
||
<button class="btn btn-secondary" type="button" @click="editVisible = false">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="importVisible" class="modal-mask" @click.self="importVisible = false">
|
||
<div class="modal">
|
||
<h3>{{ importMode === 'add' ? '新增导入' : '删除导入' }}</h3>
|
||
<div class="form-group">
|
||
<label>{{ importMode === 'add' ? '上传 Excel 文件(读取 ASIN 列,可选 国家 列)' : '上传 Excel 文件(读取 ASIN 列)' }}</label>
|
||
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickFile" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>分组</label>
|
||
<select v-model="importGroupId" :disabled="importRunning || lockedGroupId != null">
|
||
<option disabled :value="null">请选择分组</option>
|
||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||
</select>
|
||
</div>
|
||
<div v-if="importProgress" class="progress-wrap">
|
||
<div class="progress-text">{{ importProgress }}</div>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button
|
||
class="btn"
|
||
:class="{ 'btn-danger': importMode === 'delete' }"
|
||
type="button"
|
||
:disabled="importRunning"
|
||
@click="submitImport"
|
||
>
|
||
{{ importMode === 'add' ? '上传并导入' : '上传并删除' }}
|
||
</button>
|
||
<button class="btn btn-secondary" type="button" :disabled="importRunning" @click="importVisible = false">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="exportWaitVisible" class="dedupe-export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
|
||
<div class="dedupe-export-wait">
|
||
<span class="request-spinner" aria-hidden="true"></span>
|
||
<div>
|
||
<div class="dedupe-export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
|
||
<div class="dedupe-export-wait-detail">
|
||
数据量较大,请耐心等待并保持页面打开。已等待 <span>{{ exportWaitSeconds }}</span> 秒
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 像素复刻旧版 admin.html panel-dedupe-total-data(蓝白末层样式)。 */
|
||
.dedupe-view {
|
||
font-family: inherit;
|
||
color: var(--c-text, #24384d);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
}
|
||
.form-box,
|
||
.panel-box {
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 20px 22px 24px;
|
||
border: 1px solid #d8e3ee;
|
||
border-radius: 14px;
|
||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||
}
|
||
h3 {
|
||
margin: 0 0 16px;
|
||
font-size: 15px;
|
||
font-weight: 650;
|
||
color: #24384d;
|
||
letter-spacing: 0.2px;
|
||
}
|
||
.dedupe-panel-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.dedupe-panel-head h3 {
|
||
margin: 0;
|
||
}
|
||
.dedupe-head-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
.dedupe-filter-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto;
|
||
gap: 12px;
|
||
align-items: end;
|
||
margin-bottom: 16px;
|
||
}
|
||
.dedupe-filter-row .form-group {
|
||
min-width: 0;
|
||
}
|
||
.dedupe-filter-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
white-space: nowrap;
|
||
}
|
||
.dedupe-filter-date-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-end;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 16px;
|
||
}
|
||
.dedupe-filter-date-row .form-group {
|
||
flex: 0 0 200px;
|
||
min-width: 0;
|
||
}
|
||
.dedupe-filter-date-row .dedupe-filter-actions {
|
||
margin-left: auto;
|
||
}
|
||
.form-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 7px;
|
||
margin-bottom: 0;
|
||
}
|
||
.form-group label {
|
||
color: #5b6f83;
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
}
|
||
.form-group input,
|
||
.form-group select {
|
||
min-width: 0;
|
||
min-height: 42px;
|
||
padding: 8px 12px;
|
||
border: 1px solid #cbd9e6;
|
||
border-radius: 9px;
|
||
background: #f8fbfd;
|
||
color: #24384d;
|
||
font-size: 13.5px;
|
||
font-family: inherit;
|
||
color-scheme: light;
|
||
outline: none;
|
||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||
}
|
||
.form-group input:hover,
|
||
.form-group select:hover {
|
||
border-color: #9fb7cd;
|
||
}
|
||
.form-group input:focus,
|
||
.form-group select:focus {
|
||
background: #ffffff;
|
||
border-color: #5f85ad;
|
||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||
}
|
||
.form-group input[type="file"] {
|
||
background: #ffffff;
|
||
padding: 6px 10px;
|
||
}
|
||
.form-group input[type="date"] {
|
||
color-scheme: light;
|
||
}
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
min-height: 42px;
|
||
padding: 9px 18px;
|
||
border: 1px solid #4f78a5;
|
||
border-radius: 9px;
|
||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||
color: #ffffff;
|
||
font-family: inherit;
|
||
font-size: 13.5px;
|
||
cursor: pointer;
|
||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||
}
|
||
.btn:hover:not(:disabled) {
|
||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||
}
|
||
.btn:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.6;
|
||
}
|
||
.btn-secondary {
|
||
background: #ffffff;
|
||
color: #5b6f83;
|
||
border-color: #c7d7e5;
|
||
}
|
||
.btn-secondary:hover:not(:disabled) {
|
||
color: #2f5d8b;
|
||
border-color: #95b1cb;
|
||
background: #edf5fb;
|
||
}
|
||
.btn-danger {
|
||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||
border-color: #b35f6a;
|
||
}
|
||
.btn-danger:hover:not(:disabled) {
|
||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||
}
|
||
.btn-sm {
|
||
min-height: 36px;
|
||
padding: 7px 12px;
|
||
}
|
||
.table-scroll {
|
||
width: 100%;
|
||
min-width: 0;
|
||
overflow-x: auto;
|
||
border: 1px solid #dbe5ee;
|
||
border-radius: 10px;
|
||
background: #ffffff;
|
||
}
|
||
.table-scroll table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
table-layout: fixed;
|
||
}
|
||
.dedupe-table-scroll > table {
|
||
min-width: 900px;
|
||
}
|
||
.table-scroll th,
|
||
.table-scroll td {
|
||
padding: 10px 12px;
|
||
text-align: left;
|
||
font-size: 13.5px;
|
||
line-height: 1.5;
|
||
border-bottom: 1px solid #e0e8ef;
|
||
vertical-align: middle;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.table-scroll th {
|
||
background: #edf4fa;
|
||
color: #4e6479;
|
||
border-bottom-color: #d5e1eb;
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.4px;
|
||
}
|
||
.table-scroll tbody tr:hover td {
|
||
background: #f1f7fb;
|
||
}
|
||
.table-scroll tbody tr:last-child td {
|
||
border-bottom: 0;
|
||
}
|
||
.mono-cell {
|
||
font-family: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
|
||
font-size: 12.5px;
|
||
}
|
||
.ops-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
white-space: nowrap;
|
||
text-align: right;
|
||
}
|
||
|
||
.empty-tip {
|
||
padding: 44px 24px;
|
||
text-align: center;
|
||
color: #8293a5;
|
||
font-size: 13.5px;
|
||
}
|
||
.pagination {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-top: 18px;
|
||
color: #5b6f83;
|
||
font-size: 13px;
|
||
}
|
||
.pagination button {
|
||
min-height: 30px;
|
||
padding: 4px 12px;
|
||
border: 1px solid #c7d7e5;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
color: #5b6f83;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
}
|
||
.pagination button:hover:not(:disabled) {
|
||
background: #edf5fb;
|
||
border-color: #95b1cb;
|
||
color: #2f5d8b;
|
||
}
|
||
.pagination button:disabled {
|
||
background: #eef3f7;
|
||
color: #9baaba;
|
||
cursor: not-allowed;
|
||
}
|
||
.page-total {
|
||
margin-right: 4px;
|
||
}
|
||
.page-jump {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.page-jump input {
|
||
width: 56px;
|
||
min-height: 30px;
|
||
padding: 4px 8px;
|
||
border: 1px solid #cbd9e6;
|
||
border-radius: 8px;
|
||
background: #f8fbfd;
|
||
color: #24384d;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
}
|
||
.modal-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 3000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 40px 20px;
|
||
background: rgba(45, 66, 86, 0.36);
|
||
backdrop-filter: blur(6px);
|
||
}
|
||
.modal {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: min(560px, 100%);
|
||
max-height: 84vh;
|
||
padding: 22px 24px;
|
||
border: 1px solid #c7d7e5;
|
||
border-radius: 14px;
|
||
background: #ffffff;
|
||
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
|
||
overflow-y: auto;
|
||
}
|
||
.modal .form-group {
|
||
margin-bottom: 14px;
|
||
}
|
||
.modal-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 16px;
|
||
}
|
||
.progress-wrap {
|
||
margin-top: 12px;
|
||
}
|
||
.progress-text {
|
||
font-size: 12.5px;
|
||
color: #5b6f83;
|
||
line-height: 1.6;
|
||
}
|
||
.dedupe-export-wait-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 3100;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(45, 66, 86, 0.36);
|
||
backdrop-filter: blur(6px);
|
||
}
|
||
.dedupe-export-wait {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
padding: 22px 28px;
|
||
border: 1px solid #c7d7e5;
|
||
border-radius: 14px;
|
||
background: #ffffff;
|
||
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
|
||
}
|
||
.request-spinner {
|
||
width: 26px;
|
||
height: 26px;
|
||
flex: none;
|
||
border: 3px solid #d5e3ef;
|
||
border-top-color: #5f85ad;
|
||
border-radius: 50%;
|
||
animation: dedupe-spin 0.9s linear infinite;
|
||
}
|
||
@keyframes dedupe-spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
.dedupe-export-wait-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #24384d;
|
||
}
|
||
.dedupe-export-wait-detail {
|
||
margin-top: 4px;
|
||
font-size: 12.5px;
|
||
color: #5b6f83;
|
||
}
|
||
</style>
|