This commit is contained in:
2026-07-28 16:24:31 +08:00
parent f54c58c366
commit 17505b5c22
9 changed files with 617 additions and 433 deletions

View File

@@ -0,0 +1,4 @@
{
"tokens": {},
"components": {}
}

View File

@@ -0,0 +1,54 @@
<template>
<el-input
v-model="keyword"
:placeholder="placeholder"
clearable
class="search-input"
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '搜索网站、备注...' },
})
const emit = defineEmits(['update:modelValue', 'search'])
const keyword = ref(props.modelValue)
watch(() => props.modelValue, (val) => {
keyword.value = val
})
const handleSearch = () => {
emit('update:modelValue', keyword.value)
emit('search', keyword.value)
}
</script>
<style scoped>
.search-input {
width: 320px;
}
.search-input :deep(.el-input__wrapper) {
border-radius: 20px !important;
}
@media (max-width: 1400px) {
.search-input { width: 260px; }
}
@media (max-width: 768px) {
.search-input { width: 100%; }
}
</style>

View File

@@ -34,14 +34,7 @@
</el-form-item> </el-form-item>
<el-form-item label="关键词" class="search-keyword"> <el-form-item label="关键词" class="search-keyword">
<el-input v-model="localFilters.search" placeholder="搜索网站、备注" clearable @keyup.enter="handleSearch" <SearchInput v-model="localFilters.search" @search="handleSearch" />
class="wide-search-input">
<template #prefix>
<el-icon>
<Search />
</el-icon>
</template>
</el-input>
</el-form-item> </el-form-item>
</div> </div>
</div> </div>
@@ -70,6 +63,7 @@
<script setup> <script setup>
import { reactive, watch, computed } from "vue"; import { reactive, watch, computed } from "vue";
import { Search, Sort } from "@element-plus/icons-vue"; import { Search, Sort } from "@element-plus/icons-vue";
import SearchInput from "@/components/common/SearchInput.vue";
const props = defineProps({ const props = defineProps({
filters: { type: Object, required: true }, filters: { type: Object, required: true },
@@ -159,8 +153,6 @@ const setSort = (order) => {
:deep(.el-form-item__label) { color: var(--text-secondary); font-weight: 500; } :deep(.el-form-item__label) { color: var(--text-secondary); font-weight: 500; }
.wide-select { width: 180px; } .wide-select { width: 180px; }
.wide-search-input { width: 320px; }
.filter-main :deep(.el-input__wrapper), .filter-main :deep(.el-input__wrapper),
.filter-main :deep(.el-button) { border-radius: 20px !important; } .filter-main :deep(.el-button) { border-radius: 20px !important; }
:deep(.el-select-dropdown) { border-radius: 12px !important; overflow: hidden; } :deep(.el-select-dropdown) { border-radius: 12px !important; overflow: hidden; }
@@ -175,11 +167,10 @@ const setSort = (order) => {
padding-left: 12px; padding-left: 12px;
} }
@media (max-width: 1400px) { .wide-search-input { width: 260px; } }
@media (max-width: 768px) { @media (max-width: 768px) {
.search-container { flex-direction: column; } .search-container { flex-direction: column; }
.input-group-row { flex-direction: column; align-items: flex-start; } .input-group-row { flex-direction: column; align-items: flex-start; }
.divider-wrapper { display: none; } .divider-wrapper { display: none; }
.wide-select, .wide-search-input { width: 100%; } .wide-select { width: 100%; }
} }
</style> </style>

View File

@@ -62,6 +62,12 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="任务进度">
<div class="progress-input-wrap">
<el-slider v-model="form.progress" :min="0" :max="100" :step="5" show-input />
</div>
</el-form-item>
<el-form-item label="备注" prop="remark"> <el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="填写注意事项" resize="none" /> <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="填写注意事项" resize="none" />
</el-form-item> </el-form-item>
@@ -97,6 +103,7 @@ const props = defineProps({
const dialogVisible = ref(false); const dialogVisible = ref(false);
const formRef = ref(); const formRef = ref();
const submitting = ref(false); const submitting = ref(false);
const isInitializing = ref(false);
// 核心表单对象:customer_id 已包含在内 // 核心表单对象:customer_id 已包含在内
const form = reactive({ const form = reactive({
@@ -106,6 +113,7 @@ const form = reactive({
operator_id: null, operator_id: null,
user_id: null, user_id: null,
status: 0, status: 0,
progress: 0,
remark: "", remark: "",
}); });
@@ -123,6 +131,17 @@ const handleCustomerChange = () => {
form.operator_id = null; form.operator_id = null;
}; };
// 状态变为已完成(2)或已完结(3)时,自动将进度设为100
watch(
() => form.status,
(val) => {
if (isInitializing.value) return;
if (val === 2 || val === 3) {
form.progress = 100;
}
}
);
// --- 监听弹窗与回显 --- // --- 监听弹窗与回显 ---
watch( watch(
() => props.modelValue, () => props.modelValue,
@@ -130,6 +149,7 @@ watch(
dialogVisible.value = val; dialogVisible.value = val;
if (val) { // 只在弹窗打开时候,执行初始化 if (val) { // 只在弹窗打开时候,执行初始化
nextTick(() => { // 等待DOM更新完成后再操作表单数据 nextTick(() => { // 等待DOM更新完成后再操作表单数据
isInitializing.value = true;
resetForm(); // 先重置 resetForm(); // 先重置
if (props.isEditing && props.formData) { if (props.isEditing && props.formData) {
const d = props.formData; const d = props.formData;
@@ -167,6 +187,7 @@ watch(
if (u) form.user_id = u.id; if (u) form.user_id = u.id;
} }
} }
isInitializing.value = false;
}); });
} }
} }
@@ -187,6 +208,7 @@ const resetForm = () => {
operator_id: null, operator_id: null,
user_id: null, user_id: null,
status: 0, status: 0,
progress: 0,
remark: "", remark: "",
}); });
}; };
@@ -261,6 +283,10 @@ const handleSubmit = async () => {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.progress-input-wrap {
padding: 0 4px;
}
:deep(.el-form-item__label) { :deep(.el-form-item__label) {
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 500; font-weight: 500;

View File

@@ -3,12 +3,14 @@
<el-table <el-table
ref="tableRef" ref="tableRef"
:data="tickets" :data="tickets"
row-key="id"
v-loading="loading" v-loading="loading"
header-cell-class-name="table-header" header-cell-class-name="table-header"
style="width: 100%" style="width: 100%"
@selection-change="$emit('selection-change', $event)" @select="onSelect"
@select-all="onSelectAll"
> >
<el-table-column type="selection" width="50" /> <el-table-column type="selection" width="50" :reserve-selection="true" />
<el-table-column label="网站地址" min-width="180"> <el-table-column label="网站地址" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<el-link <el-link
@@ -147,21 +149,43 @@
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref, reactive } from "vue";
import { formatUrl, formatDateTime } from "@/utils/format"; import { formatUrl, formatDateTime } from "@/utils/format";
defineProps({ const props = defineProps({
tickets: { type: Array, default: () => [] }, tickets: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
}); });
defineEmits(["edit", "delete", "selection-change"]); const emit = defineEmits(["edit", "delete", "selection-change"]);
const tableRef = ref(null); const tableRef = ref(null);
const selectedMap = reactive(new Map());
const emitSelection = () => {
emit("selection-change", Array.from(selectedMap.values()));
};
const onSelect = (selection, row) => {
if (selection.some((r) => r.id === row.id)) {
selectedMap.set(row.id, row);
} else {
selectedMap.delete(row.id);
}
emitSelection();
};
const onSelectAll = (selection) => {
props.tickets.forEach((t) => selectedMap.delete(t.id));
selection.forEach((row) => selectedMap.set(row.id, row));
emitSelection();
};
defineExpose({ defineExpose({
clearSelection() { clearSelection() {
tableRef.value?.clearSelection(); tableRef.value?.clearSelection();
selectedMap.clear();
emit("selection-change", []);
}, },
}); });

View File

@@ -14,7 +14,6 @@ function createService(basePath) {
service.interceptors.response.use( service.interceptors.response.use(
response => response.data, response => response.data,
error => { error => {
const resData = error.response?.data const resData = error.response?.data
const status = error.response?.status const status = error.response?.status

View File

@@ -7,36 +7,23 @@
<p class="desc">登录以继续使用系统</p> <p class="desc">登录以继续使用系统</p>
</div> </div>
<el-form <el-form ref="formRef" :model="form" :rules="rules" class="login-form" @keyup.enter="handleLogin">
ref="formRef"
:model="form"
:rules="rules"
class="login-form"
@keyup.enter="handleLogin"
>
<el-form-item prop="username"> <el-form-item prop="username">
<el-input <el-input v-model="form.username" placeholder="员工编号" size="large" clearable>
v-model="form.username"
placeholder="员工编号"
size="large"
clearable
>
<template #prefix> <template #prefix>
<el-icon><User /></el-icon> <el-icon>
<User />
</el-icon>
</template> </template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item prop="password"> <el-form-item prop="password">
<el-input <el-input v-model="form.password" type="password" placeholder="访问密钥" size="large" show-password>
v-model="form.password"
type="password"
placeholder="访问密钥"
size="large"
show-password
>
<template #prefix> <template #prefix>
<el-icon><Lock /></el-icon> <el-icon>
<Lock />
</el-icon>
</template> </template>
</el-input> </el-input>
</el-form-item> </el-form-item>
@@ -45,13 +32,7 @@
<el-checkbox v-model="rememberMe">保持登录</el-checkbox> <el-checkbox v-model="rememberMe">保持登录</el-checkbox>
</div> </div>
<el-button <el-button type="primary" size="large" class="submit-btn" :loading="loading" @click="handleLogin">
type="primary"
size="large"
class="submit-btn"
:loading="loading"
@click="handleLogin"
>
登 录 登 录
</el-button> </el-button>
</el-form> </el-form>

View File

@@ -60,6 +60,7 @@
<div class="batch-bar" v-if="selectedTickets.length"> <div class="batch-bar" v-if="selectedTickets.length">
<span class="batch-info">已选 <strong>{{ selectedTickets.length }}</strong> 个工单</span> <span class="batch-info">已选 <strong>{{ selectedTickets.length }}</strong> 个工单</span>
<el-button type="warning" @click="handleBatchFinish">一键已完结</el-button> <el-button type="warning" @click="handleBatchFinish">一键已完结</el-button>
<el-button type="primary" @click="handleBatchExport">一键导出</el-button>
<el-button link type="info" @click="ticketListRef.clearSelection()">取消选择</el-button> <el-button link type="info" @click="ticketListRef.clearSelection()">取消选择</el-button>
</div> </div>
@@ -89,6 +90,7 @@ import {
CircleCheck, CircleCheck,
Checked, Checked,
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
import * as XLSX from "xlsx";
// API 引入 // API 引入
import { ticketApi } from "@/api/ticket"; import { ticketApi } from "@/api/ticket";
@@ -272,6 +274,7 @@ const handleAdd = () => {
operator_id: null, operator_id: null,
user_id: null, user_id: null,
status: 0, status: 0,
progress: 0,
remark: "", remark: "",
}; };
showAddDialog.value = true; showAddDialog.value = true;
@@ -356,6 +359,32 @@ const handleBatchFinish = async () => {
} }
}; };
const handleBatchExport = () => {
const data = selectedTickets.value.map((item) => ({
域名: item.website,
数据量: item.datanum || 0,
运营人员: item.operator_name,
采集人员: item.username,
备注: item.remark,
开始时间: item.create_time,
结束时间: item.lock_time,
}));
const worksheet = XLSX.utils.json_to_sheet(data);
const keys = Object.keys(data[0] || {});
worksheet["!cols"] = keys.map((key) => ({
wch: Math.max(
key.replace(/[^\x00-\xff]/g, "00").length,
...data.map((r) => String(r[key] ?? "").replace(/[^\x00-\xff]/g, "00").length)
) + 3,
}));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "工单导出");
XLSX.writeFile(workbook, `工单导出_${new Date().toISOString().slice(0, 10)}.xlsx`);
ElMessage.success(`已导出 ${data.length} 条工单`);
};
const handleSizeChange = (val) => { const handleSizeChange = (val) => {
pagination.limit = val; pagination.limit = val;
loadTickets(); loadTickets();

File diff suppressed because it is too large Load Diff