You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1082 lines
30 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="order-list">
<el-card shadow="never">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item :label="$t('label.hospitalName')">
<el-select
v-model="filters.hospitalId"
:placeholder="$t('common.all')"
clearable
filterable
style="width: 200px"
>
<el-option
v-for="h in hospitals"
:key="h.id"
:label="h.name"
:value="h.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t('table.status')">
<el-select
v-model="filters.status"
:placeholder="$t('common.all')"
clearable
style="width: 140px"
>
<el-option label="待处理" value="待处理" />
<el-option label="已完成" value="已完成" />
</el-select>
</el-form-item>
<el-form-item :label="$t('table.priority')">
<el-select
v-model="filters.priority"
:placeholder="$t('common.all')"
clearable
style="width: 120px"
>
<el-option label="高" value="高" />
<el-option label="中" value="中" />
<el-option label="低" value="低" />
</el-select>
</el-form-item>
<el-form-item :label="$t('table.type')">
<el-select
v-model="filters.type"
:placeholder="$t('common.all')"
clearable
style="width: 120px"
>
<el-option label="故障问题" value="故障问题" />
<el-option label="使用咨询" value="使用咨询" />
<el-option label="功能需求" value="功能需求" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item :label="$t('placeholder.keyword')">
<el-input
v-model="filters.keyword"
:placeholder="$t('placeholder.keyword')"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item :label="$t('table.time')">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
:start-placeholder="$t('common.startDate')"
:end-placeholder="$t('common.endDate')"
value-format="YYYY-MM-DD"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{
$t("btn.query")
}}</el-button>
<el-button @click="onReset">{{ $t("btn.reset") }}</el-button>
<el-button type="success" @click="openQuick">{{
$t("btn.quickAddOrder")
}}</el-button>
</el-form-item>
</el-form>
<!-- 工单总览 -->
<div class="order-stats">
<div
class="stat-card stat-total"
:class="{ active: activeStat === 'total' }"
@click="onStatClick('total')"
>
<div class="stat-icon">📋</div>
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">{{ $t("stat.totalOrders") }}</div>
</div>
<div
class="stat-card stat-pending"
:class="{ active: activeStat === 'pending' }"
@click="onStatClick('pending')"
>
<div class="stat-icon">⏳</div>
<div class="stat-value">{{ stats.pending }}</div>
<div class="stat-label">{{ $t("stat.pendingOrders") }}</div>
</div>
<div
class="stat-card stat-completed"
:class="{ active: activeStat === 'completed' }"
@click="onStatClick('completed')"
>
<div class="stat-icon">✅</div>
<div class="stat-value">{{ stats.completed }}</div>
<div class="stat-label">{{ $t("stat.completedOrders") }}</div>
</div>
<div
class="stat-card stat-high"
:class="{ active: activeStat === 'high' }"
@click="onStatClick('high')"
>
<div class="stat-icon">🔴</div>
<div class="stat-value">{{ stats.highPriority }}</div>
<div class="stat-label">{{ $t("stat.highPriorityOrders") }}</div>
</div>
</div>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column :label="$t('table.id')" prop="orderNo" width="160" />
<el-table-column
:label="$t('table.hospital')"
prop="hospitalName"
min-width="160"
show-overflow-tooltip
/>
<el-table-column :label="$t('table.priority')" width="90">
<template #default="{ row }">
<el-tag :type="priorityType(row.priority)" size="small">{{
row.priority
}}</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('table.title')"
prop="title"
min-width="300"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.type')"
prop="serviceType"
width="120"
/>
<el-table-column
:label="$t('table.dept')"
prop="department"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.submitter')"
prop="submitter"
min-width="100"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.feedbackchannel')"
prop="feedbackChannel"
min-width="100"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.registrar')"
prop="registrarName"
min-width="100"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.handler')"
prop="processName"
min-width="100"
show-overflow-tooltip
/>
<el-table-column
:label="$t('table.handleTime')"
prop="completedAt"
width="170"
>
<template #default="{ row }">
{{ row.completedAt || "-" }}
</template>
</el-table-column>
<el-table-column :label="$t('table.status')" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{
row.status
}}</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('table.time')"
prop="createdAt"
width="170"
/>
<el-table-column :label="$t('table.action')" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="goDetail(row.id)">{{
$t("btn.detail")
}}</el-button>
<el-button
link
:type="row.status === '已完成' ? 'warning' : 'success'"
@click="goProcess(row.id)"
>{{
row.status === "已完成" ? $t("btn.modify") : $t("btn.process")
}}</el-button
>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadList"
@current-change="loadList"
/>
</div>
</el-card>
<!-- 快速提交工单 弹窗 -->
<el-dialog
v-model="quickVisible"
:title="$t('modal.submit')"
width="640px"
:close-on-click-modal="false"
@closed="onQuickClosed"
>
<el-form
ref="quickFormRef"
:model="quickForm"
:rules="quickRules"
label-width="100px"
>
<el-form-item :label="$t('label.hospitalName')" prop="hospitalId">
<el-select
v-model="quickForm.hospitalId"
:placeholder="$t('msg.pleaseSelectHospital')"
filterable
style="width: 100%"
>
<el-option
v-for="h in hospitals"
:key="h.id"
:label="h.name"
:value="h.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t('label.title')" prop="title">
<div class="title-row">
<el-input
v-model="quickForm.title"
:placeholder="$t('placeholder.title')"
maxlength="100"
show-word-limit
/>
<div class="color-tag-picker" ref="colorPickerRef">
<div
class="color-tag-preview"
:style="{ background: quickForm.colorTag || '#dcdfe6' }"
:class="{ 'is-empty': !quickForm.colorTag }"
@click.stop="toggleColorPicker"
></div>
<div
v-show="colorPickerVisible"
class="color-tag-dropdown"
@click.stop
>
<div class="color-tag-title">{{ $t("label.colorTag") }}</div>
<div class="color-tag-options">
<div
v-for="c in colorOptions"
:key="c.value"
class="color-tag-option"
:class="{ active: quickForm.colorTag === c.value }"
:style="{ background: c.value }"
:title="c.label"
@click="selectColor(c.value)"
></div>
</div>
</div>
</div>
</div>
</el-form-item>
<el-form-item :label="$t('label.type')" prop="serviceType">
<el-select
v-model="quickForm.serviceType"
:placeholder="$t('placeholder.type')"
clearable
>
<el-option label="故障问题" value="故障问题" />
<el-option label="使用咨询" value="使用咨询" />
<el-option label="功能需求" value="功能需求" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label-width="0">
<div class="inline-row">
<el-form-item :label="$t('label.priority')" prop="priority" class="inline-item">
<el-radio-group v-model="quickForm.priority">
<el-radio-button label="高">高</el-radio-button>
<el-radio-button label="中">中</el-radio-button>
<el-radio-button label="低">低</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="$t('label.feedbackchannel')" prop="feedbackchannel" class="inline-item">
<el-select
v-model="quickForm.feedbackchannel"
:placeholder="$t('placeholder.feedbackchannel')"
clearable
style="width: 160px"
>
<el-option label="微信群" value="微信群" />
<el-option label="微信" value="微信" />
<el-option label="电话" value="电话" />
<el-option label="现场" value="现场" />
<el-option label="钉钉群" value="钉钉群" />
<el-option label="钉钉" value="钉钉" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</div>
</el-form-item>
<el-form-item :label="$t('label.dept')" prop="department">
<el-input
v-model="quickForm.department"
:placeholder="$t('placeholder.dept')"
/>
</el-form-item>
<el-form-item :label="$t('label.name')" prop="submitter">
<el-input
v-model="quickForm.submitter"
:placeholder="$t('placeholder.name')"
/>
</el-form-item>
<el-form-item :label="$t('label.createat')" prop="createdat">
<el-date-picker
v-model="quickForm.createdat"
type="datetime"
:placeholder="$t('placeholder.createat')"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="$t('label.description')" prop="description">
<div class="rich-editor">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
mode="default"
style="border-bottom: 1px solid #dcdfe6"
/>
<Editor
v-model="quickForm.description"
:default-config="editorConfig"
mode="default"
style="height: 200px; overflow-y: auto"
@on-created="handleEditorCreated"
@custom-paste="handleEditorPasteImage"
/>
</div>
</el-form-item>
<el-form-item :label="$t('label.attachment')">
<el-upload
v-model:file-list="quickForm.files"
:auto-upload="false"
:limit="1"
:on-exceed="handleAttachmentExceed"
:on-remove="handleAttachmentRemove"
:on-change="handleAttachmentChange"
>
<el-button type="primary" plain>{{ $t("btn.upload") }}</el-button>
<template #tip>
<div class="el-upload__tip">
{{ $t("label.attachmentTip") }}
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="quickVisible = false">{{
$t("common.cancel")
}}</el-button>
<el-button
type="primary"
:loading="quickSubmitting"
@click="submitQuick"
>{{ $t("common.submit") }}</el-button
>
</template>
</el-dialog>
</div>
</template>
<script setup>
import dayjs from "dayjs";
import {
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
shallowRef,
} from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { useI18n } from "vue-i18n";
import {
createAdminOrder,
getAdminHospitals,
getAdminOrders,
} from "@/service/modular/admin";
import { useUserStore } from "@/stores/api/user";
const { t: $t } = useI18n();
const router = useRouter();
const userStore = useUserStore();
const list = ref([]);
const hospitals = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const dateRange = ref([]);
const filters = reactive({
hospitalId: undefined,
status: "",
priority: "",
type: "",
keyword: "",
});
const loadHospitals = async () => {
try {
const res = await getAdminHospitals({ page: 1, pageSize: 1000 });
hospitals.value = res?.list || [];
} catch (_) {
hospitals.value = [];
}
};
const priorityType = (p) =>
p === "高" ? "danger" : p === "中" ? "warning" : "info";
const statusType = (s) =>
({ 待处理: "warning", 处理中: "primary", 已完成: "success", 已取消: "info" })[
s
] || "info";
// 工单总览统计
const stats = reactive({
total: 0,
pending: 0,
completed: 0,
highPriority: 0,
});
// 当前选中的总览卡片total | pending | completed | high
const activeStat = ref("total");
const parseStats = (res) => {
let payload = res?.data;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch (_) {
payload = {};
}
}
stats.completed = Number(payload?.TotalStatus1 ?? 0);
stats.pending = Number(payload?.TotalStatus2 ?? 0);
stats.total = stats.completed + stats.pending;
stats.highPriority = Number(payload?.TotalPriority ?? 0);
};
const loadList = async () => {
loading.value = true;
try {
const params = { page: page.value, pageSize: pageSize.value, ...filters };
if (dateRange.value?.length === 2) {
params.startDate = dateRange.value[0];
params.endDate = dateRange.value[1];
}
const res = await getAdminOrders(params);
list.value = res?.list || [];
list.value.forEach((item) => {
if (item.createdAt) {
item.createdAt = dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss");
}
if (item.completedAt) {
item.completedAt = dayjs(item.completedAt).format(
"YYYY-MM-DD HH:mm:ss",
);
}
});
total.value = res?.total || 0;
parseStats(res);
} finally {
loading.value = false;
}
};
// 总览卡片点击:根据卡片类型设置筛选条件并重新加载
const onStatClick = (type) => {
activeStat.value = type;
if (type === "total") {
filters.status = "";
filters.priority = "";
} else if (type === "pending") {
filters.status = "待处理";
filters.priority = "";
} else if (type === "completed") {
filters.status = "已完成";
filters.priority = "";
} else if (type === "high") {
filters.status = "";
filters.priority = "高";
}
page.value = 1;
loadList();
};
const onSearch = () => {
page.value = 1;
loadList();
};
const onReset = () => {
Object.assign(filters, {
hospitalId: undefined,
status: "",
priority: "",
type: "",
keyword: "",
});
dateRange.value = [];
activeStat.value = "total";
onSearch();
};
const goDetail = (id) => router.push(`/admin/orders/detail/${id}`);
const goProcess = (id) => router.push(`/admin/orders/process/${id}`);
// 颜色标签可选值(颜色 Hex 取自设计原型)
const colorOptions = [
{ label: "蓝", value: "#667eea" },
{ label: "绿", value: "#52c41a" },
{ label: "青", value: "#13c2c2" },
{ label: "紫", value: "#722ed1" },
{ label: "橙", value: "#fa8c16" },
{ label: "红", value: "#ff4d4f" },
];
const colorPickerVisible = ref(false);
const colorPickerRef = ref();
const toggleColorPicker = () => {
colorPickerVisible.value = !colorPickerVisible.value;
};
const selectColor = (val) => {
quickForm.colorTag = val;
colorPickerVisible.value = false;
};
const onDocClick = (e) => {
if (!colorPickerVisible.value) return;
const el = colorPickerRef.value;
if (el && !el.contains(e.target)) {
colorPickerVisible.value = false;
}
};
// 快速提交工单(弹窗)
const quickVisible = ref(false);
const quickSubmitting = ref(false);
const quickFormRef = ref();
const quickForm = reactive({
hospitalId: undefined,
title: "",
serviceType: "",
priority: "中",
feedbackchannel: "",
department: "",
colorTag: "#667eea",
description: "",
submitter: "",
createdat: "",
files: [],
});
// 生成当前时间(带时分秒)作为提交时间默认值
const nowDatetime = () => dayjs().format("YYYY-MM-DD HH:mm:ss");
const MAX_FILE_SIZE = 50 * 1024 * 1024;
// 粘贴到富文本的图片大小上限base64 内联存储,避免 HTML 过大)
const MAX_PASTE_IMAGE_SIZE = 5 * 1024 * 1024;
// el-upload 事件:选文件后立即校验大小
const handleAttachmentChange = (file) => {
if (file?.size > MAX_FILE_SIZE) {
ElMessage.warning("文件不能超过 50MB");
// 把超限文件从列表里移除
quickForm.files = quickForm.files.filter(
(f) => f.uid !== file.uid || f.size <= MAX_FILE_SIZE,
);
}
};
// el-upload 事件:超过 limit 时的回调
const handleAttachmentExceed = () => {
ElMessage.warning("只能上传 1 个文件");
};
// el-upload 事件:删除文件
const handleAttachmentRemove = () => {
// el-upload 会自动从 v-model:file-list 移除,这里无需额外处理
};
const quickRules = {
hospitalId: [
{
required: true,
message: () => $t("msg.pleaseSelectHospital"),
trigger: "change",
},
],
title: [
{
required: true,
message: () => $t("msg.pleaseInputTitle"),
trigger: "blur",
},
],
serviceType: [
{
required: true,
message: () => $t("msg.pleaseSelectType"),
trigger: "blur",
},
],
priority: [
{
required: true,
message: () => $t("msg.pleaseSelectPriority"),
trigger: "change",
},
],
feedbackchannel: [
{
required: true,
message: () => $t("msg.pleaseSelectFeedbackChannel"),
trigger: "change",
},
],
department: [
{
required: true,
whitespace: true,
message: () => $t("msg.pleaseInputDept"),
trigger: "blur",
},
],
submitter: [
{
required: true,
whitespace: true,
message: () => $t("msg.pleaseInputName"),
trigger: "blur",
},
],
description: [
{
required: true,
validator: (_, value, callback) => {
const hasImage = /<img\s/i.test(value || "");
const text = (value || "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, "")
.trim();
if (!text && !hasImage)
return callback(new Error($t("msg.pleaseInputDescription")));
callback();
},
trigger: "change",
},
],
};
// 快速提交工单-富文本编辑器配置
const editorRef = shallowRef();
const toolbarConfig = {
excludeKeys: [
"uploadImage",
"insertVideo",
"uploadVideo",
"group-video",
"emotion",
"group-emoji",
],
};
// 从剪贴板中捞出图片文件(同时兼容 items 和 files 两种来源)
const collectPastedImages = (clipboardData) => {
const files = [];
const items = clipboardData?.items;
if (items) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file" && item.type?.startsWith("image/")) {
const file = item.getAsFile();
if (file) files.push(file);
}
}
}
if (files.length === 0 && clipboardData?.files) {
for (let i = 0; i < clipboardData.files.length; i++) {
const file = clipboardData.files[i];
if (file.type?.startsWith("image/")) files.push(file);
}
}
return files;
};
// 共用的图片粘贴处理逻辑
const handleEditorPasteImage = (editor, event) => {
if (event.__wpsImageHandled) return;
const imageFiles = collectPastedImages(event.clipboardData);
if (imageFiles.length === 0) return;
event.__wpsImageHandled = true;
event.preventDefault();
if (typeof event.stopPropagation === "function") event.stopPropagation();
if (typeof event.stopImmediatePropagation === "function")
event.stopImmediatePropagation();
if (typeof editor.focus === "function") editor.focus();
const savedSelection = editor.selection;
imageFiles.forEach((file) => {
if (file.size > MAX_PASTE_IMAGE_SIZE) {
ElMessage.warning("粘贴的图片不能超过 5MB");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result;
if (!dataUrl) {
ElMessage.error("图片读取失败");
return;
}
try {
if (savedSelection) editor.select(savedSelection);
} catch (_) {
if (typeof editor.focus === "function") editor.focus();
}
if (typeof editor.focus === "function") editor.focus();
editor.dangerouslyInsertHtml(
`<img src="${dataUrl}" style="max-width:100%;height:auto;" />`,
);
if (typeof editor.updateView === "function") editor.updateView();
};
reader.onerror = () => {
ElMessage.error("图片处理失败");
};
reader.readAsDataURL(file);
});
return false;
};
const editorConfig = {
placeholder: $t("placeholder.description"),
};
let pasteHandlerRef = null;
const handleEditorCreated = (editor) => {
editorRef.value = editor;
nextTick(() => {
const root = typeof editor.$el === "function" ? editor.$el() : editor.$el;
if (!root) return;
const editable = root.querySelector?.('[contenteditable="true"]') || root;
if (pasteHandlerRef) {
editable.removeEventListener("paste", pasteHandlerRef, { capture: true });
}
pasteHandlerRef = (event) => handleEditorPasteImage(editor, event);
editable.addEventListener("paste", pasteHandlerRef, { capture: true });
});
};
const resetQuickForm = () => {
Object.assign(quickForm, {
hospitalId: undefined,
title: "",
serviceType: "",
priority: "中",
feedbackchannel: "",
department: "",
colorTag: "#667eea",
description: "",
submitter: "",
createdat: "",
files: [],
});
quickFormRef.value?.clearValidate();
};
const openQuick = () => {
// 打开弹窗时自动填入当前时间作为提交时间默认值
quickForm.createdat = nowDatetime();
quickVisible.value = true;
};
const onQuickClosed = () => {
resetQuickForm();
};
const submitQuick = async () => {
// dept / submitter 去掉两端空格后再校验,避免仅含空格的“伪必填”绕过
quickForm.department = (quickForm.department || "").trim();
quickForm.submitter = (quickForm.submitter || "").trim();
await quickFormRef.value.validate(async (valid) => {
if (!valid) return;
quickSubmitting.value = true;
try {
const formData = new FormData();
const { files, ...rest } = quickForm;
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined && v !== null && v !== "") {
formData.append(k, v);
}
}
formData.append("registrarName", userStore.userInfo?.userName || "");
// el-upload 的 file-list 元素 raw 字段就是 File 对象
for (const f of files || []) {
if (f.raw) formData.append("files", f.raw, f.name);
}
await createAdminOrder(formData);
ElMessage.success($t("msg.submitSuccess"));
quickVisible.value = false;
loadList();
} finally {
quickSubmitting.value = false;
}
});
};
onMounted(() => {
loadHospitals();
loadList();
document.addEventListener("click", onDocClick);
});
const removePasteListener = () => {
const editor = editorRef.value;
if (!editor || !pasteHandlerRef) return;
const root = typeof editor.$el === "function" ? editor.$el() : editor.$el;
if (!root) return;
const editable = root.querySelector?.('[contenteditable="true"]') || root;
editable.removeEventListener("paste", pasteHandlerRef, { capture: true });
};
onBeforeUnmount(() => {
document.removeEventListener("click", onDocClick);
removePasteListener();
pasteHandlerRef = null;
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
});
</script>
<style scoped lang="scss">
.filter-form {
margin-bottom: 12px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.rich-editor {
border: 1px solid #dcdfe6;
border-radius: 4px;
width: 100%;
overflow: hidden;
background: #fff;
}
.rich-editor :deep(.w-e-toolbar) {
background: #fafafa;
}
// +
.title-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.title-row .el-input {
flex: 1;
}
// +
.inline-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 24px;
width: 100%;
}
.inline-row .inline-item {
margin-bottom: 0 !important;
}
.inline-row .inline-item :deep(.el-form-item__content) {
display: flex;
align-items: center;
}
.color-tag-picker {
position: relative;
flex-shrink: 0;
}
.color-tag-preview {
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.color-tag-preview:hover {
transform: scale(1.08);
}
.color-tag-preview.is-empty {
background-image: linear-gradient(
135deg,
#f5f5f5 25%,
transparent 25%,
transparent 50%,
#f5f5f5 50%,
#f5f5f5 75%,
transparent 75%
);
background-size: 8px 8px;
box-shadow: 0 0 0 1px #dcdfe6;
}
.color-tag-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 10;
background: #fff;
border-radius: 10px;
padding: 12px 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 180px;
}
.color-tag-title {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.color-tag-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.color-tag-option {
width: 24px;
height: 24px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition:
transform 0.2s ease,
border-color 0.2s ease;
}
.color-tag-option:hover {
transform: scale(1.12);
}
.color-tag-option.active {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.color-tag-clear {
background: #f5f5f5 !important;
position: relative;
}
.color-tag-clear::before,
.color-tag-clear::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 14px;
height: 2px;
background: #999;
transform-origin: center;
}
.color-tag-clear::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.color-tag-clear::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
//
.order-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.stat-card {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border-left: 4px solid #d9d9d9;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
cursor: pointer;
user-select: none;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
.stat-card.active {
background: #f0f7ff;
box-shadow: 0 4px 14px rgba(24, 144, 255, 0.18);
transform: translateY(-2px);
}
.stat-card.active.stat-pending {
background: #fff7e6;
box-shadow: 0 4px 14px rgba(250, 140, 22, 0.18);
}
.stat-card.active.stat-completed {
background: #f6ffed;
box-shadow: 0 4px 14px rgba(82, 196, 26, 0.18);
}
.stat-card.active.stat-high {
background: #fff1f0;
box-shadow: 0 4px 14px rgba(255, 77, 79, 0.18);
}
.stat-total {
border-left-color: #1890ff;
}
.stat-pending {
border-left-color: #fa8c16;
}
.stat-completed {
border-left-color: #52c41a;
}
.stat-high {
border-left-color: #ff4d4f;
}
.stat-icon {
font-size: 28px;
line-height: 1;
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: #333;
line-height: 1.1;
}
.stat-label {
font-size: 13px;
color: #999;
margin-top: 4px;
}
</style>